The index pipeline (add_all → index.write → write_tree) is O(N_tree) and cannot commit the real 1179-file / 570 MB-pack clone (index.write measured up to 611 s on FAT's racy-clean re-hash). stage_and_commit is now an O(depth) TreeBuilder splice of exactly the paths the editor saved or deleted — ~2-2.8 s on the real clone — with the working tree as source of truth (existing file → insert, missing → remove). Storage records those repo-relative paths on every save/delete and journals them to /sd/.typoena-dirty (atomic, only on growth), so a power pull can't strand a saved-but-unpublished note now that nothing walks the tree. take_dirty → publish_succeeded/publish_failed settles each publish's snapshot from the UI outcome handler. Also required by / discovered with the splice: - mwindow opts at git-service start (32-bit defaults would OOM PSRAM on the first real-pack access; bench-proven 256 KB / 4 MB). - 16-FD mount for git builds (libgit2 holds pack+idx descriptors open; the editor's 4-FD budget overruns). - reconcile is a soft reset (no index to reset anymore); side win: a remote-only added file is carried by the replay instead of dropped. - stranded-commit recovery: tree-unchanged now pushes anyway when origin/<branch> lacks HEAD (a commit whose push failed used to be silently never retried). - radio-free up-to-date: empty dirty set + origin at HEAD answers without bringing Wi-Fi up. - try_push splits ref rejection (reconcilable) from transport failure (surfaced directly) — the first on-device run burned a doomed reconcile on "unsupported URL protocol" and hid the cause. Deliberate behavior change: files changed on the card outside the editor are never committed anymore (also retires the macOS-cruft filter). Trail: docs/tradeoff-curves/sync-commit-staging.md.
524 lines
25 KiB
Rust
524 lines
25 KiB
Rust
//! SD-card persistence — mount, atomic save, crash recovery.
|
||
//!
|
||
//! The editor's notes live at `/sd/repo/notes.md` on a FAT filesystem on the
|
||
//! microSD card. This module owns bringing that card up and reading/writing the
|
||
//! buffer safely across power loss. It is the graduation of the Spike 3 bench
|
||
//! binary (`src/bin/sd_fat.rs`): that spike proved the raw stack on hardware
|
||
//! (verified 2026-07-11); the proven bits now live here so the editor and the
|
||
//! spike share one implementation instead of the spike being a dead-end proof.
|
||
//!
|
||
//! ## Storage split (ADR-007)
|
||
//!
|
||
//! FAT-on-SD holds the git working copy (`/sd/repo/`) and local scratch
|
||
//! (`/sd/local/`); device config is compiled into the binary in v0.1. The repo
|
||
//! is provisioned host-side (`just init` / `just load` copy a clone onto the
|
||
//! card) and opened — not cloned — on device, so this module never creates the
|
||
//! repo directory: a missing `/sd/repo` means the card wasn't provisioned, which
|
||
//! the boot path surfaces as a fatal "re-run `just init`" rather than silently
|
||
//! papering over.
|
||
//!
|
||
//! ## Dedicated SPI3 bus (ADR-012)
|
||
//!
|
||
//! The card sits on its own SPI3 host (SCK 14, MOSI 15, MISO 13, CS 10). The EPD
|
||
//! keeps SPI2. The EPD driver holds an exclusive `spi_device_acquire_bus` lock
|
||
//! for its whole lifetime, so a shared bus would lock the SD out; giving the SD
|
||
//! its own host sidesteps that for ~2 GPIOs. See the `mount` docs and ADR-012.
|
||
//!
|
||
//! ## Atomic save + crash recovery (the load-bearing part)
|
||
//!
|
||
//! FAT gives weak power-loss guarantees, so a save is: write `notes.md.tmp`,
|
||
//! `fsync`, unlink the target, rename the tmp over it. On FAT that unlink is
|
||
//! mandatory — FatFS's `f_rename` returns `FR_EXIST` on an existing destination
|
||
//! (it does *not* replace like POSIX `rename(2)`; Spike 3 finding). That unlink
|
||
//! opens a small window where the target is gone while the complete new content
|
||
//! sits in the tmp. [`Storage::recover`] closes the loop at boot — see its docs
|
||
//! for the exact case analysis, which is subtler than "promote the tmp."
|
||
|
||
use std::cell::RefCell;
|
||
use std::collections::BTreeSet;
|
||
use std::fs;
|
||
use std::io::Write as _;
|
||
use std::mem::MaybeUninit;
|
||
use std::path::Path;
|
||
use std::ptr;
|
||
|
||
use anyhow::{bail, Context, Result};
|
||
use esp_idf_svc::sys::{self, esp};
|
||
|
||
/// SD wiring on its own SPI3 host (ADR-012).
|
||
const PIN_SCK: i32 = 14;
|
||
const PIN_MOSI: i32 = 15;
|
||
const PIN_MISO: i32 = 13;
|
||
const PIN_CS: i32 = 10;
|
||
|
||
/// SD clock. Conservative for bench jumper wires: SDSPI's 20 MHz default is
|
||
/// prone to CRC errors on long unterminated jumpers, which look like a stack
|
||
/// failure when they're really signal integrity. 10 MHz keeps margin; raise
|
||
/// toward 20 MHz on a clean PCB. Init always runs at 400 kHz regardless.
|
||
const SD_FREQ_KHZ: i32 = 10_000;
|
||
|
||
/// Host flags from `sd_protocol_types.h` — `BIT(3)` / `BIT(5)`. Inlined because
|
||
/// bindgen doesn't fold the nested `BIT()` macro into a constant.
|
||
const SDMMC_HOST_FLAG_SPI: u32 = 1 << 3;
|
||
const SDMMC_HOST_FLAG_DEINIT_ARG: u32 = 1 << 5;
|
||
|
||
/// FAT mount point.
|
||
pub const MOUNT: &str = "/sd";
|
||
/// Git working copy — provisioned host-side, opened on device.
|
||
pub const REPO_DIR: &str = "/sd/repo";
|
||
/// The one file v0.1 opens.
|
||
pub const NOTES: &str = "/sd/repo/notes.md";
|
||
/// Staging name for the atomic save. Two dots → needs long-filename support
|
||
/// (`CONFIG_FATFS_LFN_HEAP=y`, set in sdkconfig.defaults).
|
||
const NOTES_TMP: &str = "/sd/repo/notes.md.tmp";
|
||
|
||
/// Largest file [`Storage::load`] will read into the buffer. v0.1 caps notes at
|
||
/// 256 KiB; a larger file refuses to open with a clear message rather than
|
||
/// exhausting the rope. Saving is *not* capped — never refuse to persist the
|
||
/// user's work once it's in the buffer.
|
||
pub const MAX_FILE_BYTES: u64 = 256 * 1024;
|
||
|
||
/// The C mount point (`/sd\0`) for the esp-idf FFI calls.
|
||
const MOUNT_C: &std::ffi::CStr = c"/sd";
|
||
|
||
/// Dirty-path journal — one repo-relative path per line, mirroring the in-RAM
|
||
/// dirty set (see [`Storage::take_dirty`]). At the card root, *outside*
|
||
/// `/sd/repo`, so it can never itself be committed. Without it a power pull
|
||
/// would strand every file saved-but-not-yet-published in that session: the
|
||
/// splice commit only visits recorded paths (nothing walks the tree anymore),
|
||
/// so an unrecorded change would never reach the remote.
|
||
const DIRTY_JOURNAL: &str = "/sd/.typoena-dirty";
|
||
|
||
/// VFS open-file budget for the editor path: it opens only a note and its
|
||
/// `*.tmp`, so a tight budget keeps FatFS's per-file buffers off the heap.
|
||
const MAX_FILES_EDITOR: i32 = 4;
|
||
/// VFS open-file budget for the git tooling. libgit2 keeps the pack + `.idx`
|
||
/// (and commit-graph) descriptors open for the repo's lifetime and opens loose
|
||
/// objects on top, so a `read_tree` walk overruns [`MAX_FILES_EDITOR`] with a
|
||
/// "no free file descriptors" error. Matches the flash-FAT git binaries' 16.
|
||
const MAX_FILES_GIT: i32 = 16;
|
||
|
||
/// A mounted SD card. Holds the live card handle for its lifetime; v0.1 never
|
||
/// unmounts (the card stays up for the whole power session). Not `Send` — the
|
||
/// handle lives on the task that mounted it (the ui/main task). The git thread
|
||
/// reaches `/sd/repo` through plain `std::fs`; FatFS's per-volume reentrancy
|
||
/// lock serialises the two, so no extra mutex is needed here.
|
||
pub struct Storage {
|
||
card: *mut sys::sdmmc_card_t,
|
||
/// Repo-relative paths saved or `:delete`d since the last confirmed
|
||
/// publish — the editor-side half of the O(depth) splice commit
|
||
/// (`git_sync::stage_and_commit` visits exactly these paths and nothing
|
||
/// else). Mirrored to [`DIRTY_JOURNAL`] whenever it changes, so the record
|
||
/// survives a power pull. `RefCell` because recording happens inside
|
||
/// `&self` save/delete calls; `Storage` already lives on one task only.
|
||
dirty: RefCell<Dirty>,
|
||
}
|
||
|
||
/// The two halves of the dirty record: `pending` accumulates between syncs;
|
||
/// `take_dirty` moves it to `in_flight` for the duration of a publish so a
|
||
/// failure can put it back (and a save landing *during* the publish re-enters
|
||
/// `pending`, riding the next one). The journal always carries the union.
|
||
#[derive(Default)]
|
||
struct Dirty {
|
||
pending: BTreeSet<String>,
|
||
in_flight: BTreeSet<String>,
|
||
}
|
||
|
||
/// What [`Storage::recover`] did with a leftover `*.tmp` at boot.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Recovery {
|
||
/// No `*.tmp` present — clean shutdown last time.
|
||
Clean,
|
||
/// `*.tmp` and the target both present: the crash could have landed
|
||
/// mid-write, so the tmp is untrustworthy. Kept the committed target,
|
||
/// discarded the tmp. The in-flight (unsaved) edit is lost — the documented
|
||
/// "you get the previous version" behaviour.
|
||
DiscardedTmp,
|
||
/// Only `*.tmp` present: the target had already been unlinked, so the tmp is
|
||
/// the newest complete, fsync'd copy. Promoted it to the target.
|
||
PromotedTmp,
|
||
}
|
||
|
||
impl Storage {
|
||
/// Bring up SPI3 and mount the FAT filesystem at `/sd`, then run crash
|
||
/// recovery ([`Storage::recover`]) so storage is in a consistent state
|
||
/// before the caller reads anything.
|
||
///
|
||
/// `format_if_mount_failed` is **false**: this is the user's card with their
|
||
/// writing on it, so a transient mount hiccup must never trigger a reformat.
|
||
/// (The Spike 3 bench binary sets it true for convenience on blank cards;
|
||
/// this path must not.)
|
||
pub fn mount() -> Result<Self> {
|
||
Self::mount_with_max_files(MAX_FILES_EDITOR)
|
||
}
|
||
|
||
/// Like [`Storage::mount`], but with the larger [`MAX_FILES_GIT`] open-file
|
||
/// budget the git tooling (bench / sync) needs — libgit2 holds several
|
||
/// descriptors open at once, which the editor's default budget can't cover.
|
||
pub fn mount_for_git() -> Result<Self> {
|
||
Self::mount_with_max_files(MAX_FILES_GIT)
|
||
}
|
||
|
||
fn mount_with_max_files(max_files: i32) -> Result<Self> {
|
||
// 1) SPI3 with the SD's four lines. Dedicated bus (ADR-012) — no EPD
|
||
// deselect needed: the panel is on SPI2 and can't contend here.
|
||
// SAFETY: a zeroed spi_bus_config_t is valid (all pins default 0); we
|
||
// set the used pins and mark the quad lines unused (-1).
|
||
let mut bus: sys::spi_bus_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
|
||
bus.__bindgen_anon_1.mosi_io_num = PIN_MOSI;
|
||
bus.__bindgen_anon_2.miso_io_num = PIN_MISO;
|
||
bus.sclk_io_num = PIN_SCK;
|
||
bus.__bindgen_anon_3.quadwp_io_num = -1;
|
||
bus.__bindgen_anon_4.quadhd_io_num = -1;
|
||
bus.max_transfer_sz = 4096;
|
||
esp!(unsafe {
|
||
sys::spi_bus_initialize(
|
||
sys::spi_host_device_t_SPI3_HOST,
|
||
&bus,
|
||
sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _,
|
||
)
|
||
})
|
||
.context("spi_bus_initialize(SPI3)")?;
|
||
|
||
// 1b) Internal pull-ups on the SD lines. The SD spec wants ~10 kΩ
|
||
// pull-ups; bench jumpers have none, so MISO floats between response
|
||
// bytes and a stray bit reads back as a spurious R1 "illegal
|
||
// command" that fails init. The ESP32's internal ~45 kΩ pull-ups are
|
||
// usually enough on short wires; an external 10 kΩ MISO→3V3 is the
|
||
// proper fix on a real board.
|
||
for pin in [PIN_SCK, PIN_MOSI, PIN_MISO, PIN_CS] {
|
||
esp!(unsafe { sys::gpio_set_pull_mode(pin, sys::gpio_pull_mode_t_GPIO_PULLUP_ONLY) })
|
||
.with_context(|| format!("pull-up on GPIO {pin}"))?;
|
||
}
|
||
|
||
// 2) SDSPI host descriptor — hand-rolled SDSPI_HOST_DEFAULT() (bindgen
|
||
// drops the macro). The fn pointers are esp-idf's sdspi_host_* ops.
|
||
// SAFETY: zeroed is a valid start (all fn-pointer Options = None); we
|
||
// fill exactly the fields the C macro sets.
|
||
let mut host: sys::sdmmc_host_t = unsafe { MaybeUninit::zeroed().assume_init() };
|
||
host.flags = SDMMC_HOST_FLAG_SPI | SDMMC_HOST_FLAG_DEINIT_ARG;
|
||
host.slot = sys::spi_host_device_t_SPI3_HOST as i32;
|
||
host.max_freq_khz = SD_FREQ_KHZ;
|
||
host.io_voltage = 3.3;
|
||
host.driver_strength = sys::sdmmc_driver_strength_t_SDMMC_DRIVER_STRENGTH_B;
|
||
host.current_limit = sys::sdmmc_current_limit_t_SDMMC_CURRENT_LIMIT_200MA;
|
||
host.init = Some(sys::sdspi_host_init);
|
||
host.set_card_clk = Some(sys::sdspi_host_set_card_clk);
|
||
host.do_transaction = Some(sys::sdspi_host_do_transaction);
|
||
host.__bindgen_anon_1.deinit_p = Some(sys::sdspi_host_remove_device);
|
||
host.io_int_enable = Some(sys::sdspi_host_io_int_enable);
|
||
host.io_int_wait = Some(sys::sdspi_host_io_int_wait);
|
||
host.get_real_freq = Some(sys::sdspi_host_get_real_freq);
|
||
host.input_delay_phase = sys::sdmmc_delay_phase_t_SDMMC_DELAY_PHASE_0;
|
||
host.check_buffer_alignment = Some(sys::sdspi_host_check_buffer_alignment);
|
||
|
||
// 3) Device (slot) config — CS 10, no card-detect / write-protect / int.
|
||
// SAFETY: zeroed is valid; we set the host, CS, and mark the rest unused.
|
||
let mut slot: sys::sdspi_device_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
|
||
slot.host_id = sys::spi_host_device_t_SPI3_HOST;
|
||
slot.gpio_cs = PIN_CS;
|
||
slot.gpio_cd = -1;
|
||
slot.gpio_wp = -1;
|
||
slot.gpio_int = -1;
|
||
|
||
// 4) Mount config. format_if_mount_failed = FALSE — see method docs.
|
||
let mount = sys::esp_vfs_fat_mount_config_t {
|
||
format_if_mount_failed: false,
|
||
max_files,
|
||
allocation_unit_size: 16 * 1024,
|
||
disk_status_check_enable: false,
|
||
use_one_fat: false,
|
||
};
|
||
|
||
let mut card: *mut sys::sdmmc_card_t = ptr::null_mut();
|
||
let rc = unsafe {
|
||
sys::esp_vfs_fat_sdspi_mount(MOUNT_C.as_ptr(), &host, &slot, &mount, &mut card)
|
||
};
|
||
|
||
// Turn the driver's opaque error into something actionable. The one we
|
||
// hit in practice: a card that rejects CMD59 (SPI-mode CRC on/off) after
|
||
// CMD0/CMD8 succeed. That's a card-firmware limitation (common on
|
||
// large/counterfeit SDXC), not a wiring fault — and we keep CRC required
|
||
// rather than run the user's notes over an unchecked bus.
|
||
if rc == sys::ESP_ERR_NOT_SUPPORTED {
|
||
bail!(
|
||
"SD card rejected CMD59 (SPI-mode CRC). CMD0/CMD8 succeeded, so wiring is \
|
||
fine — this card's firmware just doesn't support CRC in SPI mode (common on \
|
||
large/counterfeit SDXC). Use a genuine card, ideally ≤32 GB. We keep CRC \
|
||
required on purpose: a writing device shouldn't run over an unchecked bus."
|
||
);
|
||
}
|
||
esp!(rc).context("esp_vfs_fat_sdspi_mount (card present? inserted? FAT-formatted?)")?;
|
||
|
||
let storage = Storage {
|
||
card,
|
||
dirty: RefCell::new(Dirty::default()),
|
||
};
|
||
let (max_khz, real_khz) = storage.negotiated_khz();
|
||
log::info!("SD mounted at {MOUNT} — max {max_khz} kHz, negotiated {real_khz} kHz");
|
||
|
||
match storage.recover().context("boot crash recovery")? {
|
||
Recovery::Clean => {}
|
||
Recovery::DiscardedTmp => log::warn!(
|
||
"recovery: found {NOTES_TMP} alongside {NOTES} — last save didn't finish; \
|
||
kept the committed file, discarded the incomplete tmp"
|
||
),
|
||
Recovery::PromotedTmp => log::warn!(
|
||
"recovery: found {NOTES_TMP} with no {NOTES} — promoted the tmp (it is the \
|
||
newest complete copy)"
|
||
),
|
||
}
|
||
let carried = storage.load_dirty_journal();
|
||
if carried > 0 {
|
||
log::info!(
|
||
"dirty journal: {carried} unpublished path(s) carried over from a previous \
|
||
session — the next :sync will commit them"
|
||
);
|
||
}
|
||
Ok(storage)
|
||
}
|
||
|
||
/// The card's ceiling and negotiated SPI clock, in kHz (`(max, real)`).
|
||
/// `real` is what SDSPI settled on after init and is the speed reads/writes
|
||
/// actually run at — worth logging on the bench where wiring caps it.
|
||
pub fn negotiated_khz(&self) -> (i32, i32) {
|
||
// SAFETY: `card` is a live handle for the lifetime of `self` (the mount
|
||
// is never torn down while a `Storage` exists).
|
||
unsafe {
|
||
(
|
||
(*self.card).max_freq_khz as i32,
|
||
(*self.card).real_freq_khz as i32,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Total / free bytes on the FAT volume.
|
||
pub fn usage(&self) -> Result<(u64, u64)> {
|
||
let mut total: u64 = 0;
|
||
let mut free: u64 = 0;
|
||
esp!(unsafe { sys::esp_vfs_fat_info(MOUNT_C.as_ptr(), &mut total, &mut free) })
|
||
.context("esp_vfs_fat_info")?;
|
||
Ok((total, free))
|
||
}
|
||
|
||
/// Whether the working copy exists. A missing `/sd/repo` means the card
|
||
/// wasn't provisioned (`just init`); the boot path treats that as fatal.
|
||
pub fn repo_present(&self) -> bool {
|
||
Path::new(REPO_DIR).is_dir()
|
||
}
|
||
|
||
/// Read `notes.md` into a `String` — the boot default note. Thin wrapper over
|
||
/// [`Storage::load_path`].
|
||
pub fn load(&self) -> Result<String> {
|
||
self.load_path(NOTES)
|
||
}
|
||
|
||
/// Read an arbitrary file under `/sd` into a `String`. Returns an empty string
|
||
/// if the file doesn't exist yet (a `:e` of a not-yet-created name, or a fresh
|
||
/// repo). Refuses a file larger than [`MAX_FILE_BYTES`] rather than loading it.
|
||
///
|
||
/// The multi-file (v0.5) load path: the editor names the file, the host reads
|
||
/// it here and hands the text back through `Editor::install_loaded`.
|
||
pub fn load_path(&self, path: &str) -> Result<String> {
|
||
match fs::metadata(path) {
|
||
Ok(m) if m.len() > MAX_FILE_BYTES => bail!(
|
||
"{path} is {} KiB — over the {} KiB limit; open it on a computer to split it",
|
||
m.len() / 1024,
|
||
MAX_FILE_BYTES / 1024
|
||
),
|
||
// Read the file verbatim. The editor's `rows = #\n + 1` model renders a
|
||
// trailing '\n' as an empty last line, and we *want* that: a note ends
|
||
// with a visible blank line that reflects its POSIX terminator. Since
|
||
// `save_path` guarantees that terminator, this load and that save form an
|
||
// identity round-trip for any device-written file (which always ends in
|
||
// '\n') — no strip needed, and none wanted.
|
||
Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
|
||
Err(e) => Err(e).with_context(|| format!("stat {path}")),
|
||
}
|
||
}
|
||
|
||
/// Atomically persist `contents` to `notes.md`. Thin wrapper over
|
||
/// [`Storage::save_path`].
|
||
pub fn save(&self, contents: &str) -> Result<()> {
|
||
self.save_path(NOTES, contents)
|
||
}
|
||
|
||
/// Atomically persist `contents` to an arbitrary file under `/sd`: write the
|
||
/// tmp, fsync, unlink the target, rename over it. See the module docs for why
|
||
/// the unlink is mandatory on FAT. Boot recovery ([`Storage::recover`]) still
|
||
/// only covers the default `notes.md`; per-file recovery for the other v0.5
|
||
/// buffers is deferred to the v0.9 crash-safety work — the atomic swap here
|
||
/// already protects each individual save.
|
||
pub fn save_path(&self, path: &str, contents: &str) -> Result<()> {
|
||
// Record BEFORE writing: a crash in between leaves an over-approximate
|
||
// journal (the splice of an unchanged path is a no-op), whereas the
|
||
// reverse order could leave a changed file no record ever points at.
|
||
self.record_dirty(path);
|
||
Self::atomic_write(path, contents)
|
||
}
|
||
|
||
/// The atomic write primitive behind [`Storage::save_path`] and the dirty
|
||
/// journal: write `{path}.tmp`, fsync, unlink the target, rename over it.
|
||
fn atomic_write(path: &str, contents: &str) -> Result<()> {
|
||
let tmp = format!("{path}.tmp");
|
||
{
|
||
let mut f = fs::File::create(&tmp)
|
||
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
|
||
f.write_all(contents.as_bytes())
|
||
.with_context(|| format!("write {tmp}"))?;
|
||
// Insert a final newline only if the buffer lacks one (POSIX text
|
||
// convention; keeps git from flagging "No newline at end of file").
|
||
// `load_path` reads verbatim, so this is the sole place the terminator is
|
||
// guaranteed — and because it's guarded, the file mirrors the buffer's
|
||
// trailing newlines exactly: one visible trailing blank line stays one,
|
||
// never doubled. A buffer that already ends in '\n' passes through as-is.
|
||
if !contents.ends_with('\n') {
|
||
f.write_all(b"\n")
|
||
.with_context(|| format!("write final newline to {tmp}"))?;
|
||
}
|
||
// FatFS f_sync — flush the tmp fully before it can replace the target.
|
||
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
|
||
}
|
||
// FatFS f_rename won't overwrite, so unlink the target first (tolerate a
|
||
// missing target: the first-ever save has nothing to remove).
|
||
match fs::remove_file(path) {
|
||
Ok(()) => {}
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(e) => return Err(e).with_context(|| format!("unlink {path} before rename")),
|
||
}
|
||
fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Unlink a file under `/sd` (`:delete`). Tolerates a missing target — an
|
||
/// already-gone file is a success, so the call is idempotent. Also clears a
|
||
/// stray `{path}.tmp` best-effort, so a crash-interrupted save can't leave the
|
||
/// file half-present after a delete. For a Tracked file this leaves the
|
||
/// working copy short one file; the next publish's `add --all` stages it.
|
||
pub fn delete_path(&self, path: &str) -> Result<()> {
|
||
// Same record-first rule as `save_path`: the splice treats a recorded
|
||
// path with no file behind it as "remove from the tree".
|
||
self.record_dirty(path);
|
||
let _ = fs::remove_file(format!("{path}.tmp"));
|
||
match fs::remove_file(path) {
|
||
Ok(()) => Ok(()),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(e) => Err(e).with_context(|| format!("unlink {path}")),
|
||
}
|
||
}
|
||
|
||
/// Note a working-copy file as (possibly) differing from HEAD. Paths
|
||
/// outside `/sd/repo` (`/sd/local`, `/sd/ca.pem`, the journal itself) are
|
||
/// not git's business and are skipped. The journal is rewritten only when
|
||
/// the set actually grows, so re-saving the same note between syncs costs
|
||
/// no extra card I/O.
|
||
fn record_dirty(&self, abs_path: &str) {
|
||
let Some(rel) = abs_path
|
||
.strip_prefix(REPO_DIR)
|
||
.and_then(|r| r.strip_prefix('/'))
|
||
else {
|
||
return;
|
||
};
|
||
if rel.is_empty() {
|
||
return;
|
||
}
|
||
let grew = self.dirty.borrow_mut().pending.insert(rel.to_string());
|
||
if grew {
|
||
self.persist_dirty();
|
||
}
|
||
}
|
||
|
||
/// 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`.
|
||
pub fn take_dirty(&self) -> BTreeSet<String> {
|
||
let mut d = self.dirty.borrow_mut();
|
||
let taken = std::mem::take(&mut d.pending);
|
||
d.in_flight.extend(taken.iter().cloned());
|
||
taken
|
||
}
|
||
|
||
/// The publish that took the last snapshot committed (or confirmed
|
||
/// up-to-date): drop its paths and shrink the journal. Anything saved
|
||
/// while it ran is still in `pending` and rides the next sync.
|
||
pub fn publish_succeeded(&self) {
|
||
self.dirty.borrow_mut().in_flight.clear();
|
||
self.persist_dirty();
|
||
}
|
||
|
||
/// The publish failed: return its snapshot to pending so the next `:sync`
|
||
/// 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) {
|
||
let mut d = self.dirty.borrow_mut();
|
||
let inflight = std::mem::take(&mut d.in_flight);
|
||
d.pending.extend(inflight);
|
||
}
|
||
|
||
/// Mirror `pending ∪ in_flight` to [`DIRTY_JOURNAL`], atomically.
|
||
/// Best-effort: a failed journal write must not fail the save that
|
||
/// triggered it — the set stays correct in RAM and the journal heals on
|
||
/// the next change.
|
||
fn persist_dirty(&self) {
|
||
let contents = {
|
||
let d = self.dirty.borrow();
|
||
let mut out = String::new();
|
||
for p in d.pending.union(&d.in_flight) {
|
||
out.push_str(p);
|
||
out.push('\n');
|
||
}
|
||
out
|
||
};
|
||
if let Err(e) = Self::atomic_write(DIRTY_JOURNAL, &contents) {
|
||
log::warn!("dirty journal write FAILED ({e:#}); set kept in RAM only");
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
fn load_dirty_journal(&self) -> usize {
|
||
let Ok(text) = fs::read_to_string(DIRTY_JOURNAL) else {
|
||
return 0; // no journal yet — nothing carried over
|
||
};
|
||
let mut d = self.dirty.borrow_mut();
|
||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||
d.pending.insert(line.to_string());
|
||
}
|
||
d.pending.len()
|
||
}
|
||
|
||
/// Reconcile a leftover `notes.md.tmp` at boot. The save sequence is
|
||
/// write-tmp → fsync → unlink-target → rename, so a lingering tmp means the
|
||
/// last save was interrupted. Which way to recover depends on whether the
|
||
/// target survived:
|
||
///
|
||
/// - **tmp + target both present** — the crash could have been *during* the
|
||
/// tmp write (before fsync completed), so the tmp may be partial. The
|
||
/// target is the last fully-committed version. Keep it, delete the tmp.
|
||
/// Promoting a possibly-partial tmp over good data would be data loss.
|
||
/// - **tmp only, target absent** — the target was already unlinked, so we
|
||
/// crashed between unlink and rename. The tmp is the newest complete,
|
||
/// fsync'd copy and the only one left. Promote it (rename over the target).
|
||
/// - **neither / target only** — nothing to do.
|
||
///
|
||
/// Idempotent and safe to call on every mount; a no-op when `/sd/repo`
|
||
/// doesn't exist (no tmp can be there).
|
||
fn recover(&self) -> Result<Recovery> {
|
||
if fs::metadata(NOTES_TMP).is_err() {
|
||
return Ok(Recovery::Clean);
|
||
}
|
||
if fs::metadata(NOTES).is_ok() {
|
||
fs::remove_file(NOTES_TMP)
|
||
.with_context(|| format!("discard stale {NOTES_TMP}"))?;
|
||
Ok(Recovery::DiscardedTmp)
|
||
} else {
|
||
fs::rename(NOTES_TMP, NOTES)
|
||
.with_context(|| format!("promote {NOTES_TMP} -> {NOTES}"))?;
|
||
Ok(Recovery::PromotedTmp)
|
||
}
|
||
}
|
||
}
|