From 55d99e02593ea4e19c18ae8d8fd5978364fbb0dd Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 22 Jan 2025 15:28:25 -0500 Subject: [PATCH] git: Handle git status output for deleted-in-index state (#23483) When a file exists in HEAD, is deleted in the index, and exists again in the working copy, git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy), and the other reading `??` (untracked). Merge these two into the equivalent of `DA`. Release Notes: - Improved handling of files that are deleted in the git index but exist in HEAD and the working copy --- crates/git/src/status.rs | 27 +++++++++++++ crates/worktree/src/worktree_tests.rs | 56 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index eed2c28f08..ef862e7e96 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -62,6 +62,13 @@ impl FileStatus { }) } + pub const fn index(index_status: StatusCode) -> Self { + FileStatus::Tracked(TrackedStatus { + worktree_status: StatusCode::Unmodified, + index_status, + }) + } + /// Generate a FileStatus Code from a byte pair, as described in /// https://git-scm.com/docs/git-status#_output /// @@ -454,6 +461,26 @@ impl GitStatus { }) .collect::>(); entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); + // When a file exists in HEAD, is deleted in the index, and exists again in the working copy, + // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy) + // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`. + entries.dedup_by(|(a, a_status), (b, b_status)| { + const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted); + if a.ne(&b) { + return false; + } + match (*a_status, *b_status) { + (INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => { + *b_status = TrackedStatus { + index_status: StatusCode::Deleted, + worktree_status: StatusCode::Added, + } + .into(); + } + _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"), + } + true + }); Ok(Self { entries: entries.into(), }) diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index c47fb43180..05f0c3da25 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -2639,6 +2639,62 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_git_status_postprocessing(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let root = temp_tree(json!({ + "project": { + "sub": {}, + "a.txt": "", + }, + })); + + let work_dir = root.path().join("project"); + let repo = git_init(work_dir.as_path()); + // a.txt exists in HEAD and the working copy but is deleted in the index. + git_add("a.txt", &repo); + git_commit("Initial commit", &repo); + git_remove_index("a.txt".as_ref(), &repo); + // `sub` is a nested git repository. + let _sub = git_init(&work_dir.join("sub")); + + let tree = Worktree::local( + root.path(), + true, + Arc::new(RealFs::default()), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + tree.flush_fs_events(cx).await; + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + cx.executor().run_until_parked(); + + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + let repo = snapshot.repositories().iter().next().unwrap(); + let entries = repo.status().collect::>(); + + // `sub` doesn't appear in our computed statuses. + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); + // a.txt appears with a combined `DA` status. + assert_eq!( + entries[0].status, + TrackedStatus { + index_status: StatusCode::Deleted, + worktree_status: StatusCode::Added + } + .into() + ); + }); +} + #[gpui::test] async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { init_test(cx);