Skip to main content

newgit_core/
checkpoint.rs

1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8use crate::materializer::create_dir_all;
9use crate::store::{read_dir_sorted, read_toml_at, write_toml_at};
10
11/// One coherent snapshot across source, trackers, and resources — the record
12/// `newgit undo` restores. Stored per instance at
13/// `.newgit/checkpoints/<slug>/ckpt_NNN.toml`.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CheckpointRecord {
16    pub id: String,
17    pub branch: String,
18    pub created_at: DateTime<Utc>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub message: Option<String>,
21    pub reason: CheckpointReason,
22    pub source: SourceState,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub tracker_states: Vec<TrackerState>,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub resource_states: Vec<ResourceState>,
27}
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case")]
31pub enum CheckpointReason {
32    Explicit,
33    /// Safety checkpoint taken automatically before an undo — restoring it
34    /// is redo.
35    BeforeUndo,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct SourceState {
40    /// Workspace HEAD at checkpoint time.
41    pub head_rev: String,
42    /// Dangling commit (parent: head_rev) holding uncommitted and untracked
43    /// state; absent when the worktree was clean.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub dirty_rev: Option<String>,
46    /// Ref in the store repo keeping these commits alive.
47    pub store_ref: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct TrackerState {
52    pub name: String,
53    pub definition_rev: String,
54    /// Lane rev captured at checkpoint time; absent when the tracker had no
55    /// content (undo then clears its owned paths).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub content_rev: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct ResourceState {
62    pub name: String,
63    pub definition_rev: String,
64    /// Checkpoint mode that produced `state_ref`: none|hash|command|external.
65    pub mode: String,
66    /// `hash:<hex12>`, `tracker:<name>@<rev>`, or an opaque command/external ref.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub state_ref: Option<String>,
69    /// Resolved filesystem path substituted for `{{state_ref}}` in restore
70    /// commands, when the ref points at deposited content.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub state_path: Option<Utf8PathBuf>,
73    /// A long-running action was alive at checkpoint time; undo restarts it.
74    #[serde(default)]
75    pub was_running: bool,
76    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77    pub resolved_ports: BTreeMap<String, u16>,
78    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79    pub resolved_exports: BTreeMap<String, String>,
80}
81
82/// Written next to the checkpoint when resource restores fail during undo,
83/// so the failure survives the terminal: what failed, where the logs are,
84/// and how to re-run.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct RecoveryRecord {
87    pub checkpoint: String,
88    pub branch: String,
89    pub created_at: DateTime<Utc>,
90    pub failures: Vec<RestoreFailure>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct RestoreFailure {
95    pub resource: String,
96    pub detail: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub log: Option<Utf8PathBuf>,
99    /// The command to repair and re-run, e.g. `newgit action deps.prepare`.
100    pub retry_with: String,
101}
102
103/// One instance's checkpoint history on disk.
104#[derive(Debug, Clone)]
105pub struct CheckpointLog {
106    dir: Utf8PathBuf,
107    instance: String,
108}
109
110impl CheckpointLog {
111    /// `dir` is `.newgit/checkpoints/<slug>/`; `instance` is only for errors.
112    pub fn new(dir: Utf8PathBuf, instance: &str) -> Self {
113        Self {
114            dir,
115            instance: instance.to_owned(),
116        }
117    }
118
119    /// Records in creation order (numeric id order).
120    pub fn list(&self) -> Result<Vec<CheckpointRecord>> {
121        let mut records: Vec<CheckpointRecord> = Vec::new();
122        for entry in read_dir_sorted(&self.dir)? {
123            if entry.extension() == Some("toml")
124                && entry
125                    .file_name()
126                    .is_some_and(|name| name.starts_with("ckpt_") && !name.contains(".recovery."))
127            {
128                records.push(read_toml_at(&entry)?);
129            }
130        }
131        records.sort_by_key(|record| numeric_id(&record.id));
132        Ok(records)
133    }
134
135    pub fn load(&self, id: &str) -> Result<CheckpointRecord> {
136        let path = self.record_path(id);
137        if !path.is_file() {
138            return Err(NewgitError::UnknownCheckpoint {
139                instance: self.instance.clone(),
140                id: id.to_owned(),
141            });
142        }
143        read_toml_at(&path)
144    }
145
146    pub fn latest(&self) -> Result<CheckpointRecord> {
147        self.list()?
148            .into_iter()
149            .next_back()
150            .ok_or_else(|| NewgitError::NoCheckpoints(self.instance.clone()))
151    }
152
153    /// The id the next `save` should use.
154    pub fn next_id(&self) -> Result<String> {
155        let last = self
156            .list()?
157            .last()
158            .map(|record| numeric_id(&record.id))
159            .unwrap_or(0);
160        Ok(format!("ckpt_{:03}", last + 1))
161    }
162
163    pub fn save(&self, record: &CheckpointRecord) -> Result<Utf8PathBuf> {
164        create_dir_all(&self.dir)?;
165        let path = self.record_path(&record.id);
166        write_toml_at(&path, &format!("checkpoint `{}`", record.id), record)?;
167        Ok(path)
168    }
169
170    pub fn save_recovery(&self, record: &RecoveryRecord) -> Result<Utf8PathBuf> {
171        create_dir_all(&self.dir)?;
172        let path = self
173            .dir
174            .join(format!("{}.recovery.toml", record.checkpoint));
175        write_toml_at(
176            &path,
177            &format!("recovery record for `{}`", record.checkpoint),
178            record,
179        )?;
180        Ok(path)
181    }
182
183    fn record_path(&self, id: &str) -> Utf8PathBuf {
184        self.dir.join(format!("{id}.toml"))
185    }
186}
187
188fn numeric_id(id: &str) -> u64 {
189    id.rsplit('_')
190        .next()
191        .and_then(|suffix| suffix.parse().ok())
192        .unwrap_or(0)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn ids_are_sequential_and_survive_a_roundtrip() {
201        let temp = tempfile::tempdir().expect("tempdir");
202        let dir = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
203        let log = CheckpointLog::new(dir.join("feature-a"), "feature-a");
204
205        assert_eq!(log.next_id().expect("next"), "ckpt_001");
206        assert!(matches!(log.latest(), Err(NewgitError::NoCheckpoints(_))));
207
208        let record = CheckpointRecord {
209            id: "ckpt_001".to_owned(),
210            branch: "feature-a".to_owned(),
211            created_at: Utc::now(),
212            message: Some("before auth refactor".to_owned()),
213            reason: CheckpointReason::Explicit,
214            source: SourceState {
215                head_rev: "abc".to_owned(),
216                dirty_rev: None,
217                store_ref: "refs/newgit/checkpoints/feature-a/ckpt_001".to_owned(),
218            },
219            tracker_states: vec![],
220            resource_states: vec![],
221        };
222        log.save(&record).expect("save");
223
224        assert_eq!(log.next_id().expect("next"), "ckpt_002");
225        assert_eq!(log.latest().expect("latest"), record);
226        assert_eq!(log.load("ckpt_001").expect("load"), record);
227        assert!(matches!(
228            log.load("ckpt_009"),
229            Err(NewgitError::UnknownCheckpoint { .. })
230        ));
231    }
232}