Skip to main content

storage/
changes.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//! What changed between two checkpoints, as paths and counts.
7//!
8//! The write side of this city is git-native — every tool wave is
9//! checkpointed by a real commit — and nothing anywhere could read
10//! two of those commits back and say what moved between them. This is
11//! that read.
12//!
13//! **The scope is already right, and it is right for free.** A checkpoint
14//! stages `<scope>/*` and nothing wider, so the difference between two of
15//! them cannot contain a file the Run merely read. Other harnesses report
16//! a session diff that includes everything the session opened, and their
17//! users cannot tell what the agent actually wrote; the checkpoint being the
18//! write domain is what spares this one that.
19//!
20//! **Counts, never patch text.** `checkpoint::scan_staged` refuses to
21//! echo the bytes that matched a secret shape, because printing them to
22//! prove a leak is the leak. Patch text is file content on a socket, and
23//! that is the same question — so a hunk has to be its own request,
24//! answered through the same scan, and it is not this module.
25//!
26//! Specified by `crates/storage/spec/Changes.lean` §8-13.
27
28use std::cell::{Cell, RefCell};
29use std::path::Path;
30
31use kernel::{FileChange, GitOid, How, Lines};
32
33use crate::error::StorageError;
34
35/// The far end of the comparison.
36///
37/// Exhaustive rather than `Option<GitOid>`: "against the working tree" is
38/// a statement about now, and spelling it as an absent commit invites the
39/// caller to read it as "against nothing".
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Head {
42    Commit(GitOid),
43    WorkingTree,
44}
45
46fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
47    move |err| StorageError::Checkpoint {
48        op,
49        detail: err.message().to_owned(),
50    }
51}
52
53/// What moved between `base` and `head`, one row per file, path order.
54///
55/// Path order rather than size order: a reader looking for one file finds
56/// it in the same place every time, and a list that reorders itself as
57/// the numbers change is a list nobody can scan twice.
58///
59/// # Errors
60/// Propagates whatever opening the repository, finding the commits, or
61/// walking the difference reports.
62pub fn between(
63    city_root: &Path,
64    base: GitOid,
65    head: Head,
66) -> Result<Vec<FileChange>, StorageError> {
67    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
68    let find = |oid: GitOid| -> Result<git2::Tree<'_>, StorageError> {
69        let parsed =
70            git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
71        repo.find_commit(parsed)
72            .map_err(git_err("find a checkpoint"))?
73            .tree()
74            .map_err(git_err("read a checkpoint tree"))
75    };
76    let old = find(base)?;
77    let mut options = git2::DiffOptions::new();
78    // Renames are detected below rather than assumed here; this only asks
79    // git not to expand a whole file into a hunk it will never show.
80    options.context_lines(0);
81    let mut diff = match head {
82        Head::Commit(oid) => {
83            let new = find(oid)?;
84            repo.diff_tree_to_tree(Some(&old), Some(&new), Some(&mut options))
85        }
86        // Index included, so a file the wave staged but has not committed
87        // still counts as moved: what a person is looking at is the tree
88        // on disk, not the tree git last recorded.
89        Head::WorkingTree => repo.diff_tree_to_workdir_with_index(Some(&old), Some(&mut options)),
90    }
91    .map_err(git_err("compare two checkpoints"))?;
92    diff.find_similar(None)
93        .map_err(git_err("look for renames"))?;
94    collect(&diff)
95}
96
97/// Each commit's parents, in the order the commit object states them;
98/// `None` for an oid the repository does not hold. The repository is
99/// opened once for the whole list.
100///
101/// # Errors
102/// Propagates whatever opening the repository or reading a commit that
103/// is there reports.
104pub fn parents_of(
105    city_root: &Path,
106    oids: &[GitOid],
107) -> Result<Vec<Option<Vec<GitOid>>>, StorageError> {
108    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
109    oids.iter()
110        .map(|oid| {
111            let parsed =
112                git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a commit"))?;
113            let commit = match repo.find_commit(parsed) {
114                Ok(commit) => commit,
115                Err(absent) if absent.code() == git2::ErrorCode::NotFound => return Ok(None),
116                Err(failed) => return Err(git_err("find a commit")(failed)),
117            };
118            commit
119                .parent_ids()
120                .map(|id| {
121                    let spelled = id.to_string();
122                    GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
123                        op: "read a commit's parents",
124                        detail: format!("git named a parent {spelled}, which is not 40 hex digits"),
125                    })
126                })
127                .collect::<Result<Vec<GitOid>, StorageError>>()
128                .map(Some)
129        })
130        .collect()
131}
132
133/// Reads one prepared diff into rows.
134///
135/// Separate from [`between`] because everything above is git and
136/// everything here is this module's own reading of it: which deltas earn
137/// a row, and what a row says when git has no line count for it. Shared
138/// with `crate::status`, which prepares a different diff and needs the
139/// same reading of it — what a changed file is must have one answer
140/// whether the far end is a checkpoint or the disk.
141pub(crate) fn collect(diff: &git2::Diff<'_>) -> Result<Vec<FileChange>, StorageError> {
142    let mut rows: Vec<FileChange> = Vec::new();
143    let stats: Vec<(u32, u32)> = line_counts(diff)?;
144    for (at, delta) in diff.deltas().enumerate() {
145        let Some(path) = delta
146            .new_file()
147            .path()
148            .or_else(|| delta.old_file().path())
149            .and_then(Path::to_str)
150        else {
151            // A path this platform cannot spell is left out rather than
152            // shown under a mangled name; the count of what is listed is
153            // always the count of what a reader can act on.
154            continue;
155        };
156        #[expect(
157            clippy::wildcard_enum_match_arm,
158            reason = "git2::Delta is an upstream open enum; every status we do not name is a change to content"
159        )]
160        let how = match delta.status() {
161            // Untracked belongs with Added: a file the working tree
162            // holds and no commit does is a file this wave added,
163            // whether or not anybody staged it.
164            git2::Delta::Added | git2::Delta::Copied | git2::Delta::Untracked => How::Added,
165            git2::Delta::Deleted => How::Deleted,
166            git2::Delta::Renamed => How::Renamed {
167                from: delta
168                    .old_file()
169                    .path()
170                    .and_then(Path::to_str)
171                    .unwrap_or_default()
172                    .to_owned(),
173            },
174            _ => How::Modified,
175        };
176        // `is_binary` is only set once git has looked at the content,
177        // which `find_similar` above has made it do.
178        let binary = delta.new_file().is_binary() || delta.old_file().is_binary();
179        let lines = match (binary, stats.get(at)) {
180            (false, Some(&(added, removed))) => Lines::Counted { added, removed },
181            _ => Lines::Binary,
182        };
183        rows.push(FileChange {
184            path: path.to_owned(),
185            how,
186            lines,
187        });
188    }
189    rows.sort_by(|left, right| left.path.cmp(&right.path));
190    Ok(rows)
191}
192
193/// Added and removed lines per delta, in delta order.
194///
195/// Walked once with a callback rather than asked per file: `git2` counts
196/// lines while it walks, so one pass answers for every row and a second
197/// pass per file would diff the same trees again.
198fn line_counts(diff: &git2::Diff<'_>) -> Result<Vec<(u32, u32)>, StorageError> {
199    // Shared by two callbacks that `git2` calls one after the other and
200    // never at the same time, which is what makes a `Cell` the right
201    // amount of machinery here.
202    let counts = RefCell::new(vec![(0u32, 0u32); diff.deltas().len()]);
203    let at = Cell::new(0usize);
204    let started = Cell::new(false);
205    diff.foreach(
206        &mut |_, _| {
207            // The file callback opens each delta, so the first one is row
208            // zero and every later one steps forward.
209            if started.replace(true) {
210                at.set(at.get().saturating_add(1));
211            }
212            true
213        },
214        None,
215        None,
216        Some(&mut |_, _, line| {
217            if let Ok(mut rows) = counts.try_borrow_mut()
218                && let Some(row) = rows.get_mut(at.get())
219            {
220                match line.origin() {
221                    '+' => row.0 = row.0.saturating_add(1),
222                    '-' => row.1 = row.1.saturating_add(1),
223                    _ => {}
224                }
225            }
226            true
227        }),
228    )
229    .map_err(git_err("count the lines that moved"))?;
230    Ok(counts.into_inner())
231}
232
233#[cfg(test)]
234#[allow(
235    clippy::unwrap_used,
236    clippy::expect_used,
237    clippy::panic,
238    clippy::indexing_slicing,
239    reason = "test code"
240)]
241mod tests {
242    use super::*;
243    use kernel::TimeMs;
244
245    use crate::checkpoint::Checkpoint;
246
247    fn write(root: &Path, rel: &str, body: &str) {
248        let path = root.join(rel);
249        if let Some(parent) = path.parent() {
250            std::fs::create_dir_all(parent).unwrap();
251        }
252        std::fs::write(path, body).unwrap();
253    }
254
255    fn resident() -> crate::checkpoint::Provenance {
256        crate::checkpoint::Provenance::new(
257            kernel::RunId::CITY,
258            kernel::Address::parse("lab/parser").unwrap(),
259            kernel::B3Hash::digest(b"a city"),
260            crate::checkpoint::ModelChoice {
261                id: "test-model".to_owned(),
262                effort: None,
263            },
264        )
265    }
266
267    fn oid_of(payload: &kernel::Payload) -> GitOid {
268        let raw = serde_json::to_value(payload).unwrap()["oid"]
269            .as_str()
270            .unwrap()
271            .to_owned();
272        GitOid::parse(&raw).expect("a checkpoint names a git object")
273    }
274
275    /// Two checkpoints, and what a person wants to know about the wave between
276    /// them: which files, and how much of each.
277    #[test]
278    fn two_checkpoints_report_the_files_between_them_and_how_much_moved() {
279        let dir = tempfile::tempdir().unwrap();
280        let root = dir.path();
281        write(root, "lab/lex.rs", "one\ntwo\nthree\n");
282        write(root, "lab/keep.rs", "unchanged\n");
283        let mut checkpoint = Checkpoint::open(root).unwrap();
284        let base = oid_of(
285            &checkpoint
286                .wave_pre(&["lab".to_owned()], TimeMs::new(1_000), &resident())
287                .unwrap(),
288        );
289
290        write(root, "lab/lex.rs", "one\ntwo\nthree\nfour\nfive\n");
291        write(root, "lab/new.rs", "fresh\n");
292        std::fs::remove_file(root.join("lab/keep.rs")).unwrap();
293        let head = oid_of(
294            &checkpoint
295                .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &resident())
296                .unwrap(),
297        );
298
299        let rows = between(root, base, Head::Commit(head)).unwrap();
300        let paths: Vec<&str> = rows.iter().map(|row| row.path.as_str()).collect();
301        assert_eq!(paths, vec!["lab/keep.rs", "lab/lex.rs", "lab/new.rs"]);
302
303        assert_eq!(rows[0].how, How::Deleted);
304        assert_eq!(rows[1].how, How::Modified);
305        assert_eq!(rows[2].how, How::Added);
306        assert_eq!(
307            rows[1].lines,
308            Lines::Counted {
309                added: 2,
310                removed: 0
311            },
312            "two lines were appended to the lexer"
313        );
314    }
315
316    /// A wave that changed nothing is not a wave that could not be read.
317    #[test]
318    fn two_identical_checkpoints_report_nothing_rather_than_failing() {
319        let dir = tempfile::tempdir().unwrap();
320        let root = dir.path();
321        write(root, "lab/lex.rs", "one\n");
322        let mut checkpoint = Checkpoint::open(root).unwrap();
323        let base = oid_of(
324            &checkpoint
325                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
326                .unwrap(),
327        );
328        let head = oid_of(
329            &checkpoint
330                .wave_pre(&["lab".to_owned()], TimeMs::new(2), &resident())
331                .unwrap(),
332        );
333        assert!(between(root, base, Head::Commit(head)).unwrap().is_empty());
334    }
335
336    /// What the person is looking at is the tree on disk. A wave still
337    /// running has written files that no checkpoint holds yet, and a
338    /// change list that ignored them would describe the session as it was
339    /// one checkpoint ago.
340    #[test]
341    fn work_not_yet_checkpointed_still_counts_against_the_working_tree() {
342        let dir = tempfile::tempdir().unwrap();
343        let root = dir.path();
344        write(root, "lab/lex.rs", "one\n");
345        let mut checkpoint = Checkpoint::open(root).unwrap();
346        let base = oid_of(
347            &checkpoint
348                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
349                .unwrap(),
350        );
351
352        write(root, "lab/lex.rs", "one\ntwo\n");
353        let rows = between(root, base, Head::WorkingTree).unwrap();
354        assert_eq!(rows.len(), 1);
355        assert_eq!(rows[0].path, "lab/lex.rs");
356        assert_eq!(
357            rows[0].lines,
358            Lines::Counted {
359                added: 1,
360                removed: 0
361            }
362        );
363    }
364
365    /// A binary file has no line count, and saying `+0 −0` would be a
366    /// measurement nobody made.
367    #[test]
368    fn a_binary_file_says_so_rather_than_reporting_nothing_moved() {
369        let dir = tempfile::tempdir().unwrap();
370        let root = dir.path();
371        std::fs::create_dir_all(root.join("lab")).unwrap();
372        std::fs::write(root.join("lab/blob.bin"), [0u8, 1, 2, 0, 3]).unwrap();
373        let mut checkpoint = Checkpoint::open(root).unwrap();
374        let base = oid_of(
375            &checkpoint
376                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
377                .unwrap(),
378        );
379
380        std::fs::write(root.join("lab/blob.bin"), [0u8, 9, 9, 0, 7, 7]).unwrap();
381        let rows = between(root, base, Head::WorkingTree).unwrap();
382        assert_eq!(rows.len(), 1);
383        assert_eq!(rows[0].lines, Lines::Binary, "{:?}", rows[0]);
384    }
385
386    /// A checkpoint that is not in this repository is a refusal, never an
387    /// empty change list: "nothing moved" and "I could not look" are
388    /// different answers and a reader acts differently on each.
389    #[test]
390    fn a_checkpoint_this_city_does_not_hold_is_refused_rather_than_read_as_empty() {
391        let dir = tempfile::tempdir().unwrap();
392        let root = dir.path();
393        write(root, "lab/lex.rs", "one\n");
394        let mut checkpoint = Checkpoint::open(root).unwrap();
395        let base = oid_of(
396            &checkpoint
397                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
398                .unwrap(),
399        );
400        let stranger = GitOid::from_bytes([7u8; 20]);
401        assert!(between(root, stranger, Head::Commit(base)).is_err());
402    }
403}