feat(sync): :gl pull — fetch + fast-forward only on the git thread

The git channel now carries GitRequest::{Publish,Pull} with a shared
ensure_online preamble (Wi-Fi/clock/TLS once per session). pull_once
fetches origin — refreshing the tracking ref so the radio-free
up-to-date check stays honest — and maps 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 a SAFE
checkout then ref move: it refuses to overwrite content that differs
from HEAD, the belt under the UI gate that refuses :gl while the dirty
journal is non-empty. A RAM-dirty buffer doesn't gate — its edits win
(last-writer-wins, like the reconcile).

After a pull the UI drops clean parked buffers, re-reads the clean
active buffer in place (Editor::refresh_active), and re-walks the
palette list. Firmware 0.6.0 -> 0.7.0. On-device verification pending.
This commit is contained in:
Julien Calixte
2026-07-14 01:10:00 +02:00
parent 8d22f81804
commit a941ae39b3
6 changed files with 415 additions and 72 deletions

View File

@@ -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
}
}