Skip to main content

ralph/
checkpoint.rs

1use crate::config::CheckpointsConfig;
2use crate::errors::{RalphError, Result};
3use crate::providers::Message;
4use crate::session::Session;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CheckpointMeta {
11    pub id: String,
12    pub name: String,
13    pub turn: u32,
14    pub created_at: DateTime<Utc>,
15    pub modified_files: Vec<String>,
16    /// Git commit SHA, set when git_commit is enabled and file count exceeds threshold.
17    #[serde(default)]
18    pub git_sha: Option<String>,
19}
20
21pub struct Checkpoint {
22    pub meta: CheckpointMeta,
23    pub messages: Vec<Message>,
24    pub dir: PathBuf,
25}
26
27impl Checkpoint {
28    /// Create a checkpoint at the current session state.
29    pub async fn create(session: &Session, name: &str, config: &CheckpointsConfig) -> Result<Self> {
30        let seq = next_seq(session);
31        let id = format!("{:03}_{}", seq, sanitize_name(name));
32        let dir = session.checkpoints_dir().join(&id);
33        std::fs::create_dir_all(&dir)?;
34
35        // Snapshot all modified files
36        let files_dir = dir.join("files");
37        std::fs::create_dir_all(&files_dir)?;
38
39        let mut snapshotted_files = Vec::new();
40        for file_path in &session.modified_files {
41            if !file_path.exists() {
42                continue;
43            }
44            let rel = file_path
45                .strip_prefix(&session.meta.workspace)
46                .unwrap_or(file_path);
47            let dest = files_dir.join(rel);
48            if let Some(parent) = dest.parent() {
49                std::fs::create_dir_all(parent)?;
50            }
51            std::fs::copy(file_path, &dest)?;
52            snapshotted_files.push(rel.display().to_string());
53        }
54
55        // Save message history at this point
56        let messages_json = serde_json::to_string_pretty(&session.messages)?;
57        std::fs::write(dir.join("messages.json"), messages_json)?;
58
59        // Optionally commit to git when above the threshold
60        let git_sha = if config.git_commit && snapshotted_files.len() > config.git_commit_threshold
61        {
62            let workspace = PathBuf::from(&session.meta.workspace);
63            try_git_commit(
64                &workspace,
65                &snapshotted_files,
66                name,
67                session.meta.turn_count,
68            )
69            .await
70        } else {
71            None
72        };
73
74        let mut meta = CheckpointMeta {
75            id: id.clone(),
76            name: name.to_string(),
77            turn: session.meta.turn_count,
78            created_at: Utc::now(),
79            modified_files: snapshotted_files,
80            git_sha: None,
81        };
82        meta.git_sha = git_sha;
83
84        let meta_json = serde_json::to_string_pretty(&meta)?;
85        std::fs::write(dir.join("checkpoint.json"), meta_json)?;
86
87        if let Some(ref sha) = meta.git_sha {
88            println!(
89                "[checkpoint] Created '{}' at turn {} ({}) — git commit {}",
90                name,
91                session.meta.turn_count,
92                meta.created_at.format("%H:%M:%S"),
93                &sha[..8.min(sha.len())]
94            );
95        } else {
96            println!(
97                "[checkpoint] Created '{}' at turn {} ({})",
98                name,
99                session.meta.turn_count,
100                meta.created_at.format("%H:%M:%S")
101            );
102        }
103
104        let messages = session.messages.clone();
105        Ok(Self {
106            meta,
107            messages,
108            dir,
109        })
110    }
111
112    /// List all checkpoints for a session, sorted by sequence number.
113    pub fn list(session: &Session) -> Vec<CheckpointMeta> {
114        let cp_dir = session.checkpoints_dir();
115        if !cp_dir.exists() {
116            return Vec::new();
117        }
118
119        let mut checkpoints: Vec<CheckpointMeta> = std::fs::read_dir(&cp_dir)
120            .unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
121            .flatten()
122            .filter(|e| e.path().is_dir())
123            .filter_map(|e| {
124                let meta_path = e.path().join("checkpoint.json");
125                let content = std::fs::read_to_string(&meta_path).ok()?;
126                serde_json::from_str(&content).ok()
127            })
128            .collect();
129
130        checkpoints.sort_by_key(|c| c.id.clone());
131        checkpoints
132    }
133
134    /// Find a checkpoint by name or sequence number (1-based).
135    pub fn find(session: &Session, name_or_num: &str) -> Result<Self> {
136        let all = Self::list(session);
137
138        let meta = if let Ok(num) = name_or_num.parse::<usize>() {
139            all.into_iter().nth(num - 1)
140        } else {
141            all.into_iter()
142                .find(|c| c.name == name_or_num || c.id == name_or_num)
143        }
144        .ok_or_else(|| RalphError::CheckpointNotFound(name_or_num.to_string()))?;
145
146        let dir = session.checkpoints_dir().join(&meta.id);
147        let messages_json =
148            std::fs::read_to_string(dir.join("messages.json")).unwrap_or_else(|_| "[]".to_string());
149        let messages: Vec<Message> = serde_json::from_str(&messages_json).unwrap_or_default();
150
151        Ok(Self {
152            meta,
153            messages,
154            dir,
155        })
156    }
157
158    /// Revert the session to this checkpoint.
159    /// - If `files_only` is false, also rolls back the message history.
160    /// - Always creates a `before-revert-<timestamp>` safety checkpoint first.
161    pub async fn revert_into(
162        &self,
163        session: &mut Session,
164        files_only: bool,
165        printer: &crate::output::Printer,
166    ) -> Result<()> {
167        let workspace = PathBuf::from(&session.meta.workspace);
168
169        // Show what will change
170        self.show_revert_preview(session, files_only, printer);
171
172        // Require confirmation — not bypassable
173        let confirm = printer.confirm(
174            &format!(
175                "Reverting to checkpoint '{}' (turn {}). This cannot be skipped.",
176                self.meta.name, self.meta.turn
177            ),
178            true,
179            false,
180        );
181        if confirm != crate::output::ConfirmResult::Yes {
182            return Err(RalphError::UserAborted);
183        }
184
185        // Always create a safety checkpoint before reverting (never git-commits)
186        let safety_name = format!("before-revert-{}", Utc::now().format("%H%M%S"));
187        let _ = Checkpoint::create(session, &safety_name, &CheckpointsConfig::default()).await;
188
189        // Build the set of files that existed at checkpoint time
190        let cp_files: std::collections::HashSet<String> =
191            self.meta.modified_files.iter().cloned().collect();
192
193        // Delete files created after the checkpoint (in session.modified_files but not in checkpoint)
194        for file_path in &session.modified_files {
195            let rel = file_path
196                .strip_prefix(&workspace)
197                .unwrap_or(file_path)
198                .to_string_lossy()
199                .to_string();
200            if !cp_files.contains(&rel) && file_path.exists() {
201                std::fs::remove_file(file_path)
202                    .map_err(|e| RalphError::CheckpointRevertFailed(e.to_string()))?;
203            }
204        }
205
206        // Restore snapshotted files
207        let files_dir = self.dir.join("files");
208        if files_dir.exists() {
209            for entry in walkdir_flat(&files_dir) {
210                let rel = entry.strip_prefix(&files_dir).unwrap_or(&entry);
211                let dest = workspace.join(rel);
212                if let Some(parent) = dest.parent() {
213                    std::fs::create_dir_all(parent)?;
214                }
215                std::fs::copy(&entry, &dest)
216                    .map_err(|e| RalphError::CheckpointRevertFailed(e.to_string()))?;
217            }
218        }
219
220        if !files_only {
221            session.messages = self.messages.clone();
222            session.meta.turn_count = self.meta.turn;
223            session.flush()?;
224        }
225
226        println!(
227            "[checkpoint] Reverted to '{}'. Safety checkpoint '{}' was created.",
228            self.meta.name, safety_name
229        );
230        Ok(())
231    }
232
233    fn show_revert_preview(
234        &self,
235        session: &Session,
236        files_only: bool,
237        printer: &crate::output::Printer,
238    ) {
239        use colored::Colorize;
240        let workspace = PathBuf::from(&session.meta.workspace);
241
242        println!(
243            "\n[checkpoint] Reverting to '{}' (turn {}, {})\n",
244            self.meta.name,
245            self.meta.turn,
246            self.meta.created_at.format("%Y-%m-%d %H:%M:%S")
247        );
248
249        // Files to restore
250        println!("  Files to restore:");
251        for f in &self.meta.modified_files {
252            let dest = workspace.join(f);
253            if dest.exists() {
254                println!("    {} {}", "~".yellow(), f);
255            } else {
256                println!("    {} {} (will be created)", "+".green(), f);
257            }
258        }
259
260        // Files created after checkpoint that will be removed
261        let cp_files: std::collections::HashSet<&str> = self
262            .meta
263            .modified_files
264            .iter()
265            .map(|s| s.as_str())
266            .collect();
267        for file_path in &session.modified_files {
268            let rel = file_path
269                .strip_prefix(&workspace)
270                .unwrap_or(file_path)
271                .to_string_lossy()
272                .to_string();
273            if !cp_files.contains(rel.as_str()) && file_path.exists() {
274                println!(
275                    "    {} {} (delete — did not exist at checkpoint)",
276                    "-".red(),
277                    rel
278                );
279            }
280        }
281
282        if !files_only {
283            let turns_to_drop = session.meta.turn_count.saturating_sub(self.meta.turn);
284            println!(
285                "\n  Conversation: {} turn(s) will be discarded",
286                turns_to_drop
287            );
288        } else {
289            println!("\n  Conversation history: unchanged (--files-only)");
290        }
291        println!();
292    }
293}
294
295/// Stage the given workspace-relative file paths and commit on the current
296/// branch. Returns the full commit SHA on success, None on any failure
297/// (missing git, nothing to commit, etc.). Never switches branches.
298async fn try_git_commit(
299    workspace: &Path,
300    files: &[String],
301    name: &str,
302    turn: u32,
303) -> Option<String> {
304    if !workspace.join(".git").exists() {
305        return None;
306    }
307
308    // Stage the modified files
309    let mut add = tokio::process::Command::new("git");
310    add.arg("add").arg("--").current_dir(workspace);
311    for f in files {
312        add.arg(f);
313    }
314    let add_ok = add
315        .output()
316        .await
317        .map(|o| o.status.success())
318        .unwrap_or(false);
319    if !add_ok {
320        eprintln!("[checkpoint] Warning: git add failed — checkpoint saved without git commit.");
321        return None;
322    }
323
324    // Nothing to commit? (`git diff --cached --quiet` exits 0 when clean)
325    let clean = tokio::process::Command::new("git")
326        .args(["diff", "--cached", "--quiet"])
327        .current_dir(workspace)
328        .output()
329        .await
330        .ok()?;
331    if clean.status.success() {
332        return None; // nothing staged
333    }
334
335    // Commit on whichever branch the user is on
336    let msg = format!("ralph checkpoint: {} (turn {})", name, turn);
337    let commit = tokio::process::Command::new("git")
338        .args(["commit", "-m", &msg])
339        .current_dir(workspace)
340        .output()
341        .await
342        .ok()?;
343    if !commit.status.success() {
344        let stderr = String::from_utf8_lossy(&commit.stderr);
345        eprintln!(
346            "[checkpoint] Warning: git commit failed: {} — checkpoint saved without git commit.",
347            stderr.trim()
348        );
349        return None;
350    }
351
352    // Return the SHA
353    let sha_out = tokio::process::Command::new("git")
354        .args(["rev-parse", "HEAD"])
355        .current_dir(workspace)
356        .output()
357        .await
358        .ok()?;
359    let sha = String::from_utf8_lossy(&sha_out.stdout).trim().to_string();
360    if sha.is_empty() {
361        None
362    } else {
363        Some(sha)
364    }
365}
366
367fn next_seq(session: &Session) -> usize {
368    Checkpoint::list(session).len() + 1
369}
370
371fn sanitize_name(name: &str) -> String {
372    name.chars()
373        .map(|c| {
374            if c.is_alphanumeric() || c == '-' {
375                c
376            } else {
377                '_'
378            }
379        })
380        .collect()
381}
382
383fn walkdir_flat(dir: &Path) -> Vec<PathBuf> {
384    let mut files = Vec::new();
385    if let Ok(entries) = std::fs::read_dir(dir) {
386        for entry in entries.flatten() {
387            let path = entry.path();
388            if path.is_dir() {
389                files.extend(walkdir_flat(&path));
390            } else {
391                files.push(path);
392            }
393        }
394    }
395    files
396}