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