storage/checkpoint/sweep.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//! The post-wave sweep: what a checkpoint's tree holds and the working
7//! tree no longer does, as `file_discarded` payloads.
8//!
9//! Specified by `crates/storage/spec/Checkpoint.lean` ยง8-8.
10
11use kernel::Payload;
12use serde_json::{Map, Value};
13
14use crate::error::StorageError;
15
16use super::commit::{Checkpoint, git_err};
17
18impl Checkpoint {
19 /// The post-wave sweep: every path present at `pre_oid` and gone
20 /// from the working tree becomes a `file_discarded` payload whose
21 /// restoration points back into that commit.
22 ///
23 /// **The question is existence, and only existence.** The tree is
24 /// walked once and each blob is looked for on disk; a path that is
25 /// not there is a deletion. Asking git for the tree-to-workdir diff
26 /// instead would hash every path the tree and the worktree agree on,
27 /// and the city itself keeps writing some of those paths after the
28 /// checkpoint (a room's session projection, a worktree under a lease); a
29 /// hash taken through a Windows directory entry that trails the open
30 /// handle refuses the whole sweep. A sweep needs `Deleted` deltas,
31 /// and a deletion is answered without reading any content.
32 ///
33 /// **The tree is the checkpoint's own write domain**, not the whole city:
34 /// `wave_pre` walked exactly these paths before the wave began, so
35 /// the walk costs what staging already cost.
36 ///
37 /// # Errors
38 /// Propagates a `pre_oid` that is not an object, a commit whose tree
39 /// cannot be read, and a working-tree path that cannot be examined.
40 pub fn wave_post(&mut self, pre_oid: &str) -> Result<Vec<Payload>, StorageError> {
41 let oid = git2::Oid::from_str(pre_oid).map_err(git_err("parse checkpoint oid"))?;
42 let commit = self
43 .repo
44 .find_commit(oid)
45 .map_err(git_err("find checkpoint commit"))?;
46 let tree = commit.tree().map_err(git_err("read checkpoint tree"))?;
47 let workdir = self
48 .repo
49 .workdir()
50 .ok_or_else(|| StorageError::Checkpoint {
51 op: "sweep the working tree",
52 detail: "the city repository is bare".to_owned(),
53 })?
54 .to_path_buf();
55 let mut deleted: Vec<String> = Vec::new();
56 let mut fault: Option<StorageError> = None;
57 let walked = tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
58 if entry.kind() != Some(git2::ObjectType::Blob) {
59 return git2::TreeWalkResult::Ok;
60 }
61 // An address is UTF-8, so a name that is not cannot be swept
62 // to a `file:` address; reading it as its directory would hide
63 // a deletion.
64 let Ok(name) = entry.name() else {
65 fault = Some(StorageError::Checkpoint {
66 op: "sweep the working tree",
67 detail: format!(
68 "{dir}{}: the checkpoint holds a name that is not UTF-8",
69 String::from_utf8_lossy(entry.name_bytes())
70 ),
71 });
72 return git2::TreeWalkResult::Abort;
73 };
74 let path = format!("{dir}{name}");
75 match std::fs::symlink_metadata(workdir.join(&path)) {
76 Ok(_) => {}
77 Err(err) if err.kind() == std::io::ErrorKind::NotFound => deleted.push(path),
78 Err(err) => {
79 fault = Some(StorageError::Checkpoint {
80 op: "sweep the working tree",
81 detail: format!("{path}: {err}"),
82 });
83 return git2::TreeWalkResult::Abort;
84 }
85 }
86 git2::TreeWalkResult::Ok
87 });
88 walked.map_err(git_err("walk the checkpoint tree"))?;
89 if let Some(err) = fault {
90 return Err(err);
91 }
92 // Sorted here rather than trusted from the walk: the order these
93 // records land in is part of what a replay reproduces.
94 deleted.sort();
95 let mut payloads = Vec::new();
96 for path in deleted {
97 let mut map = Map::new();
98 map.insert(
99 "paths".to_owned(),
100 Value::Array(vec![Value::String(format!("file:{path}"))]),
101 );
102 let mut restoration = Map::new();
103 restoration.insert(
104 "tracked".to_owned(),
105 Value::String(format!("file:{path}@{pre_oid}")),
106 );
107 map.insert("restoration".to_owned(), Value::Object(restoration));
108 payloads.push(Payload::new(map).map_err(|source| StorageError::Draft { source })?);
109 }
110 Ok(payloads)
111 }
112}