Skip to main content

wt/git/
status.rs

1//! Working-tree status via `git status --porcelain` (spec §4 sanctioned
2//! subprocess read — the read most likely to need a fallback from `gix`).
3//!
4//! The parser distinguishes tracked modifications/staging ("dirty") from
5//! untracked files (spec §12), and produces the per-file list `wt status` shows.
6
7use std::path::Path;
8
9use crate::error::Result;
10use crate::git::cli::GitCli;
11
12/// A single changed path, collapsed to a display marker.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub(crate) struct StatusEntry {
15    /// `M` for a tracked modification/staging, `?` for an untracked file.
16    pub(crate) marker: char,
17    /// The file path relative to the worktree.
18    pub(crate) path: String,
19}
20
21/// A worktree's status summary.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub(crate) struct StatusSummary {
24    /// Whether any tracked files are modified or staged.
25    pub(crate) dirty: bool,
26    /// Whether any untracked files are present.
27    pub(crate) has_untracked: bool,
28    /// The changed entries, in `git`'s order.
29    pub(crate) entries: Vec<StatusEntry>,
30}
31
32/// Parses `git status --porcelain=v1 -z` output. Each record is `XY<space>path`
33/// terminated by NUL; rename/copy records carry an extra source path that is
34/// consumed and ignored.
35pub(crate) fn parse_status_porcelain(z: &str) -> StatusSummary {
36    let mut summary = StatusSummary::default();
37    let mut fields = z.split('\0');
38    while let Some(field) = fields.next() {
39        if field.len() < 3 {
40            continue;
41        }
42        let xy = &field[..2];
43        let path = &field[3..];
44        if xy == "??" {
45            summary.has_untracked = true;
46            summary.entries.push(StatusEntry {
47                marker: '?',
48                path: path.to_string(),
49            });
50        } else if xy == "!!" {
51            // Ignored; not reported.
52        } else {
53            summary.dirty = true;
54            summary.entries.push(StatusEntry {
55                marker: 'M',
56                path: path.to_string(),
57            });
58            // Rename/copy records are followed by the original path.
59            if xy.contains('R') || xy.contains('C') {
60                fields.next();
61            }
62        }
63    }
64    summary
65}
66
67/// Runs `git status` in the worktree directory and parses the result.
68pub(crate) fn status_of(git: &dyn GitCli, worktree_dir: &Path) -> Result<StatusSummary> {
69    let output = git.run(worktree_dir, &["status", "--porcelain=v1", "-z"])?;
70    Ok(parse_status_porcelain(&output))
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::git::cli::RealGit;
77    use crate::testutil::TestRepo;
78
79    #[test]
80    fn clean_worktree_is_not_dirty() {
81        let s = parse_status_porcelain("");
82        assert!(!s.dirty);
83        assert!(!s.has_untracked);
84        assert!(s.entries.is_empty());
85    }
86
87    #[test]
88    fn parses_modified_staged_and_untracked() {
89        // " M src/a" (modified), "M  src/b" (staged), "?? scratch" (untracked).
90        let z = " M src/a\0M  src/b\0?? scratch\0";
91        let s = parse_status_porcelain(z);
92        assert!(s.dirty);
93        assert!(s.has_untracked);
94        assert_eq!(s.entries.len(), 3);
95        assert_eq!(
96            s.entries[0],
97            StatusEntry {
98                marker: 'M',
99                path: "src/a".into()
100            }
101        );
102        assert_eq!(
103            s.entries[2],
104            StatusEntry {
105                marker: '?',
106                path: "scratch".into()
107            }
108        );
109    }
110
111    #[test]
112    fn untracked_only_is_not_dirty() {
113        let s = parse_status_porcelain("?? new.txt\0");
114        assert!(!s.dirty);
115        assert!(s.has_untracked);
116    }
117
118    #[test]
119    fn ignored_entries_are_skipped() {
120        let s = parse_status_porcelain("!! target/\0");
121        assert!(!s.dirty);
122        assert!(!s.has_untracked);
123        assert!(s.entries.is_empty());
124    }
125
126    #[test]
127    fn rename_consumes_source_field() {
128        // "R  new\0old\0" then a normal untracked entry.
129        let z = "R  new\0old\0?? u\0";
130        let s = parse_status_porcelain(z);
131        assert!(s.dirty);
132        assert!(s.has_untracked);
133        // The "old" source field must not be treated as its own entry.
134        assert_eq!(s.entries.len(), 2);
135        assert_eq!(s.entries[0].path, "new");
136        assert_eq!(s.entries[1].path, "u");
137    }
138
139    #[test]
140    fn status_of_real_repo() {
141        let repo = TestRepo::init();
142        // Clean.
143        let s = status_of(&RealGit, repo.root()).unwrap();
144        assert!(!s.dirty && !s.has_untracked);
145        // Modify a tracked file.
146        repo.write("README.md", "changed\n");
147        let s = status_of(&RealGit, repo.root()).unwrap();
148        assert!(s.dirty);
149        assert!(!s.has_untracked);
150        // Add an untracked file.
151        repo.write("scratch.txt", "x\n");
152        let s = status_of(&RealGit, repo.root()).unwrap();
153        assert!(s.dirty);
154        assert!(s.has_untracked);
155    }
156}