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

@@ -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 <oid>` / `up to date` / `ahead - :sync to publish` /
`diverged - resolve on a computer` / `pull: <reason>`.
Sync performance — inherited from the
[real-repo-sync kaizen](kaizen/real-repo-sync.md) (closed 2026-07-13 at

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

View File

@@ -1,6 +1,6 @@
[package]
name = "firmware"
version = "0.6.0"
version = "0.7.0"
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"

View File

@@ -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<String>,
}
/// 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<PublishRequest>,
tx: Sender<PublishOutcome>,
rx: Receiver<GitRequest>,
tx: Sender<GitOutcome>,
) {
// 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<BlockingWifi<EspWifi<'static>>>,
modem: &mut Option<Modem<'static>>,
nvs: &mut Option<EspDefaultNvsPartition>,
clock_synced: &mut bool,
tls_ready: &mut bool,
) -> Result<PullOutcome> {
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<BlockingWifi<EspWifi<'static>>>,
modem: &mut Option<Modem<'static>>,
nvs: &mut Option<EspDefaultNvsPartition>,
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<Oid> {
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<PullOutcome> {
log::info!(
"reconcile: resetting local {branch} onto origin @ {} (soft — ref move only, notes stay on the card)",
short(theirs.id())
"pull startedfree 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::<String>())
let first = full.lines().next().unwrap_or("failed");
format!("{op}: {}", first.chars().take(24).collect::<String>())
}
/// First 8 hex chars of an OID, for readable logs and the panel.

View File

@@ -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::<PublishRequest>();
let (res_tx, res_rx) = std::sync::mpsc::channel::<PublishOutcome>();
let (req_tx, req_rx) = std::sync::mpsc::channel::<GitRequest>();
let (res_tx, res_rx) = std::sync::mpsc::channel::<GitOutcome>();
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");

View File

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