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}