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;