diff --git a/docs/v0.7-search-and-git.md b/docs/v0.7-search-and-git.md index 30d0076..3046000 100644 --- a/docs/v0.7-search-and-git.md +++ b/docs/v0.7-search-and-git.md @@ -4,9 +4,9 @@ > [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md). **Status:** **`/` search + `n N` done in core** (2026-07-13, host-tested — 207 -editor tests). The **`:gl` pull command landed in the editor** (2026-07-11, -host-tested) — `Effect::Pull` + a firmware stub; the on-device fetch + -fast-forward is still to build. +editor tests). **`:gl` pull BUILT end-to-end 2026-07-14** (firmware 0.7.0, +compiles clean; editor refresh hooks host-tested, 209 tests) — **on-device +verification pending** (needs Wi-Fi + a remote that moved). - [x] `/` forward search, `n N` — done in core 2026-07-13 (host-tested). Literal, case-sensitive substring (no regex on-device, no smartcase @@ -20,8 +20,29 @@ fast-forward is still to build. over them); deliberately **not** operator targets (`dn` out of scope). - [~] `:gl` — pull: fetch + **fast-forward only**, refuse on divergence and surface it (renamed from the planned `:Gpull`). Editor command + - `Effect::Pull` done 2026-07-11 (host-tested); the git-thread - fetch/fast-forward in `git_sync` remains (only push is wired today). + `Effect::Pull` done 2026-07-11; **the git-thread fetch/fast-forward is + BUILT 2026-07-14** — on-device verification pending. How it works: + - The git thread takes a `GitRequest::Pull` on the same channel as publish + (shared `ensure_online` Wi-Fi/clock/TLS preamble). `pull_once` fetches + origin (the fetch also refreshes the remote-tracking ref, keeping + publish's radio-free up-to-date check honest), then maps the four shapes: + up to date / **LocalAhead** (stranded commit — `:sync`'s job) / clean + fast-forward / **Diverged** (refused, no merge on the device). + - The fast-forward is checkout-then-ref-move with a **SAFE** checkout: it + refuses to overwrite a file whose content differs from HEAD (belt; the UI + gate is the braces). FAT caveat: the splice never updates the index, so + SAFE re-hashes each file the pull changes — O(changed), never O(tree). + - UI gate: `:gl` is refused while the dirty journal is non-empty + ("unsynced changes - :sync first") — an unpublished save would fight the + checkout, and sync-then-pull is the single-writer appliance's natural + order. A RAM-dirty (never-saved) buffer does *not* gate: its edits simply + win over the pulled state (last-writer-wins, like the reconcile). + - After a `Pulled` outcome the UI drops clean parked buffers (next switch + re-reads the disk), re-reads the clean active buffer in place + (`Editor::refresh_active` — boot posture, undo cleared), keeps a + RAM-dirty active buffer untouched, and re-walks the palette file list. + Snackbar: `pulled ` / `up to date` / `ahead - :sync to publish` / + `diverged - resolve on a computer` / `pull: `. Sync performance — inherited from the [real-repo-sync kaizen](kaizen/real-repo-sync.md) (closed 2026-07-13 at diff --git a/editor/src/lib.rs b/editor/src/lib.rs index bb70545..ec3a587 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -960,6 +960,26 @@ impl Editor { self.set_active(path, scope, contents); } + /// Replace the active buffer's contents after the file changed on disk + /// underneath us — a `:gl` pull fast-forwarded the working copy. Same boot + /// posture as a fresh load (Normal, caret on the last char, clean, no undo + /// history — the old snapshots reference the replaced text). The host only + /// calls this when the buffer is clean; a dirty buffer's RAM edits win + /// (last-writer-wins, like the reconcile path). + pub fn refresh_active(&mut self, contents: String) { + let (path, scope) = (self.path.clone(), self.scope); + self.set_active(path, scope, contents); + } + + /// Drop every *clean* parked buffer, so the next switch to one re-reads the + /// disk ([`Effect::Load`]) instead of resurrecting a stale resident copy — + /// a `:gl` pull may have rewritten any tracked file. Dirty parked buffers + /// are kept: their unsaved edits win over the pulled state, exactly like + /// the active buffer's. + pub fn drop_clean_parked(&mut self) { + self.parked.retain(|b| b.dirty); + } + pub fn scroll_top(&self) -> usize { self.scroll_top } @@ -6427,4 +6447,38 @@ mod tests { e.handle(Key::Char('a')); let _ = e.draw(true); } + + // --- `:gl` pull support (v0.7) ------------------------------------------ + + #[test] + fn refresh_active_replaces_text_and_resets_state() { + let mut e = over("old text"); + e.handle(Key::Char('v')); // some transient state to reset + e.refresh_active("pulled text".into()); + assert_eq!(e.text, "pulled text"); + assert_eq!(e.mode(), Mode::Normal); + assert!(!e.dirty()); + assert!(e.undo.is_empty()); // old snapshots reference the old text + assert_eq!(e.path(), "/sd/repo/notes.md"); // same file, new contents + assert_eq!(e.caret, 10); // boot posture: caret on the last char + } + + #[test] + fn drop_clean_parked_keeps_only_dirty_buffers() { + let mut e = over("one"); // active: notes.md, clean + e.handle(Key::Char(':')); + for c in "enew /sd/repo/b.md".chars() { + e.handle(Key::Char(c)); + } + e.handle(Key::Enter); // notes.md parked (clean); b.md active (dirty by design) + e.handle(Key::Char(':')); + for c in "enew /sd/repo/c.md".chars() { + e.handle(Key::Char(c)); + } + e.handle(Key::Enter); // b.md parked (dirty); c.md active + assert_eq!(e.parked.len(), 2); + e.drop_clean_parked(); + let kept: Vec<&str> = e.parked.iter().map(|b| b.path.as_str()).collect(); + assert_eq!(kept, ["/sd/repo/b.md"]); // clean notes.md dropped, dirty b.md kept + } } diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 2240628..acd811f 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "firmware" -version = "0.6.0" +version = "0.7.0" authors = ["Julien Calixte "] edition = "2024" resolver = "2" diff --git a/firmware/src/git_sync.rs b/firmware/src/git_sync.rs index a8976d9..2161430 100644 --- a/firmware/src/git_sync.rs +++ b/firmware/src/git_sync.rs @@ -90,6 +90,16 @@ pub const GIT_STACK: usize = 96 * 1024; /// over ours — near-identical trees), so the second walk stays off the SD card. const ODB_CACHE_MAX_BYTES: isize = 1024 * 1024; +/// What the UI task asks the git thread to do. +pub enum GitRequest { + /// `:sync` — commit the dirty paths and push (the upload half). + Publish(PublishRequest), + /// `:gl` — fetch + fast-forward only (the download half). The UI only + /// sends this when the dirty journal is empty, so the checkout can't + /// fight an unpublished save. + Pull, +} + /// A request to publish. The UI task has already saved every dirty buffer to /// the card before sending this; `paths` is `Storage::take_dirty`'s snapshot — /// the repo-relative paths saved or `:delete`d since the last confirmed @@ -100,6 +110,13 @@ pub struct PublishRequest { pub paths: BTreeSet, } +/// What the git thread reports back, tagged by the request kind so the UI can +/// settle the dirty snapshot for a publish and refresh buffers for a pull. +pub enum GitOutcome { + Publish(PublishOutcome), + Pull(PullOutcome), +} + /// Result of a publish attempt, sent back to the UI task for the snackbar. The /// detailed error always goes to the serial log; the panel gets a short line. pub enum PublishOutcome { @@ -112,6 +129,25 @@ pub enum PublishOutcome { Failed(String), } +/// Result of a `:gl` pull attempt. Fast-forward only, by design: this device +/// never merges — a divergence is surfaced and left for a machine with a real +/// git to resolve. +pub enum PullOutcome { + /// Fast-forwarded onto origin's tip. Carries the short commit id; the UI + /// must treat every tracked file as possibly rewritten (reload buffers, + /// re-walk the palette list). + Pulled(String), + /// Origin's tip is our HEAD — nothing to pull. + UpToDate, + /// We are strictly ahead of origin (e.g. a stranded commit whose push + /// failed) — nothing to pull; the next `:sync` publishes it. + LocalAhead, + /// Local and remote histories diverged. Refused — no merge on the device. + Diverged, + /// Something failed; short reason for the panel (full error is logged). + Failed(String), +} + /// The git service loop, run on the dedicated git thread. Owns the Wi-Fi stack, /// bringing it up lazily on the first request and keeping it up afterwards. /// Blocks on `rx`; for each request it ensures connectivity + clock + trust @@ -122,8 +158,8 @@ pub fn run_git_service( modem: Modem<'static>, sys_loop: EspSystemEventLoop, nvs: EspDefaultNvsPartition, - rx: Receiver, - tx: Sender, + rx: Receiver, + tx: Sender, ) { // Process-global libgit2 tuning, once, before any repo work. The 32-bit // defaults (32 MB window / 256 MB mapped budget, mwindow.c) would @@ -165,21 +201,40 @@ pub fn run_git_service( let mut tls_ready = false; while let Ok(req) = rx.recv() { - let outcome = publish_cycle( - &sys_loop, - &mut wifi, - &mut modem, - &mut nvs, - &mut clock_synced, - &mut tls_ready, - &req.paths, - ); - let msg = match outcome { - Ok(o) => o, - Err(e) => { - log::error!("❌ :sync failed: {e:?}"); - PublishOutcome::Failed(short_reason(&e)) - } + let msg = match req { + GitRequest::Publish(req) => GitOutcome::Publish( + match publish_cycle( + &sys_loop, + &mut wifi, + &mut modem, + &mut nvs, + &mut clock_synced, + &mut tls_ready, + &req.paths, + ) { + Ok(o) => o, + Err(e) => { + log::error!("❌ :sync failed: {e:?}"); + PublishOutcome::Failed(short_reason("sync", &e)) + } + }, + ), + GitRequest::Pull => GitOutcome::Pull( + match pull_cycle( + &sys_loop, + &mut wifi, + &mut modem, + &mut nvs, + &mut clock_synced, + &mut tls_ready, + ) { + Ok(o) => o, + Err(e) => { + log::error!("❌ :gl failed: {e:?}"); + PullOutcome::Failed(short_reason("pull", &e)) + } + }, + ), }; // If the UI task has gone away there's nothing to report to; exit. if tx.send(msg).is_err() { @@ -218,12 +273,62 @@ fn publish_cycle( // and TLS run only on the first sync of a session; a warm sync skips them, so // they read 0 ms and the total collapses to just publish(fetch+commit+push). let t_total = Instant::now(); + ensure_online(sys_loop, wifi, modem, nvs, clock_synced, tls_ready)?; - // Bring Wi-Fi up once (on-demand: the radio stays off until the first :sync). + let t_publish = Instant::now(); + let outcome = publish_once(paths)?; + log::info!( + ":sync timing — publish(commit+push) {}ms, total {}ms", + t_publish.elapsed().as_millis(), + t_total.elapsed().as_millis(), + ); + Ok(outcome) +} + +/// One full pull (`:gl`): ensure connectivity, then fetch + fast-forward only. +/// Always needs the network — there is no radio-free shortcut like publish's +/// up-to-date check, because the whole point is asking origin what's new. +fn pull_cycle( + sys_loop: &EspSystemEventLoop, + wifi: &mut Option>>, + modem: &mut Option>, + nvs: &mut Option, + clock_synced: &mut bool, + tls_ready: &mut bool, +) -> Result { + if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() || WIFI_SSID.is_empty() { + bail!("git config missing — set TW_WIFI_SSID / TW_REMOTE_URL / TW_GH_USER / TW_PAT in firmware/.env and rebuild"); + } + let t_total = Instant::now(); + ensure_online(sys_loop, wifi, modem, nvs, clock_synced, tls_ready)?; + + let t_pull = Instant::now(); + let outcome = pull_once()?; + log::info!( + ":gl timing — fetch+ff {}ms, total {}ms", + t_pull.elapsed().as_millis(), + t_total.elapsed().as_millis(), + ); + Ok(outcome) +} + +/// Bring Wi-Fi + wall clock + TLS trust store up, each once per session; a +/// warm call is a no-op. Shared by publish and pull, on the git thread. Logs +/// one timing line whenever any step actually ran (the session's first +/// operation pays them all; every later one skips straight to git). +fn ensure_online( + sys_loop: &EspSystemEventLoop, + wifi: &mut Option>>, + modem: &mut Option>, + nvs: &mut Option, + clock_synced: &mut bool, + tls_ready: &mut bool, +) -> Result<()> { + // Bring Wi-Fi up once (on-demand: the radio stays off until the first use). let mut wifi_ms = 0u128; if wifi.is_none() { let t = Instant::now(); - log::info!("first :sync — bringing Wi-Fi up; free heap {}", free_heap()); + log::info!("first git op — bringing Wi-Fi up; free heap {}", free_heap()); let m = modem.take().expect("modem taken once"); let n = nvs.take().expect("nvs taken once"); let mut w = BlockingWifi::wrap( @@ -250,15 +355,10 @@ fn publish_cycle( *tls_ready = true; tls_ms = t.elapsed().as_millis(); } - - let t_publish = Instant::now(); - let outcome = publish_once(paths)?; - log::info!( - ":sync timing — wifi {wifi_ms}ms, clock {clock_ms}ms, tls {tls_ms}ms, publish(commit+push) {}ms, total {}ms", - t_publish.elapsed().as_millis(), - t_total.elapsed().as_millis(), - ); - Ok(outcome) + if wifi_ms + clock_ms + tls_ms > 0 { + log::info!("online — wifi {wifi_ms}ms, clock {clock_ms}ms, tls {tls_ms}ms"); + } + Ok(()) } /// Open `/sd/repo`, commit the working tree on the current branch, and push. @@ -605,25 +705,127 @@ fn try_push(repo: &Repository, refspec: &str) -> Result<(), PushFailure> { /// keeps it (an improvement over the old `add --all` replay, which dropped /// files the card didn't have). A real merge stays increment-B work. fn reconcile_onto_origin(repo: &Repository, branch: &str) -> Result<()> { + let theirs = fetch_origin(repo, branch)?; + log::info!( + "reconcile: resetting local {branch} onto origin @ {} (soft — ref move only, notes stay on the card)", + short(theirs) + ); + let their_obj = repo.find_object(theirs, None)?; + repo.reset(&their_obj, git2::ResetType::Soft, None) + .context("soft reset onto origin")?; + Ok(()) +} + +/// Fetch `branch` from origin and return the fetched tip's commit id. Shared +/// by the pull and the post-rejection reconcile. Also refreshes the +/// remote-tracking ref, keeping [`tracking_tip`] (and with it publish's +/// radio-free up-to-date check) honest about what origin has. +fn fetch_origin(repo: &Repository, branch: &str) -> Result { let mut remote = repo.find_remote("origin")?; let mut fo = FetchOptions::new(); fo.remote_callbacks(auth_callbacks()); remote .fetch(&[branch], Some(&mut fo), None) .context("fetch origin")?; - - let fetch_head = repo + let theirs = repo .find_reference("FETCH_HEAD") - .context("no FETCH_HEAD after fetch")?; - let theirs = repo.reference_to_annotated_commit(&fetch_head)?; + .context("no FETCH_HEAD after fetch")? + .peel_to_commit() + .context("FETCH_HEAD is not a commit")? + .id(); + repo.reference( + &format!("refs/remotes/origin/{branch}"), + theirs, + true, + "typoena fetch", + ) + .context("updating remote-tracking ref")?; + Ok(theirs) +} + +/// Open `/sd/repo`, fetch origin, and **fast-forward only** — never a merge. +/// The four non-failure shapes map to [`PullOutcome`]: already current, we're +/// strictly ahead (a stranded commit — `:sync`'s job), a clean fast-forward, +/// or a divergence (refused; a machine with a real git resolves it). +/// +/// The fast-forward is checkout-then-ref-move, with a **SAFE** checkout: it +/// refuses to overwrite a working-copy file whose content differs from HEAD's. +/// The UI already gates `:gl` on an empty dirty journal, so in normal use +/// nothing conflicts; the belt catches files edited behind git's back (e.g. +/// desktop edits made directly on the card — deliberately never committed by +/// the device since the splice landed). One FAT caveat, matching publish's +/// index-avoidance: the splice never updates the index, so its stat cache is +/// stale and SAFE re-hashes each file the pull wants to change — fine for a +/// few notes, and still O(changed), never O(tree). +fn pull_once() -> Result { log::info!( - "reconcile: resetting local {branch} onto origin @ {} (soft — ref move only, notes stay on the card)", - short(theirs.id()) + "pull started — free heap {} ({} internal)", + free_heap(), + internal_free_heap() ); - let their_obj = repo.find_object(theirs.id(), None)?; - repo.reset(&their_obj, git2::ResetType::Soft, None) - .context("soft reset onto origin")?; - Ok(()) + let repo = Repository::open(REPO_DIR).with_context(|| { + format!("opening git repo at {REPO_DIR} — provision the card with a clone (just init) whose origin is your remote") + })?; + let branch = repo + .head()? + .shorthand() + .context("HEAD has no branch shorthand")? + .to_string(); + + let t_fetch = Instant::now(); + let theirs = fetch_origin(&repo, &branch)?; + let fetch_ms = t_fetch.elapsed().as_millis(); + + let head = repo.head()?.peel_to_commit()?.id(); + if theirs == head { + log::info!("pull: origin @ {} == HEAD — up to date (fetch {fetch_ms}ms)", short(head)); + return Ok(PullOutcome::UpToDate); + } + if repo + .graph_descendant_of(head, theirs) + .context("descendant check (local ahead)")? + { + log::info!( + "pull: HEAD {} is ahead of origin {} — nothing to pull, :sync publishes it", + short(head), + short(theirs) + ); + return Ok(PullOutcome::LocalAhead); + } + if !repo + .graph_descendant_of(theirs, head) + .context("descendant check (fast-forward)")? + { + log::warn!( + "pull: origin {} and HEAD {} diverged — refusing (ff-only, no merge on the device)", + short(theirs), + short(head) + ); + return Ok(PullOutcome::Diverged); + } + + 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")?; + repo.reference( + &format!("refs/heads/{branch}"), + theirs, + true, + "typoena pull: fast-forward", + ) + .context("fast-forwarding the branch ref")?; + log::info!( + "pull: fast-forwarded {branch} {} -> {} — fetch {fetch_ms}ms, checkout {}ms, free heap {} ({} internal)", + short(head), + short(theirs), + t_co.elapsed().as_millis(), + free_heap(), + internal_free_heap() + ); + Ok(PullOutcome::Pulled(short(theirs))) } /// Auth + cert callbacks shared by fetch and push. Captures only the baked @@ -682,12 +884,13 @@ fn install_tls_trust_store() -> Result<()> { Ok(()) } -/// A short, panel-friendly reason from an error chain (first line, clamped). The -/// full chain is logged separately; the editor clamps this to the panel width. -fn short_reason(e: &anyhow::Error) -> String { +/// A short, panel-friendly reason from an error chain (first line, clamped), +/// prefixed with the operation ("sync" / "pull"). The full chain is logged +/// separately; the editor clamps this to the panel width. +fn short_reason(op: &str, e: &anyhow::Error) -> String { let full = format!("{e}"); - let first = full.lines().next().unwrap_or("sync failed"); - format!("sync: {}", first.chars().take(24).collect::()) + let first = full.lines().next().unwrap_or("failed"); + format!("{op}: {}", first.chars().take(24).collect::()) } /// First 8 hex chars of an OID, for readable logs and the panel. diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 79d46cc..24fb377 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -90,13 +90,13 @@ fn main() -> anyhow::Result<()> { let (git_tx, git_rx) = { use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::nvs::EspDefaultNvsPartition; - use firmware::git_sync::{run_git_service, PublishOutcome, PublishRequest, GIT_STACK}; + use firmware::git_sync::{run_git_service, GitOutcome, GitRequest, GIT_STACK}; let sys_loop = EspSystemEventLoop::take()?; let nvs = EspDefaultNvsPartition::take()?; let modem = peripherals.modem; - let (req_tx, req_rx) = std::sync::mpsc::channel::(); - let (res_tx, res_rx) = std::sync::mpsc::channel::(); + let (req_tx, req_rx) = std::sync::mpsc::channel::(); + let (res_tx, res_rx) = std::sync::mpsc::channel::(); std::thread::Builder::new() .name("git".into()) .stack_size(GIT_STACK) @@ -247,8 +247,9 @@ fn main() -> anyhow::Result<()> { // (publish_succeeded) or retried (publish_failed). #[cfg(feature = "git")] { + use firmware::git_sync::{GitRequest, PublishRequest}; let paths = storage.take_dirty(); - match git_tx.send(firmware::git_sync::PublishRequest { paths }) { + match git_tx.send(GitRequest::Publish(PublishRequest { paths })) { Ok(()) => ed.set_notice("syncing..."), Err(_) => { // Thread gone — nothing will report back, so @@ -262,11 +263,27 @@ fn main() -> anyhow::Result<()> { log::info!(":sync — saved; light build (no `git` feature) — push skipped"); } Effect::Pull => { - // `:gl` — fetch + fast-forward from the remote. The - // on-device fetch/fast-forward on the git thread is v0.7 - // work (git_sync only exposes push today), so acknowledge - // and no-op for now. - ed.set_notice("pull: not wired yet (v0.7)"); + // `:gl` — fetch + fast-forward, on the git thread like a + // publish. Gated on an empty dirty journal: unpublished + // saves would fight the checkout, and `:sync` first is + // the appliance's natural order anyway. (A RAM-dirty + // buffer that was never saved doesn't gate — its edits + // simply win over the pulled state, see the outcome + // handler below.) + #[cfg(feature = "git")] + { + use firmware::git_sync::GitRequest; + if storage.has_dirty() { + ed.set_notice("pull: unsynced changes - :sync first"); + } else { + match git_tx.send(GitRequest::Pull) { + Ok(()) => ed.set_notice("pulling..."), + Err(_) => ed.set_notice("pull: git thread down"), + } + } + } + #[cfg(not(feature = "git"))] + log::info!(":gl — light build (no `git` feature) — pull skipped"); } Effect::Delete { path, scope } => delete_buffer(&storage, &mut ed, path, scope), Effect::SavePrefs { contents } => save_prefs(&storage, &mut ed, &contents), @@ -281,24 +298,63 @@ fn main() -> anyhow::Result<()> { last_kbd = kbd; if keys == 0 { - // A finished publish reports its outcome here (the push ran on the + // A finished git operation reports its outcome here (it ran on the // git thread while we idled). Show it in the snackbar with a silent // full-area partial — no keystroke will arrive to trigger a repaint. #[cfg(feature = "git")] if let Ok(outcome) = git_rx.try_recv() { - use firmware::git_sync::PublishOutcome::*; - // Settle the dirty snapshot this publish took: confirmed - // published (or up to date) → forget it; failed → back to - // pending so the next :sync retries the same paths. - match &outcome { - Pushed(_) | UpToDate => storage.publish_succeeded(), - Failed(_) => storage.publish_failed(), - } - ed.set_notice(match outcome { - Pushed(oid) => format!("synced {oid}"), - UpToDate => "up to date".to_string(), - Failed(reason) => reason, - }); + use firmware::git_sync::{GitOutcome, PublishOutcome, PullOutcome}; + let notice = match outcome { + GitOutcome::Publish(outcome) => { + // Settle the dirty snapshot this publish took: confirmed + // published (or up to date) → forget it; failed → back to + // pending so the next :sync retries the same paths. + match &outcome { + PublishOutcome::Pushed(_) | PublishOutcome::UpToDate => { + storage.publish_succeeded() + } + PublishOutcome::Failed(_) => storage.publish_failed(), + } + match outcome { + PublishOutcome::Pushed(oid) => format!("synced {oid}"), + PublishOutcome::UpToDate => "up to date".to_string(), + PublishOutcome::Failed(reason) => reason, + } + } + GitOutcome::Pull(outcome) => match outcome { + // The working copy moved under us: stale resident + // buffers must re-read the disk. Clean parked buffers + // are dropped (they reload on the next switch), the + // clean active buffer is re-read now, and a RAM-dirty + // buffer is left alone — its edits win, last-writer- + // wins like the publish reconcile. The palette list is + // re-walked for files the pull added or removed. + PullOutcome::Pulled(oid) => { + ed.drop_clean_parked(); + if ed.dirty() { + log::info!( + "post-pull: {} is RAM-dirty — kept (its edits win)", + ed.path() + ); + } else if !ed.path().is_empty() { + match storage.load_path(ed.path()) { + Ok(text) => ed.refresh_active(text), + Err(e) => log::warn!( + "post-pull reload of {} FAILED ({e:#}); buffer kept", + ed.path() + ), + } + } + ed.set_file_list(enumerate_files()); + format!("pulled {oid}") + } + PullOutcome::UpToDate => "up to date".to_string(), + PullOutcome::LocalAhead => "ahead - :sync to publish".to_string(), + PullOutcome::Diverged => "diverged - resolve on a computer".to_string(), + PullOutcome::Failed(reason) => reason, + }, + }; + ed.set_notice(notice); ed.draw_into(&mut back, true); if let Err(e) = epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT) { log::warn!("sync-notice repaint FAILED ({e}); full refresh next"); diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs index fcc26a5..a01661a 100644 --- a/firmware/src/persistence.rs +++ b/firmware/src/persistence.rs @@ -429,6 +429,15 @@ impl Storage { } } + /// Whether any saved-but-unpublished paths are recorded (pending or riding + /// an in-flight publish). `:gl` refuses to pull while this is true: a + /// fast-forward checkout would fight those files, and `:sync` first is the + /// single-writer appliance's natural order anyway. + pub fn has_dirty(&self) -> bool { + let d = self.dirty.borrow(); + !d.pending.is_empty() || !d.in_flight.is_empty() + } + /// Snapshot the dirty paths for a publish (repo-relative). The snapshot /// moves to `in_flight` — the journal keeps carrying it — until the UI /// task reports the outcome: [`Storage::publish_succeeded`] forgets it,