Skip to main content

storage/
status.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 has not been checkpointed yet: the branch, its drift from the
7//! upstream it tracks, and the files that differ from the last commit.
8//!
9//! **Why this is not [`crate::between`] with a different far end.**
10//! That function compares two points a caller already holds and answers
11//! nothing about where the repository itself stands. Which branch is
12//! checked out, whether it has an upstream, and how far it has run
13//! ahead of it are facts about the repository rather than about a pair
14//! of trees — and they are the three a person asks before they ask
15//! which files moved. The rows are read through `changes::collect` all
16//! the same, so what a changed file is keeps one answer.
17//!
18//! **Untracked files count.** A file an agent wrote and never staged is
19//! exactly the file a person is looking for, and a list that showed
20//! only tracked changes would report a new module as nothing at all.
21//!
22//! **A checkpoint does not move the head.** `checkpoint::wave_pre` files its
23//! commit under `refs/sprawling/`, so the head is the base commit or
24//! the last landing, and a comparison against it would report every
25//! wave since as uncommitted. The caller therefore names the checkpoint it
26//! wants compared against; the head is the fallback for a city that has
27//! checkpointed nothing.
28//!
29//! Specified by `crates/storage/spec/Status.lean` §8-22.
30
31use std::path::Path;
32
33use kernel::{FileChange, GitOid};
34
35use crate::changes::collect;
36use crate::error::StorageError;
37
38/// How far a branch has run from the upstream it tracks.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Drift {
41    pub ahead: u64,
42    pub behind: u64,
43}
44
45/// Where a repository stands right now.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct WorkingStatus {
48    /// The branch checked out; `None` on a detached head, which is a
49    /// state a person can be in and one a name cannot describe.
50    pub branch: Option<String>,
51    /// `None` when the branch tracks no upstream — which is not the
52    /// same fact as being level with one.
53    pub drift: Option<Drift>,
54    /// Files that differ from the named base commit (the head commit
55    /// when the caller names none), untracked ones included, in path
56    /// order.
57    pub files: Vec<FileChange>,
58}
59
60fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
61    move |err| StorageError::Checkpoint {
62        op,
63        detail: err.message().to_owned(),
64    }
65}
66
67/// What stands between `base` and the disk under `scope`, and where the
68/// repository itself stands.
69///
70/// `scope` is a repository-relative directory: a building is a directory
71/// and the question a building's page asks is about its own files, so
72/// filtering here costs one pathspec rather than a second pass over the
73/// answer. `base` is the checkpoint to compare against; absent falls back to
74/// the head, which is what a city that has checkpointed nothing has.
75///
76/// A repository with no commit at all answers every file as added
77/// rather than failing: a city initialised this minute has a working
78/// tree and no head, and "none of this is checkpointed" is what a reader
79/// needs to be told.
80///
81/// # Errors
82/// Propagates whatever opening the repository, reading its head,
83/// finding `base`, or walking the difference reports.
84pub fn working_status(
85    city_root: &Path,
86    scope: Option<&str>,
87    base: Option<GitOid>,
88) -> Result<WorkingStatus, StorageError> {
89    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
90    let head = match repo.head() {
91        Ok(found) => Some(found),
92        // An unborn branch is a repository with no commit on it yet.
93        Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
94        Err(err) => return Err(git_err("read the repository head")(err)),
95    };
96    let branch = head
97        .as_ref()
98        .filter(|found| found.is_branch())
99        .and_then(|found| found.shorthand().ok())
100        .map(str::to_owned);
101    let commit = match base {
102        Some(oid) => {
103            let parsed =
104                git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
105            Some(
106                repo.find_commit(parsed)
107                    .map_err(git_err("find a checkpoint"))?,
108            )
109        }
110        None => match &head {
111            Some(found) => Some(
112                found
113                    .peel_to_commit()
114                    .map_err(git_err("find the head commit"))?,
115            ),
116            None => None,
117        },
118    };
119    let tree = match &commit {
120        Some(found) => Some(found.tree().map_err(git_err("read a checkpoint tree"))?),
121        None => None,
122    };
123    let mut options = git2::DiffOptions::new();
124    options.context_lines(0);
125    options.include_untracked(true);
126    options.recurse_untracked_dirs(true);
127    if let Some(scope) = scope {
128        options.pathspec(scope);
129    }
130    let mut diff = repo
131        .diff_tree_to_workdir_with_index(tree.as_ref(), Some(&mut options))
132        .map_err(git_err("compare the working tree with the head"))?;
133    diff.find_similar(None)
134        .map_err(git_err("look for renames"))?;
135    Ok(WorkingStatus {
136        branch: branch.clone(),
137        drift: drift_of(&repo, branch.as_deref())?,
138        files: collect(&diff)?,
139    })
140}
141
142/// How far the named branch has run from its upstream.
143///
144/// `None` for a detached head and for a branch nobody set an upstream
145/// on; a branch whose upstream was deleted behind its back reads the
146/// same way, because what a reader can act on is identical.
147fn drift_of(repo: &git2::Repository, branch: Option<&str>) -> Result<Option<Drift>, StorageError> {
148    let Some(name) = branch else {
149        return Ok(None);
150    };
151    let Ok(local) = repo.find_branch(name, git2::BranchType::Local) else {
152        return Ok(None);
153    };
154    let Ok(upstream) = local.upstream() else {
155        return Ok(None);
156    };
157    let (Some(here), Some(there)) = (local.get().target(), upstream.get().target()) else {
158        return Ok(None);
159    };
160    let (ahead, behind) = repo
161        .graph_ahead_behind(here, there)
162        .map_err(git_err("measure the drift from the upstream"))?;
163    Ok(Some(Drift {
164        ahead: u64::try_from(ahead).unwrap_or(u64::MAX),
165        behind: u64::try_from(behind).unwrap_or(u64::MAX),
166    }))
167}
168
169#[cfg(test)]
170#[allow(
171    clippy::unwrap_used,
172    clippy::expect_used,
173    clippy::panic,
174    clippy::indexing_slicing,
175    reason = "test code"
176)]
177mod tests {
178    use super::*;
179    use kernel::{How, TimeMs};
180
181    use crate::checkpoint::Checkpoint;
182
183    fn oid_of(payload: &kernel::Payload) -> GitOid {
184        let raw = serde_json::to_value(payload).unwrap()["oid"]
185            .as_str()
186            .unwrap()
187            .to_owned();
188        GitOid::parse(&raw).expect("a checkpoint names a git object")
189    }
190
191    fn write(root: &Path, rel: &str, body: &str) {
192        let path = root.join(rel);
193        if let Some(parent) = path.parent() {
194            std::fs::create_dir_all(parent).unwrap();
195        }
196        std::fs::write(path, body).unwrap();
197    }
198
199    fn resident() -> crate::checkpoint::Provenance {
200        crate::checkpoint::Provenance::new(
201            kernel::RunId::CITY,
202            kernel::Address::parse("lab/parser").unwrap(),
203            kernel::B3Hash::digest(b"a city"),
204            crate::checkpoint::ModelChoice {
205                id: "test-model".to_owned(),
206                effort: None,
207            },
208        )
209    }
210
211    /// The file a person is looking for is the one an agent wrote and
212    /// never staged, so an untracked file is a row like any other.
213    #[test]
214    fn a_file_written_since_the_last_checkpoint_is_listed_untracked_included() {
215        let dir = tempfile::tempdir().unwrap();
216        let root = dir.path();
217        write(root, "lab/lex.rs", "one\n");
218        let mut checkpoint = Checkpoint::open(root).unwrap();
219        let base = oid_of(
220            &checkpoint
221                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
222                .unwrap(),
223        );
224
225        write(root, "lab/lex.rs", "one\ntwo\n");
226        write(root, "lab/fresh.rs", "new\n");
227        let status = working_status(root, Some("lab"), Some(base)).unwrap();
228        let paths: Vec<&str> = status.files.iter().map(|row| row.path.as_str()).collect();
229        assert_eq!(paths, vec!["lab/fresh.rs", "lab/lex.rs"]);
230        assert_eq!(status.files[0].how, How::Added);
231        assert_eq!(status.files[1].how, How::Modified);
232    }
233
234    /// A building asks about its own files, so another building's work
235    /// is not in its answer.
236    #[test]
237    fn the_scope_keeps_another_buildings_work_out_of_the_answer() {
238        let dir = tempfile::tempdir().unwrap();
239        let root = dir.path();
240        write(root, "lab/lex.rs", "one\n");
241        let mut checkpoint = Checkpoint::open(root).unwrap();
242        let base = oid_of(
243            &checkpoint
244                .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
245                .unwrap(),
246        );
247
248        write(root, "hall/Notes.md", "elsewhere\n");
249        assert!(
250            working_status(root, Some("lab"), Some(base))
251                .unwrap()
252                .files
253                .is_empty()
254        );
255        let whole = working_status(root, None, Some(base)).unwrap();
256        assert_eq!(
257            whole
258                .files
259                .iter()
260                .map(|row| row.path.as_str())
261                .collect::<Vec<_>>(),
262            vec!["hall/Notes.md"]
263        );
264    }
265
266    /// A branch nobody set an upstream on is not a branch level with
267    /// one, and a page that read `0/0` would say the work is pushed.
268    #[test]
269    fn a_branch_with_no_upstream_reports_no_drift_rather_than_zero() {
270        let dir = tempfile::tempdir().unwrap();
271        let root = dir.path();
272        write(root, "lab/lex.rs", "one\n");
273        let mut checkpoint = Checkpoint::open(root).unwrap();
274        checkpoint
275            .ensure_base(&["lab".to_owned()], TimeMs::new(1), &resident())
276            .unwrap();
277        let status = working_status(root, None, None).unwrap();
278        assert!(status.branch.is_some(), "{status:?}");
279        assert_eq!(status.drift, None);
280    }
281
282    /// A city checkpointed nothing yet still has a working tree, and every
283    /// file in it is work no commit holds.
284    #[test]
285    fn a_city_with_no_commit_reports_its_files_rather_than_failing() {
286        let dir = tempfile::tempdir().unwrap();
287        let root = dir.path();
288        write(root, "lab/lex.rs", "one\n");
289        Checkpoint::open(root).unwrap();
290        let status = working_status(root, None, None).unwrap();
291        assert_eq!(status.branch, None);
292        assert_eq!(
293            status
294                .files
295                .iter()
296                .map(|row| row.how.clone())
297                .collect::<Vec<_>>(),
298            vec![How::Added]
299        );
300    }
301}