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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user