fix(firmware): harden :sync publish — skip macOS sidecars, fast-forward first

Two robustness fixes surfaced by commit 07d87772 (the first editor-driven
:sync), which shipped three macOS AppleDouble files to typoena-test:

- Staging filter: add_all now runs a per-path callback that skips ._* and
  .DS_Store, so Finder/Spotlight cruft on the FAT card never lands in a
  commit. Device-level, so it protects every repo without a per-repo
  .gitignore.

- Pre-commit fast-forward: publish_once fetches origin and, if the local
  branch is behind, fast-forwards to it via a MIXED reset before committing.
  This lets the device absorb a foreign push (e.g. the sidecar cleanup) and
  fast-forward cleanly instead of stacking a commit on a stale base and
  diverging at push time. The mixed reset moves the ref+index but leaves the
  working tree, so the just-saved unsynced note isn't lost.
This commit is contained in:
Julien Calixte
2026-07-11 16:09:41 +02:00
parent 8dc6ee362f
commit e57709a9ee

View File

@@ -25,6 +25,7 @@
//! baked at build time (`TW_*`, ADR-007: v0.1 device config is compiled in).
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -182,11 +183,27 @@ fn publish_once() -> Result<PublishOutcome> {
format!("opening git repo at {REPO_DIR} — provision the card with a clone (just init) whose origin is your remote")
})?;
// Absorb any foreign push before committing, so a remote that has moved ahead
// (e.g. a maintenance commit) fast-forwards cleanly instead of diverging when
// we push. Committing first and reconciling later can't undo a divergence.
fast_forward_before_commit(&repo).context("pre-commit fast-forward")?;
// Stage everything (add --all also stages deletions, for a future note-delete)
// and build the tree from what the editor saved.
// and build the tree from what the editor saved. The per-path filter drops
// macOS AppleDouble sidecars (`._name`) and `.DS_Store` that Finder/Spotlight
// sprinkle onto the FAT card whenever it's mounted on a Mac — without it, a
// blind add --all sweeps them into the commit (it did once: 07d87772 shipped
// `._.git`, `._README.md`, `._notes.md`). Filtering here fixes it for *every*
// repo at the device level, so no per-repo `.gitignore` is needed.
let mut index = repo.index().context("opening index")?;
let mut skip_macos_cruft = |path: &Path, _matched: &[u8]| -> i32 {
match path.file_name().and_then(|n| n.to_str()) {
Some(name) if name.starts_with("._") || name == ".DS_Store" => 1, // skip
_ => 0, // add
}
};
index
.add_all(["*"], IndexAddOption::DEFAULT, None)
.add_all(["*"], IndexAddOption::DEFAULT, Some(&mut skip_macos_cruft))
.context("staging (add --all)")?;
index.write().context("writing index")?;
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
@@ -273,6 +290,56 @@ fn try_push(repo: &Repository, refspec: &str) -> Result<()> {
Ok(())
}
/// Before committing, fetch origin and fast-forward the local branch if it has
/// fallen behind — so the device self-heals from a *foreign* push (a maintenance
/// commit, another tool) instead of stacking a commit on a stale base and
/// diverging at push time (the `07d87772` cleanup was exactly such a push).
///
/// Uses a **MIXED** reset, not the force checkout `fetch_and_integrate` uses: the
/// note the editor just saved is in the working tree but not yet committed, so the
/// branch ref and index move to origin while the working tree is left untouched —
/// no un-synced writing is lost, and the next `add_all` re-stages it on top of the
/// updated tip. Best-effort: transient fetch failures fall through to the existing
/// optimistic commit → push → retry path; only a genuine divergence hard-stops.
fn fast_forward_before_commit(repo: &Repository) -> Result<()> {
// Unborn HEAD (first commit into an empty remote): nothing to reconcile.
let Ok(head) = repo.head() else {
return Ok(());
};
let Some(branch) = head.shorthand().map(str::to_string) else {
return Ok(()); // detached HEAD — not a case this appliance produces
};
let mut remote = repo.find_remote("origin")?;
let mut fo = FetchOptions::new();
fo.remote_callbacks(auth_callbacks());
if let Err(e) = remote.fetch(&[branch.as_str()], Some(&mut fo), None) {
log::warn!("pre-commit fetch skipped ({e}); committing optimistically");
return Ok(());
}
let Ok(fetch_head) = repo.find_reference("FETCH_HEAD") else {
return Ok(()); // remote has no such branch yet
};
let theirs = repo.reference_to_annotated_commit(&fetch_head)?;
let (analysis, _) = repo.merge_analysis(&[&theirs])?;
if analysis.is_up_to_date() {
return Ok(()); // local is at or ahead of origin — commit as-is
}
if analysis.is_fast_forward() {
log::info!(
"pre-commit: local {branch} is behind origin — fast-forwarding to {} (mixed, keeps the unsaved note)",
short(theirs.id())
);
let their_obj = repo.find_object(theirs.id(), None)?;
repo.reset(&their_obj, git2::ResetType::Mixed, None)
.context("mixed reset to origin during pre-commit fast-forward")?;
return Ok(());
}
bail!("origin/{branch} diverged from local before commit — needs a real merge (increment B, deferred)")
}
/// Fetch origin and integrate `branch` into the local branch. Handles up-to-date
/// and fast-forward (the common single-writer cases). A real divergence needs a
/// merge commit written to FATFS — deferred to increment B — so it bails.