Skip to main content

undo_core/
journal.rs

1//! The append-only journal, the rollback executor, and redo.
2//!
3//! Layout under a `.undo/` directory at the project root:
4//!   journal.jsonl   — one JSON row per line (checkpoints + effects); source of truth
5//!   state.json      — small O(1) cache: seq, current checkpoint, tracked set
6//!   redo.json       — after-state captured at the last rollback (enables `undo redo`)
7//!   objects/<hash>  — captured prior file contents (see `store`)
8//!   lock            — flock target serializing concurrent writers
9//!
10//! Durability rules that make this trustworthy:
11//!   - Every whole-file write (journal rewrite, state, redo) is write-temp-then-rename,
12//!     which is atomic on POSIX. A crash never leaves a half-written journal.
13//!   - A rollback only truncates the journal if *every* inverse succeeded. If any
14//!     step fails, the journal is left intact so the user can safely retry.
15//!   - Mutating operations hold an exclusive flock, so an agent and a human CLI
16//!     can't corrupt the journal by writing at the same time.
17
18use crate::effect::Effect;
19use crate::meta::{self, Meta};
20use crate::store::Store;
21use fs2::FileExt;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeSet;
24use std::fs::{self, File, OpenOptions};
25use std::io::{self, BufRead, BufReader, Write};
26use std::path::{Component, Path, PathBuf};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29/// One line in the journal.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum Row {
33    Checkpoint {
34        id: String,
35        label: String,
36        ts: u64,
37    },
38    Effect {
39        seq: u64,
40        checkpoint: String,
41        ts: u64,
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        agent: Option<String>,
44        effect: Effect,
45    },
46}
47
48/// Small persisted cache so common operations don't re-parse the whole journal.
49#[derive(Debug, Default, Serialize, Deserialize)]
50struct State {
51    /// Highest effect sequence number issued.
52    seq: u64,
53    /// Monotonic high-water mark for checkpoint ids — never reused, even after rollback.
54    checkpoint_high: u64,
55    /// The checkpoint new effects attach to.
56    current_checkpoint: Option<String>,
57    /// Absolute paths already captured under the current checkpoint (dedup).
58    tracked: Vec<String>,
59}
60
61/// After-state captured at rollback time so the rollback itself can be undone.
62#[derive(Debug, Serialize, Deserialize)]
63struct RedoLog {
64    rows: Vec<Row>,
65    after: Vec<AfterSnap>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69struct AfterSnap {
70    path: PathBuf,
71    state: AfterState,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75#[serde(tag = "t", rename_all = "snake_case")]
76enum AfterState {
77    File { blob: String, meta: Meta },
78    Symlink { target: PathBuf },
79    Dir { mode: u32 },
80    Absent,
81}
82
83/// What's changed since the last checkpoint.
84#[derive(Debug, Serialize)]
85pub struct Status {
86    pub checkpoint: Option<(String, String)>,
87    pub effects: Vec<Effect>,
88}
89
90/// What a rollback did.
91#[derive(Debug, Serialize)]
92pub struct RollbackReport {
93    pub checkpoint: String,
94    pub reverted: Vec<String>,
95    pub skipped: Vec<String>,
96    pub failed: Vec<String>,
97}
98
99/// What a redo did.
100#[derive(Debug, Serialize)]
101pub struct RedoReport {
102    pub restored: Vec<String>,
103    pub failed: Vec<String>,
104}
105
106/// A handle on one project's undo history.
107pub struct Undo {
108    workdir: PathBuf,
109    root: PathBuf,
110    store: Store,
111}
112
113impl Undo {
114    pub fn dir_name() -> &'static str {
115        ".undo"
116    }
117
118    fn at(workdir: &Path) -> Undo {
119        let root = workdir.join(Self::dir_name());
120        let store = Store::new(root.join("objects"));
121        Undo {
122            workdir: workdir.to_path_buf(),
123            root,
124            store,
125        }
126    }
127
128    /// Create a fresh `.undo` under `workdir`, and protect the user from
129    /// committing captured secrets by adding `.undo/` to `.gitignore`.
130    pub fn init(workdir: &Path) -> io::Result<Undo> {
131        let u = Undo::at(workdir);
132        fs::create_dir_all(&u.root)?;
133        u.store.ensure()?;
134        if !u.journal_path().exists() {
135            atomic_write(&u.journal_path(), b"")?;
136        }
137        if !u.state_path().exists() {
138            u.save_state(&State::default())?;
139        }
140        u.ensure_gitignore();
141        Ok(u)
142    }
143
144    /// Walk up from `start` to find an existing `.undo` directory.
145    pub fn discover(start: &Path) -> io::Result<Option<Undo>> {
146        let mut cur = Some(start.to_path_buf());
147        while let Some(dir) = cur {
148            if dir.join(Self::dir_name()).is_dir() {
149                let u = Undo::at(&dir);
150                u.ensure_state()?;
151                return Ok(Some(u));
152            }
153            cur = dir.parent().map(|p| p.to_path_buf());
154        }
155        Ok(None)
156    }
157
158    fn journal_path(&self) -> PathBuf {
159        self.root.join("journal.jsonl")
160    }
161    fn state_path(&self) -> PathBuf {
162        self.root.join("state.json")
163    }
164    fn redo_path(&self) -> PathBuf {
165        self.root.join("redo.json")
166    }
167
168    /// Acquire the exclusive cross-process lock for the duration of a mutation.
169    fn lock(&self) -> io::Result<File> {
170        let f = OpenOptions::new()
171            .create(true)
172            .write(true)
173            .truncate(false)
174            .open(self.root.join("lock"))?;
175        f.lock_exclusive()?;
176        Ok(f)
177    }
178
179    // ---- journal i/o -------------------------------------------------------
180
181    /// Read every row in order. Malformed lines are skipped defensively.
182    pub fn rows(&self) -> io::Result<Vec<Row>> {
183        let file = match fs::File::open(self.journal_path()) {
184            Ok(f) => f,
185            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
186            Err(e) => return Err(e),
187        };
188        let mut out = vec![];
189        for line in BufReader::new(file).lines() {
190            let line = line?;
191            if line.trim().is_empty() {
192                continue;
193            }
194            if let Ok(row) = serde_json::from_str::<Row>(&line) {
195                out.push(row);
196            }
197        }
198        Ok(out)
199    }
200
201    fn append_row(&self, row: &Row) -> io::Result<()> {
202        let mut f = OpenOptions::new()
203            .create(true)
204            .append(true)
205            .open(self.journal_path())?;
206        writeln!(f, "{}", serde_json::to_string(row).map_err(invalid_data)?)?;
207        f.sync_all()?;
208        Ok(())
209    }
210
211    fn rewrite_journal(&self, rows: &[Row]) -> io::Result<()> {
212        let mut buf = String::new();
213        for r in rows {
214            buf.push_str(&serde_json::to_string(r).map_err(invalid_data)?);
215            buf.push('\n');
216        }
217        atomic_write(&self.journal_path(), buf.as_bytes())
218    }
219
220    // ---- state -------------------------------------------------------------
221
222    fn load_state(&self) -> io::Result<State> {
223        match fs::read(self.state_path()) {
224            Ok(b) => Ok(serde_json::from_slice(&b).unwrap_or_default()),
225            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(State::default()),
226            Err(e) => Err(e),
227        }
228    }
229
230    fn save_state(&self, s: &State) -> io::Result<()> {
231        let b = serde_json::to_vec_pretty(s).map_err(invalid_data)?;
232        atomic_write(&self.state_path(), &b)
233    }
234
235    /// Recompute the state cache from the journal (after truncation or migration).
236    fn rebuild_state(&self, rows: &[Row]) -> io::Result<()> {
237        let prev_high = self.load_state().map(|s| s.checkpoint_high).unwrap_or(0);
238        let mut st = State {
239            checkpoint_high: prev_high,
240            ..Default::default()
241        };
242        for r in rows {
243            match r {
244                Row::Checkpoint { id, .. } => {
245                    st.current_checkpoint = Some(id.clone());
246                    st.tracked.clear();
247                    if let Some(n) = parse_cp(id) {
248                        st.checkpoint_high = st.checkpoint_high.max(n);
249                    }
250                }
251                Row::Effect { seq, effect, .. } => {
252                    st.seq = st.seq.max(*seq);
253                    if let Some(p) = effect.path() {
254                        st.tracked.push(p.to_string_lossy().to_string());
255                    }
256                }
257            }
258        }
259        self.save_state(&st)
260    }
261
262    fn ensure_state(&self) -> io::Result<()> {
263        if !self.state_path().exists() {
264            let rows = self.rows()?;
265            self.rebuild_state(&rows)?;
266        }
267        Ok(())
268    }
269
270    /// Ensure a checkpoint exists to attach effects to, creating one if needed.
271    /// Mutates `st` (caller persists) and appends the checkpoint row if created.
272    fn ensure_cp(&self, st: &mut State) -> io::Result<String> {
273        if let Some(id) = &st.current_checkpoint {
274            return Ok(id.clone());
275        }
276        st.checkpoint_high += 1;
277        let id = format!("cp{:03}", st.checkpoint_high);
278        st.current_checkpoint = Some(id.clone());
279        st.tracked.clear();
280        self.append_row(&Row::Checkpoint {
281            id: id.clone(),
282            label: "auto".to_string(),
283            ts: now_millis(),
284        })?;
285        Ok(id)
286    }
287
288    // ---- public mutations --------------------------------------------------
289
290    /// Mark a point in time. Returns the checkpoint id.
291    pub fn checkpoint(&self, label: &str) -> io::Result<String> {
292        let _lock = self.lock()?;
293        self.clear_redo();
294        let mut st = self.load_state()?;
295        st.checkpoint_high += 1;
296        let id = format!("cp{:03}", st.checkpoint_high);
297        st.current_checkpoint = Some(id.clone());
298        st.tracked.clear();
299        self.append_row(&Row::Checkpoint {
300            id: id.clone(),
301            label: label.to_string(),
302            ts: now_millis(),
303        })?;
304        self.save_state(&st)?;
305        Ok(id)
306    }
307
308    pub fn current_checkpoint(&self) -> io::Result<Option<String>> {
309        Ok(self.load_state()?.current_checkpoint)
310    }
311
312    /// The project root (the directory containing `.undo`).
313    pub fn workdir(&self) -> &Path {
314        &self.workdir
315    }
316
317    /// Capture a path (and, if it's a directory, everything under it) before the
318    /// agent changes it. Returns the effects recorded. New forward activity here
319    /// invalidates any pending redo.
320    pub fn track(&self, path: &Path) -> io::Result<Vec<Effect>> {
321        let _lock = self.lock()?;
322        self.clear_redo();
323        let mut st = self.load_state()?;
324        let abs = self.resolve(path);
325        self.guard(&abs)?;
326
327        // Cheap idempotency: if this exact path is already captured under the
328        // current checkpoint, re-tracking is a no-op. This is what makes
329        // re-tracking the project root (every Bash / session) O(1) instead of
330        // re-hashing the whole tree.
331        let abs_key = abs.to_string_lossy().to_string();
332        if st.tracked.iter().any(|t| t == &abs_key) {
333            return Ok(vec![]);
334        }
335
336        let cp = self.ensure_cp(&mut st)?;
337
338        let mut effects = vec![];
339        self.snapshot_path(&abs, &mut effects)?;
340
341        let mut tracked: BTreeSet<String> = st.tracked.iter().cloned().collect();
342        let mut recorded = vec![];
343        for e in effects {
344            let key = e
345                .path()
346                .map(|p| p.to_string_lossy().to_string())
347                .unwrap_or_default();
348            if !tracked.insert(key) {
349                continue; // already captured under this checkpoint
350            }
351            st.seq += 1;
352            self.append_row(&Row::Effect {
353                seq: st.seq,
354                checkpoint: cp.clone(),
355                ts: now_millis(),
356                agent: None,
357                effect: e.clone(),
358            })?;
359            recorded.push(e);
360        }
361        st.tracked = tracked.into_iter().collect();
362        self.save_state(&st)?;
363        Ok(recorded)
364    }
365
366    /// Record an arbitrary effect (used by the MCP/NAPI layer for http/exec).
367    pub fn record(&self, effect: Effect, agent: Option<String>) -> io::Result<()> {
368        let _lock = self.lock()?;
369        self.clear_redo();
370        let mut st = self.load_state()?;
371        let cp = self.ensure_cp(&mut st)?;
372        st.seq += 1;
373        self.append_row(&Row::Effect {
374            seq: st.seq,
375            checkpoint: cp,
376            ts: now_millis(),
377            agent,
378            effect,
379        })?;
380        self.save_state(&st)?;
381        Ok(())
382    }
383
384    // ---- reads -------------------------------------------------------------
385
386    pub fn status(&self) -> io::Result<Status> {
387        let mut checkpoint = None;
388        let mut effects = vec![];
389        for r in self.rows()? {
390            match r {
391                Row::Checkpoint { id, label, .. } => {
392                    checkpoint = Some((id, label));
393                    effects.clear();
394                }
395                Row::Effect { effect, .. } => effects.push(effect),
396            }
397        }
398        Ok(Status {
399            checkpoint,
400            effects,
401        })
402    }
403
404    pub fn log(&self) -> io::Result<Vec<Row>> {
405        self.rows()
406    }
407
408    /// A PR-style diff of everything changed since the last checkpoint, built
409    /// from undo's captured before-state vs. the current files.
410    pub fn diff(&self) -> io::Result<Vec<crate::DiffEntry>> {
411        let status = self.status()?;
412        Ok(crate::diff::diff_effects(&status.effects, &self.store))
413    }
414
415    pub fn can_redo(&self) -> bool {
416        self.redo_path().exists()
417    }
418
419    // ---- rollback ----------------------------------------------------------
420
421    /// Reverse every effect recorded after `target` (or the latest checkpoint if
422    /// `None`). The journal is only truncated if *all* inverses succeed; on any
423    /// hard failure it's left intact so a retry is safe.
424    pub fn rollback(&self, target: Option<&str>) -> io::Result<RollbackReport> {
425        let _lock = self.lock()?;
426        let rows = self.rows()?;
427        let cp_idx = match target {
428            Some(id) => rows
429                .iter()
430                .position(|r| matches!(r, Row::Checkpoint { id: cid, .. } if cid == id)),
431            None => rows
432                .iter()
433                .rposition(|r| matches!(r, Row::Checkpoint { .. })),
434        }
435        .ok_or_else(|| {
436            io::Error::new(
437                io::ErrorKind::NotFound,
438                "no matching checkpoint to roll back to",
439            )
440        })?;
441
442        let cp_id = match &rows[cp_idx] {
443            Row::Checkpoint { id, .. } => id.clone(),
444            _ => unreachable!(),
445        };
446
447        let after_rows: Vec<Row> = rows[cp_idx + 1..].to_vec();
448        let effects: Vec<Effect> = after_rows
449            .iter()
450            .filter_map(|r| match r {
451                Row::Effect { effect, .. } => Some(effect.clone()),
452                _ => None,
453            })
454            .collect();
455
456        // Capture current ("after") state so the rollback can itself be undone.
457        let after = self.capture_after(&effects)?;
458
459        let mut reverted = vec![];
460        let mut skipped = vec![];
461        let mut failed = vec![];
462        for eff in effects.iter().rev() {
463            match self.invert(eff) {
464                Ok(Some(msg)) => reverted.push(msg),
465                Ok(None) => skipped.push(format!("{} (manual)", eff.describe())),
466                Err(e) => failed.push(format!("{} (error: {e})", eff.describe())),
467            }
468        }
469
470        if failed.is_empty() {
471            self.rewrite_journal(&rows[..=cp_idx])?;
472            self.rebuild_state(&rows[..=cp_idx])?;
473            self.save_redo(&RedoLog {
474                rows: after_rows,
475                after,
476            })?;
477        }
478        // If anything failed, the journal is untouched: retrying rollback is safe.
479
480        Ok(RollbackReport {
481            checkpoint: cp_id,
482            reverted,
483            skipped,
484            failed,
485        })
486    }
487
488    /// Undo the last rollback: restore the agent's changes and re-extend the
489    /// journal so you can roll back again.
490    pub fn redo(&self) -> io::Result<RedoReport> {
491        let _lock = self.lock()?;
492        let redo = self
493            .load_redo()?
494            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "nothing to redo"))?;
495
496        let mut snaps = redo.after;
497        snaps.sort_by_key(order_key);
498
499        let mut restored = vec![];
500        let mut failed = vec![];
501        for s in &snaps {
502            match self.apply_after(s) {
503                Ok(msg) => restored.push(msg),
504                Err(e) => failed.push(format!("{} (error: {e})", s.path.display())),
505            }
506        }
507
508        if failed.is_empty() {
509            for r in &redo.rows {
510                self.append_row(r)?;
511            }
512            let rows = self.rows()?;
513            self.rebuild_state(&rows)?;
514            self.clear_redo();
515        }
516
517        Ok(RedoReport { restored, failed })
518    }
519
520    /// Selective undo: reverse just one file, leaving every other change in
521    /// place. Reverts the most recent effect recorded for `path` and removes it
522    /// from the journal. Returns a description, or `None` if nothing was tracked
523    /// for that path. Directories aren't selectable (use `rollback`).
524    pub fn revert(&self, path: &Path) -> io::Result<Option<String>> {
525        let _lock = self.lock()?;
526        self.clear_redo();
527        let abs = self.resolve(path);
528        let rows = self.rows()?;
529
530        let idx = rows.iter().rposition(
531            |r| matches!(r, Row::Effect { effect, .. } if effect.path() == Some(abs.as_path())),
532        );
533        let Some(idx) = idx else {
534            return Ok(None);
535        };
536        let effect = match &rows[idx] {
537            Row::Effect { effect, .. } => effect.clone(),
538            _ => unreachable!(),
539        };
540
541        match effect {
542            Effect::File { .. } | Effect::PathCreate { .. } | Effect::Symlink { .. } => {
543                let msg = self.invert(&effect)?;
544                let mut kept = rows;
545                kept.remove(idx);
546                self.rewrite_journal(&kept)?;
547                self.rebuild_state(&kept)?;
548                Ok(msg)
549            }
550            _ => Err(io::Error::new(
551                io::ErrorKind::InvalidInput,
552                "selective revert is for single files — use rollback for directories",
553            )),
554        }
555    }
556
557    // ---- snapshot / invert / redo internals --------------------------------
558
559    fn snapshot_path(&self, abs: &Path, out: &mut Vec<Effect>) -> io::Result<()> {
560        match fs::symlink_metadata(abs) {
561            Err(e) if e.kind() == io::ErrorKind::NotFound => {
562                out.push(Effect::PathCreate {
563                    path: abs.to_path_buf(),
564                });
565                Ok(())
566            }
567            Err(e) => Err(e),
568            Ok(m) => {
569                let ft = m.file_type();
570                if ft.is_symlink() {
571                    let target = fs::read_link(abs)?;
572                    out.push(Effect::Symlink {
573                        path: abs.to_path_buf(),
574                        target,
575                    });
576                } else if ft.is_dir() {
577                    let mode = meta::capture(abs)?.mode;
578                    let mut entries = vec![];
579                    let mut children = vec![];
580                    for ent in fs::read_dir(abs)? {
581                        let ent = ent?;
582                        let child = ent.path();
583                        if child == self.root || is_ignored_name(&ent.file_name()) {
584                            continue; // never descend into .undo or noise dirs
585                        }
586                        entries.push(ent.file_name().to_string_lossy().to_string());
587                        children.push(child);
588                    }
589                    entries.sort();
590                    out.push(Effect::Dir {
591                        path: abs.to_path_buf(),
592                        mode,
593                        entries,
594                    });
595                    for c in children {
596                        self.snapshot_path(&c, out)?;
597                    }
598                } else {
599                    let blob = self.store.put_file(abs)?;
600                    let meta = meta::capture(abs)?;
601                    out.push(Effect::File {
602                        path: abs.to_path_buf(),
603                        prev_blob: blob,
604                        meta,
605                    });
606                }
607                Ok(())
608            }
609        }
610    }
611
612    /// Apply a single effect's inverse. Ok(Some(msg)) = reverted with a label,
613    /// Ok(None) = audit-only / manual, Err = hard failure.
614    fn invert(&self, eff: &Effect) -> io::Result<Option<String>> {
615        match eff {
616            Effect::PathCreate { path } => {
617                remove_any(path)?;
618                Ok(Some(format!("removed  {}", path.display())))
619            }
620            Effect::File {
621                path,
622                prev_blob,
623                meta,
624            } => {
625                if let Some(parent) = path.parent() {
626                    fs::create_dir_all(parent)?;
627                }
628                remove_if_incompatible(path)?;
629                let data = self.store.get(prev_blob)?;
630                atomic_write(path, &data)?;
631                meta::apply(path, *meta)?;
632                Ok(Some(format!("restored {}", path.display())))
633            }
634            Effect::Symlink { path, target } => {
635                remove_any(path)?;
636                if let Some(parent) = path.parent() {
637                    fs::create_dir_all(parent)?;
638                }
639                symlink(target, path)?;
640                Ok(Some(format!("relinked {}", path.display())))
641            }
642            Effect::Dir {
643                path,
644                mode,
645                entries,
646            } => {
647                fs::create_dir_all(path)?;
648                meta::set_mode(path, *mode)?;
649                let keep: BTreeSet<&str> = entries.iter().map(String::as_str).collect();
650                for ent in fs::read_dir(path)? {
651                    let ent = ent?;
652                    let child = ent.path();
653                    if child == self.root || is_ignored_name(&ent.file_name()) {
654                        continue; // never prune .undo or ignored dirs (node_modules, etc.)
655                    }
656                    let name = ent.file_name().to_string_lossy().to_string();
657                    if !keep.contains(name.as_str()) {
658                        remove_any(&child)?; // prune what the agent added
659                    }
660                }
661                Ok(Some(format!("dir      {}", path.display())))
662            }
663            Effect::HttpMutation { .. } | Effect::Exec { .. } => Ok(None),
664        }
665    }
666
667    /// Snapshot the current ("after") state of everything a rollback is about to
668    /// touch, so the rollback can itself be undone. This is the blast radius:
669    /// every effect path *and* — for tracked directories — their current
670    /// children, since rollback prunes agent-added files that have no effect of
671    /// their own. Without this, redo couldn't recreate what rollback pruned.
672    fn capture_after(&self, effects: &[Effect]) -> io::Result<Vec<AfterSnap>> {
673        let mut seen = BTreeSet::new();
674        let mut out = vec![];
675        for e in effects {
676            if let Some(p) = e.path() {
677                self.capture_after_path(p, &mut seen, &mut out)?;
678            }
679        }
680        Ok(out)
681    }
682
683    fn capture_after_path(
684        &self,
685        p: &Path,
686        seen: &mut BTreeSet<String>,
687        out: &mut Vec<AfterSnap>,
688    ) -> io::Result<()> {
689        if !seen.insert(p.to_string_lossy().to_string()) {
690            return Ok(());
691        }
692        let state = match fs::symlink_metadata(p) {
693            Err(e) if e.kind() == io::ErrorKind::NotFound => AfterState::Absent,
694            Err(e) => return Err(e),
695            Ok(m) => {
696                if m.file_type().is_symlink() {
697                    AfterState::Symlink {
698                        target: fs::read_link(p)?,
699                    }
700                } else if m.is_dir() {
701                    for ent in fs::read_dir(p)? {
702                        let ent = ent?;
703                        let child = ent.path();
704                        if child == self.root || is_ignored_name(&ent.file_name()) {
705                            continue;
706                        }
707                        self.capture_after_path(&child, seen, out)?;
708                    }
709                    AfterState::Dir {
710                        mode: meta::capture(p)?.mode,
711                    }
712                } else {
713                    AfterState::File {
714                        blob: self.store.put_file(p)?,
715                        meta: meta::capture(p)?,
716                    }
717                }
718            }
719        };
720        out.push(AfterSnap {
721            path: p.to_path_buf(),
722            state,
723        });
724        Ok(())
725    }
726
727    fn apply_after(&self, s: &AfterSnap) -> io::Result<String> {
728        match &s.state {
729            AfterState::Absent => {
730                remove_any(&s.path)?;
731                Ok(format!("removed  {}", s.path.display()))
732            }
733            AfterState::File { blob, meta } => {
734                if let Some(parent) = s.path.parent() {
735                    fs::create_dir_all(parent)?;
736                }
737                remove_if_incompatible(&s.path)?;
738                let data = self.store.get(blob)?;
739                atomic_write(&s.path, &data)?;
740                meta::apply(&s.path, *meta)?;
741                Ok(format!("restored {}", s.path.display()))
742            }
743            AfterState::Symlink { target } => {
744                remove_any(&s.path)?;
745                symlink(target, &s.path)?;
746                Ok(format!("relinked {}", s.path.display()))
747            }
748            AfterState::Dir { mode } => {
749                fs::create_dir_all(&s.path)?;
750                meta::set_mode(&s.path, *mode)?;
751                Ok(format!("dir      {}", s.path.display()))
752            }
753        }
754    }
755
756    // ---- redo persistence --------------------------------------------------
757
758    fn save_redo(&self, redo: &RedoLog) -> io::Result<()> {
759        let b = serde_json::to_vec(redo).map_err(invalid_data)?;
760        atomic_write(&self.redo_path(), &b)
761    }
762
763    fn load_redo(&self) -> io::Result<Option<RedoLog>> {
764        match fs::read(self.redo_path()) {
765            Ok(b) => Ok(serde_json::from_slice(&b).ok()),
766            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
767            Err(e) => Err(e),
768        }
769    }
770
771    fn clear_redo(&self) {
772        let _ = fs::remove_file(self.redo_path());
773    }
774
775    // ---- path safety -------------------------------------------------------
776
777    fn resolve(&self, path: &Path) -> PathBuf {
778        if path.is_absolute() {
779            path.to_path_buf()
780        } else {
781            self.workdir.join(path)
782        }
783    }
784
785    /// Refuse to touch anything outside the project root, or inside `.undo`.
786    fn guard(&self, abs: &Path) -> io::Result<()> {
787        let norm = lexical_normalize(abs);
788        let wd = lexical_normalize(&self.workdir);
789        if !norm.starts_with(&wd) {
790            return Err(io::Error::new(
791                io::ErrorKind::PermissionDenied,
792                format!(
793                    "refusing to track a path outside the project: {}",
794                    abs.display()
795                ),
796            ));
797        }
798        if norm.starts_with(lexical_normalize(&self.root)) {
799            return Err(io::Error::new(
800                io::ErrorKind::PermissionDenied,
801                "refusing to track undo's own .undo directory",
802            ));
803        }
804        Ok(())
805    }
806
807    fn ensure_gitignore(&self) {
808        let gi = self.workdir.join(".gitignore");
809        let contents = fs::read_to_string(&gi).unwrap_or_default();
810        if contents
811            .lines()
812            .any(|l| matches!(l.trim(), ".undo" | ".undo/" | "/.undo" | "/.undo/"))
813        {
814            return;
815        }
816        let mut next = contents;
817        if !next.is_empty() && !next.ends_with('\n') {
818            next.push('\n');
819        }
820        next.push_str(
821            "# agent-undo: snapshots of your files (may contain secrets) — never commit\n.undo/\n",
822        );
823        let _ = fs::write(&gi, next); // best-effort; never fail init over this
824    }
825}
826
827// ---- free helpers ----------------------------------------------------------
828
829fn order_key(s: &AfterSnap) -> (u8, isize) {
830    let depth = s.path.components().count() as isize;
831    match s.state {
832        // Recreate parents before children; remove children before parents.
833        AfterState::Absent => (1, -depth),
834        _ => (0, depth),
835    }
836}
837
838/// Directory names that are never captured by undo (and never pruned on
839/// rollback). These are noise — regenerable build output, dependency caches,
840/// VCS internals — that would bloat snapshots and slow whole-project tracking
841/// to no benefit. Kept as a deliberate, documented default; finer `.gitignore`
842/// awareness can layer on top later without changing any call site.
843const IGNORED_DIRS: &[&str] = &[
844    ".git",
845    ".undo",
846    "node_modules",
847    "target",
848    "dist",
849    "build",
850    ".next",
851    ".nuxt",
852    ".svelte-kit",
853    ".turbo",
854    ".venv",
855    "venv",
856    "__pycache__",
857    ".mypy_cache",
858    ".pytest_cache",
859    ".gradle",
860    ".idea",
861    ".cargo",
862    "vendor",
863];
864
865fn is_ignored_name(name: &std::ffi::OsStr) -> bool {
866    name.to_str().is_some_and(|n| IGNORED_DIRS.contains(&n))
867}
868
869/// True if any component of `path` is an ignored directory (e.g. `node_modules`,
870/// `.git`, `.undo`). The watcher uses this to filter events with the exact same
871/// definition the snapshotter uses, so the two never disagree.
872pub fn path_is_ignored(path: &Path) -> bool {
873    path.components().any(|c| match c {
874        Component::Normal(n) => is_ignored_name(n),
875        _ => false,
876    })
877}
878
879fn remove_any(path: &Path) -> io::Result<()> {
880    match fs::symlink_metadata(path) {
881        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
882        Err(e) => Err(e),
883        Ok(m) => {
884            if m.is_dir() {
885                fs::remove_dir_all(path)
886            } else {
887                fs::remove_file(path) // regular file or symlink (not followed)
888            }
889        }
890    }
891}
892
893/// If a directory or symlink occupies a path where we need to write a regular
894/// file, clear it first.
895fn remove_if_incompatible(path: &Path) -> io::Result<()> {
896    match fs::symlink_metadata(path) {
897        Ok(m) if m.is_dir() || m.file_type().is_symlink() => remove_any(path),
898        _ => Ok(()),
899    }
900}
901
902#[cfg(unix)]
903fn symlink(target: &Path, link: &Path) -> io::Result<()> {
904    std::os::unix::fs::symlink(target, link)
905}
906
907#[cfg(windows)]
908fn symlink(target: &Path, link: &Path) -> io::Result<()> {
909    std::os::windows::fs::symlink_file(target, link)
910}
911
912/// Write a whole file atomically: temp file in the same dir, then rename over
913/// the target (atomic on POSIX; `MoveFileEx`-with-replace on Windows).
914fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
915    let dir = path.parent().unwrap_or_else(|| Path::new("."));
916    let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), now_nanos()));
917    {
918        let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
919        f.write_all(data)?;
920        f.sync_all()?;
921    }
922    // On Windows the replace can transiently fail if an antivirus/indexer holds
923    // a handle on the target for a moment; a few quick retries make it reliable.
924    // On POSIX the first attempt always succeeds.
925    let mut attempt = 0;
926    loop {
927        match fs::rename(&tmp, path) {
928            Ok(()) => return Ok(()),
929            Err(e) => {
930                attempt += 1;
931                if attempt >= 10 {
932                    let _ = fs::remove_file(&tmp);
933                    return Err(e);
934                }
935                std::thread::sleep(std::time::Duration::from_millis(20));
936            }
937        }
938    }
939}
940
941/// Lexically resolve `.` and `..` without touching the filesystem (so it works
942/// for paths that don't exist yet and can't be fooled by `../` traversal).
943fn lexical_normalize(p: &Path) -> PathBuf {
944    let mut out = PathBuf::new();
945    for comp in p.components() {
946        match comp {
947            Component::ParentDir => {
948                out.pop();
949            }
950            Component::CurDir => {}
951            other => out.push(other.as_os_str()),
952        }
953    }
954    out
955}
956
957fn parse_cp(id: &str) -> Option<u64> {
958    id.strip_prefix("cp").and_then(|n| n.parse().ok())
959}
960
961fn now_millis() -> u64 {
962    SystemTime::now()
963        .duration_since(UNIX_EPOCH)
964        .map(|d| d.as_millis() as u64)
965        .unwrap_or(0)
966}
967
968fn now_nanos() -> u128 {
969    SystemTime::now()
970        .duration_since(UNIX_EPOCH)
971        .map(|d| d.as_nanos())
972        .unwrap_or(0)
973}
974
975fn invalid_data(e: serde_json::Error) -> io::Error {
976    io::Error::new(io::ErrorKind::InvalidData, e)
977}