Skip to main content

recursive/
rewind.rs

1//! Selective rewind for a session's checkpoint chain.
2//!
3//! Reads `checkpoints.jsonl`, identifies which files this session
4//! touched in turns >= the rewind cutoff, detects conflicts where the
5//! current workspace state diverges from this session's last known
6//! post-snapshot for those files, and asks [`ShadowRepo::restore_paths`]
7//! to revert only that file subset.
8//!
9//! Multi-session safety: because `restore_paths` operates on a path
10//! whitelist, files modified by a sibling session are never touched
11//! unless they happen to also fall within this session's touched set
12//! (in which case the conflict is surfaced and the user must
13//! `--force`).
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18use crate::checkpoint::{CheckpointId, RestoreStats, ShadowRepo};
19use crate::checkpoint_log::{read_log, truncate_to_turn};
20use crate::error::{Error, Result};
21
22/// Outcome of a [`rewind`] call when conflicts were detected.
23#[derive(Debug, Clone)]
24pub struct ConflictReport {
25    /// Files that have diverged from this session's last known state.
26    pub files: Vec<String>,
27}
28
29/// Plan describing what a rewind would do, for a dry-run preview.
30#[derive(Debug, Clone)]
31pub struct RewindPlan {
32    /// Target checkpoint to restore the touched files to.
33    pub target: CheckpointId,
34    /// Workspace-relative paths in this session's touched set.
35    pub touched_paths: Vec<String>,
36    /// The last "post" checkpoint this session recorded. Used for
37    /// conflict detection.
38    pub last_known_post: Option<CheckpointId>,
39    /// Turns that will be removed from `checkpoints.jsonl` and from
40    /// the conversation transcript on commit.
41    pub turns_to_drop: Vec<usize>,
42}
43
44/// Compute a [`RewindPlan`] for rewinding `to_turn`.
45///
46/// `to_turn` is the turn index whose **start** state we want to
47/// restore. So `to_turn = 0` undoes everything; `to_turn = 3`
48/// preserves turns 0..=2 and undoes 3 and later.
49///
50/// Returns an error if the log doesn't contain `to_turn` (e.g. the
51/// session never reached that turn) or if no `pre` checkpoint was
52/// recorded for it.
53pub fn plan_rewind(log_path: &Path, to_turn: usize) -> Result<RewindPlan> {
54    let recs = read_log(log_path)?;
55    if recs.is_empty() {
56        return Err(Error::Tool {
57            name: "rewind".into(),
58            message: "no checkpoints recorded for this session".into(),
59        });
60    }
61
62    let target_rec = recs
63        .iter()
64        .find(|r| r.turn == to_turn)
65        .ok_or_else(|| Error::Tool {
66            name: "rewind".into(),
67            message: format!("turn {to_turn} is not in this session's checkpoint log"),
68        })?;
69
70    let target = target_rec.pre.clone().ok_or_else(|| Error::Tool {
71        name: "rewind".into(),
72        message: format!(
73            "turn {to_turn} has no pre-snapshot recorded; \
74             cannot rewind to its start"
75        ),
76    })?;
77
78    let mut touched: HashSet<String> = HashSet::new();
79    let mut turns_to_drop = Vec::new();
80    for r in &recs {
81        if r.turn >= to_turn {
82            turns_to_drop.push(r.turn);
83            for p in &r.touched_files {
84                touched.insert(p.clone());
85            }
86        }
87    }
88    let mut touched_paths: Vec<String> = touched.into_iter().collect();
89    touched_paths.sort();
90
91    let last_known_post = recs.last().map(|r| r.post.clone());
92
93    Ok(RewindPlan {
94        target,
95        touched_paths,
96        last_known_post,
97        turns_to_drop,
98    })
99}
100
101/// Check whether the workspace's current state for the touched files
102/// matches the session's last-known post-snapshot. Returns the list
103/// of conflicting paths (empty list = safe to proceed).
104pub fn detect_conflicts(repo: &ShadowRepo, plan: &RewindPlan) -> Result<Vec<String>> {
105    let last = match &plan.last_known_post {
106        Some(id) => id,
107        None => return Ok(vec![]),
108    };
109    let mut conflicts = Vec::new();
110    for path in &plan.touched_paths {
111        let abs = repo.workspace().join(path);
112        let current = std::fs::read(&abs).ok();
113        let expected = repo.read_file_at(last, path)?;
114        if current != expected {
115            conflicts.push(path.clone());
116        }
117    }
118    Ok(conflicts)
119}
120
121/// Apply a rewind plan: restore files, then truncate the checkpoint
122/// log. Transcript truncation is left to the caller because it owns
123/// `transcript.jsonl`.
124///
125/// `force = false` aborts on detected conflicts and returns
126/// `Err(Error::Tool { ... })` whose message lists each file. Callers
127/// can choose to retry with `force = true`.
128pub fn apply_rewind(
129    repo: &ShadowRepo,
130    log_path: &Path,
131    plan: &RewindPlan,
132    force: bool,
133) -> Result<RewindResult> {
134    if !force {
135        let conflicts = detect_conflicts(repo, plan)?;
136        if !conflicts.is_empty() {
137            return Err(Error::Tool {
138                name: "rewind".into(),
139                message: format!(
140                    "rewind blocked by {} conflicting file(s): {}\n\
141                     Re-run with --force to overwrite.",
142                    conflicts.len(),
143                    conflicts.join(", ")
144                ),
145            });
146        }
147    }
148    let stats = repo.restore_paths(&plan.target, &plan.touched_paths)?;
149    truncate_to_turn(
150        log_path,
151        plan.turns_to_drop.iter().copied().min().unwrap_or(0),
152    )?;
153    Ok(RewindResult {
154        stats,
155        dropped_turns: plan.turns_to_drop.clone(),
156    })
157}
158
159/// Result of a successful rewind.
160#[derive(Debug, Clone)]
161pub struct RewindResult {
162    pub stats: RestoreStats,
163    pub dropped_turns: Vec<usize>,
164}
165
166/// Convenience: locate `checkpoints.jsonl` for a session given the
167/// workspace root and a session id. Mirrors the path layout chosen
168/// by `SessionWriter`.
169pub fn checkpoint_log_path(workspace: &Path, workspace_slug: &str, session_id: &str) -> PathBuf {
170    workspace
171        .join(".recursive")
172        .join("sessions")
173        .join(workspace_slug)
174        .join(session_id)
175        .join("checkpoints.jsonl")
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
182    use std::fs;
183    use std::process::Command;
184    use tempfile::TempDir;
185
186    fn has_git() -> bool {
187        Command::new("git").arg("--version").output().is_ok()
188    }
189
190    /// Workspace tempdir + sibling shadow tempdir. The pair is passed
191    /// to `ShadowRepo::open_at` so tests don't need the global env
192    /// lock and can run in parallel. `dir.path()` returns the
193    /// workspace; `shadow_dir(&dir)` returns the shadow path.
194    struct ShadowWs {
195        workspace: TempDir,
196        shadow: TempDir,
197    }
198
199    impl ShadowWs {
200        fn path(&self) -> &Path {
201            self.workspace.path()
202        }
203        fn open_repo(&self) -> Result<ShadowRepo> {
204            ShadowRepo::open_at(self.path(), self.shadow.path().join("shadow-git"))
205        }
206    }
207
208    fn shadow_ws() -> ShadowWs {
209        ShadowWs {
210            workspace: tempfile::tempdir().expect("workspace tempdir"),
211            shadow: tempfile::tempdir().expect("shadow tempdir"),
212        }
213    }
214
215    fn write_log(path: &Path, records: &[CheckpointRecord]) {
216        let w = CheckpointLogWriter::open(path).unwrap();
217        for r in records {
218            w.append(r).unwrap();
219        }
220    }
221
222    fn rec(turn: usize, pre: Option<&str>, post: &str, touched: &[&str]) -> CheckpointRecord {
223        CheckpointRecord {
224            turn,
225            pre: pre.map(|s| CheckpointId(s.to_string())),
226            post: CheckpointId(post.to_string()),
227            touched_files: touched.iter().map(|s| s.to_string()).collect(),
228            touched_via: TouchedVia::Structured,
229            started_at: 0,
230            finished_at: 0,
231        }
232    }
233
234    #[test]
235    fn plan_rewind_collects_touched_files_across_dropped_turns() {
236        let dir = tempfile::tempdir().unwrap();
237        let log = dir.path().join("c.jsonl");
238        write_log(
239            &log,
240            &[
241                rec(0, Some("p0"), "q0", &["a.txt"]),
242                rec(1, Some("q0"), "q1", &["b.txt"]),
243                rec(2, Some("q1"), "q2", &["c.txt"]),
244            ],
245        );
246        let plan = plan_rewind(&log, 1).unwrap();
247        assert_eq!(plan.target.0, "q0");
248        assert_eq!(plan.touched_paths, vec!["b.txt", "c.txt"]);
249        assert_eq!(plan.turns_to_drop, vec![1, 2]);
250        assert_eq!(
251            plan.last_known_post.as_ref().map(|c| c.0.as_str()),
252            Some("q2")
253        );
254    }
255
256    #[test]
257    fn plan_rewind_to_zero_drops_all() {
258        let dir = tempfile::tempdir().unwrap();
259        let log = dir.path().join("c.jsonl");
260        write_log(
261            &log,
262            &[
263                rec(0, Some("p0"), "q0", &["a.txt"]),
264                rec(1, Some("q0"), "q1", &["b.txt"]),
265            ],
266        );
267        let plan = plan_rewind(&log, 0).unwrap();
268        assert_eq!(plan.turns_to_drop, vec![0, 1]);
269    }
270
271    #[test]
272    fn plan_rewind_errors_on_unknown_turn() {
273        let dir = tempfile::tempdir().unwrap();
274        let log = dir.path().join("c.jsonl");
275        write_log(&log, &[rec(0, Some("p0"), "q0", &[])]);
276        assert!(plan_rewind(&log, 5).is_err());
277    }
278
279    #[test]
280    fn detect_conflicts_flags_externally_modified_file() {
281        if !has_git() {
282            return;
283        }
284        let dir = shadow_ws();
285        fs::write(dir.path().join("a.txt"), "v0").unwrap();
286        let repo = dir.open_repo().unwrap();
287        let pre = repo.snapshot_for_session("s", "pre").unwrap();
288        fs::write(dir.path().join("a.txt"), "v1").unwrap();
289        let post = repo.snapshot_for_session("s", "post").unwrap();
290
291        // Simulate "external" change after this session's last snapshot.
292        fs::write(dir.path().join("a.txt"), "v2-from-someone-else").unwrap();
293
294        let plan = RewindPlan {
295            target: pre.clone(),
296            touched_paths: vec!["a.txt".into()],
297            last_known_post: Some(post),
298            turns_to_drop: vec![0],
299        };
300        let conflicts = detect_conflicts(&repo, &plan).unwrap();
301        assert_eq!(conflicts, vec!["a.txt".to_string()]);
302    }
303
304    #[test]
305    fn apply_rewind_restores_and_truncates_log() {
306        if !has_git() {
307            return;
308        }
309        let dir = shadow_ws();
310        let log = dir.path().join("c.jsonl");
311        let target_path = dir.path().join("file.txt");
312        fs::write(&target_path, "before").unwrap();
313        let repo = dir.open_repo().unwrap();
314        let pre = repo.snapshot_for_session("s", "t0 pre").unwrap();
315        fs::write(&target_path, "after").unwrap();
316        let post = repo.snapshot_for_session("s", "t0 post").unwrap();
317
318        write_log(
319            &log,
320            &[CheckpointRecord {
321                turn: 0,
322                pre: Some(pre.clone()),
323                post: post.clone(),
324                touched_files: vec!["file.txt".into()],
325                touched_via: TouchedVia::Structured,
326                started_at: 0,
327                finished_at: 0,
328            }],
329        );
330
331        let plan = plan_rewind(&log, 0).unwrap();
332        let result = apply_rewind(&repo, &log, &plan, false).unwrap();
333        assert_eq!(result.stats.restored, 1);
334        assert_eq!(fs::read_to_string(&target_path).unwrap(), "before");
335        assert!(read_log(&log).unwrap().is_empty());
336    }
337
338    #[test]
339    fn apply_rewind_blocks_on_conflict_without_force() {
340        if !has_git() {
341            return;
342        }
343        let dir = shadow_ws();
344        let log = dir.path().join("c.jsonl");
345        let f = dir.path().join("file.txt");
346        fs::write(&f, "v0").unwrap();
347        let repo = dir.open_repo().unwrap();
348        let pre = repo.snapshot_for_session("s", "pre").unwrap();
349        fs::write(&f, "v1").unwrap();
350        let post = repo.snapshot_for_session("s", "post").unwrap();
351
352        write_log(
353            &log,
354            &[CheckpointRecord {
355                turn: 0,
356                pre: Some(pre.clone()),
357                post: post.clone(),
358                touched_files: vec!["file.txt".into()],
359                touched_via: TouchedVia::Structured,
360                started_at: 0,
361                finished_at: 0,
362            }],
363        );
364
365        // External edit between session's last snapshot and rewind.
366        fs::write(&f, "external-edit").unwrap();
367
368        let plan = plan_rewind(&log, 0).unwrap();
369        let err = apply_rewind(&repo, &log, &plan, false).unwrap_err();
370        assert!(err.to_string().contains("conflict"));
371    }
372
373    #[test]
374    fn apply_rewind_force_overrides_conflict() {
375        if !has_git() {
376            return;
377        }
378        let dir = shadow_ws();
379        let log = dir.path().join("c.jsonl");
380        let f = dir.path().join("file.txt");
381        fs::write(&f, "v0").unwrap();
382        let repo = dir.open_repo().unwrap();
383        let pre = repo.snapshot_for_session("s", "pre").unwrap();
384        fs::write(&f, "v1").unwrap();
385        let post = repo.snapshot_for_session("s", "post").unwrap();
386
387        write_log(
388            &log,
389            &[CheckpointRecord {
390                turn: 0,
391                pre: Some(pre.clone()),
392                post: post.clone(),
393                touched_files: vec!["file.txt".into()],
394                touched_via: TouchedVia::Structured,
395                started_at: 0,
396                finished_at: 0,
397            }],
398        );
399
400        fs::write(&f, "external").unwrap();
401        let plan = plan_rewind(&log, 0).unwrap();
402        let _ = apply_rewind(&repo, &log, &plan, true).unwrap();
403        assert_eq!(fs::read_to_string(&f).unwrap(), "v0");
404    }
405
406    #[test]
407    fn rewind_does_not_touch_sibling_session_files() {
408        if !has_git() {
409            return;
410        }
411        let dir = shadow_ws();
412        let log_a = dir.path().join("a.jsonl");
413        let mine = dir.path().join("mine.txt");
414        let theirs = dir.path().join("theirs.txt");
415        fs::write(&mine, "mine-v0").unwrap();
416        fs::write(&theirs, "theirs-v0").unwrap();
417        let repo = dir.open_repo().unwrap();
418
419        let pre_a = repo.snapshot_for_session("a", "pre").unwrap();
420        // A modifies its own file.
421        fs::write(&mine, "mine-v1").unwrap();
422        let post_a = repo.snapshot_for_session("a", "post").unwrap();
423
424        // B (sibling) modifies its own file *after* A took its post-snapshot.
425        fs::write(&theirs, "theirs-v1").unwrap();
426
427        write_log(
428            &log_a,
429            &[CheckpointRecord {
430                turn: 0,
431                pre: Some(pre_a.clone()),
432                post: post_a.clone(),
433                touched_files: vec!["mine.txt".into()],
434                touched_via: TouchedVia::Structured,
435                started_at: 0,
436                finished_at: 0,
437            }],
438        );
439
440        let plan = plan_rewind(&log_a, 0).unwrap();
441        let _ = apply_rewind(&repo, &log_a, &plan, false).unwrap();
442
443        assert_eq!(fs::read_to_string(&mine).unwrap(), "mine-v0");
444        assert_eq!(
445            fs::read_to_string(&theirs).unwrap(),
446            "theirs-v1",
447            "sibling session's file must not be touched"
448        );
449    }
450}