diff --git a/Cargo.toml b/Cargo.toml
index c5688b7..6af328a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ futures-util = "0.3.32"
gethostname = "1.1.0"
rand = "0.10.1"
ratatui = "0.30.0"
-rusqlite = { version = "0.39.0", features = ["bundled", "column_metadata"] }
+rusqlite = { version = "0.39.0", features = ["backup", "bundled", "column_metadata"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_yml = "0.0.12"
sysinfo = { version = "0.39.0", default-features = false, features = ["system"] }
diff --git a/docs/plans/20260524-adr-0006-undo-snapshots.md b/docs/plans/20260524-adr-0006-undo-snapshots.md
index 045d218..b347605 100644
--- a/docs/plans/20260524-adr-0006-undo-snapshots.md
+++ b/docs/plans/20260524-adr-0006-undo-snapshots.md
@@ -207,7 +207,7 @@ amendment records:
data/
.csv
playground.db
.snapshots/
- index.json # ordered ring + redo stack: ids, timestamps,
+ index.yaml # ordered ring + redo stack: ids, timestamps,
# command text, status; the source of truth
# for ordering/eviction
0001/
@@ -221,7 +221,7 @@ amendment records:
- **`.snapshots/` is git-ignored, export-excluded, and on the
temp-cleanup allowlist** (R13).
- The **ring** (undo) and the **redo stack** are both recorded in
- `index.json`; snapshot payload dirs are shared storage referenced by
+ `index.yaml`; snapshot payload dirs are shared storage referenced by
id. Eviction beyond 50 deletes the oldest payload dir.
- Ids are monotonic; never reused, to avoid stale references.
@@ -253,7 +253,7 @@ A snapshot brackets the existing 4-step persistence sequence:
3. atomic-rename text into place (step 3)
4. commit db (step 4)
5. FINALIZE snapshot (NEW) — atomic-rename .staging/ → .snapshots//,
- append to index.json ring, evict oldest if
+ append to index.yaml ring, evict oldest if
>50, clear redo stack.
On any failure in 1–4 → txn rolls back; DISCARD .staging/.
```
@@ -296,7 +296,7 @@ Mirrors the existing two-phase `rebuild` modal flow, with the
1. `undo` parses to `Command::App(AppCommand::Undo)` →
`dispatch_app_command` returns `Action::PrepareUndo`.
-2. Runtime handles `PrepareUndo`: reads `.snapshots/index.json` top
+2. Runtime handles `PrepareUndo`: reads `.snapshots/index.yaml` top
entry (command text + timestamp) — a cheap file read, like
`summarize_project` does for rebuild — and posts
`AppEvent::UndoPrepared { command, when }` (or
@@ -356,7 +356,7 @@ primitive collapses a batch to a single ring entry:
| `src/app.rs` | `Modal::UndoConfirm` (+ redo) struct; event arms; `handle_undo_confirm_key`; dispatch arms |
| `src/ui.rs` | `render_undo_confirm` (mirror `render_rebuild_confirm`) |
| `src/db.rs` | `is_mutating`; `stage/finalize/discard_snapshot`; `Request::Undo`/`Redo`/`BeginBatch`/`EndBatch`; dispatcher wrap; `undo_enabled` + `in_batch` worker fields |
-| `src/persistence/` (or new `src/snapshots/`) | ring + redo store, `index.json`, eviction, restore |
+| `src/undo.rs` (new) | snapshot ring + redo store, `index.yaml`, stage/finalize/discard/undo/redo/cleanup, eviction, restore (named `undo` — `src/snapshots/` is the insta dir) |
| `src/runtime.rs` | `is_app_lifecycle_entry_word += undo,redo`; `PrepareUndo/Undo` (+redo) handling; thread `no_undo` to worker; bracket `run_replay` with Begin/EndBatch |
| `src/project/mod.rs` | `.snapshots/` in `.gitignore` template |
| `src/archive.rs` | exclude `.snapshots/` from export |
@@ -423,7 +423,7 @@ explicit future items (hardlink optimisation) for user awareness.
1. **Cargo + CLI:** add `backup` feature; `--no-undo` flag + parse
tests. (R14, R9-partial)
-2. **Snapshot store module:** `index.json` model, ring + redo, stage/
+2. **Snapshot store module:** `index.yaml` model, ring + redo, stage/
finalize/discard/restore, eviction — Tier-1 tests first against a
temp dir + in-memory/temp db. (R2, R3, R4, R6, R7)
3. **Worker integration:** `is_mutating` (exhaustive), dispatcher wrap,
diff --git a/src/cli.rs b/src/cli.rs
index 20012fe..c5c15ea 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -29,6 +29,12 @@ pub struct Args {
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
+ /// `--no-undo`: disable the auto-snapshot / undo machinery for
+ /// this run (ADR-0006 Amendment 1). When set, no snapshots are
+ /// taken — zero per-command overhead — and `undo` / `redo`
+ /// report that undo is turned off. The escape hatch for small
+ /// hardware where per-command snapshotting is too heavy.
+ pub no_undo: bool,
}
/// Usage banner printed by `--help`.
@@ -109,6 +115,7 @@ impl Args {
let mut project_path: Option = None;
let mut resume = false;
let mut help = false;
+ let mut no_undo = false;
let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() {
match arg.as_str() {
@@ -118,6 +125,9 @@ impl Args {
"--resume" => {
resume = true;
}
+ "--no-undo" => {
+ no_undo = true;
+ }
"--theme" => {
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
theme = match value.as_str() {
@@ -164,6 +174,7 @@ impl Args {
project_path,
resume,
help,
+ no_undo,
})
}
}
@@ -300,6 +311,28 @@ mod tests {
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
}
+ #[test]
+ fn no_undo_flag_parses() {
+ let args = Args::parse(["--no-undo"]).unwrap();
+ assert!(args.no_undo);
+ }
+
+ #[test]
+ fn no_undo_defaults_off() {
+ let args = Args::parse(std::iter::empty::<&str>()).unwrap();
+ assert!(!args.no_undo, "undo is enabled unless --no-undo is given");
+ }
+
+ #[test]
+ fn no_undo_coexists_with_positional_path() {
+ let args = Args::parse(["--no-undo", "/home/me/MyProject"]).unwrap();
+ assert!(args.no_undo);
+ assert_eq!(
+ args.project_path.as_deref(),
+ Some(std::path::Path::new("/home/me/MyProject"))
+ );
+ }
+
#[test]
fn unknown_double_dash_flag_errors_even_with_positional() {
// Make sure the path-vs-flag distinction is robust:
diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml
index ff8d166..cf23959 100644
--- a/src/friendly/strings/en-US.yaml
+++ b/src/friendly/strings/en-US.yaml
@@ -191,6 +191,10 @@ help:
Errors out if no previous project is
recorded. Mutually exclusive with
.
+ --no-undo Disable the undo machinery for this run:
+ no snapshot is taken before each change
+ (no per-command overhead), and undo/redo
+ report that undo is turned off.
App-level commands (typed inside the app, available in both modes):
quit Exit cleanly.
diff --git a/src/lib.rs b/src/lib.rs
index fa076fa..2484d48 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,3 +24,4 @@ pub mod runtime;
pub mod theme;
pub mod type_change;
pub mod ui;
+pub mod undo;
diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs
index f0035b3..5970748 100644
--- a/src/persistence/mod.rs
+++ b/src/persistence/mod.rs
@@ -32,6 +32,14 @@ mod yaml;
pub(crate) use csv_io::{decode_cell, parse_csv};
pub(crate) use yaml::parse_schema;
+/// Current UTC time as an ISO-8601 `Z` string — the same shape
+/// `history.log` records (ADR-0015 §5). Exposed crate-wide so the
+/// undo snapshot ring (ADR-0006) timestamps entries identically.
+#[must_use]
+pub(crate) fn utc_iso8601_now() -> String {
+ history::utc_iso8601_now()
+}
+
/// Owns persistence to a single project on disk. Cheap to
/// move; the db worker holds one instance for its lifetime.
#[derive(Debug, Clone)]
diff --git a/src/undo.rs b/src/undo.rs
new file mode 100644
index 0000000..fba8ae4
--- /dev/null
+++ b/src/undo.rs
@@ -0,0 +1,771 @@
+//! Undo/redo snapshot ring (ADR-0006 Amendment 1).
+//!
+//! A *snapshot* is a whole-project copy taken before a mutation:
+//! the database (via SQLite's online backup API) plus the
+//! authoritative text (`project.yaml` + `data/*.csv`, copied as
+//! files). Undo restores all three directly, re-establishing a
+//! consistent `(db, yaml, csv)` triple (ADR-0015). Storing both the
+//! database and its text projection means undo is a direct restore —
+//! no rebuild step.
+//!
+//! On-disk layout under `/.snapshots/`:
+//!
+//! ```text
+//! index.yaml — the ordered undo ring + redo stack (source of truth)
+//! / — one payload per snapshot: playground.db, project.yaml,
+//! data/*.csv
+//! .staging/ — a snapshot being assembled (only one at a time; the
+//! db worker is single-threaded)
+//! ```
+//!
+//! **Payload semantics.** Each undo entry's payload is the state that
+//! existed *before* its command ran; each redo entry's payload is the
+//! state *after* its command ran (the state undo moved away from).
+//! Undo pops the newest undo entry, snapshots the current state onto
+//! the redo stack, then restores the popped payload. Redo is the
+//! mirror. New work (a fresh `finalize`) clears the redo stack
+//! (ADR-0006 Amendment 1: redo discarded on new work).
+//!
+//! **Worker-only.** Every method that touches the database takes the
+//! live `&Connection` / `&mut Connection` owned by the db worker, so
+//! all live-database access stays on the worker thread (the `db.rs`
+//! invariant). `backup` / `restore` go through that connection and
+//! snapshot *files*; this module never opens the live database file
+//! directly.
+
+use std::fs;
+use std::io::Write as _;
+use std::path::{Path, PathBuf};
+
+use rusqlite::{Connection, MAIN_DB};
+use serde::{Deserialize, Serialize};
+
+use crate::persistence::utc_iso8601_now;
+use crate::project::{DATA_DIR, PLAYGROUND_DB, PROJECT_YAML};
+
+/// Default undo ring depth (ADR-0006 Amendment 1: N = 50, raised
+/// from the original ADR's N = 10 because single-step undo counts
+/// commands). A single tunable constant.
+pub const DEFAULT_RING_CAPACITY: usize = 50;
+
+/// Directory under the project that holds the ring.
+const SNAPSHOTS_DIR: &str = ".snapshots";
+/// In-flight snapshot directory (one at a time).
+const STAGING_DIR: &str = ".staging";
+/// The ring index file.
+const INDEX_FILE: &str = "index.yaml";
+
+#[derive(Debug)]
+pub enum SnapshotError {
+ Io {
+ op: &'static str,
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ Db(rusqlite::Error),
+ Index {
+ message: String,
+ },
+}
+
+impl std::fmt::Display for SnapshotError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Io { op, path, source } => {
+ write!(f, "snapshot {op} failed for {}: {source}", path.display())
+ }
+ Self::Db(e) => write!(f, "snapshot database operation failed: {e}"),
+ Self::Index { message } => write!(f, "snapshot index error: {message}"),
+ }
+ }
+}
+
+impl std::error::Error for SnapshotError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io { source, .. } => Some(source),
+ Self::Db(e) => Some(e),
+ Self::Index { .. } => None,
+ }
+ }
+}
+
+impl From for SnapshotError {
+ fn from(e: rusqlite::Error) -> Self {
+ Self::Db(e)
+ }
+}
+
+type Result = std::result::Result;
+
+/// A staged-but-not-committed snapshot. Held by the worker between
+/// `stage` and `finalize`/`discard`; for a batch command the worker
+/// holds one of these across the whole batch.
+#[derive(Debug, Clone)]
+pub struct Staged {
+ command: String,
+ timestamp: String,
+}
+
+/// What `peek_*` / `undo` / `redo` report back to the caller — enough
+/// to build the confirmation prompt ("Undo `` (run )?").
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SnapshotMeta {
+ pub id: u64,
+ pub timestamp: String,
+ pub command: String,
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+struct Entry {
+ id: u64,
+ timestamp: String,
+ command: String,
+}
+
+impl Entry {
+ fn meta(&self) -> SnapshotMeta {
+ SnapshotMeta {
+ id: self.id,
+ timestamp: self.timestamp.clone(),
+ command: self.command.clone(),
+ }
+ }
+}
+
+#[derive(Default, Serialize, Deserialize)]
+struct Index {
+ /// Monotonic id allocator; never reuses an id.
+ next_id: u64,
+ /// Undo ring; the *last* element is the most recent (top).
+ undo: Vec,
+ /// Redo stack; the *last* element is the most recently undone.
+ redo: Vec,
+}
+
+/// The snapshot ring for one project. Cheap to construct; the worker
+/// holds one for the project's lifetime (or `None` when `--no-undo`).
+#[derive(Debug, Clone)]
+pub struct SnapshotStore {
+ project_dir: PathBuf,
+ root: PathBuf,
+ cap: usize,
+}
+
+impl SnapshotStore {
+ #[must_use]
+ pub fn new(project_dir: &Path, cap: usize) -> Self {
+ Self {
+ project_dir: project_dir.to_path_buf(),
+ root: project_dir.join(SNAPSHOTS_DIR),
+ cap,
+ }
+ }
+
+ // ---- public API ----------------------------------------------------
+
+ /// Stage a snapshot of the *current* committed state (database +
+ /// text) into `.staging/`. Call before a mutation's transaction
+ /// opens; `finalize` commits it into the ring after the db commit,
+ /// or `discard` drops it if the mutation rolled back.
+ pub fn stage(&self, live: &Connection, command: &str) -> Result {
+ let staging = self.staging_dir();
+ remove_dir_all_if_exists(&staging)?;
+ tracing::debug!(command, dir = %staging.display(), "staging undo snapshot");
+ self.snapshot_into(live, &staging)?;
+ Ok(Staged {
+ command: command.to_string(),
+ timestamp: utc_iso8601_now(),
+ })
+ }
+
+ /// Commit a staged snapshot into the undo ring: rename `.staging/`
+ /// to a fresh `/`, push the entry, **clear the redo stack**
+ /// (new work) and evict the oldest beyond the cap. Returns the new
+ /// undo entry's metadata.
+ pub fn finalize(&self, staged: Staged) -> Result {
+ let mut index = self.load_index()?;
+ let id = index.next_id;
+ index.next_id += 1;
+
+ let dest = self.payload_dir(id);
+ rename(&self.staging_dir(), &dest)?;
+
+ let entry = Entry {
+ id,
+ timestamp: staged.timestamp,
+ command: staged.command,
+ };
+ let meta = entry.meta();
+ index.undo.push(entry);
+
+ // New work invalidates redo (ADR-0006 Amendment 1).
+ for r in std::mem::take(&mut index.redo) {
+ remove_dir_all_if_exists(&self.payload_dir(r.id))?;
+ }
+
+ // Evict oldest beyond the cap.
+ while index.undo.len() > self.cap {
+ let old = index.undo.remove(0);
+ tracing::debug!(id = old.id, "evicting oldest undo snapshot (ring full)");
+ remove_dir_all_if_exists(&self.payload_dir(old.id))?;
+ }
+
+ self.save_index(&index)?;
+ tracing::info!(id = meta.id, command = %meta.command, "undo snapshot recorded");
+ Ok(meta)
+ }
+
+ /// Drop a staged snapshot (the mutation rolled back, so there is
+ /// nothing to undo).
+ pub fn discard(&self, _staged: Staged) -> Result<()> {
+ tracing::debug!("discarding staged undo snapshot (op did not commit)");
+ remove_dir_all_if_exists(&self.staging_dir())
+ }
+
+ /// Metadata of the snapshot `undo` would restore, without
+ /// restoring it. `None` when the ring is empty.
+ pub fn peek_undo(&self) -> Result