sprawling-storage 0.0.9

The append-only, hash-chained Ledger of a sprawling city, its content store, checkpoints and worktrees.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! What changed between two checkpoints, as paths and counts.
//!
//! The write side of this city is git-native — every tool wave is
//! checkpointed by a real commit — and nothing anywhere could read
//! two of those commits back and say what moved between them. This is
//! that read.
//!
//! **The scope is already right, and it is right for free.** A checkpoint
//! stages `<scope>/*` and nothing wider, so the difference between two of
//! them cannot contain a file the Run merely read. Other harnesses report
//! a session diff that includes everything the session opened, and their
//! users cannot tell what the agent actually wrote; the checkpoint being the
//! write domain is what spares this one that.
//!
//! **Counts, never patch text.** `checkpoint::scan_staged` refuses to
//! echo the bytes that matched a secret shape, because printing them to
//! prove a leak is the leak. Patch text is file content on a socket, and
//! that is the same question — so a hunk has to be its own request,
//! answered through the same scan, and it is not this module.
//!
//! Specified by `crates/storage/spec/Changes.lean` §8-13.

use std::cell::{Cell, RefCell};
use std::path::Path;

use kernel::{FileChange, GitOid, How, Lines};

use crate::error::StorageError;

/// The far end of the comparison.
///
/// Exhaustive rather than `Option<GitOid>`: "against the working tree" is
/// a statement about now, and spelling it as an absent commit invites the
/// caller to read it as "against nothing".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Head {
    Commit(GitOid),
    WorkingTree,
}

fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
    move |err| StorageError::Checkpoint {
        op,
        detail: err.message().to_owned(),
    }
}

/// What moved between `base` and `head`, one row per file, path order.
///
/// Path order rather than size order: a reader looking for one file finds
/// it in the same place every time, and a list that reorders itself as
/// the numbers change is a list nobody can scan twice.
///
/// # Errors
/// Propagates whatever opening the repository, finding the commits, or
/// walking the difference reports.
pub fn between(
    city_root: &Path,
    base: GitOid,
    head: Head,
) -> Result<Vec<FileChange>, StorageError> {
    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
    let find = |oid: GitOid| -> Result<git2::Tree<'_>, StorageError> {
        let parsed =
            git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
        repo.find_commit(parsed)
            .map_err(git_err("find a checkpoint"))?
            .tree()
            .map_err(git_err("read a checkpoint tree"))
    };
    let old = find(base)?;
    let mut options = git2::DiffOptions::new();
    // Renames are detected below rather than assumed here; this only asks
    // git not to expand a whole file into a hunk it will never show.
    options.context_lines(0);
    let mut diff = match head {
        Head::Commit(oid) => {
            let new = find(oid)?;
            repo.diff_tree_to_tree(Some(&old), Some(&new), Some(&mut options))
        }
        // Index included, so a file the wave staged but has not committed
        // still counts as moved: what a person is looking at is the tree
        // on disk, not the tree git last recorded.
        Head::WorkingTree => repo.diff_tree_to_workdir_with_index(Some(&old), Some(&mut options)),
    }
    .map_err(git_err("compare two checkpoints"))?;
    diff.find_similar(None)
        .map_err(git_err("look for renames"))?;
    collect(&diff)
}

/// Each commit's parents, in the order the commit object states them;
/// `None` for an oid the repository does not hold. The repository is
/// opened once for the whole list.
///
/// # Errors
/// Propagates whatever opening the repository or reading a commit that
/// is there reports.
pub fn parents_of(
    city_root: &Path,
    oids: &[GitOid],
) -> Result<Vec<Option<Vec<GitOid>>>, StorageError> {
    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
    oids.iter()
        .map(|oid| {
            let parsed =
                git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a commit"))?;
            let commit = match repo.find_commit(parsed) {
                Ok(commit) => commit,
                Err(absent) if absent.code() == git2::ErrorCode::NotFound => return Ok(None),
                Err(failed) => return Err(git_err("find a commit")(failed)),
            };
            commit
                .parent_ids()
                .map(|id| {
                    let spelled = id.to_string();
                    GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
                        op: "read a commit's parents",
                        detail: format!("git named a parent {spelled}, which is not 40 hex digits"),
                    })
                })
                .collect::<Result<Vec<GitOid>, StorageError>>()
                .map(Some)
        })
        .collect()
}

