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/// **Goal 284**: with on-demand checkpoints, the target checkpoint is
51/// the `id` field of the most recent log entry with `turn < to_turn`,
52/// or the `pre` field of the entry for `to_turn` (old auto-snapshot
53/// sessions). If neither is available, returns an error.
54///
55/// Returns an error if the log doesn't reach `to_turn` or if no
56/// suitable checkpoint was recorded.
57pub fn plan_rewind(log_path: &Path, to_turn: usize) -> Result<RewindPlan> {
58    let recs = read_log(log_path)?;
59    if recs.is_empty() {
60        return Err(Error::Tool {
61            name: "rewind".into(),
62            call_id: None,
63            message: "no checkpoints recorded for this session".into(),
64        });
65    }
66
67    // Find the target checkpoint: prefer the `id` of the most recent
68    // entry before `to_turn`, falling back to the `pre` field of the
69    // entry for `to_turn` (old auto-snapshot sessions).
70    let target: CheckpointId = if to_turn == 0 {
71        // Rewind to start: use first record's pre (old auto-snapshot
72        // sessions), falling back to its id (new on-demand sessions).
73        // The id of the first record is the closest thing to "before
74        // turn 0" we have in the on-demand model.
75        recs.first()
76            .and_then(|r| r.pre.clone().or_else(|| Some(r.id.clone())))
77            .ok_or_else(|| Error::Tool {
78                name: "rewind".into(),
79                call_id: None,
80                message: "no checkpoint recorded before turn 0".into(),
81            })?
82    } else {
83        // Find the most recent entry with turn < to_turn.
84        let prev = recs.iter().rev().find(|r| r.turn < to_turn);
85        match prev {
86            Some(r) => r.id.clone(),
87            None => {
88                // Fall back to the pre field of the entry at to_turn
89                // (for old auto-snapshot sessions where pre marks the
90                // state before this turn's changes).
91                let target_rec =
92                    recs.iter()
93                        .find(|r| r.turn == to_turn)
94                        .ok_or_else(|| Error::Tool {
95                            name: "rewind".into(),
96                            call_id: None,
97                            message: format!(
98                                "turn {to_turn} is not in this session's checkpoint log"
99                            ),
100                        })?;
101                target_rec.pre.clone().ok_or_else(|| Error::Tool {
102                    name: "rewind".into(),
103                    call_id: None,
104                    message: format!(
105                        "turn {to_turn} has no pre-snapshot recorded; \
106                         cannot rewind to its start"
107                    ),
108                })?
109            }
110        }
111    };
112
113    let mut touched: HashSet<String> = HashSet::new();
114    let mut turns_to_drop = Vec::new();
115    for r in &recs {
116        if r.turn >= to_turn {
117            turns_to_drop.push(r.turn);
118            for p in &r.touched_files {
119                touched.insert(p.clone());
120            }
121        }
122    }
123    let mut touched_paths: Vec<String> = touched.into_iter().collect();
124    touched_paths.sort();
125
126    let last_known_post = recs.last().map(|r| r.id.clone());
127
128    Ok(RewindPlan {
129        target,
130        touched_paths,
131        last_known_post,
132        turns_to_drop,
133    })
134}
135
136/// Check whether the workspace's current state for the touched files
137/// matches the session's last-known post-snapshot. Returns the list
138/// of conflicting paths (empty list = safe to proceed).
139pub fn detect_conflicts(repo: &ShadowRepo, plan: &RewindPlan) -> Result<Vec<String>> {
140    let last = match &plan.last_known_post {
141        Some(id) => id,
142        None => return Ok(vec![]),
143    };
144    let mut conflicts = Vec::new();
145    for path in &plan.touched_paths {
146        let abs = repo.workspace().join(path);
147        let current = std::fs::read(&abs).ok();
148        let expected = repo.read_file_at(last, path)?;
149        if current != expected {
150            conflicts.push(path.clone());
151        }
152    }
153    Ok(conflicts)
154}
155
156/// Apply a rewind plan: restore files, then truncate the checkpoint
157/// log. Transcript truncation is left to the caller because it owns
158/// `transcript.jsonl`.
159///
160/// `force = false` aborts on detected conflicts and returns
161/// `Err(Error::Tool { ... })` whose message lists each file. Callers
162/// can choose to retry with `force = true`.
163pub fn apply_rewind(
164    repo: &ShadowRepo,
165    log_path: &Path,
166    plan: &RewindPlan,
167    force: bool,
168) -> Result<RewindResult> {
169    if !force {
170        let conflicts = detect_conflicts(repo, plan)?;
171        if !conflicts.is_empty() {
172            return Err(Error::Tool {
173                name: "rewind".into(),
174                call_id: None,
175                message: format!(
176                    "rewind blocked by {} conflicting file(s): {}\n\
177                     Re-run with --force to overwrite.",
178                    conflicts.len(),
179                    conflicts.join(", ")
180                ),
181            });
182        }
183    }
184    let stats = repo.restore_paths(&plan.target, &plan.touched_paths)?;
185    truncate_to_turn(
186        log_path,
187        plan.turns_to_drop.iter().copied().min().unwrap_or(0),
188    )?;
189    Ok(RewindResult {
190        stats,
191        dropped_turns: plan.turns_to_drop.clone(),
192    })
193}
194
195/// Result of a successful rewind.
196#[derive(Debug, Clone)]
197pub struct RewindResult {
198    pub stats: RestoreStats,
199    pub dropped_turns: Vec<usize>,
200}
201
202/// Convenience: locate `checkpoints.jsonl` for a session given the
203/// workspace root and a session id. Mirrors the path layout chosen
204/// by `SessionWriter`.
205pub fn checkpoint_log_path(workspace: &Path, workspace_slug: &str, session_id: &str) -> PathBuf {
206    workspace
207        .join(".recursive")
208        .join("sessions")
209        .join(workspace_slug)
210        .join(session_id)
211        .join("checkpoints.jsonl")
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
218    use std::fs;
219    use std::process::Command;
220    use tempfile::TempDir;
221
222    #[test]
223    fn checkpoint_log_path_has_correct_structure() {
224        // kills function-level replacement and component-order mutations
225        use std::path::PathBuf;
226        let workspace = PathBuf::from("/home/user/project");
227        let path = checkpoint_log_path(&workspace, "my-slug", "sess-01");
228        assert_eq!(
229            path,
230            PathBuf::from(
231                "/home/user/project/.recursive/sessions/my-slug/sess-01/checkpoints.jsonl"
232            )
233        );
234    }
235
236    fn has_git() -> bool {
237        Command::new("git").arg("--version").output().is_ok()
238    }
239
240    /// Workspace tempdir + sibling shadow tempdir. The pair is passed
241    /// to `ShadowRepo::open_at` so tests don't need the global env
242    /// lock and can run in parallel. `dir.path()` returns the
243    /// workspace; `shadow_dir(&dir)` returns the shadow path.
244    struct ShadowWs {
245        workspace: TempDir,
246        shadow: TempDir,
247    }
248
249    impl ShadowWs {
250        fn path(&self) -> &Path {
251            self.workspace.path()
252        }
253        fn open_repo(&self) -> Result<ShadowRepo> {
254            ShadowRepo::open_at(self.path(), self.shadow.path().join("shadow-git"))
255        }
256    }
257
258    fn shadow_ws() -> ShadowWs {
259        ShadowWs {
260            workspace: tempfile::tempdir().expect("workspace tempdir"),
261            shadow: tempfile::tempdir().expect("shadow tempdir"),
262        }
263    }
264
265    fn write_log(path: &Path, records: &[CheckpointRecord]) {
266        let w = CheckpointLogWriter::open(path).unwrap();
267        for r in records {
268            w.append(r).unwrap();
269        }
270    }
271
272    fn rec(turn: usize, pre: Option<&str>, id: &str, touched: &[&str]) -> CheckpointRecord {
273        CheckpointRecord {
274            turn,
275            pre: pre.map(|s| CheckpointId(s.to_string())),
276            id: CheckpointId(id.to_string()),
277            message: None,
278            touched_files: touched.iter().map(|s| s.to_string()).collect(),
279            touched_via: TouchedVia::Structured,
280            started_at: 0,
281            finished_at: 0,
282            saved_at: 0,
283        }
284    }
285
286    #[test]
287    fn plan_rewind_collects_touched_files_across_dropped_turns() {
288        let dir = tempfile::tempdir().unwrap();
289        let log = dir.path().join("c.jsonl");
290        write_log(
291            &log,
292            &[
293                rec(0, Some("p0"), "q0", &["a.txt"]),
294                rec(1, Some("q0"), "q1", &["b.txt"]),
295                rec(2, Some("q1"), "q2", &["c.txt"]),
296            ],
297        );
298        let plan = plan_rewind(&log, 1).unwrap();
299        assert_eq!(plan.target.0, "q0");
300        assert_eq!(plan.touched_paths, vec!["b.txt", "c.txt"]);
301        assert_eq!(plan.turns_to_drop, vec![1, 2]);
302        assert_eq!(
303            plan.last_known_post.as_ref().map(|c| c.0.as_str()),
304            Some("q2")
305        );
306    }
307
308    #[test]
309    fn plan_rewind_to_zero_drops_all() {
310        let dir = tempfile::tempdir().unwrap();
311        let log = dir.path().join("c.jsonl");
312        write_log(
313            &log,
314            &[
315                rec(0, Some("p0"), "q0", &["a.txt"]),
316                rec(1, Some("q0"), "q1", &["b.txt"]),
317            ],
318        );
319        let plan = plan_rewind(&log, 0).unwrap();
320        assert_eq!(plan.turns_to_drop, vec![0, 1]);
321    }
322
323    /// Goal 284: on-demand checkpoints have no `pre` field. Verify
324    /// `plan_rewind(to_turn=0)` uses the `id` of the first record
325    /// as the target when `pre` is absent.
326    #[test]
327    fn plan_rewind_to_zero_uses_first_id_when_no_pre() {
328        let dir = tempfile::tempdir().unwrap();
329        let log = dir.path().join("c.jsonl");
330        write_log(
331            &log,
332            &[
333                rec(0, None, "q0", &["a.txt"]),
334                rec(1, None, "q1", &["b.txt"]),
335            ],
336        );
337        let plan = plan_rewind(&log, 0).unwrap();
338        assert_eq!(plan.target.0, "q0");
339        assert_eq!(plan.turns_to_drop, vec![0, 1]);
340    }
341
342    #[test]
343    fn plan_rewind_uses_closest_previous_checkpoint() {
344        let dir = tempfile::tempdir().unwrap();
345        let log = dir.path().join("c.jsonl");
346        write_log(&log, &[rec(0, Some("p0"), "q0", &["a.txt"])]);
347        // to_turn=5 is beyond the last recorded turn; use turn 0's id.
348        let plan = plan_rewind(&log, 5).unwrap();
349        assert_eq!(plan.target.0, "q0");
350        assert!(plan.turns_to_drop.is_empty());
351        assert!(plan.touched_paths.is_empty());
352    }
353
354    #[test]
355    fn detect_conflicts_flags_externally_modified_file() {
356        if !has_git() {
357            return;
358        }
359        let dir = shadow_ws();
360        fs::write(dir.path().join("a.txt"), "v0").unwrap();
361        let repo = dir.open_repo().unwrap();
362        let pre = repo.snapshot_for_session("s", "pre").unwrap();
363        fs::write(dir.path().join("a.txt"), "v1").unwrap();
364        let post = repo.snapshot_for_session("s", "post").unwrap();
365
366        // Simulate "external" change after this session's last snapshot.
367        fs::write(dir.path().join("a.txt"), "v2-from-someone-else").unwrap();
368
369        let plan = RewindPlan {
370            target: pre.clone(),
371            touched_paths: vec!["a.txt".into()],
372            last_known_post: Some(post),
373            turns_to_drop: vec![0],
374        };
375        let conflicts = detect_conflicts(&repo, &plan).unwrap();
376        assert_eq!(conflicts, vec!["a.txt".to_string()]);
377    }
378
379    #[test]
380    fn apply_rewind_restores_and_truncates_log() {
381        if !has_git() {
382            return;
383        }
384        let dir = shadow_ws();
385        let log = dir.path().join("c.jsonl");
386        let target_path = dir.path().join("file.txt");
387        fs::write(&target_path, "before").unwrap();
388        let repo = dir.open_repo().unwrap();
389        let pre = repo.snapshot_for_session("s", "t0 pre").unwrap();
390        fs::write(&target_path, "after").unwrap();
391        let post = repo.snapshot_for_session("s", "t0 post").unwrap();
392
393        write_log(
394            &log,
395            &[CheckpointRecord {
396                turn: 0,
397                pre: Some(pre.clone()),
398                id: post.clone(),
399                message: None,
400                touched_files: vec!["file.txt".into()],
401                touched_via: TouchedVia::Structured,
402                started_at: 0,
403                finished_at: 0,
404                saved_at: 0,
405            }],
406        );
407
408        let plan = plan_rewind(&log, 0).unwrap();
409        let result = apply_rewind(&repo, &log, &plan, false).unwrap();
410        assert_eq!(result.stats.restored, 1);
411        assert_eq!(fs::read_to_string(&target_path).unwrap(), "before");
412        assert!(read_log(&log).unwrap().is_empty());
413    }
414
415    #[test]
416    fn apply_rewind_blocks_on_conflict_without_force() {
417        if !has_git() {
418            return;
419        }
420        let dir = shadow_ws();
421        let log = dir.path().join("c.jsonl");
422        let f = dir.path().join("file.txt");
423        fs::write(&f, "v0").unwrap();
424        let repo = dir.open_repo().unwrap();
425        let pre = repo.snapshot_for_session("s", "pre").unwrap();
426        fs::write(&f, "v1").unwrap();
427        let post = repo.snapshot_for_session("s", "post").unwrap();
428
429        write_log(
430            &log,
431            &[CheckpointRecord {
432                turn: 0,
433                pre: Some(pre.clone()),
434                id: post.clone(),
435                message: None,
436                touched_files: vec!["file.txt".into()],
437                touched_via: TouchedVia::Structured,
438                started_at: 0,
439                finished_at: 0,
440                saved_at: 0,
441            }],
442        );
443
444        // External edit between session's last snapshot and rewind.
445        fs::write(&f, "external-edit").unwrap();
446
447        let plan = plan_rewind(&log, 0).unwrap();
448        let err = apply_rewind(&repo, &log, &plan, false).unwrap_err();
449        assert!(err.to_string().contains("conflict"));
450    }
451
452    #[test]
453    fn apply_rewind_force_overrides_conflict() {
454        if !has_git() {
455            return;
456        }
457        let dir = shadow_ws();
458        let log = dir.path().join("c.jsonl");
459        let f = dir.path().join("file.txt");
460        fs::write(&f, "v0").unwrap();
461        let repo = dir.open_repo().unwrap();
462        let pre = repo.snapshot_for_session("s", "pre").unwrap();
463        fs::write(&f, "v1").unwrap();
464        let post = repo.snapshot_for_session("s", "post").unwrap();
465
466        write_log(
467            &log,
468            &[CheckpointRecord {
469                turn: 0,
470                pre: Some(pre.clone()),
471                id: post.clone(),
472                message: None,
473                touched_files: vec!["file.txt".into()],
474                touched_via: TouchedVia::Structured,
475                started_at: 0,
476                finished_at: 0,
477                saved_at: 0,
478            }],
479        );
480
481        fs::write(&f, "external").unwrap();
482        let plan = plan_rewind(&log, 0).unwrap();
483        let _ = apply_rewind(&repo, &log, &plan, true).unwrap();
484        assert_eq!(fs::read_to_string(&f).unwrap(), "v0");
485    }
486
487    #[test]
488    fn rewind_does_not_touch_sibling_session_files() {
489        if !has_git() {
490            return;
491        }
492        let dir = shadow_ws();
493        let log_a = dir.path().join("a.jsonl");
494        let mine = dir.path().join("mine.txt");
495        let theirs = dir.path().join("theirs.txt");
496        fs::write(&mine, "mine-v0").unwrap();
497        fs::write(&theirs, "theirs-v0").unwrap();
498        let repo = dir.open_repo().unwrap();
499
500        let pre_a = repo.snapshot_for_session("a", "pre").unwrap();
501        // A modifies its own file.
502        fs::write(&mine, "mine-v1").unwrap();
503        let post_a = repo.snapshot_for_session("a", "post").unwrap();
504
505        // B (sibling) modifies its own file *after* A took its post-snapshot.
506        fs::write(&theirs, "theirs-v1").unwrap();
507
508        write_log(
509            &log_a,
510            &[CheckpointRecord {
511                turn: 0,
512                pre: Some(pre_a.clone()),
513                id: post_a.clone(),
514                message: None,
515                touched_files: vec!["mine.txt".into()],
516                touched_via: TouchedVia::Structured,
517                started_at: 0,
518                finished_at: 0,
519                saved_at: 0,
520            }],
521        );
522
523        let plan = plan_rewind(&log_a, 0).unwrap();
524        let _ = apply_rewind(&repo, &log_a, &plan, false).unwrap();
525
526        assert_eq!(fs::read_to_string(&mine).unwrap(), "mine-v0");
527        assert_eq!(
528            fs::read_to_string(&theirs).unwrap(),
529            "theirs-v1",
530            "sibling session's file must not be touched"
531        );
532    }
533
534    #[test]
535    fn plan_rewind_no_prev_and_target_has_no_pre_returns_error() {
536        // kills `ok_or_else(|| "turn {to_turn} has no pre-snapshot recorded")` guard removal
537        // Scenario: only turn=3 exists (no prev with turn < 3), and turn=3 has no `pre` field.
538        let dir = tempfile::tempdir().unwrap();
539        let log_path = dir.path().join("ck.jsonl");
540        // Only turn 3 exists with no pre field.
541        write_log(&log_path, &[rec(3, None, "snap-3-id", &["x.txt"])]);
542        let err = plan_rewind(&log_path, 3);
543        assert!(
544            err.is_err(),
545            "turn with no pre-snapshot must return an error"
546        );
547        let msg = format!("{}", err.unwrap_err());
548        assert!(
549            msg.contains("no pre-snapshot") || msg.contains("pre"),
550            "error must mention missing pre-snapshot; got: {msg}"
551        );
552    }
553
554    #[test]
555    fn plan_rewind_returns_error_for_empty_log() {
556        // kills `if recs.is_empty() { return Err(...) }` guard-removal mutation
557        let dir = tempfile::tempdir().unwrap();
558        let log_path = dir.path().join("empty.jsonl");
559        // Write an empty file (no records).
560        std::fs::write(&log_path, "").unwrap();
561        let err = plan_rewind(&log_path, 1);
562        assert!(err.is_err(), "empty log must return an error");
563        let msg = format!("{}", err.unwrap_err());
564        assert!(
565            msg.contains("no checkpoints"),
566            "error must mention 'no checkpoints', got: {msg}"
567        );
568    }
569
570    #[test]
571    fn plan_rewind_returns_error_when_turn_not_in_log_and_no_prev() {
572        // kills the `ok_or_else(|| Error::Tool { "turn {to_turn} is not in this session" })` path.
573        // We need to_turn=3 where all existing records have turn >= 3, so `prev` (turn < 3) is None,
574        // and to_turn=3 itself does not exist.
575        let dir = tempfile::tempdir().unwrap();
576        let log_path = dir.path().join("ck.jsonl");
577        // Only turn 5 exists. to_turn=3: no record has turn < 3, and turn 3 is not in the log.
578        write_log(
579            &log_path,
580            &[rec(5, Some("snap-pre"), "snap-post", &["a.txt"])],
581        );
582        let err = plan_rewind(&log_path, 3);
583        assert!(err.is_err(), "non-existent turn must return an error");
584        let msg = format!("{}", err.unwrap_err());
585        assert!(
586            msg.contains("3"),
587            "error must mention the missing turn number, got: {msg}"
588        );
589    }
590
591    #[test]
592    fn checkpoint_log_path_omits_workspace_slug_duplication() {
593        // kills literal mutations in the path segments
594        let ws = std::path::Path::new("/home/user/myproject");
595        let path = checkpoint_log_path(ws, "myproject", "sess-abc");
596        let expected = ws
597            .join(".recursive")
598            .join("sessions")
599            .join("myproject")
600            .join("sess-abc")
601            .join("checkpoints.jsonl");
602        assert_eq!(path, expected);
603    }
604}