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};
12
13use crate::alias::WriteTarget;
14use crate::bundle::landing::{Bits, land};
15use crate::error::StorageError;
16use crate::real_fs::RealFs;
17
18use super::base::{BaseOf, BaseTarget};
19use super::opening::HeadMove;
20use super::provenance::Provenance;
21use super::scan::CommitPlan;
22
23/// How a checkpoint names itself in a commit subject. The whole set, because
24/// a checkpoint that showed one of several prefixes would read like a checkpoint
25/// that staged one of them.
26pub(super) fn subject_of(scopes: &[String]) -> String {
27    if scopes.is_empty() {
28        return "checkpoint: .".to_owned();
29    }
30    format!("checkpoint: {}", scopes.join(" "))
31}
32
33/// Where a wave checkpoint is filed: under `refs/sprawling/`, which no
34/// branch listing, push or `git log` walks by accident, and named by the
35/// commit itself. A run checkpoints through more than one handle - the lane's
36/// own and the bench's forecast net - and the reference is the only thing
37/// that keeps a checkpoint from `git gc`, so its name cannot come from a count
38/// that a second handle also keeps.
39pub(super) fn checkpoint_ref(of: &Provenance, oid: git2::Oid) -> String {
40    format!("refs/sprawling/runs/{}/{oid}", of.run())
41}
42
43/// The `checkpoint_committed` payload, in one place so a checkpoint and a
44/// base commit cannot describe themselves differently.
45///
46/// It carries what the commit's own trailers carry that the record
47/// cannot say for itself: the model and the effort. The run and the
48/// actor are the record's identity and are not repeated here.
49pub(super) fn committed(
50    oid: git2::Oid,
51    of: &Provenance,
52    scopes: &[String],
53    files: Vec<String>,
54) -> Result<Payload, StorageError> {
55    let spelled = oid.to_string();
56    let oid = GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
57        op: "record a checkpoint commit",
58        detail: format!("git named this commit {spelled}, which is not 40 hex digits"),
59    })?;
60    let record = CheckpointCommitted::Committed(Commit {
61        oid,
62        by: of.attribution(),
63        scope: scopes.to_vec(),
64        files,
65    });
66    Payload::of(&record).map_err(|source| StorageError::Draft { source })
67}
68
69pub struct Checkpoint {
70    pub(crate) repo: git2::Repository,
71    /// The last commit this handle made, checkpoint or landing. The scan
72    /// compares against it, because a checkpoint does not move HEAD.
73    pub(crate) last: Option<git2::Oid>,
74    /// Which index this handle stages into (`checkpoint::opening`).
75    pub(crate) index: super::opening::IndexOwner,
76}
77
78pub(crate) fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
79    move |err| StorageError::Checkpoint {
80        op,
81        detail: err.message().to_owned(),
82    }
83}
84
85impl Checkpoint {
86    /// Makes sure the city has one commit, and makes no more than that.
87    ///
88    /// A worktree branches from a commit, so a city that has never been
89    /// checkpointed cannot lend a tree. Committing on every dispatch would
90    /// move the trunk under every request already waiting. Returns the
91    /// commit it made, or `None` when there already was one.
92    ///
93    /// The commit is written the way [`Checkpoint::base_checkpoint`] writes
94    /// one: every object in memory, then one pack, then the index and the
95    /// branch, so a city of N files pays one file write for its objects
96    /// rather than N, and a refusal leaves the index and HEAD as they were.
97    ///
98    /// # Errors
99    /// Propagates whatever staging, scanning, packing and committing report.
100    pub fn ensure_base(
101        &mut self,
102        scopes: &[String],
103        t: TimeMs,
104        of: &Provenance,
105    ) -> Result<Option<Payload>, StorageError> {
106        if Self::head_commit(&self.repo)?.is_some() {
107            return Ok(None);
108        }
109        let _head_moves = super::opening::moving_head()?;
110        if Self::head_commit(&self.repo)?.is_some() {
111            return Ok(None);
112        }
113        // The one checkpoint that moves the branch, written as one pack on a
114        // second handle so this one keeps a disk-backed store (storage D27).
115        let made =
116            self.reopened()?
117                .write_held(&BaseOf { scopes, t, of }, BaseTarget::Head, &mut |_| {});
118        // The base wrote the index file through the other handle.
119        self.repo
120            .index()
121            .and_then(|mut index| index.read(true))
122            .map_err(git_err("read the index the base wrote"))?;
123        match made {
124            Ok(payload) => Ok(Some(payload)),
125            // Another writer made the base between the read and the swap:
126            // the same city as if it had gone first (storage §8-39).
127            Err(_) if Self::head_commit(&self.repo)?.is_some() => Ok(None),
128            Err(refused) => Err(refused),
129        }
130    }
131
132    /// The pre-wave checkpoint: stage everything under `scopes`, scan it, and
133    /// commit at the injected time. Returns the `checkpoint_committed`
134    /// payload.
135    ///
136    /// **`scopes` is the run's write domain, not its room** (`crates/storage/spec/Checkpoint/Provenance.lean`
137    /// §8-18). The two were allowed to differ once, and every
138    /// file a resident wrote between them - a building's own documents,
139    /// a second declared prefix - was staged by no checkpoint, reported by no
140    /// `changes` query, and restorable from no `file_discarded` record.
141    ///
142    /// **The branch does not move**: the commit is written
143    /// with no reference update and pointed at by
144    /// `refs/sprawling/runs/<run>/<oid>`, so a person whose own folder
145    /// became this city keeps their own history instead of one
146    /// `checkpoint:` line per tool wave. The oid means what it always
147    /// meant - the tree is there and `wave_post` restores from it.
148    ///
149    /// # Errors
150    /// Propagates staging, the staged-secret scan, the commit, and a
151    /// reference the repository will not write.
152    pub fn wave_pre(
153        &mut self,
154        scopes: &[String],
155        t: TimeMs,
156        of: &Provenance,
157    ) -> Result<Payload, StorageError> {
158        let files = self.stage_scopes(scopes)?;
159        self.scan_staged()?;
160        let oid = self.commit(&CommitPlan {
161            t,
162            of,
163            subject: &subject_of(scopes),
164            head: HeadMove::Leave,
165        })?;
166        self.repo
167            .reference(
168                &checkpoint_ref(of, oid),
169                oid,
170                true,
171                "sprawling: a wave checkpoint",
172            )
173            .map_err(git_err("file a wave checkpoint"))?;
174        committed(oid, of, scopes, files)
175    }
176
177    /// The way back a `file_discarded` names: the blob at `address` in
178    /// commit `oid`, written to the same path under the working tree.
179    ///
180    /// Neither the index nor HEAD moves: putting a file back is not a
181    /// commit, and a person who restored one file should not find a new
182    /// line in their own branch's history. A link on the path, or a file
183    /// already at the target with other bytes, is refused rather than
184    /// followed or overwritten.
185    ///
186    /// # Errors
187    /// A commit the repository no longer holds (the checkpoint reference is
188    /// what keeps one), a path that commit does not hold as a file, a
189    /// bare repository, an address in the protected metadata subtree
190    /// (`Address::is_reserved`), and a write the file system refuses;
191    /// `StorageError::Alias` when a symbolic link or junction sits on the
192    /// path below the working tree, because `Address` bounds the
193    /// spelling of a path and not where the disk resolves it.
194    pub fn restore(&self, address: &Address, oid: &GitOid) -> Result<(), StorageError> {
195        let refused = |detail: String| StorageError::Checkpoint {
196            op: "restore a discarded file",
197            detail,
198        };
199        if address.is_reserved() {
200            return Err(refused(format!(
201                "{address} is protected metadata; the city writes it through its own gates, not a restore"
202            )));
203        }
204        let commit = git2::Oid::from_str(&oid.to_string())
205            .and_then(|oid| self.repo.find_commit(oid))
206            .map_err(git_err("find the restoration commit"))?;
207        let blob = commit
208            .tree()
209            .and_then(|tree| tree.get_path(Path::new(address.as_str())))
210            .and_then(|entry| entry.to_object(&self.repo))
211            .map_err(git_err("find the discarded file in its commit"))?
212            .into_blob()
213            .map_err(|_| refused(format!("{address}@{oid} is not a file")))?;
214        let workdir = self
215            .repo
216            .workdir()
217            .ok_or_else(|| refused("the city repository is bare".to_owned()))?;
218        let target = WriteTarget::within(
219            "restore a discarded file",
220            workdir,
221            &workdir.join(address.as_str()),
222        )?;
223        let target = target.as_path();
224        if let Some(parent) = target.parent() {
225            std::fs::create_dir_all(parent)
226                .map_err(|err| refused(format!("{}: {err}", parent.display())))?;
227        }
228        match std::fs::read(target) {
229            Ok(current) if current == blob.content() => return Ok(()),
230            Ok(_) => {
231                return Err(refused(format!(
232                    "{address}: a file already sits there; move it aside and restore again"
233                )));
234            }
235            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
236            Err(err) => return Err(refused(format!("{address}: {err}"))),
237        }
238        std::fs::OpenOptions::new()
239            .write(true)
240            .create_new(true)
241            .open(target)
242            .and_then(|mut file| std::io::Write::write_all(&mut file, blob.content()))
243            .map_err(|err| refused(format!("{address}: {err}")))
244    }
245
246    /// Takes `address` back from `point` into the city's own working
247    /// tree: the point's bytes in place of what is there, or no file when
248    /// the point holds none (`crates/storage/spec/Checkpoint.lean` §8-33). Returns the
249    /// `file_restored` record of this step, `name` empty for this tree.
250    ///
251    /// # Errors
252    /// What [`Checkpoint::restore`] refuses for the path and the commit,
253    /// something other than a file at `address` in `point`, and a write
254    /// or a removal the file system refuses.
255    pub fn take_back(
256        &self,
257        address: &Address,
258        point: &GitOid,
259    ) -> Result<FileRestored, StorageError> {
260        let refused = |detail: String| StorageError::Checkpoint {
261            op: "take a file back from a checkpoint",
262            detail,
263        };
264        if address.is_reserved() {
265            return Err(refused(format!(
266                "{address} is protected metadata; the city writes it through its own gates"
267            )));
268        }
269        let tree = git2::Oid::from_str(&point.to_string())
270            .and_then(|oid| self.repo.find_commit(oid))
271            .and_then(|commit| commit.tree())
272            .map_err(git_err("find the checkpoint to take a file back from"))?;
273        let workdir = self
274            .repo
275            .workdir()
276            .ok_or_else(|| refused("the city repository is bare".to_owned()))?;
277        let target = WriteTarget::within(
278            "take a file back from a checkpoint",
279            workdir,
280            &workdir.join(address.as_str()),
281        )?;
282        let restored = FileRestored {
283            name: String::new(),
284            path: address.as_str().to_owned(),
285            point: *point,
286        };
287        let entry = match tree.get_path(Path::new(address.as_str())) {
288            Ok(entry) => entry,
289            // The point never held it: taking it back is taking it away.
290            Err(err) if err.code() == git2::ErrorCode::NotFound => {
291                return match std::fs::remove_file(target.as_path()) {
292                    Ok(()) => Ok(restored),
293                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(restored),
294                    Err(err) => Err(refused(format!("{address}: {err}"))),
295                };
296            }
297            Err(err) => return Err(git_err("find the file in the checkpoint")(err)),
298        };
299        let blob = entry
300            .to_object(&self.repo)
301            .map_err(git_err("read the file in the checkpoint"))?
302            .into_blob()
303            .map_err(|_| refused(format!("{address}@{point} is not a file")))?;
304        if let Some(parent) = target.as_path().parent() {
305            std::fs::create_dir_all(parent)
306                .map_err(|err| refused(format!("{}: {err}", parent.display())))?;
307        }
308        land(&mut RealFs::new(), target, blob.content(), Bits::OfReplaced)?;
309        Ok(restored)
310    }
311}
312
313#[cfg(test)]
314#[allow(
315    clippy::unwrap_used,
316    clippy::expect_used,
317    clippy::panic,
318    clippy::indexing_slicing,
319    reason = "test code"
320)]
321#[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
322mod tests;
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used, reason = "test code")]
326mod restore_tests;