git: Handle git status output for deleted-in-index state (#23483)

Cole Miller created

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

Change summary

crates/git/src/status.rs              | 27 +++++++++++++
crates/worktree/src/worktree_tests.rs | 56 +++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)

Detailed changes

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::<Vec<_>>();
         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(),
         })

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::<Vec<_>>();
+
+        // `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);