/// Reads one prepared diff into rows.
///
/// Separate from [`between`] because everything above is git and
/// everything here is this module's own reading of it: which deltas earn
/// a row, and what a row says when git has no line count for it. Shared
/// with `crate::status`, which prepares a different diff and needs the
/// same reading of it — what a changed file is must have one answer
/// whether the far end is a checkpoint or the disk.
pub(crate) fn collect(diff: &git2::Diff<'_>) -> Result<Vec<FileChange>, StorageError> {
    let mut rows: Vec<FileChange> = Vec::new();
    let stats: Vec<(u32, u32)> = line_counts(diff)?;
    for (at, delta) in diff.deltas().enumerate() {
        let Some(path) = delta
            .new_file()
            .path()
            .or_else(|| delta.old_file().path())
            .and_then(Path::to_str)
        else {
            // A path this platform cannot spell is left out rather than
            // shown under a mangled name; the count of what is listed is
            // always the count of what a reader can act on.
            continue;
        };
        #[expect(
            clippy::wildcard_enum_match_arm,
            reason = "git2::Delta is an upstream open enum; every status we do not name is a change to content"
        )]
        let how = match delta.status() {
            // Untracked belongs with Added: a file the working tree
            // holds and no commit does is a file this wave added,
            // whether or not anybody staged it.
            git2::Delta::Added | git2::Delta::Copied | git2::Delta::Untracked => How::Added,
            git2::Delta::Deleted => How::Deleted,
            git2::Delta::Renamed => How::Renamed {
                from: delta
                    .old_file()
                    .path()
                    .and_then(Path::to_str)
                    .unwrap_or_default()
                    .to_owned(),
            },
            _ => How::Modified,
        };
        // `is_binary` is only set once git has looked at the content,
        // which `find_similar` above has made it do.
        let binary = delta.new_file().is_binary() || delta.old_file().is_binary();
        let lines = match (binary, stats.get(at)) {
            (false, Some(&(added, removed))) => Lines::Counted { added, removed },
            _ => Lines::Binary,
        };
        rows.push(FileChange {
            path: path.to_owned(),
            how,
            lines,
        });
    }
    rows.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(rows)
}

/// Added and removed lines per delta, in delta order.
///
/// Walked once with a callback rather than asked per file: `git2` counts
/// lines while it walks, so one pass answers for every row and a second
/// pass per file would diff the same trees again.
fn line_counts(diff: &git2::Diff<'_>) -> Result<Vec<(u32, u32)>, StorageError> {
    // Shared by two callbacks that `git2` calls one after the other and
    // never at the same time, which is what makes a `Cell` the right
    // amount of machinery here.
    let counts = RefCell::new(vec![(0u32, 0u32); diff.deltas().len()]);
    let at = Cell::new(0usize);
    let started = Cell::new(false);
    diff.foreach(
        &mut |_, _| {
            // The file callback opens each delta, so the first one is row
            // zero and every later one steps forward.
            if started.replace(true) {
                at.set(at.get().saturating_add(1));
            }
            true
        },
        None,
        None,
        Some(&mut |_, _, line| {
            if let Ok(mut rows) = counts.try_borrow_mut()
                && let Some(row) = rows.get_mut(at.get())
            {
                match line.origin() {
                    '+' => row.0 = row.0.saturating_add(1),
                    '-' => row.1 = row.1.saturating_add(1),
                    _ => {}
                }
            }
            true
        }),
    )
    .map_err(git_err("count the lines that moved"))?;
    Ok(counts.into_inner())
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    reason = "test code"
)]
mod tests {
    use super::*;
    use kernel::TimeMs;

    use crate::checkpoint::Checkpoint;

    fn write(root: &Path, rel: &str, body: &str) {
        let path = root.join(rel);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(path, body).unwrap();
    }

    fn resident() -> crate::checkpoint::Provenance {
        crate::checkpoint::Provenance::new(
            kernel::RunId::CITY,
            kernel::Address::parse("lab/parser").unwrap(),
            kernel::B3Hash::digest(b"a city"),
            crate::checkpoint::ModelChoice {
                id: "test-model".to_owned(),
                effort: None,
            },
        )
    }

