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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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::<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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user