fix(sync): apply the pull fast-forward as a tree diff, not checkout_tree

First on-device ff attempt (2026-07-14) crashed: libgit2's SAFE
checkout iterates the whole working directory (readdir over SPI on all
~1100 files) to find dirty files — the same O(tree) wall the splice
commit avoids — and with fetch memory still resident it exhausted
internal DRAM; esp-idf's spi_master then NULL-derefs on its own
failed-DMA-alloc path instead of erroring.

apply_tree_diff mirrors the splice: diff_tree_to_tree skips identical
subtree OIDs in memory, then writes/deletes exactly the changed paths
(unlink+tmp+rename — FAT rename won't overwrite). Belt kept O(changed):
any to-be-clobbered file whose content no longer hashes to the old blob
aborts the pull before the first write. Half-applied + unmoved ref
self-heals on the next :gl (idempotent re-apply).

Also raise the internal DMA reserve 32→64 KB so the SPI driver never
sees a failed alloc (its error path is the crash), and log the fetch
completion so the fetch/apply phases are separable in the serial log.
This commit is contained in:
Julien Calixte
2026-07-14 09:17:36 +02:00
parent 0734c1f67e
commit 329995ab58
2 changed files with 116 additions and 6 deletions

View File

@@ -785,6 +785,12 @@ fn pull_once() -> Result<PullOutcome> {
let t_fetch = Instant::now();
let theirs = fetch_origin(&repo, &branch)?;
let fetch_ms = t_fetch.elapsed().as_millis();
log::info!(
"pull: fetched origin @ {} in {fetch_ms}ms — free heap {} ({} internal)",
short(theirs),
free_heap(),
internal_free_heap()
);
let head = repo.head()?.peel_to_commit()?.id();
if theirs == head {
@@ -815,11 +821,7 @@ fn pull_once() -> Result<PullOutcome> {
}
let t_co = Instant::now();
let target = repo.find_object(theirs, None)?;
let mut co = git2::build::CheckoutBuilder::new();
co.safe();
repo.checkout_tree(&target, Some(&mut co))
.context("checkout of origin's tree")?;
let changed = apply_tree_diff(&repo, head, theirs)?;
repo.reference(
&format!("refs/heads/{branch}"),
theirs,
@@ -828,7 +830,7 @@ fn pull_once() -> Result<PullOutcome> {
)
.context("fast-forwarding the branch ref")?;
log::info!(
"pull: fast-forwarded {branch} {} -> {} — fetch {fetch_ms}ms, checkout {}ms, free heap {} ({} internal)",
"pull: fast-forwarded {branch} {} -> {} — fetch {fetch_ms}ms, apply {}ms ({changed} file(s)), free heap {} ({} internal)",
short(head),
short(theirs),
t_co.elapsed().as_millis(),
@@ -838,6 +840,106 @@ fn pull_once() -> Result<PullOutcome> {
Ok(PullOutcome::Pulled(short(theirs)))
}
/// Bring the working copy from `head`'s tree to `theirs`' tree by applying the
/// tree-to-tree diff directly: write each added/modified blob, unlink each
/// deleted path, and touch nothing else. Returns the number of files changed.
///
/// This is `checkout_tree`'s job, done the splice way. libgit2's SAFE checkout
/// iterates the whole **working directory** (readdir over SPI on every one of
/// ~1100 files) to decide what's dirty — the same O(tree) wall the splice
/// commit exists to avoid, and what actually killed the first on-device ff
/// attempt (2026-07-14): the walk ran with fetch memory still resident,
/// internal DRAM hit zero, and esp-idf's spi_master null-derefs on its own
/// failed-DMA-alloc path. The tree-to-tree diff never touches the workdir:
/// identical subtree OIDs are skipped wholesale, so both the diff and the
/// apply are O(changed).
///
/// Safety belt (what SAFE's rehash gave us, kept O(changed)): before touching
/// anything, every to-be-overwritten/deleted file whose disk content no longer
/// hashes to the OLD tree's blob aborts the pull — those are edits made behind
/// git's back (e.g. desktop edits directly on the card), and clobbering them
/// silently is worse than refusing. The check reads only the files the pull
/// wants to change; the UI's empty-dirty-journal gate covers device-side
/// saves.
///
/// Writes are unlink + tmp + rename (FAT f_rename won't overwrite), so a
/// power-pull mid-apply leaves at worst a `.gltmp` orphan and a half-applied
/// working copy with the ref NOT yet moved — the next `:gl` re-applies
/// idempotently on the same diff.
fn apply_tree_diff(repo: &Repository, head: Oid, theirs: Oid) -> Result<usize> {
use git2::Delta;
let head_tree = repo.find_commit(head)?.tree().context("HEAD tree")?;
let their_tree = repo.find_commit(theirs)?.tree().context("target tree")?;
let diff = repo
.diff_tree_to_tree(Some(&head_tree), Some(&their_tree), None)
.context("diffing HEAD..origin trees")?;
// Pass 1 — verify: refuse before the first write if any file we are about
// to replace or remove was hand-edited (content no longer matches the old
// blob). A missing file is fine (nothing to clobber).
for d in diff.deltas() {
let (old, path) = match d.status() {
Delta::Modified | Delta::Typechange => {
(d.old_file().id(), d.old_file().path())
}
Delta::Deleted => (d.old_file().id(), d.old_file().path()),
_ => continue, // Added: nothing on disk to protect
};
let Some(rel) = path.and_then(|p| p.to_str()) else {
continue;
};
let abs = format!("{REPO_DIR}/{rel}");
match fs::read(&abs) {
Ok(bytes) => {
let disk = Oid::hash_object(ObjectType::Blob, &bytes)
.with_context(|| format!("hashing {rel}"))?;
if disk != old {
bail!("local change in {rel} — pull refused (edit made behind git; resolve on a computer)");
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {rel}")),
}
}
// Pass 2 — apply.
let mut changed = 0usize;
for d in diff.deltas() {
match d.status() {
Delta::Added | Delta::Modified | Delta::Typechange => {
let Some(rel) = d.new_file().path().and_then(|p| p.to_str()) else {
continue;
};
let abs = format!("{REPO_DIR}/{rel}");
if let Some(dir) = std::path::Path::new(&abs).parent() {
fs::create_dir_all(dir).with_context(|| format!("mkdir for {rel}"))?;
}
let blob = repo
.find_blob(d.new_file().id())
.with_context(|| format!("reading blob for {rel}"))?;
let tmp = format!("{abs}.gltmp");
fs::write(&tmp, blob.content()).with_context(|| format!("writing {rel}"))?;
let _ = fs::remove_file(&abs); // FAT rename won't overwrite
fs::rename(&tmp, &abs).with_context(|| format!("landing {rel}"))?;
changed += 1;
}
Delta::Deleted => {
let Some(rel) = d.old_file().path().and_then(|p| p.to_str()) else {
continue;
};
match fs::remove_file(format!("{REPO_DIR}/{rel}")) {
Ok(()) => changed += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("deleting {rel}")),
}
}
_ => {}
}
}
Ok(changed)
}
/// Auth + cert callbacks shared by fetch and push. Captures only the baked
/// consts, so a fresh set can be built per operation. The PAT is handed to
/// libgit2 here and never logged.