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