Skip to main content

storage/checkpoint/
commit.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Checkpoints: base, wave pre/post.
7
8use std::path::Path;
9
10use kernel::event::record::{CheckpointCommitted, Commit, FileRestored};
11use kernel::{Address, GitOid, Payload, TimeMs};
12use serde_json::{Map, Value};
13
14use crate::alias::WriteTarget;
15use crate::bundle::landing::{Bits, land};
16use crate::error::StorageError;
17use crate::real_fs::RealFs;
18
19use super::provenance::Provenance;
20use super::scan::CommitPlan;
21
22/// How a checkpoint names itself in a commit subject. The whole set, because
23/// a checkpoint that showed one of several prefixes would read like a checkpoint
24/// that staged one of them.
25pub(super) fn subject_of(scopes: &[String]) -> String {
26    if scopes.is_empty() {
27        return "checkpoint: .".to_owned();
28    }
29    format!("checkpoint: {}", scopes.join(" "))
30}
31
32/// Where a wave checkpoint is filed: under `refs/sprawling/`, which no
33/// branch listing, push or `git log` walks by accident, and named by the
34/// commit itself. A run checkpoints through more than one handle - the lane's
35/// own and the bench's forecast net - and the reference is the only thing
36/// that keeps a checkpoint from `git gc`, so its name cannot come from a count
37/// that a second handle also keeps.
38pub(super) fn checkpoint_ref(of: &Provenance, oid: git2::Oid) -> String {
39    format!("refs/sprawling/runs/{}/{oid}", of.run())
40}
41
42/// The `checkpoint_committed` payload, in one place so a checkpoint and a
43/// base commit cannot describe themselves differently.
44///
45/// It carries what the commit's own trailers carry that the record
46/// cannot say for itself: the model and the effort. The run and the
47/// actor are the record's identity and are not repeated here.
48pub(super) fn committed(
49    oid: git2::Oid,
50    of: &Provenance,
51    scopes: &[String],
52    files: Vec<String>,
53) -> Result<Payload, StorageError> {
54    let spelled = oid.to_string();
55    let oid = GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
56        op: "record a checkpoint commit",
57        detail: format!("git named this commit {spelled}, which is not 40 hex digits"),
58    })?;
59    let record = CheckpointCommitted::Committed(Commit {
60        oid,
61        by: of.attribution(),
62        scope: scopes.to_vec(),
63        files,
64    });
65    Payload::of(&record).map_err(|source| StorageError::Draft { source })
66}
67
68pub struct Checkpoint {
69    pub(crate) repo: git2::Repository,
70    /// The last commit this handle made, checkpoint or landing. The scan
71    /// compares against it, because a checkpoint does not move HEAD.
72    pub(crate) last: Option<git2::Oid>,
73}
74
75pub(crate) fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
76    move |err| StorageError::Checkpoint {
77        op,
78        detail: err.message().to_owned(),
79    }
80}
81
82impl Checkpoint {
83    /// Opens the city repository, initialising one when absent. The
84    /// genesis commit is the first wave's, not this call's: an empty
85    /// repository is a valid state, and inventing history here would
86    /// make the first checkpoint unattributable.
87    pub fn open(city_root: &Path) -> Result<Checkpoint, StorageError> {
88        let repo = match git2::Repository::open(city_root) {
89            Ok(repo) => repo,
90            Err(_) => git2::Repository::init(city_root).map_err(git_err("init repository"))?,
91        };
92        // The city's files round-trip byte for byte, whatever this
93        // machine's git is configured to do to other people's
94        // repositories. A checkout that rewrote line endings would make
95        // a file disagree with the hash the ledger holds for it, and the
96        // disagreement would look like corruption rather than like a
97        // setting. Set on every open, because the setting is a property
98        // of this repository rather than of the moment it was created.
99        repo.config()
100            .and_then(|mut config| config.set_bool("core.autocrlf", false))
101            .map_err(git_err("pin the repository's line endings"))?;
102        Ok(Checkpoint { repo, last: None })
103    }
104
105    /// Makes sure the city has one commit, and makes no more than that.
106    ///
107    /// A worktree branches from a commit, so a city that has never been
108    /// checkpointed cannot lend a tree. Committing on every dispatch would
109    /// move the trunk under every request already waiting. Returns the
110    /// commit it made, or `None` when there already was one.
111    ///
112    /// # Errors
113    /// Propagates whatever staging and committing report.
114    pub fn ensure_base(
115        &mut self,
116        scopes: &[String],
117        t: TimeMs,
118        of: &Provenance,
119    ) -> Result<Option<Payload>, StorageError> {
120        if self.repo.head().is_ok() {
121            return Ok(None);
122        }
123        let files = self.stage_scopes(scopes)?;
124        self.scan_staged()?;
125        // The one checkpoint that moves the branch: a worktree branches from a
126        // commit, and a city that has none can lend no tree.
127        let oid = self.commit(&CommitPlan {
128            t,
129            of,
130            subject: &subject_of(scopes),
131            onto_head: true,
132        })?;
133        committed(oid, of, scopes, files).map(Some)
134    }
135
136    /// The pre-wave checkpoint: stage everything under `scopes`, scan it, and
137    /// commit at the injected time. Returns the `checkpoint_committed`
138    /// payload.
139    ///
140    /// **`scopes` is the run's write domain, not its room** (`crates/storage/spec/Checkpoint/Provenance.lean`
141    /// §8-18). The two were allowed to differ once, and every
142    /// file a resident wrote between them - a building's own documents,
143    /// a second declared prefix - was staged by no checkpoint, reported by no
144    /// `changes` query, and restorable from no `file_discarded` record.
145    ///
146    /// **The branch does not move**: the commit is written
147    /// with no reference update and pointed at by
148    /// `refs/sprawling/runs/<run>/<oid>`, so a person whose own folder
149    /// became this city keeps their own history instead of one
150    /// `checkpoint:` line per tool wave. The oid means what it always
151    /// meant - the tree is there and `wave_post` restores from it.
152    ///
153    /// # Errors
154    /// Propagates staging, the staged-secret scan, the commit, and a
155    /// reference the repository will not write.
156    pub fn wave_pre(
157        &mut self,
158        scopes: &[String],
159        t: TimeMs,
160        of: &Provenance,
161    ) -> Result<Payload, StorageError> {
162        let files = self.stage_scopes(scopes)?;
163        self.scan_staged()?;
164        let oid = self.commit(&CommitPlan {
165            t,
166            of,
167            subject: &subject_of(scopes),
168            onto_head: false,
169        })?;
170        self.repo
171            .reference(
172                &checkpoint_ref(of, oid),
173                oid,
174                true,
175                "sprawling: a wave checkpoint",
176            )
177            .map_err(git_err("file a wave checkpoint"))?;
178        committed(oid, of, scopes, files)
179    }
180
181    /// The post-wave sweep: every path present at `pre_oid` and gone
182    /// from the working tree becomes a `file_discarded` payload whose
183    /// restoration points back into that commit.
184    ///
185    /// **The question is existence, and only existence.** The tree is
186    /// walked once and each blob is looked for on disk; a path that is
187    /// not there is a deletion. Asking git for the tree-to-workdir diff
188    /// instead would hash every path the tree and the worktree agree on,
189    /// and the city itself keeps writing some of those paths after the
190    /// checkpoint (a room's session projection, a worktree under a lease); a
191    /// hash taken through a Windows directory entry that trails the open
192    /// handle refuses the whole sweep. A sweep needs `Deleted` deltas,
193    /// and a deletion is answered without reading any content.
194    ///
195    /// **The tree is the checkpoint's own write domain**, not the whole city:
196    /// `wave_pre` walked exactly these paths before the wave began, so
197    /// the walk costs what staging already cost.
198    ///
199    /// # Errors
200    /// Propagates a `pre_oid` that is not an object, a commit whose tree
201    /// cannot be read, and a working-tree path that cannot be examined.
202    pub fn wave_post(&mut self, pre_oid: &str) -> Result<Vec<Payload>, StorageError> {
203        let oid = git2::Oid::from_str(pre_oid).map_err(git_err("parse checkpoint oid"))?;
204        let commit = self
205            .repo
206            .find_commit(oid)
207            .map_err(git_err("find checkpoint commit"))?;
208        let tree = commit.tree().map_err(git_err("read checkpoint tree"))?;
209        let workdir = self
210            .repo
211            .workdir()
212            .ok_or_else(|| StorageError::Checkpoint {
213                op: "sweep the working tree",
214                detail: "the city repository is bare".to_owned(),
215            })?
216            .to_path_buf();
217        let mut deleted: Vec<String> = Vec::new();
218        let mut fault: Option<StorageError> = None;
219        let walked = tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
220            if entry.kind() != Some(git2::ObjectType::Blob) {
221                return git2::TreeWalkResult::Ok;
222            }
223            let path = format!("{dir}{}", entry.name().unwrap_or_default());
224            match std::fs::symlink_metadata(workdir.join(&path)) {
225                Ok(_) => {}
226                Err(err) if err.kind() == std::io::ErrorKind::NotFound => deleted.push(path),
227                Err(err) => {
228                    fault = Some(StorageError::Checkpoint {
229                        op: "sweep the working tree",
230                        detail: format!("{path}: {err}"),
231                    });
232                    return git2::TreeWalkResult::Abort;
233                }
234            }
235            git2::TreeWalkResult::Ok
236        });
237        walked.map_err(git_err("walk the checkpoint tree"))?;
238        if let Some(err) = fault {
239            return Err(err);
240        }
241        // Sorted here rather than trusted from the walk: the order these
242        // records land in is part of what a replay reproduces.
243        deleted.sort();
244        let mut payloads = Vec::new();
245        for path in deleted {
246            let mut map = Map::new();
247            map.insert(
248                "paths".to_owned(),
249                Value::Array(vec![Value::String(format!("file:{path}"))]),
250            );
251            let mut restoration = Map::new();
252            restoration.insert(
253                "tracked".to_owned(),
254                Value::String(format!("file:{path}@{pre_oid}")),
255            );
256            map.insert("restoration".to_owned(), Value::Object(restoration));
257            payloads.push(Payload::new(map).map_err(|source| StorageError::Draft { source })?);
258        }
259        Ok(payloads)
260    }
261
262    /// The way back a `file_discarded` names: the blob at `address` in
263    /// commit `oid`, written to the same path under the working tree.
264    ///
265    /// Neither the index nor HEAD moves: putting a file back is not a
266    /// commit, and a person who restored one file should not find a new
267    /// line in their own branch's history. A link on the path, or a file
268    /// already at the target with other bytes, is refused rather than
269    /// followed or overwritten.
270    ///
271    /// # Errors
272    /// A commit the repository no longer holds (the checkpoint reference is
273    /// what keeps one), a path that commit does not hold as a file, a
274    /// bare repository, an address in the protected metadata subtree
275    /// (`Address::is_reserved`), and a write the file system refuses;
276    /// `StorageError::Alias` when a symbolic link or junction sits on the
277    /// path below the working tree, because `Address` bounds the
278    /// spelling of a path and not where the disk resolves it.
279    pub fn restore(&self, address: &Address, oid: &GitOid) -> Result<(), StorageError> {
280        let refused = |detail: String| StorageError::Checkpoint {
281            op: "restore a discarded file",
282            detail,
283        };
284        if address.is_reserved() {
285            return Err(refused(format!(
286                "{address} is protected metadata; the city writes it through its own gates, not a restore"
287            )));
288        }
289        let commit = git2::Oid::from_str(&oid.to_string())
290            .and_then(|oid| self.repo.find_commit(oid))
291            .map_err(git_err("find the restoration commit"))?;
292        let blob = commit
293            .tree()
294            .and_then(|tree| tree.get_path(Path::new(address.as_str())))
295            .and_then(|entry| entry.to_object(&self.repo))
296            .map_err(git_err("find the discarded file in its commit"))?
297            .into_blob()
298            .map_err(|_| refused(format!("{address}@{oid} is not a file")))?;
299        let workdir = self
300            .repo
301            .workdir()
302            .ok_or_else(|| refused("the city repository is bare".to_owned()))?;
303        let target = WriteTarget::within(
304            "restore a discarded file",
305            workdir,
306            &workdir.join(address.as_str()),
307        )?;
308        let target = target.as_path();
309        if let Some(parent) = target.parent() {
310            std::fs::create_dir_all(parent)
311                .map_err(|err| refused(format!("{}: {err}", parent.display())))?;
312        }
313        match std::fs::read(target) {
314            Ok(current) if current == blob.content() => return Ok(()),
315            Ok(_) => {
316                return Err(refused(format!(
317                    "{address}: a file already sits there; move it aside and restore again"
318                )));
319            }
320            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
321            Err(err) => return Err(refused(format!("{address}: {err}"))),
322        }
323        std::fs::OpenOptions::new()
324            .write(true)
325            .create_new(true)
326            .open(target)
327            .and_then(|mut file| std::io::Write::write_all(&mut file, blob.content()))
328            .map_err(|err| refused(format!("{address}: {err}")))
329    }
330
331    /// Takes `address` back from `point` into the city's own working
332    /// tree: the point's bytes in place of what is there, or no file when
333    /// the point holds none (`crates/storage/spec/Checkpoint.lean` §8-33). Returns the
334    /// `file_restored` record of this step, `name` empty for this tree.
335    ///
336    /// # Errors
337    /// What [`Checkpoint::restore`] refuses for the path and the commit,
338    /// something other than a file at `address` in `point`, and a write
339    /// or a removal the file system refuses.
340    pub fn take_back(
341        &self,
342        address: &Address,
343        point: &GitOid,
344    ) -> Result<FileRestored, StorageError> {
345        let refused = |detail: String| StorageError::Checkpoint {
346            op: "take a file back from a checkpoint",
347            detail,
348        };
349        if address.is_reserved() {
350            return Err(refused(format!(
351                "{address} is protected metadata; the city writes it through its own gates"
352            )));
353        }
354        let tree = git2::Oid::from_str(&point.to_string())
355            .and_then(|oid| self.repo.find_commit(oid))
356            .and_then(|commit| commit.tree())
357            .map_err(git_err("find the checkpoint to take a file back from"))?;
358        let workdir = self
359            .repo
360            .workdir()
361            .ok_or_else(|| refused("the city repository is bare".to_owned()))?;
362        let target = WriteTarget::within(
363            "take a file back from a checkpoint",
364            workdir,
365            &workdir.join(address.as_str()),
366        )?;
367        let restored = FileRestored {
368            name: String::new(),
369            path: address.as_str().to_owned(),
370            point: *point,
371        };
372        let entry = match tree.get_path(Path::new(address.as_str())) {
373            Ok(entry) => entry,
374            // The point never held it: taking it back is taking it away.
375            Err(err) if err.code() == git2::ErrorCode::NotFound => {
376                return match std::fs::remove_file(target.as_path()) {
377                    Ok(()) => Ok(restored),
378                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(restored),
379                    Err(err) => Err(refused(format!("{address}: {err}"))),
380                };
381            }
382            Err(err) => return Err(git_err("find the file in the checkpoint")(err)),
383        };
384        let blob = entry
385            .to_object(&self.repo)
386            .map_err(git_err("read the file in the checkpoint"))?
387            .into_blob()
388            .map_err(|_| refused(format!("{address}@{point} is not a file")))?;
389        if let Some(parent) = target.as_path().parent() {
390            std::fs::create_dir_all(parent)
391                .map_err(|err| refused(format!("{}: {err}", parent.display())))?;
392        }
393        land(&mut RealFs::new(), target, blob.content(), Bits::OfReplaced)?;
394        Ok(restored)
395    }
396}
397
398#[cfg(test)]
399#[allow(
400    clippy::unwrap_used,
401    clippy::expect_used,
402    clippy::panic,
403    clippy::indexing_slicing,
404    reason = "test code"
405)]
406mod tests;
407
408#[cfg(test)]
409#[allow(clippy::unwrap_used, reason = "test code")]
410mod restore_tests;