    fn oid_of(payload: &kernel::Payload) -> GitOid {
        let raw = serde_json::to_value(payload).unwrap()["oid"]
            .as_str()
            .unwrap()
            .to_owned();
        GitOid::parse(&raw).expect("a checkpoint names a git object")
    }

    /// Two checkpoints, and what a person wants to know about the wave between
    /// them: which files, and how much of each.
    #[test]
    fn two_checkpoints_report_the_files_between_them_and_how_much_moved() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "lab/lex.rs", "one\ntwo\nthree\n");
        write(root, "lab/keep.rs", "unchanged\n");
        let mut checkpoint = Checkpoint::open(root).unwrap();
        let base = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(1_000), &resident())
                .unwrap(),
        );

        write(root, "lab/lex.rs", "one\ntwo\nthree\nfour\nfive\n");
        write(root, "lab/new.rs", "fresh\n");
        std::fs::remove_file(root.join("lab/keep.rs")).unwrap();
        let head = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &resident())
                .unwrap(),
        );

        let rows = between(root, base, Head::Commit(head)).unwrap();
        let paths: Vec<&str> = rows.iter().map(|row| row.path.as_str()).collect();
        assert_eq!(paths, vec!["lab/keep.rs", "lab/lex.rs", "lab/new.rs"]);

        assert_eq!(rows[0].how, How::Deleted);
        assert_eq!(rows[1].how, How::Modified);
        assert_eq!(rows[2].how, How::Added);
        assert_eq!(
            rows[1].lines,
            Lines::Counted {
                added: 2,
                removed: 0
            },
            "two lines were appended to the lexer"
        );
    }

    /// A wave that changed nothing is not a wave that could not be read.
    #[test]
    fn two_identical_checkpoints_report_nothing_rather_than_failing() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "lab/lex.rs", "one\n");
        let mut checkpoint = Checkpoint::open(root).unwrap();
        let base = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
                .unwrap(),
        );
        let head = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(2), &resident())
                .unwrap(),
        );
        assert!(between(root, base, Head::Commit(head)).unwrap().is_empty());
    }

    /// What the person is looking at is the tree on disk. A wave still
    /// running has written files that no checkpoint holds yet, and a
    /// change list that ignored them would describe the session as it was
    /// one checkpoint ago.
    #[test]
    fn work_not_yet_checkpointed_still_counts_against_the_working_tree() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "lab/lex.rs", "one\n");
        let mut checkpoint = Checkpoint::open(root).unwrap();
        let base = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
                .unwrap(),
        );

        write(root, "lab/lex.rs", "one\ntwo\n");
        let rows = between(root, base, Head::WorkingTree).unwrap();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].path, "lab/lex.rs");
        assert_eq!(
            rows[0].lines,
            Lines::Counted {
                added: 1,
                removed: 0
            }
        );
    }

    /// A binary file has no line count, and saying `+0 −0` would be a
    /// measurement nobody made.
    #[test]
    fn a_binary_file_says_so_rather_than_reporting_nothing_moved() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join("lab")).unwrap();
        std::fs::write(root.join("lab/blob.bin"), [0u8, 1, 2, 0, 3]).unwrap();
        let mut checkpoint = Checkpoint::open(root).unwrap();
        let base = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
                .unwrap(),
        );

        std::fs::write(root.join("lab/blob.bin"), [0u8, 9, 9, 0, 7, 7]).unwrap();
        let rows = between(root, base, Head::WorkingTree).unwrap();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].lines, Lines::Binary, "{:?}", rows[0]);
    }

    /// A checkpoint that is not in this repository is a refusal, never an
    /// empty change list: "nothing moved" and "I could not look" are
    /// different answers and a reader acts differently on each.
    #[test]
    fn a_checkpoint_this_city_does_not_hold_is_refused_rather_than_read_as_empty() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "lab/lex.rs", "one\n");
        let mut checkpoint = Checkpoint::open(root).unwrap();
        let base = oid_of(
            &checkpoint
                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
                .unwrap(),
        );
        let stranger = GitOid::from_bytes([7u8; 20]);
        assert!(between(root, stranger, Head::Commit(base)).is_err());
    }
}