1use 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#[derive(Debug, Clone)]
24pub struct ConflictReport {
25 pub files: Vec<String>,
27}
28
29#[derive(Debug, Clone)]
31pub struct RewindPlan {
32 pub target: CheckpointId,
34 pub touched_paths: Vec<String>,
36 pub last_known_post: Option<CheckpointId>,
39 pub turns_to_drop: Vec<usize>,
42}
43
44pub 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
101pub 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
121pub 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#[derive(Debug, Clone)]
161pub struct RewindResult {
162 pub stats: RestoreStats,
163 pub dropped_turns: Vec<usize>,
164}
165
166pub 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 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 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 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 fs::write(&mine, "mine-v1").unwrap();
422 let post_a = repo.snapshot_for_session("a", "post").unwrap();
423
424 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}