use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::checkpoint::{CheckpointId, RestoreStats, ShadowRepo};
use crate::checkpoint_log::{read_log, truncate_to_turn};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct ConflictReport {
pub files: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RewindPlan {
pub target: CheckpointId,
pub touched_paths: Vec<String>,
pub last_known_post: Option<CheckpointId>,
pub turns_to_drop: Vec<usize>,
}
pub fn plan_rewind(log_path: &Path, to_turn: usize) -> Result<RewindPlan> {
let recs = read_log(log_path)?;
if recs.is_empty() {
return Err(Error::Tool {
name: "rewind".into(),
message: "no checkpoints recorded for this session".into(),
});
}
let target_rec = recs
.iter()
.find(|r| r.turn == to_turn)
.ok_or_else(|| Error::Tool {
name: "rewind".into(),
message: format!("turn {to_turn} is not in this session's checkpoint log"),
})?;
let target = target_rec.pre.clone().ok_or_else(|| Error::Tool {
name: "rewind".into(),
message: format!(
"turn {to_turn} has no pre-snapshot recorded; \
cannot rewind to its start"
),
})?;
let mut touched: HashSet<String> = HashSet::new();
let mut turns_to_drop = Vec::new();
for r in &recs {
if r.turn >= to_turn {
turns_to_drop.push(r.turn);
for p in &r.touched_files {
touched.insert(p.clone());
}
}
}
let mut touched_paths: Vec<String> = touched.into_iter().collect();
touched_paths.sort();
let last_known_post = recs.last().map(|r| r.post.clone());
Ok(RewindPlan {
target,
touched_paths,
last_known_post,
turns_to_drop,
})
}
pub fn detect_conflicts(repo: &ShadowRepo, plan: &RewindPlan) -> Result<Vec<String>> {
let last = match &plan.last_known_post {
Some(id) => id,
None => return Ok(vec![]),
};
let mut conflicts = Vec::new();
for path in &plan.touched_paths {
let abs = repo.workspace().join(path);
let current = std::fs::read(&abs).ok();
let expected = repo.read_file_at(last, path)?;
if current != expected {
conflicts.push(path.clone());
}
}
Ok(conflicts)
}
pub fn apply_rewind(
repo: &ShadowRepo,
log_path: &Path,
plan: &RewindPlan,
force: bool,
) -> Result<RewindResult> {
if !force {
let conflicts = detect_conflicts(repo, plan)?;
if !conflicts.is_empty() {
return Err(Error::Tool {
name: "rewind".into(),
message: format!(
"rewind blocked by {} conflicting file(s): {}\n\
Re-run with --force to overwrite.",
conflicts.len(),
conflicts.join(", ")
),
});
}
}
let stats = repo.restore_paths(&plan.target, &plan.touched_paths)?;
truncate_to_turn(
log_path,
plan.turns_to_drop.iter().copied().min().unwrap_or(0),
)?;
Ok(RewindResult {
stats,
dropped_turns: plan.turns_to_drop.clone(),
})
}
#[derive(Debug, Clone)]
pub struct RewindResult {
pub stats: RestoreStats,
pub dropped_turns: Vec<usize>,
}
pub fn checkpoint_log_path(workspace: &Path, workspace_slug: &str, session_id: &str) -> PathBuf {
workspace
.join(".recursive")
.join("sessions")
.join(workspace_slug)
.join(session_id)
.join("checkpoints.jsonl")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn has_git() -> bool {
Command::new("git").arg("--version").output().is_ok()
}
struct ShadowWs {
workspace: TempDir,
shadow: TempDir,
}
impl ShadowWs {
fn path(&self) -> &Path {
self.workspace.path()
}
fn open_repo(&self) -> Result<ShadowRepo> {
ShadowRepo::open_at(self.path(), self.shadow.path().join("shadow-git"))
}
}
fn shadow_ws() -> ShadowWs {
ShadowWs {
workspace: tempfile::tempdir().expect("workspace tempdir"),
shadow: tempfile::tempdir().expect("shadow tempdir"),
}
}
fn write_log(path: &Path, records: &[CheckpointRecord]) {
let w = CheckpointLogWriter::open(path).unwrap();
for r in records {
w.append(r).unwrap();
}
}
fn rec(turn: usize, pre: Option<&str>, post: &str, touched: &[&str]) -> CheckpointRecord {
CheckpointRecord {
turn,
pre: pre.map(|s| CheckpointId(s.to_string())),
post: CheckpointId(post.to_string()),
touched_files: touched.iter().map(|s| s.to_string()).collect(),
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}
}
#[test]
fn plan_rewind_collects_touched_files_across_dropped_turns() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("c.jsonl");
write_log(
&log,
&[
rec(0, Some("p0"), "q0", &["a.txt"]),
rec(1, Some("q0"), "q1", &["b.txt"]),
rec(2, Some("q1"), "q2", &["c.txt"]),
],
);
let plan = plan_rewind(&log, 1).unwrap();
assert_eq!(plan.target.0, "q0");
assert_eq!(plan.touched_paths, vec!["b.txt", "c.txt"]);
assert_eq!(plan.turns_to_drop, vec![1, 2]);
assert_eq!(
plan.last_known_post.as_ref().map(|c| c.0.as_str()),
Some("q2")
);
}
#[test]
fn plan_rewind_to_zero_drops_all() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("c.jsonl");
write_log(
&log,
&[
rec(0, Some("p0"), "q0", &["a.txt"]),
rec(1, Some("q0"), "q1", &["b.txt"]),
],
);
let plan = plan_rewind(&log, 0).unwrap();
assert_eq!(plan.turns_to_drop, vec![0, 1]);
}
#[test]
fn plan_rewind_errors_on_unknown_turn() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("c.jsonl");
write_log(&log, &[rec(0, Some("p0"), "q0", &[])]);
assert!(plan_rewind(&log, 5).is_err());
}
#[test]
fn detect_conflicts_flags_externally_modified_file() {
if !has_git() {
return;
}
let dir = shadow_ws();
fs::write(dir.path().join("a.txt"), "v0").unwrap();
let repo = dir.open_repo().unwrap();
let pre = repo.snapshot_for_session("s", "pre").unwrap();
fs::write(dir.path().join("a.txt"), "v1").unwrap();
let post = repo.snapshot_for_session("s", "post").unwrap();
fs::write(dir.path().join("a.txt"), "v2-from-someone-else").unwrap();
let plan = RewindPlan {
target: pre.clone(),
touched_paths: vec!["a.txt".into()],
last_known_post: Some(post),
turns_to_drop: vec![0],
};
let conflicts = detect_conflicts(&repo, &plan).unwrap();
assert_eq!(conflicts, vec!["a.txt".to_string()]);
}
#[test]
fn apply_rewind_restores_and_truncates_log() {
if !has_git() {
return;
}
let dir = shadow_ws();
let log = dir.path().join("c.jsonl");
let target_path = dir.path().join("file.txt");
fs::write(&target_path, "before").unwrap();
let repo = dir.open_repo().unwrap();
let pre = repo.snapshot_for_session("s", "t0 pre").unwrap();
fs::write(&target_path, "after").unwrap();
let post = repo.snapshot_for_session("s", "t0 post").unwrap();
write_log(
&log,
&[CheckpointRecord {
turn: 0,
pre: Some(pre.clone()),
post: post.clone(),
touched_files: vec!["file.txt".into()],
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}],
);
let plan = plan_rewind(&log, 0).unwrap();
let result = apply_rewind(&repo, &log, &plan, false).unwrap();
assert_eq!(result.stats.restored, 1);
assert_eq!(fs::read_to_string(&target_path).unwrap(), "before");
assert!(read_log(&log).unwrap().is_empty());
}
#[test]
fn apply_rewind_blocks_on_conflict_without_force() {
if !has_git() {
return;
}
let dir = shadow_ws();
let log = dir.path().join("c.jsonl");
let f = dir.path().join("file.txt");
fs::write(&f, "v0").unwrap();
let repo = dir.open_repo().unwrap();
let pre = repo.snapshot_for_session("s", "pre").unwrap();
fs::write(&f, "v1").unwrap();
let post = repo.snapshot_for_session("s", "post").unwrap();
write_log(
&log,
&[CheckpointRecord {
turn: 0,
pre: Some(pre.clone()),
post: post.clone(),
touched_files: vec!["file.txt".into()],
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}],
);
fs::write(&f, "external-edit").unwrap();
let plan = plan_rewind(&log, 0).unwrap();
let err = apply_rewind(&repo, &log, &plan, false).unwrap_err();
assert!(err.to_string().contains("conflict"));
}
#[test]
fn apply_rewind_force_overrides_conflict() {
if !has_git() {
return;
}
let dir = shadow_ws();
let log = dir.path().join("c.jsonl");
let f = dir.path().join("file.txt");
fs::write(&f, "v0").unwrap();
let repo = dir.open_repo().unwrap();
let pre = repo.snapshot_for_session("s", "pre").unwrap();
fs::write(&f, "v1").unwrap();
let post = repo.snapshot_for_session("s", "post").unwrap();
write_log(
&log,
&[CheckpointRecord {
turn: 0,
pre: Some(pre.clone()),
post: post.clone(),
touched_files: vec!["file.txt".into()],
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}],
);
fs::write(&f, "external").unwrap();
let plan = plan_rewind(&log, 0).unwrap();
let _ = apply_rewind(&repo, &log, &plan, true).unwrap();
assert_eq!(fs::read_to_string(&f).unwrap(), "v0");
}
#[test]
fn rewind_does_not_touch_sibling_session_files() {
if !has_git() {
return;
}
let dir = shadow_ws();
let log_a = dir.path().join("a.jsonl");
let mine = dir.path().join("mine.txt");
let theirs = dir.path().join("theirs.txt");
fs::write(&mine, "mine-v0").unwrap();
fs::write(&theirs, "theirs-v0").unwrap();
let repo = dir.open_repo().unwrap();
let pre_a = repo.snapshot_for_session("a", "pre").unwrap();
fs::write(&mine, "mine-v1").unwrap();
let post_a = repo.snapshot_for_session("a", "post").unwrap();
fs::write(&theirs, "theirs-v1").unwrap();
write_log(
&log_a,
&[CheckpointRecord {
turn: 0,
pre: Some(pre_a.clone()),
post: post_a.clone(),
touched_files: vec!["mine.txt".into()],
touched_via: TouchedVia::Structured,
started_at: 0,
finished_at: 0,
}],
);
let plan = plan_rewind(&log_a, 0).unwrap();
let _ = apply_rewind(&repo, &log_a, &plan, false).unwrap();
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine-v0");
assert_eq!(
fs::read_to_string(&theirs).unwrap(),
"theirs-v1",
"sibling session's file must not be touched"
);
}
}