feat(editor): rename :sync to :gp
Pairs with :gl as the push half of the git pair. Same behavior (fmt → save → commit → push); notices, logs, and current READMEs follow, historical docs keep :sync as a record of their time.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//! On-device git publish — the transport behind the editor's `:sync`.
|
||||
//! On-device git publish — the transport behind the editor's `:gp`.
|
||||
//!
|
||||
//! Graduated from the `src/bin/git_sync.rs` spike (milestone #2A, hardware-
|
||||
//! verified 2026-07-07). The spike proved `open` + fast-forward `push` over
|
||||
@@ -17,7 +17,7 @@
|
||||
//! notes. A `/sd/repo` that isn't a valid repo is a provisioning error
|
||||
//! (`just init`), surfaced as such, not papered over.
|
||||
//! 3. **No synthetic content.** The spike appended a marker line; here the
|
||||
//! editor has already saved the user's buffers before `:sync` signals us,
|
||||
//! editor has already saved the user's buffers before `:gp` signals us,
|
||||
//! so we just commit + push what's on disk.
|
||||
//! 4. **The commit is an O(depth) TreeBuilder splice, not an index pass.**
|
||||
//! The request carries the repo-relative paths saved/deleted since the last
|
||||
@@ -92,7 +92,7 @@ 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).
|
||||
/// `:gp` — 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
|
||||
@@ -140,7 +140,7 @@ pub enum PullOutcome {
|
||||
/// 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.
|
||||
/// failed) — nothing to pull; the next `:gp` publishes it.
|
||||
LocalAhead,
|
||||
/// Local and remote histories diverged. Refused — no merge on the device.
|
||||
Diverged,
|
||||
@@ -214,7 +214,7 @@ pub fn run_git_service(
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::error!("❌ :sync failed: {e:?}");
|
||||
log::error!("❌ :gp failed: {e:?}");
|
||||
PublishOutcome::Failed(short_reason("sync", &e))
|
||||
}
|
||||
},
|
||||
@@ -260,16 +260,16 @@ fn publish_cycle(
|
||||
}
|
||||
|
||||
// Nothing recorded dirty and origin's tracking ref already has HEAD: this
|
||||
// `:sync` has nothing to do — say so without touching the radio (~150 ms
|
||||
// `:gp` has nothing to do — say so without touching the radio (~150 ms
|
||||
// instead of a Wi-Fi + TLS round). A stranded local commit (committed but
|
||||
// never pushed, e.g. a push that failed mid-air) makes the check false and
|
||||
// takes the full path below, where publish_once pushes it.
|
||||
if paths.is_empty() && remote_current().unwrap_or(false) {
|
||||
log::info!(":sync — no dirty paths and origin has HEAD; up to date, radio untouched");
|
||||
log::info!(":gp — no dirty paths and origin has HEAD; up to date, radio untouched");
|
||||
return Ok(PublishOutcome::UpToDate);
|
||||
}
|
||||
|
||||
// Phases are timed so a cold :sync reports where the seconds go. Wi-Fi, clock
|
||||
// Phases are timed so a cold :gp reports where the seconds go. Wi-Fi, clock
|
||||
// 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();
|
||||
@@ -278,7 +278,7 @@ fn publish_cycle(
|
||||
let t_publish = Instant::now();
|
||||
let outcome = publish_once(paths)?;
|
||||
log::info!(
|
||||
":sync timing — publish(commit+push) {}ms, total {}ms",
|
||||
":gp timing — publish(commit+push) {}ms, total {}ms",
|
||||
t_publish.elapsed().as_millis(),
|
||||
t_total.elapsed().as_millis(),
|
||||
);
|
||||
@@ -763,7 +763,7 @@ fn update_tracking(repo: &Repository, branch: &str, tip: Oid) -> Result<()> {
|
||||
|
||||
/// 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,
|
||||
/// strictly ahead (a stranded commit — `:gp`'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
|
||||
@@ -827,7 +827,7 @@ fn pull_once() -> Result<PullOutcome> {
|
||||
let _ = remote.disconnect();
|
||||
update_tracking(&repo, &branch, theirs)?;
|
||||
log::info!(
|
||||
"pull: HEAD {} is ahead of origin {} — nothing to pull, :sync publishes it (ls-refs {ls_ms}ms, no fetch)",
|
||||
"pull: HEAD {} is ahead of origin {} — nothing to pull, :gp publishes it (ls-refs {ls_ms}ms, no fetch)",
|
||||
short(head),
|
||||
short(theirs)
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ pub mod epd;
|
||||
pub mod net;
|
||||
pub mod persistence;
|
||||
|
||||
// On-device git publish (the editor's `:sync` transport). Behind the `git`
|
||||
// On-device git publish (the editor's `:gp` transport). Behind the `git`
|
||||
// feature so a light build never pulls libgit2/git2 — see main.rs `publish` and
|
||||
// the feature note in Cargo.toml.
|
||||
#[cfg(feature = "git")]
|
||||
|
||||
@@ -93,8 +93,8 @@ fn main() -> anyhow::Result<()> {
|
||||
// Bring up the USB keyboard in the background; keys arrive via next_key().
|
||||
usb_kbd::start()?;
|
||||
|
||||
// Spawn the dedicated git thread — the `:sync` publish transport. It owns
|
||||
// the Wi-Fi stack (brought up lazily on the first `:sync`, so the radio
|
||||
// Spawn the dedicated git thread — the `:gp` publish transport. It owns
|
||||
// the Wi-Fi stack (brought up lazily on the first `:gp`, so the radio
|
||||
// stays off until you publish) and parks on `git_tx` until signalled; the
|
||||
// push runs off the UI loop, and its outcome returns on `git_rx` for the
|
||||
// snackbar. Behind the `git` feature so a light build carries no libgit2.
|
||||
@@ -114,7 +114,7 @@ fn main() -> anyhow::Result<()> {
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(move || run_git_service(modem, sys_loop, nvs, req_rx, res_tx))?;
|
||||
log::info!(
|
||||
"git thread up ({} KB stack); Wi-Fi comes up on the first :sync",
|
||||
"git thread up ({} KB stack); Wi-Fi comes up on the first :gp",
|
||||
GIT_STACK / 1024
|
||||
);
|
||||
(req_tx, res_rx)
|
||||
@@ -191,7 +191,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// The only two framebuffers the loop ever uses, both allocated here at
|
||||
// boot: every repaint below renders into `back` (`draw_into` reuses its
|
||||
// allocation) and swaps it with `shown` on success. A repaint must never
|
||||
// allocate — a background `:sync` push can take the heap to the floor, and
|
||||
// allocate — a background `:gp` push can take the heap to the floor, and
|
||||
// a failed `Vec` alloc aborts the whole app (the 2026-07-13 OOM: 66 s into
|
||||
// the push, one HalfPageUp repaint died on a 27 KB framebuffer).
|
||||
let mut back = Frame::new_white();
|
||||
@@ -218,7 +218,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Service the host-side effects the batch queued, in order. A file open
|
||||
// queues a Save of the outgoing dirty buffer *then* a Load of the target;
|
||||
// `:sync` queues a Save of the current buffer *then* Publish. Save/Load
|
||||
// `:gp` queues a Save of the current buffer *then* Publish. Save/Load
|
||||
// are inline (fast SD IO); Publish hands off to the git thread — behind
|
||||
// the `git` feature, so a light build carries no libgit2/git2.
|
||||
//
|
||||
@@ -262,12 +262,12 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "git"))]
|
||||
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
|
||||
log::info!(":gp — saved; light build (no `git` feature) — push skipped");
|
||||
}
|
||||
Effect::Pull => {
|
||||
// `: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
|
||||
// saves would fight the checkout, and `:gp` 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
|
||||
@@ -279,8 +279,8 @@ fn main() -> anyhow::Result<()> {
|
||||
// Log it too — on the 2026-07-14 run this gate
|
||||
// firing looked like a silent no-op in the
|
||||
// serial log.
|
||||
log::info!(":gl refused — dirty journal non-empty; :sync first");
|
||||
ed.set_notice("pull: unsynced changes - :sync first");
|
||||
log::info!(":gl refused — dirty journal non-empty; :gp first");
|
||||
ed.set_notice("pull: unsynced changes - :gp first");
|
||||
} else {
|
||||
match git_tx.send(GitRequest::Pull) {
|
||||
Ok(()) => ed.set_notice("pulling..."),
|
||||
@@ -314,7 +314,7 @@ fn main() -> anyhow::Result<()> {
|
||||
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.
|
||||
// pending so the next :gp retries the same paths.
|
||||
match &outcome {
|
||||
PublishOutcome::Pushed(_) | PublishOutcome::UpToDate => {
|
||||
storage.publish_succeeded()
|
||||
@@ -357,7 +357,7 @@ fn main() -> anyhow::Result<()> {
|
||||
format!("pulled {oid}")
|
||||
}
|
||||
PullOutcome::UpToDate => "up to date".to_string(),
|
||||
PullOutcome::LocalAhead => "ahead - :sync to publish".to_string(),
|
||||
PullOutcome::LocalAhead => "ahead - :gp to publish".to_string(),
|
||||
PullOutcome::Diverged => "diverged - resolve on a computer".to_string(),
|
||||
PullOutcome::Failed(reason) => reason,
|
||||
},
|
||||
@@ -410,7 +410,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// buffer so a power pull can't cost more than the last couple seconds.
|
||||
// Silent — no snackbar and no forced e-ink flash (a safety net, not an
|
||||
// action; `:w` is the loud save). Unformatted: fmt only runs on an
|
||||
// explicit `:w`/`:sync`, never reflowing text mid-session. Fires once
|
||||
// explicit `:w`/`:gp`, never reflowing text mid-session. Fires once
|
||||
// per idle window (`idle_saved`), so a failing save can't busy-loop.
|
||||
if !idle_saved
|
||||
&& ed.prefs().save_on_idle
|
||||
@@ -506,7 +506,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// exactly like a failed `save_buffer`. Drop this frame, leave `shown`
|
||||
// untouched so the next paint repaints the same diff, and force a
|
||||
// clean full refresh then. Typical cause: internal DMA-capable RAM
|
||||
// briefly starved by Wi-Fi/TLS during a background `:sync`; it frees
|
||||
// briefly starved by Wi-Fi/TLS during a background `:gp`; it frees
|
||||
// the moment the push finishes.
|
||||
log::warn!("{refresh} refresh #{updates} FAILED ({e}); frame dropped, full refresh next");
|
||||
force_full = true;
|
||||
@@ -595,7 +595,7 @@ fn save_buffer(storage: &Storage, ed: &mut Editor, path: &str, contents: &str) {
|
||||
/// Persist the preferences file after a palette `>` command changed a pref
|
||||
/// (`Effect::SavePrefs`). The editor already applied the change live and
|
||||
/// serialized it; this is a plain atomic write to the fixed `.typoena.toml`
|
||||
/// path. Under `/sd/repo`, so it rides the next `:sync` to other devices.
|
||||
/// path. Under `/sd/repo`, so it rides the next `:gp` to other devices.
|
||||
fn save_prefs(storage: &Storage, ed: &mut Editor, contents: &str) {
|
||||
match storage.save_path(PREFS_PATH, contents) {
|
||||
Ok(()) => log::info!("prefs saved to {PREFS_PATH}"),
|
||||
@@ -626,21 +626,21 @@ fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
|
||||
|
||||
/// Unlink a file from the card (`:delete`). The editor has already dropped it
|
||||
/// from its model and switched away, so this is pure IO plus the snackbar. For a
|
||||
/// Tracked file the removal is left in the git working copy — the next `:sync`'s
|
||||
/// Tracked file the removal is left in the git working copy — the next `:gp`'s
|
||||
/// `add --all` stages the deletion — so nothing git-specific happens here. A
|
||||
/// failure keeps the file on disk and says so; the buffer has still switched, so
|
||||
/// the file is recoverable by re-opening it.
|
||||
fn delete_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
|
||||
// Scope-qualified label (`repo/notes.md`), so the snackbar names exactly which
|
||||
// file left the card — and, for a Tracked file, that the removal is only local
|
||||
// until the next `:sync` publishes it (deleting from the card alone never
|
||||
// until the next `:gp` publishes it (deleting from the card alone never
|
||||
// touches the remote — that mirrors how a Save is local until Publish).
|
||||
let label = path.strip_prefix("/sd/").unwrap_or(&path);
|
||||
match storage.delete_path(&path) {
|
||||
Ok(()) => {
|
||||
log::info!("deleted {path} ({scope:?})");
|
||||
ed.set_notice(match scope {
|
||||
Scope::Tracked => format!("deleted {label} - :sync to publish"),
|
||||
Scope::Tracked => format!("deleted {label} - :gp to publish"),
|
||||
Scope::Local => format!("deleted {label}"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Storage {
|
||||
if carried > 0 {
|
||||
log::info!(
|
||||
"dirty journal: {carried} unpublished path(s) carried over from a previous \
|
||||
session — the next :sync will commit them"
|
||||
session — the next :gp will commit them"
|
||||
);
|
||||
}
|
||||
Ok(storage)
|
||||
@@ -431,7 +431,7 @@ 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
|
||||
/// fast-forward checkout would fight those files, and `:gp` first is the
|
||||
/// single-writer appliance's natural order anyway.
|
||||
pub fn has_dirty(&self) -> bool {
|
||||
let d = self.dirty.borrow();
|
||||
@@ -441,7 +441,7 @@ impl Storage {
|
||||
/// 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,
|
||||
/// [`Storage::publish_failed`] returns it to pending for the next `:sync`.
|
||||
/// [`Storage::publish_failed`] returns it to pending for the next `:gp`.
|
||||
pub fn take_dirty(&self) -> BTreeSet<String> {
|
||||
let mut d = self.dirty.borrow_mut();
|
||||
let taken = std::mem::take(&mut d.pending);
|
||||
@@ -457,7 +457,7 @@ impl Storage {
|
||||
self.persist_dirty();
|
||||
}
|
||||
|
||||
/// The publish failed: return its snapshot to pending so the next `:sync`
|
||||
/// The publish failed: return its snapshot to pending so the next `:gp`
|
||||
/// retries it (the splice is idempotent, so a retry of an already-clean
|
||||
/// path is free). The journal already carries these paths — no rewrite.
|
||||
pub fn publish_failed(&self) {
|
||||
@@ -487,7 +487,7 @@ impl Storage {
|
||||
|
||||
/// Seed the dirty set from the journal at mount — the paths a previous
|
||||
/// session saved but never got confirmed as published (power pull, failed
|
||||
/// sync, or simply no `:sync` before shutdown). Returns how many.
|
||||
/// sync, or simply no `:gp` before shutdown). Returns how many.
|
||||
fn load_dirty_journal(&self) -> usize {
|
||||
let Ok(text) = fs::read_to_string(DIRTY_JOURNAL) else {
|
||||
return 0; // no journal yet — nothing carried over
|
||||
|
||||
Reference in New Issue
Block a user