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;