Skip to main content

majordomus_cli/environment/
vcs.rs

1//! Version control for the environment snapshot: branch, head, upstream, how far ahead
2//! and behind, and what is dirty — from **one** `git status --porcelain=v2 --branch`.
3//!
4//! One call, because this runs on every `cd`. The obvious implementation asks git seven
5//! times (`rev-parse`, `symbolic-ref`, `rev-list --count`, `status`, ...) and pays seven
6//! process spawns for facts a single porcelain-v2 report already carries. [`crate::git`]
7//! keeps its own narrow calls for the index build, which asks different questions at a
8//! different moment; this is the one the hot path uses.
9//!
10//! Nothing here writes: `status` is read-only, `--no-optional-locks` keeps it from taking
11//! the index lock to refresh stat information, so a snapshot taken while a rebase is in
12//! flight cannot interfere with it.
13
14use std::path::Path;
15use std::process::Command;
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// The command every fact in this module comes from. Named so that a snapshot's
21/// provenance can quote what produced it rather than saying "git".
22pub const SOURCE: &str = "git status --porcelain=v2 --branch";
23
24/// What version control says about the checkout, or why it could not be asked.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(tag = "state", rename_all = "snake_case")]
27pub enum VcsState {
28    /// `git` answered.
29    Git(GitWorkingTree),
30    /// `git` could not be asked, or this is not a work tree.
31    Unavailable {
32        /// What went wrong.
33        reason: String,
34    },
35}
36
37impl VcsState {
38    /// The work tree, when git answered.
39    pub fn tree(&self) -> Option<&GitWorkingTree> {
40        match self {
41            VcsState::Git(t) => Some(t),
42            VcsState::Unavailable { .. } => None,
43        }
44    }
45}
46
47/// The state of one work tree, as porcelain v2 reports it.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
49pub struct GitWorkingTree {
50    /// The commit HEAD names, or `None` in a repository with no commits yet.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub head: Option<String>,
53    /// The branch, or `None` when HEAD is detached or unborn.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub branch: Option<String>,
56    /// Whether HEAD names a commit directly rather than a branch.
57    pub detached: bool,
58    /// The upstream the branch tracks, when it tracks one.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub upstream: Option<String>,
61    /// Commits this branch has that its upstream does not; `None` without an upstream.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub ahead: Option<u32>,
64    /// Commits the upstream has that this branch does not; `None` without an upstream.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub behind: Option<u32>,
67    /// Tracked files with staged changes.
68    pub staged: u32,
69    /// Tracked files changed in the work tree and not staged.
70    pub modified: u32,
71    /// Files git does not track and is not ignoring.
72    pub untracked: u32,
73    /// Files with an unresolved merge.
74    pub conflicted: u32,
75    /// Whether nothing at all is staged, modified, untracked or conflicted.
76    pub clean: bool,
77    /// Every path that is not clean, repository-relative, in git's order. Used to decide
78    /// whether the cached tier is still valid, and shown to nobody.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub changed_paths: Vec<String>,
81}
82
83impl GitWorkingTree {
84    /// How many tracked files differ from HEAD, staged or not.
85    pub fn dirty_files(&self) -> u32 {
86        self.staged + self.modified + self.conflicted
87    }
88}
89
90/// Ask git about `root` with one call. Never fails: a missing `git`, a directory that is
91/// not a work tree, or a report this cannot parse all become
92/// [`VcsState::Unavailable`] with the reason.
93///
94/// `budget` bounds nothing here — `git status` has no timeout of its own and a repository
95/// large enough to exceed a shell prompt's patience is a fact worth feeling — but the
96/// call is the single most expensive thing in a fast resolution, which is why there is
97/// exactly one of it.
98pub fn inspect(root: &Path) -> VcsState {
99    let out = Command::new("git")
100        .arg("-C")
101        .arg(root)
102        // --no-optional-locks: a status may otherwise take the index lock to write back
103        // refreshed stat information, and a snapshot must not contend with the work.
104        .args([
105            "--no-optional-locks",
106            "status",
107            "--porcelain=v2",
108            "--branch",
109            "--untracked-files=normal",
110            "-z",
111        ])
112        .output();
113    let out = match out {
114        Ok(out) => out,
115        Err(e) => {
116            return VcsState::Unavailable {
117                reason: format!("cannot run git: {e}"),
118            }
119        }
120    };
121    if !out.status.success() {
122        return VcsState::Unavailable {
123            reason: String::from_utf8_lossy(&out.stderr).trim().to_string(),
124        };
125    }
126    match parse(&String::from_utf8_lossy(&out.stdout)) {
127        Some(tree) => VcsState::Git(tree),
128        None => VcsState::Unavailable {
129            reason: "git status --porcelain=v2 produced no branch header".into(),
130        },
131    }
132}
133
134/// Parse a `git status --porcelain=v2 --branch -z` report.
135///
136/// Records are NUL-separated. A rename record (`2 `) carries two NUL-separated paths, so
137/// the parser consumes one extra record after it; a parser that does not is silently one
138/// record out of step for the rest of the report, which is the classic way this format is
139/// got wrong.
140///
141/// ```
142/// use majordomus_cli::environment::vcs::parse;
143/// // One record per entry, joined by the NUL the report separates them with.
144/// let report = [
145///     "# branch.oid abc123",
146///     "# branch.head master",
147///     "# branch.upstream origin/master",
148///     "# branch.ab +2 -1",
149///     "1 .M N... 100644 100644 100644 aaa bbb src/a.rs",
150///     "1 M. N... 100644 100644 100644 ccc ddd src/b.rs",
151///     "? notes.txt",
152/// ].join("\0");
153/// let tree = parse(&report).expect("a branch header");
154/// assert_eq!(tree.branch.as_deref(), Some("master"));
155/// assert_eq!(tree.upstream.as_deref(), Some("origin/master"));
156/// assert_eq!((tree.ahead, tree.behind), (Some(2), Some(1)));
157/// assert_eq!((tree.staged, tree.modified, tree.untracked), (1, 1, 1));
158/// assert!(!tree.clean);
159/// ```
160pub fn parse(report: &str) -> Option<GitWorkingTree> {
161    let mut head = None;
162    let mut branch = None;
163    let mut detached = false;
164    let mut upstream = None;
165    let mut ahead = None;
166    let mut behind = None;
167    let (mut staged, mut modified, mut untracked, mut conflicted) = (0u32, 0u32, 0u32, 0u32);
168    let mut changed_paths: Vec<String> = Vec::new();
169    let mut saw_header = false;
170
171    let mut records = report.split('\0').filter(|r| !r.is_empty());
172    while let Some(record) = records.next() {
173        match record.as_bytes().first() {
174            Some(b'#') => {
175                saw_header = true;
176                let rest = record.strip_prefix("# ").unwrap_or(record);
177                let (key, value) = match rest.split_once(' ') {
178                    Some(pair) => pair,
179                    None => continue,
180                };
181                match key {
182                    "branch.oid" if value != "(initial)" => head = Some(value.to_string()),
183                    "branch.head" if value == "(detached)" => detached = true,
184                    "branch.head" => branch = Some(value.to_string()),
185                    "branch.upstream" => upstream = Some(value.to_string()),
186                    "branch.ab" => {
187                        let mut parts = value.split_whitespace();
188                        ahead = parts.next().and_then(signed);
189                        behind = parts.next().and_then(signed);
190                    }
191                    _ => {}
192                }
193            }
194            // "1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>"
195            Some(b'1') => {
196                if let Some((x, y, path)) = ordinary(record, 8) {
197                    if x != '.' {
198                        staged += 1;
199                    }
200                    if y != '.' {
201                        modified += 1;
202                    }
203                    changed_paths.push(path);
204                }
205            }
206            // "2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>" then the original
207            // path as its own NUL-separated record.
208            Some(b'2') => {
209                if let Some((x, y, path)) = ordinary(record, 9) {
210                    if x != '.' {
211                        staged += 1;
212                    }
213                    if y != '.' {
214                        modified += 1;
215                    }
216                    changed_paths.push(path);
217                }
218                // consume the original path of the rename, whatever it is
219                let _ = records.next();
220            }
221            Some(b'u') => {
222                conflicted += 1;
223                if let Some((_, _, path)) = ordinary(record, 10) {
224                    changed_paths.push(path);
225                }
226            }
227            Some(b'?') => {
228                untracked += 1;
229                if let Some(path) = record.strip_prefix("? ") {
230                    changed_paths.push(path.to_string());
231                }
232            }
233            // '!' is an ignored file, which --untracked-files=normal does not report and
234            // which would not be news if it did.
235            _ => {}
236        }
237    }
238    if !saw_header {
239        return None;
240    }
241    changed_paths.sort();
242    changed_paths.dedup();
243    Some(GitWorkingTree {
244        head,
245        branch,
246        detached,
247        upstream,
248        ahead,
249        behind,
250        staged,
251        modified,
252        untracked,
253        conflicted,
254        clean: staged == 0 && modified == 0 && untracked == 0 && conflicted == 0,
255        changed_paths,
256    })
257}
258
259/// The two status characters and the path of an entry whose path starts at `fields`.
260fn ordinary(record: &str, fields: usize) -> Option<(char, char, String)> {
261    let mut chars = record.split(' ').nth(1)?.chars();
262    let x = chars.next()?;
263    let y = chars.next()?;
264    let path = record.splitn(fields + 1, ' ').nth(fields)?;
265    Some((x, y, path.to_string()))
266}
267
268/// `+2` or `-1` as a count.
269fn signed(text: &str) -> Option<u32> {
270    text.trim_start_matches(['+', '-']).parse().ok()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn a_clean_checkout_is_clean() {
279        let tree = parse("# branch.oid abc\0# branch.head master\0").expect("a header");
280        assert!(tree.clean);
281        assert_eq!(tree.dirty_files(), 0);
282        assert_eq!(tree.branch.as_deref(), Some("master"));
283        assert_eq!(tree.ahead, None, "no upstream is not zero ahead");
284    }
285
286    #[test]
287    fn a_detached_head_has_no_branch_and_says_so() {
288        let tree = parse("# branch.oid abc\0# branch.head (detached)\0").expect("a header");
289        assert!(tree.detached);
290        assert_eq!(tree.branch, None);
291    }
292
293    #[test]
294    fn an_unborn_repository_has_no_head() {
295        let tree = parse("# branch.oid (initial)\0# branch.head master\0").expect("a header");
296        assert_eq!(tree.head, None);
297        assert_eq!(tree.branch.as_deref(), Some("master"));
298    }
299
300    /// A rename record is followed by a second record holding the original path. A parser
301    /// that does not consume it reads that path as the next entry — it starts with neither
302    /// `1` nor `?`, so it is silently dropped, and every count after a rename is wrong by
303    /// however many renames preceded it. This is the assertion that would catch it.
304    #[test]
305    fn a_rename_consumes_its_original_path_record() {
306        let report = "# branch.oid abc\0# branch.head master\0\
307                      2 R. N... 100644 100644 100644 aaa bbb R100 new.rs\0old.rs\0\
308                      ? untracked.txt\0";
309        let tree = parse(report).expect("a header");
310        assert_eq!(tree.staged, 1);
311        assert_eq!(
312            tree.untracked, 1,
313            "the untracked entry after a rename is seen"
314        );
315        assert_eq!(tree.changed_paths, vec!["new.rs", "untracked.txt"]);
316    }
317
318    #[test]
319    fn a_path_with_spaces_survives_the_field_split() {
320        let report = "# branch.oid abc\0# branch.head master\0\
321                      1 .M N... 100644 100644 100644 aaa bbb docs/a file.md\0";
322        let tree = parse(report).expect("a header");
323        assert_eq!(tree.changed_paths, vec!["docs/a file.md"]);
324        assert_eq!(tree.modified, 1);
325    }
326
327    #[test]
328    fn a_conflict_is_neither_staged_nor_modified() {
329        let report = "# branch.oid abc\0# branch.head master\0\
330                      u UU N... 100644 100644 100644 100644 aaa bbb ccc src/x.rs\0";
331        let tree = parse(report).expect("a header");
332        assert_eq!(tree.conflicted, 1);
333        assert_eq!(tree.staged, 0);
334        assert!(!tree.clean);
335    }
336
337    #[test]
338    fn a_report_without_a_branch_header_is_not_a_report() {
339        assert!(parse("").is_none());
340        assert!(parse("? x\0").is_none());
341    }
342
343    #[test]
344    fn inspecting_a_directory_that_is_not_a_work_tree_says_why() {
345        let dir = tempfile::tempdir().expect("a temporary directory");
346        match inspect(dir.path()) {
347            VcsState::Unavailable { reason } => assert!(!reason.is_empty()),
348            VcsState::Git(_) => panic!("a bare temporary directory is not a work tree"),
349        }
350    }
351}