Skip to main content

lattice_vcs/
working_tree.rs

1use std::path::{Path, PathBuf};
2
3use crate::{Repository, Result, VcsError};
4
5/// One axis of a path's change, as git reports it.
6///
7/// Deliberately does NOT include a "clean" or "both staged and
8/// unstaged" value: those are properties of the pair, not of one
9/// axis, and [`PathChange`] expresses them (`None` and `Some` on both
10/// sides respectively). Encoding them here is what produced the
11/// original bug — a staged modification had to claim to be `Added`
12/// so the consumer would file it in the right section.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum PathStatus {
15    /// Content changed. On the index axis: staged for commit. On the
16    /// worktree axis: not yet staged.
17    Modified,
18    /// Path exists on this side but not the one it is compared against
19    /// — a new file.
20    Added,
21    /// Path was removed.
22    Deleted,
23    /// Path moved from somewhere else; the origin is
24    /// [`PathChange::original_path`]. Index axis only — git does not
25    /// detect worktree renames.
26    Renamed,
27    /// Path was copied from somewhere else; origin as for
28    /// [`Self::Renamed`]. Only produced when rename detection is
29    /// configured to find copies (`status.renames=copies`).
30    Copied,
31    /// The path's TYPE changed — regular file ⇄ symlink ⇄ submodule —
32    /// with or without a content change. Git spells this `T`, and
33    /// dropping it (as this parser used to) makes the file vanish from
34    /// the status view entirely: a change you can neither see nor
35    /// stage.
36    TypeChanged,
37    /// File is not tracked by git. A whole-path state, not an axis.
38    Untracked,
39    /// File is ignored via `.gitignore`. A whole-path state.
40    Ignored,
41    /// File has unmerged entries — a real merge conflict, carrying
42    /// WHICH of git's seven combinations it is. A whole-path state:
43    /// the resolution work is in the worktree, so it is reported
44    /// there.
45    Unmerged(UnmergedKind),
46}
47
48/// Which unmerged combination git reported.
49///
50/// "Us" is the branch being merged INTO (`HEAD` / the branch you are
51/// rebasing onto); "them" is the incoming side. During a rebase or
52/// cherry-pick the two are inverted relative to what people expect —
53/// git replays your commits ONTO the upstream, so "us" is the
54/// upstream and "them" is your own commit. Naming the side is the
55/// whole value of distinguishing these: "both modified" and "deleted
56/// by them" call for completely different resolutions, and a generic
57/// "unmerged" tells the user neither.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum UnmergedKind {
60    /// `DD` — deleted on both sides.
61    BothDeleted,
62    /// `AU` — added by us, unmerged on their side.
63    AddedByUs,
64    /// `UD` — deleted by them.
65    DeletedByThem,
66    /// `UA` — added by them.
67    AddedByThem,
68    /// `DU` — deleted by us.
69    DeletedByUs,
70    /// `AA` — added on both sides.
71    BothAdded,
72    /// `UU` — modified on both sides. The ordinary conflict.
73    BothModified,
74    /// A `U` combination git documents no name for. Kept rather than
75    /// guessed at: reporting it as one of the seven would be a lie,
76    /// and dropping it would make the path invisible in the status —
77    /// the same silent omission `T` used to have.
78    Other,
79}
80
81impl UnmergedKind {
82    /// Git's own wording for this combination, as `git status` prints
83    /// it in the long format.
84    pub fn label(self) -> &'static str {
85        match self {
86            Self::BothDeleted => "both deleted",
87            Self::AddedByUs => "added by us",
88            Self::DeletedByThem => "deleted by them",
89            Self::AddedByThem => "added by them",
90            Self::DeletedByUs => "deleted by us",
91            Self::BothAdded => "both added",
92            Self::BothModified => "both modified",
93            Self::Other => "unmerged",
94        }
95    }
96
97    /// Every named combination, for exhaustive tests and label tables.
98    pub const ALL: [Self; 8] = [
99        Self::BothDeleted,
100        Self::AddedByUs,
101        Self::DeletedByThem,
102        Self::AddedByThem,
103        Self::DeletedByUs,
104        Self::BothAdded,
105        Self::BothModified,
106        Self::Other,
107    ];
108}
109
110/// A path's porcelain `XY` status, kept as the TWO independent axes git
111/// reports rather than collapsed into one value.
112///
113/// `X` is the index vs HEAD ("what a commit would record") and `Y` is
114/// the working tree vs the index ("what staging would add"). They vary
115/// independently: `MM` means a file has staged changes *and* further
116/// unstaged ones, and it belongs in both of magit's sections with
117/// "modified" on each row.
118///
119/// Collapsing them into a single [`PathStatus`] is what produced the
120/// reported bug — a staged modification (`M `) had to be reported as
121/// `Added` to make it land in the staged section, so it rendered as
122/// "new file", and `MM` had to be reported as `Conflicted` (which means
123/// a merge conflict) purely to make it appear in both. One value cannot
124/// answer both "which section" and "what label" without lying about one
125/// of them.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct PathChange {
128    /// Index vs HEAD. `None` when nothing is staged for this path.
129    pub staged: Option<PathStatus>,
130    /// Working tree vs index. `None` when the worktree matches the
131    /// index. Also carries [`PathStatus::Untracked`] / `Ignored` /
132    /// `Unmerged`, which are whole-path states rather than one axis.
133    pub unstaged: Option<PathStatus>,
134    /// Where a [`PathStatus::Renamed`] / [`PathStatus::Copied`] path
135    /// came from. `None` for every other status.
136    pub original_path: Option<PathBuf>,
137}
138
139impl PathChange {
140    /// Nothing staged, nothing modified.
141    pub const CLEAN: Self = Self {
142        staged: None,
143        unstaged: None,
144        original_path: None,
145    };
146
147    /// `true` when git reports no change on either axis.
148    pub fn is_clean(&self) -> bool {
149        self.staged.is_none() && self.unstaged.is_none()
150    }
151}
152
153/// Working tree and index status operations.
154///
155/// Uses `git status --porcelain=v1` for status classification.
156pub struct WorkingTree;
157
158impl WorkingTree {
159    /// Return the status of a single path in the repository.
160    ///
161    /// Uses `git status --porcelain=v1 -- <path>`.
162    pub fn path_status(repo: &Repository, path: impl AsRef<Path>) -> Result<PathChange> {
163        let path = path.as_ref();
164        let output =
165            // `-z` for the same reason as `statuses` — a non-ASCII
166            // path would otherwise come back quoted and escaped.
167            repo.run_git_str(["status", "--porcelain=v1", "-z", "--", &path.to_string_lossy()])?;
168
169        if output.trim().is_empty() {
170            // File might be tracked but clean, or nonexistent.
171            // Check if it's tracked at all.
172            match repo.run_git_str(["ls-files", "--error-unmatch", "--", &path.to_string_lossy()]) {
173                Ok(_) => Ok(PathChange::CLEAN),
174                Err(_) => Err(VcsError::StatusParse(format!(
175                    "path not in repository: {}",
176                    path.display()
177                ))),
178            }
179        } else {
180            let record = output.split('\0').find(|r| !r.is_empty()).unwrap_or("");
181            Ok(parse_porcelain_line(record))
182        }
183    }
184
185    /// Return the status of every file in the working tree that has
186    /// changed relative to the index or HEAD.
187    ///
188    /// Includes untracked files. Uses `git status --porcelain=v1`.
189    pub fn statuses(repo: &Repository) -> Result<Vec<(PathBuf, PathChange)>> {
190        // `-z` is not an optimisation, it is the only correct form.
191        //
192        // Without it git QUOTES any path that needs escaping, and
193        // `core.quotepath` defaults to on — so every non-ASCII filename
194        // comes back as `"\303\251t\303\251.txt"`, quotes and octal
195        // escapes included, and the parsed `PathBuf` names a file that
196        // does not exist. Renames also arrive as `old -> new` in one
197        // field, which cannot be split unambiguously: ` -> ` is legal in
198        // a filename.
199        //
200        // With `-z`, records are NUL-terminated, paths are verbatim, and
201        // a rename/copy is TWO records — the new path, then the original
202        // (note the order is the reverse of the ` -> ` form).
203        let output = repo.run_git_str(["status", "--porcelain=v1", "-z"])?;
204        let mut results = Vec::new();
205        let mut records = output.split('\0').filter(|r| !r.is_empty());
206
207        while let Some(record) = records.next() {
208            // "XY " plus at least one byte of path.
209            if record.len() < 4 {
210                continue;
211            }
212            let change = parse_porcelain_line(record);
213            // Byte 3 is always a char boundary: `XY` and the separator
214            // are ASCII.
215            let path = PathBuf::from(&record[3..]);
216            // Only the INDEX axis carries rename/copy detection in
217            // porcelain v1, so only an `R`/`C` in column X means git
218            // emitted an extra origin record. Keying off column Y as
219            // well would swallow the next file's record whenever git
220            // did not emit one.
221            let original_path = match record.chars().next() {
222                Some('R' | 'C') => records.next().map(PathBuf::from),
223                _ => None,
224            };
225            results.push((
226                path,
227                PathChange {
228                    original_path,
229                    ..change
230                },
231            ));
232        }
233
234        Ok(results)
235    }
236}
237
238/// Parse a single `git status --porcelain=v1` line into a [`PathChange`].
239///
240/// Porcelain format: `XY PATH` where X is the index status and Y is the
241/// working-tree status. The two are decoded SEPARATELY — see
242/// [`PathChange`] for why collapsing them is a bug rather than a
243/// simplification.
244/// An unmerged path, on the worktree axis.
245fn unmerged(kind: UnmergedKind) -> PathChange {
246    PathChange {
247        unstaged: Some(PathStatus::Unmerged(kind)),
248        ..PathChange::CLEAN
249    }
250}
251
252fn parse_porcelain_line(line: &str) -> PathChange {
253    let chars: Vec<char> = line.chars().take(2).collect();
254    if chars.len() < 2 {
255        return PathChange::CLEAN;
256    }
257    let x = chars[0]; // index (staging area) status
258    let y = chars[1]; // working tree status
259
260    // Whole-path states first: these are not per-axis, and git spells
261    // them with the same char in both columns.
262    match (x, y) {
263        ('?', '?') => {
264            return PathChange {
265                unstaged: Some(PathStatus::Untracked),
266                ..PathChange::CLEAN
267            };
268        }
269        ('!', '!') => {
270            return PathChange {
271                unstaged: Some(PathStatus::Ignored),
272                ..PathChange::CLEAN
273            };
274        }
275        // Unmerged. Git documents exactly seven combinations; each is
276        // decoded by name because they call for different resolutions
277        // ("both modified" and "deleted by them" are not the same
278        // problem). Reported on the unstaged axis because the
279        // resolution work is in the worktree.
280        ('D', 'D') => return unmerged(UnmergedKind::BothDeleted),
281        ('A', 'U') => return unmerged(UnmergedKind::AddedByUs),
282        ('U', 'D') => return unmerged(UnmergedKind::DeletedByThem),
283        ('U', 'A') => return unmerged(UnmergedKind::AddedByThem),
284        ('D', 'U') => return unmerged(UnmergedKind::DeletedByUs),
285        ('A', 'A') => return unmerged(UnmergedKind::BothAdded),
286        ('U', 'U') => return unmerged(UnmergedKind::BothModified),
287        // Any other `U` pairing: still unmerged, still visible, but not
288        // claimed to be one of the seven.
289        ('U', _) | (_, 'U') => return unmerged(UnmergedKind::Other),
290        _ => {}
291    }
292
293    // Index axis (vs HEAD).
294    let staged = match x {
295        'M' => Some(PathStatus::Modified),
296        'A' => Some(PathStatus::Added),
297        'D' => Some(PathStatus::Deleted),
298        'R' => Some(PathStatus::Renamed),
299        'C' => Some(PathStatus::Copied),
300        'T' => Some(PathStatus::TypeChanged),
301        _ => None,
302    };
303
304    // Worktree axis (vs index). Git does not detect worktree renames,
305    // so `R`/`C` cannot appear here.
306    let unstaged = match y {
307        'M' => Some(PathStatus::Modified),
308        'D' => Some(PathStatus::Deleted),
309        'A' => Some(PathStatus::Added),
310        'T' => Some(PathStatus::TypeChanged),
311        _ => None,
312    };
313
314    PathChange {
315        staged,
316        unstaged,
317        original_path: None,
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn parse(xy: &str) -> PathChange {
326        parse_porcelain_line(&format!("{xy} some/path.txt"))
327    }
328
329    /// The porcelain table, pinned per axis.
330    ///
331    /// Reported 2026-08-10: a staged modification rendered as "new
332    /// file" in magit's Staged-changes section. `('M', ' ')` was mapped
333    /// to `PathStatus::Added` — not a typo, but the only way to make a
334    /// single collapsed value land the row in the staged section, since
335    /// magit's refresh chose the section FROM that value. The label was
336    /// the price. `MM` had the same shape: reported as `Conflicted`
337    /// (which means a merge conflict) purely to appear in both
338    /// sections.
339    ///
340    /// Both axes are decoded separately now, so "which section" and
341    /// "what label" stop fighting over one value.
342    #[test]
343    fn porcelain_xy_decodes_both_axes_independently() {
344        // The reported case: staged modification. Staged, and MODIFIED
345        // — not added.
346        assert_eq!(
347            parse("M "),
348            PathChange {
349                staged: Some(PathStatus::Modified),
350                unstaged: None,
351                ..PathChange::CLEAN
352            }
353        );
354        // Unstaged modification.
355        assert_eq!(
356            parse(" M"),
357            PathChange {
358                staged: None,
359                unstaged: Some(PathStatus::Modified),
360                ..PathChange::CLEAN
361            }
362        );
363        // Both axes at once — two modifications, not a conflict.
364        assert_eq!(
365            parse("MM"),
366            PathChange {
367                staged: Some(PathStatus::Modified),
368                unstaged: Some(PathStatus::Modified),
369                ..PathChange::CLEAN
370            }
371        );
372        // A genuinely new file, staged.
373        assert_eq!(
374            parse("A "),
375            PathChange {
376                staged: Some(PathStatus::Added),
377                unstaged: None,
378                ..PathChange::CLEAN
379            }
380        );
381        // Staged new file with further unstaged edits.
382        assert_eq!(
383            parse("AM"),
384            PathChange {
385                staged: Some(PathStatus::Added),
386                unstaged: Some(PathStatus::Modified),
387                ..PathChange::CLEAN
388            }
389        );
390        // Deletions, each side.
391        assert_eq!(
392            parse("D "),
393            PathChange {
394                staged: Some(PathStatus::Deleted),
395                unstaged: None,
396                ..PathChange::CLEAN
397            }
398        );
399        assert_eq!(
400            parse(" D"),
401            PathChange {
402                staged: None,
403                unstaged: Some(PathStatus::Deleted),
404                ..PathChange::CLEAN
405            }
406        );
407        // Rename / copy are their own statuses now — they used to be
408        // reported as `Added`, so a staged rename read as "new file".
409        assert_eq!(parse("R ").staged, Some(PathStatus::Renamed));
410        assert_eq!(parse("C ").staged, Some(PathStatus::Copied));
411        // Renamed in the index AND edited in the worktree.
412        assert_eq!(
413            parse("RM"),
414            PathChange {
415                staged: Some(PathStatus::Renamed),
416                unstaged: Some(PathStatus::Modified),
417                ..PathChange::CLEAN
418            }
419        );
420        // Type change (file ⇄ symlink ⇄ submodule), each axis. These
421        // used to decode to `None` on both sides, which silently
422        // dropped the path from the status view — a change the user
423        // could neither see nor stage.
424        assert_eq!(parse("T ").staged, Some(PathStatus::TypeChanged));
425        assert_eq!(parse(" T").unstaged, Some(PathStatus::TypeChanged));
426        assert_eq!(
427            parse("TT"),
428            PathChange {
429                staged: Some(PathStatus::TypeChanged),
430                unstaged: Some(PathStatus::TypeChanged),
431                ..PathChange::CLEAN
432            }
433        );
434        // Git never reports a worktree rename/copy, so those columns
435        // stay unclaimed rather than guessing.
436        assert_eq!(parse(" R").unstaged, None);
437        assert_eq!(parse(" C").unstaged, None);
438    }
439
440    /// Whole-path states are not per-axis: git spells them in both
441    /// columns and they describe the path, not one side of it.
442    #[test]
443    fn porcelain_whole_path_states() {
444        assert_eq!(parse("??").unstaged, Some(PathStatus::Untracked));
445        assert_eq!(parse("??").staged, None);
446        assert_eq!(parse("!!").unstaged, Some(PathStatus::Ignored));
447        // All SEVEN unmerged combinations, each decoded by name. They
448        // call for different resolutions — "both modified" and
449        // "deleted by them" are not the same problem — so collapsing
450        // them into one "unmerged" tells the user nothing about what
451        // to do.
452        for (xy, kind) in [
453            ("DD", UnmergedKind::BothDeleted),
454            ("AU", UnmergedKind::AddedByUs),
455            ("UD", UnmergedKind::DeletedByThem),
456            ("UA", UnmergedKind::AddedByThem),
457            ("DU", UnmergedKind::DeletedByUs),
458            ("AA", UnmergedKind::BothAdded),
459            ("UU", UnmergedKind::BothModified),
460        ] {
461            assert_eq!(
462                parse(xy).unstaged,
463                Some(PathStatus::Unmerged(kind)),
464                "{xy} is {}",
465                kind.label()
466            );
467            // Never mistaken for a staged change: the resolution work
468            // is in the worktree.
469            assert_eq!(parse(xy).staged, None, "{xy} claims nothing staged");
470        }
471        // A `U` pairing git documents no name for stays visible and is
472        // not claimed to be one of the seven.
473        assert_eq!(
474            parse("UM").unstaged,
475            Some(PathStatus::Unmerged(UnmergedKind::Other))
476        );
477        // Every named kind has git's own wording, and no two collide.
478        let labels: std::collections::HashSet<&str> =
479            UnmergedKind::ALL.iter().map(|k| k.label()).collect();
480        assert_eq!(
481            labels.len(),
482            UnmergedKind::ALL.len(),
483            "labels must be distinct"
484        );
485        // Too short to carry a status ⇒ nothing claimed.
486        assert!(parse_porcelain_line("").is_clean());
487    }
488}