Represent git statuses more faithfully (#23082)

Cole Miller , Mikayla , and Conrad created

First, parse the output of `git status --porcelain=v1` into a
representation that can handle the full "grammar" and doesn't lose
information.

Second, as part of pushing this throughout the codebase, expand the use
of the existing `GitSummary` type to all the places where status
propagation is in play (i.e., anywhere we're dealing with a mix of files
and directories), and get rid of the previous `GitSummary ->
GitFileStatus` conversion.

- [x] Synchronize new representation over collab
  - [x] Update zed.proto
  - [x] Update DB models
- [x] Update `GitSummary` and summarization for the new `FileStatus`
- [x] Fix all tests
  - [x] worktree
  - [x] collab
- [x] Clean up `FILE_*` constants
- [x] New collab tests to exercise syncing of complex statuses
- [x] Run it locally and make sure it looks good

Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Conrad <conrad@zed.dev>

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql            |   3 
crates/collab/migrations/20250113230049_expand_git_status_information.sql |  13 
crates/collab/src/db.rs                                                   |  90 
crates/collab/src/db/queries/projects.rs                                  |  18 
crates/collab/src/db/queries/rooms.rs                                     |   5 
crates/collab/src/db/tables/worktree_repository_statuses.rs               |  15 
crates/collab/src/tests/integration_tests.rs                              |  81 
crates/collab/src/tests/random_project_collaboration_tests.rs             |  54 
crates/editor/src/git/project_diff.rs                                     |  42 
crates/editor/src/items.rs                                                |  28 
crates/fs/src/fs.rs                                                       |  16 
crates/git/src/repository.rs                                              |  64 
crates/git/src/status.rs                                                  | 338 
crates/git_ui/src/git_panel.rs                                            |  30 
crates/git_ui/src/git_ui.rs                                               |  27 
crates/image_viewer/src/image_viewer.rs                                   |  11 
crates/outline_panel/src/outline_panel.rs                                 |  14 
crates/project/src/project.rs                                             |   7 
crates/project_panel/src/project_panel.rs                                 |  18 
crates/proto/proto/zed.proto                                              |  29 
crates/tab_switcher/src/tab_switcher.rs                                   |   5 
crates/worktree/src/worktree.rs                                           | 335 
crates/worktree/src/worktree_tests.rs                                     | 326 
script/zed-local                                                          |   6 
24 files changed, 1,019 insertions(+), 556 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -112,6 +112,9 @@ CREATE TABLE "worktree_repository_statuses" (
     "work_directory_id" INT8 NOT NULL,
     "repo_path" VARCHAR NOT NULL,
     "status" INT8 NOT NULL,
+    "status_kind" INT4 NOT NULL,
+    "first_status" INT4 NULL,
+    "second_status" INT4 NULL,
     "scan_id" INT8 NOT NULL,
     "is_deleted" BOOL NOT NULL,
     PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path),

crates/collab/migrations/20250113230049_expand_git_status_information.sql 🔗

@@ -0,0 +1,13 @@
+ALTER TABLE worktree_repository_statuses
+ADD COLUMN status_kind INTEGER,
+ADD COLUMN first_status INTEGER,
+ADD COLUMN second_status INTEGER;
+
+UPDATE worktree_repository_statuses
+SET
+    status_kind = 0;
+
+ALTER TABLE worktree_repository_statuses
+ALTER COLUMN status_kind
+SET
+    NOT NULL;

crates/collab/src/db.rs 🔗

@@ -35,6 +35,7 @@ use std::{
 };
 use time::PrimitiveDateTime;
 use tokio::sync::{Mutex, OwnedMutexGuard};
+use worktree_repository_statuses::StatusKind;
 use worktree_settings_file::LocalSettingsKind;
 
 #[cfg(test)]
@@ -805,3 +806,92 @@ impl LocalSettingsKind {
         }
     }
 }
+
+fn db_status_to_proto(
+    entry: worktree_repository_statuses::Model,
+) -> anyhow::Result<proto::StatusEntry> {
+    use proto::git_file_status::{Tracked, Unmerged, Variant};
+
+    let (simple_status, variant) =
+        match (entry.status_kind, entry.first_status, entry.second_status) {
+            (StatusKind::Untracked, None, None) => (
+                proto::GitStatus::Added as i32,
+                Variant::Untracked(Default::default()),
+            ),
+            (StatusKind::Ignored, None, None) => (
+                proto::GitStatus::Added as i32,
+                Variant::Ignored(Default::default()),
+            ),
+            (StatusKind::Unmerged, Some(first_head), Some(second_head)) => (
+                proto::GitStatus::Conflict as i32,
+                Variant::Unmerged(Unmerged {
+                    first_head,
+                    second_head,
+                }),
+            ),
+            (StatusKind::Tracked, Some(index_status), Some(worktree_status)) => {
+                let simple_status = if worktree_status != proto::GitStatus::Unmodified as i32 {
+                    worktree_status
+                } else if index_status != proto::GitStatus::Unmodified as i32 {
+                    index_status
+                } else {
+                    proto::GitStatus::Unmodified as i32
+                };
+                (
+                    simple_status,
+                    Variant::Tracked(Tracked {
+                        index_status,
+                        worktree_status,
+                    }),
+                )
+            }
+            _ => {
+                return Err(anyhow!(
+                    "Unexpected combination of status fields: {entry:?}"
+                ))
+            }
+        };
+    Ok(proto::StatusEntry {
+        repo_path: entry.repo_path,
+        simple_status,
+        status: Some(proto::GitFileStatus {
+            variant: Some(variant),
+        }),
+    })
+}
+
+fn proto_status_to_db(
+    status_entry: proto::StatusEntry,
+) -> (String, StatusKind, Option<i32>, Option<i32>) {
+    use proto::git_file_status::{Tracked, Unmerged, Variant};
+
+    let (status_kind, first_status, second_status) = status_entry
+        .status
+        .clone()
+        .and_then(|status| status.variant)
+        .map_or(
+            (StatusKind::Untracked, None, None),
+            |variant| match variant {
+                Variant::Untracked(_) => (StatusKind::Untracked, None, None),
+                Variant::Ignored(_) => (StatusKind::Ignored, None, None),
+                Variant::Unmerged(Unmerged {
+                    first_head,
+                    second_head,
+                }) => (StatusKind::Unmerged, Some(first_head), Some(second_head)),
+                Variant::Tracked(Tracked {
+                    index_status,
+                    worktree_status,
+                }) => (
+                    StatusKind::Tracked,
+                    Some(index_status),
+                    Some(worktree_status),
+                ),
+            },
+        );
+    (
+        status_entry.repo_path,
+        status_kind,
+        first_status,
+        second_status,
+    )
+}

crates/collab/src/db/queries/projects.rs 🔗

@@ -360,6 +360,8 @@ impl Database {
                         update.updated_repositories.iter().flat_map(
                             |repository: &proto::RepositoryEntry| {
                                 repository.updated_statuses.iter().map(|status_entry| {
+                                    let (repo_path, status_kind, first_status, second_status) =
+                                        proto_status_to_db(status_entry.clone());
                                     worktree_repository_statuses::ActiveModel {
                                         project_id: ActiveValue::set(project_id),
                                         worktree_id: ActiveValue::set(worktree_id),
@@ -368,8 +370,11 @@ impl Database {
                                         ),
                                         scan_id: ActiveValue::set(update.scan_id as i64),
                                         is_deleted: ActiveValue::set(false),
-                                        repo_path: ActiveValue::set(status_entry.repo_path.clone()),
-                                        status: ActiveValue::set(status_entry.status as i64),
+                                        repo_path: ActiveValue::set(repo_path),
+                                        status: ActiveValue::set(0),
+                                        status_kind: ActiveValue::set(status_kind),
+                                        first_status: ActiveValue::set(first_status),
+                                        second_status: ActiveValue::set(second_status),
                                     }
                                 })
                             },
@@ -384,7 +389,9 @@ impl Database {
                         ])
                         .update_columns([
                             worktree_repository_statuses::Column::ScanId,
-                            worktree_repository_statuses::Column::Status,
+                            worktree_repository_statuses::Column::StatusKind,
+                            worktree_repository_statuses::Column::FirstStatus,
+                            worktree_repository_statuses::Column::SecondStatus,
                         ])
                         .to_owned(),
                     )
@@ -759,10 +766,7 @@ impl Database {
                     let mut updated_statuses = Vec::new();
                     while let Some(status_entry) = repository_statuses.next().await {
                         let status_entry: worktree_repository_statuses::Model = status_entry?;
-                        updated_statuses.push(proto::StatusEntry {
-                            repo_path: status_entry.repo_path,
-                            status: status_entry.status as i32,
-                        });
+                        updated_statuses.push(db_status_to_proto(status_entry)?);
                     }
 
                     worktree.repository_entries.insert(

crates/collab/src/db/queries/rooms.rs 🔗

@@ -732,10 +732,7 @@ impl Database {
                             if db_status.is_deleted {
                                 removed_statuses.push(db_status.repo_path);
                             } else {
-                                updated_statuses.push(proto::StatusEntry {
-                                    repo_path: db_status.repo_path,
-                                    status: db_status.status as i32,
-                                });
+                                updated_statuses.push(db_status_to_proto(db_status)?);
                             }
                         }
 

crates/collab/src/db/tables/worktree_repository_statuses.rs 🔗

@@ -12,11 +12,26 @@ pub struct Model {
     pub work_directory_id: i64,
     #[sea_orm(primary_key)]
     pub repo_path: String,
+    /// Old single-code status field, no longer used but kept here to mirror the DB schema.
     pub status: i64,
+    pub status_kind: StatusKind,
+    /// For unmerged entries, this is the `first_head` status. For tracked entries, this is the `index_status`.
+    pub first_status: Option<i32>,
+    /// For unmerged entries, this is the `second_head` status. For tracked entries, this is the `worktree_status`.
+    pub second_status: Option<i32>,
     pub scan_id: i64,
     pub is_deleted: bool,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
+#[sea_orm(rs_type = "i32", db_type = "Integer")]
+pub enum StatusKind {
+    Untracked = 0,
+    Ignored = 1,
+    Unmerged = 2,
+    Tracked = 3,
+}
+
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
 pub enum Relation {}
 

crates/collab/src/tests/integration_tests.rs 🔗

@@ -13,7 +13,8 @@ use client::{User, RECEIVE_TIMEOUT};
 use collections::{HashMap, HashSet};
 use fs::{FakeFs, Fs as _, RemoveOptions};
 use futures::{channel::mpsc, StreamExt as _};
-use git::repository::GitFileStatus;
+
+use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
 use gpui::{
     px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent,
     TestAppContext, UpdateGlobal,
@@ -2889,11 +2890,20 @@ async fn test_git_status_sync(
     const A_TXT: &str = "a.txt";
     const B_TXT: &str = "b.txt";
 
+    const A_STATUS_START: FileStatus = FileStatus::Tracked(TrackedStatus {
+        index_status: StatusCode::Added,
+        worktree_status: StatusCode::Modified,
+    });
+    const B_STATUS_START: FileStatus = FileStatus::Unmerged(UnmergedStatus {
+        first_head: UnmergedStatusCode::Updated,
+        second_head: UnmergedStatusCode::Deleted,
+    });
+
     client_a.fs().set_status_for_repo_via_git_operation(
         Path::new("/dir/.git"),
         &[
-            (Path::new(A_TXT), GitFileStatus::Added),
-            (Path::new(B_TXT), GitFileStatus::Added),
+            (Path::new(A_TXT), A_STATUS_START),
+            (Path::new(B_TXT), B_STATUS_START),
         ],
     );
 
@@ -2913,7 +2923,7 @@ async fn test_git_status_sync(
     #[track_caller]
     fn assert_status(
         file: &impl AsRef<Path>,
-        status: Option<GitFileStatus>,
+        status: Option<FileStatus>,
         project: &Project,
         cx: &AppContext,
     ) {
@@ -2926,20 +2936,29 @@ async fn test_git_status_sync(
     }
 
     project_local.read_with(cx_a, |project, cx| {
-        assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx);
-        assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
+        assert_status(&Path::new(A_TXT), Some(A_STATUS_START), project, cx);
+        assert_status(&Path::new(B_TXT), Some(B_STATUS_START), project, cx);
     });
 
     project_remote.read_with(cx_b, |project, cx| {
-        assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx);
-        assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
+        assert_status(&Path::new(A_TXT), Some(A_STATUS_START), project, cx);
+        assert_status(&Path::new(B_TXT), Some(B_STATUS_START), project, cx);
+    });
+
+    const A_STATUS_END: FileStatus = FileStatus::Tracked(TrackedStatus {
+        index_status: StatusCode::Added,
+        worktree_status: StatusCode::Unmodified,
+    });
+    const B_STATUS_END: FileStatus = FileStatus::Tracked(TrackedStatus {
+        index_status: StatusCode::Deleted,
+        worktree_status: StatusCode::Unmodified,
     });
 
     client_a.fs().set_status_for_repo_via_working_copy_change(
         Path::new("/dir/.git"),
         &[
-            (Path::new(A_TXT), GitFileStatus::Modified),
-            (Path::new(B_TXT), GitFileStatus::Modified),
+            (Path::new(A_TXT), A_STATUS_END),
+            (Path::new(B_TXT), B_STATUS_END),
         ],
     );
 
@@ -2949,33 +2968,13 @@ async fn test_git_status_sync(
     // Smoke test status reading
 
     project_local.read_with(cx_a, |project, cx| {
-        assert_status(
-            &Path::new(A_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
-        assert_status(
-            &Path::new(B_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
+        assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx);
+        assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx);
     });
 
     project_remote.read_with(cx_b, |project, cx| {
-        assert_status(
-            &Path::new(A_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
-        assert_status(
-            &Path::new(B_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
+        assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx);
+        assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx);
     });
 
     // And synchronization while joining
@@ -2983,18 +2982,8 @@ async fn test_git_status_sync(
     executor.run_until_parked();
 
     project_remote_c.read_with(cx_c, |project, cx| {
-        assert_status(
-            &Path::new(A_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
-        assert_status(
-            &Path::new(B_TXT),
-            Some(GitFileStatus::Modified),
-            project,
-            cx,
-        );
+        assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx);
+        assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx);
     });
 }
 

crates/collab/src/tests/random_project_collaboration_tests.rs 🔗

@@ -6,7 +6,7 @@ use call::ActiveCall;
 use collections::{BTreeMap, HashMap};
 use editor::Bias;
 use fs::{FakeFs, Fs as _};
-use git::repository::GitFileStatus;
+use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
 use gpui::{BackgroundExecutor, Model, TestAppContext};
 use language::{
     range_to_lsp, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, PointUtf16,
@@ -127,7 +127,7 @@ enum GitOperation {
     },
     WriteGitStatuses {
         repo_path: PathBuf,
-        statuses: Vec<(PathBuf, GitFileStatus)>,
+        statuses: Vec<(PathBuf, FileStatus)>,
         git_operation: bool,
     },
 }
@@ -1458,17 +1458,7 @@ fn generate_git_operation(rng: &mut StdRng, client: &TestClient) -> GitOperation
 
             let statuses = file_paths
                 .into_iter()
-                .map(|paths| {
-                    (
-                        paths,
-                        match rng.gen_range(0..3_u32) {
-                            0 => GitFileStatus::Added,
-                            1 => GitFileStatus::Modified,
-                            2 => GitFileStatus::Conflict,
-                            _ => unreachable!(),
-                        },
-                    )
-                })
+                .map(|path| (path, gen_status(rng)))
                 .collect::<Vec<_>>();
 
             let git_operation = rng.gen::<bool>();
@@ -1613,3 +1603,41 @@ fn gen_file_name(rng: &mut StdRng) -> String {
     }
     name
 }
+
+fn gen_status(rng: &mut StdRng) -> FileStatus {
+    fn gen_status_code(rng: &mut StdRng) -> StatusCode {
+        match rng.gen_range(0..7) {
+            0 => StatusCode::Modified,
+            1 => StatusCode::TypeChanged,
+            2 => StatusCode::Added,
+            3 => StatusCode::Deleted,
+            4 => StatusCode::Renamed,
+            5 => StatusCode::Copied,
+            6 => StatusCode::Unmodified,
+            _ => unreachable!(),
+        }
+    }
+
+    fn gen_unmerged_status_code(rng: &mut StdRng) -> UnmergedStatusCode {
+        match rng.gen_range(0..3) {
+            0 => UnmergedStatusCode::Updated,
+            1 => UnmergedStatusCode::Added,
+            2 => UnmergedStatusCode::Deleted,
+            _ => unreachable!(),
+        }
+    }
+
+    match rng.gen_range(0..4) {
+        0 => FileStatus::Untracked,
+        1 => FileStatus::Ignored,
+        2 => FileStatus::Unmerged(UnmergedStatus {
+            first_head: gen_unmerged_status_code(rng),
+            second_head: gen_unmerged_status_code(rng),
+        }),
+        3 => FileStatus::Tracked(TrackedStatus {
+            index_status: gen_status_code(rng),
+            worktree_status: gen_status_code(rng),
+        }),
+        _ => unreachable!(),
+    }
+}

crates/editor/src/git/project_diff.rs 🔗

@@ -9,10 +9,7 @@ use std::{
 use anyhow::{anyhow, Context as _};
 use collections::{BTreeMap, HashMap};
 use feature_flags::FeatureFlagAppExt;
-use git::{
-    diff::{BufferDiff, DiffHunk},
-    repository::GitFileStatus,
-};
+use git::diff::{BufferDiff, DiffHunk};
 use gpui::{
     actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView,
     InteractiveElement, Model, Render, Subscription, Task, View, WeakView,
@@ -54,7 +51,6 @@ struct ProjectDiffEditor {
 
 #[derive(Debug)]
 struct Changes {
-    _status: GitFileStatus,
     buffer: Model<Buffer>,
     hunks: Vec<DiffHunk>,
 }
@@ -199,14 +195,13 @@ impl ProjectDiffEditor {
                             .repositories()
                             .iter()
                             .flat_map(|entry| {
-                                entry.status().map(|git_entry| {
-                                    (git_entry.combined_status(), entry.join(git_entry.repo_path))
-                                })
+                                entry
+                                    .status()
+                                    .map(|git_entry| entry.join(git_entry.repo_path))
                             })
-                            .filter_map(|(status, path)| {
+                            .filter_map(|path| {
                                 let id = snapshot.entry_for_path(&path)?.id;
                                 Some((
-                                    status,
                                     id,
                                     ProjectPath {
                                         worktree_id: snapshot.id(),
@@ -218,9 +213,9 @@ impl ProjectDiffEditor {
                         Some(
                             applicable_entries
                                 .into_iter()
-                                .map(|(status, entry_id, entry_path)| {
+                                .map(|(entry_id, entry_path)| {
                                     let open_task = project.open_path(entry_path.clone(), cx);
-                                    (status, entry_id, entry_path, open_task)
+                                    (entry_id, entry_path, open_task)
                                 })
                                 .collect::<Vec<_>>(),
                         )
@@ -234,15 +229,10 @@ impl ProjectDiffEditor {
                         let mut new_entries = Vec::new();
                         let mut buffers = HashMap::<
                             ProjectEntryId,
-                            (
-                                GitFileStatus,
-                                text::BufferSnapshot,
-                                Model<Buffer>,
-                                BufferDiff,
-                            ),
+                            (text::BufferSnapshot, Model<Buffer>, BufferDiff),
                         >::default();
                         let mut change_sets = Vec::new();
-                        for (status, entry_id, entry_path, open_task) in open_tasks {
+                        for (entry_id, entry_path, open_task) in open_tasks {
                             let Some(buffer) = open_task
                                 .await
                                 .and_then(|(_, opened_model)| {
@@ -272,7 +262,6 @@ impl ProjectDiffEditor {
                                 buffers.insert(
                                     entry_id,
                                     (
-                                        status,
                                         buffer.read(cx).text_snapshot(),
                                         buffer,
                                         change_set.read(cx).diff_to_buffer.clone(),
@@ -295,11 +284,10 @@ impl ProjectDiffEditor {
                     .background_executor()
                     .spawn(async move {
                         let mut new_changes = HashMap::<ProjectEntryId, Changes>::default();
-                        for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers {
+                        for (entry_id, (buffer_snapshot, buffer, buffer_diff)) in buffers {
                             new_changes.insert(
                                 entry_id,
                                 Changes {
-                                    _status: status,
                                     buffer,
                                     hunks: buffer_diff
                                         .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot)
@@ -1107,6 +1095,7 @@ impl Render for ProjectDiffEditor {
 
 #[cfg(test)]
 mod tests {
+    use git::status::{StatusCode, TrackedStatus};
     use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
     use project::buffer_store::BufferChangeSet;
     use serde_json::json;
@@ -1224,7 +1213,14 @@ mod tests {
         });
         fs.set_status_for_repo_via_git_operation(
             Path::new("/root/.git"),
-            &[(Path::new("file_a"), GitFileStatus::Modified)],
+            &[(
+                Path::new("file_a"),
+                TrackedStatus {
+                    worktree_status: StatusCode::Modified,
+                    index_status: StatusCode::Unmodified,
+                }
+                .into(),
+            )],
         );
         cx.executor()
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));

crates/editor/src/items.rs 🔗

@@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _, Result};
 use collections::HashSet;
 use file_icons::FileIcons;
 use futures::future::try_join_all;
-use git::repository::GitFileStatus;
+use git::status::GitSummary;
 use gpui::{
     point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId, EventEmitter,
     IntoElement, Model, ParentElement, Pixels, SharedString, Styled, Task, View, ViewContext,
@@ -27,8 +27,6 @@ use project::{
 };
 use rpc::proto::{self, update_view, PeerId};
 use settings::Settings;
-use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
-
 use std::{
     any::TypeId,
     borrow::Cow,
@@ -43,6 +41,7 @@ use theme::{Theme, ThemeSettings};
 use ui::{h_flex, prelude::*, IconDecorationKind, Label};
 use util::{paths::PathExt, ResultExt, TryFutureExt};
 use workspace::item::{BreadcrumbText, FollowEvent};
+use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ProjectItem},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
@@ -621,10 +620,10 @@ impl Item for Editor {
                         .worktree_for_id(path.worktree_id, cx)?
                         .read(cx)
                         .snapshot()
-                        .status_for_file(path.path);
+                        .status_for_file(path.path)?;
 
                     Some(entry_git_aware_label_color(
-                        git_status,
+                        git_status.summary(),
                         entry.is_ignored,
                         params.selected,
                     ))
@@ -1560,20 +1559,17 @@ pub fn entry_diagnostic_aware_icon_decoration_and_color(
     }
 }
 
-pub fn entry_git_aware_label_color(
-    git_status: Option<GitFileStatus>,
-    ignored: bool,
-    selected: bool,
-) -> Color {
+pub fn entry_git_aware_label_color(git_status: GitSummary, ignored: bool, selected: bool) -> Color {
     if ignored {
         Color::Ignored
+    } else if git_status.conflict > 0 {
+        Color::Conflict
+    } else if git_status.modified > 0 {
+        Color::Modified
+    } else if git_status.added > 0 || git_status.untracked > 0 {
+        Color::Created
     } else {
-        match git_status {
-            Some(GitFileStatus::Added) | Some(GitFileStatus::Untracked) => Color::Created,
-            Some(GitFileStatus::Modified) => Color::Modified,
-            Some(GitFileStatus::Conflict) => Color::Conflict,
-            Some(GitFileStatus::Deleted) | None => entry_label_color(selected),
-        }
+        entry_label_color(selected)
     }
 }
 

crates/fs/src/fs.rs 🔗

@@ -5,6 +5,8 @@ mod mac_watcher;
 pub mod fs_watcher;
 
 use anyhow::{anyhow, Result};
+#[cfg(any(test, feature = "test-support"))]
+use git::status::FileStatus;
 use git::GitHostingProviderRegistry;
 
 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
@@ -41,7 +43,7 @@ use util::ResultExt;
 #[cfg(any(test, feature = "test-support"))]
 use collections::{btree_map, BTreeMap};
 #[cfg(any(test, feature = "test-support"))]
-use git::repository::{FakeGitRepositoryState, GitFileStatus};
+use git::repository::FakeGitRepositoryState;
 #[cfg(any(test, feature = "test-support"))]
 use parking_lot::Mutex;
 #[cfg(any(test, feature = "test-support"))]
@@ -1285,11 +1287,11 @@ impl FakeFs {
     pub fn set_status_for_repo_via_working_copy_change(
         &self,
         dot_git: &Path,
-        statuses: &[(&Path, GitFileStatus)],
+        statuses: &[(&Path, FileStatus)],
     ) {
         self.with_git_state(dot_git, false, |state| {
-            state.worktree_statuses.clear();
-            state.worktree_statuses.extend(
+            state.statuses.clear();
+            state.statuses.extend(
                 statuses
                     .iter()
                     .map(|(path, content)| ((**path).into(), *content)),
@@ -1305,11 +1307,11 @@ impl FakeFs {
     pub fn set_status_for_repo_via_git_operation(
         &self,
         dot_git: &Path,
-        statuses: &[(&Path, GitFileStatus)],
+        statuses: &[(&Path, FileStatus)],
     ) {
         self.with_git_state(dot_git, true, |state| {
-            state.worktree_statuses.clear();
-            state.worktree_statuses.extend(
+            state.statuses.clear();
+            state.statuses.extend(
                 statuses
                     .iter()
                     .map(|(path, content)| ((**path).into(), *content)),

crates/git/src/repository.rs 🔗

@@ -1,4 +1,4 @@
-use crate::status::GitStatusPair;
+use crate::status::FileStatus;
 use crate::GitHostingProviderRegistry;
 use crate::{blame::Blame, status::GitStatus};
 use anyhow::{anyhow, Context, Result};
@@ -7,7 +7,6 @@ use git2::BranchType;
 use gpui::SharedString;
 use parking_lot::Mutex;
 use rope::Rope;
-use serde::{Deserialize, Serialize};
 use std::borrow::Borrow;
 use std::sync::LazyLock;
 use std::{
@@ -294,7 +293,7 @@ pub struct FakeGitRepositoryState {
     pub event_emitter: smol::channel::Sender<PathBuf>,
     pub index_contents: HashMap<PathBuf, String>,
     pub blames: HashMap<PathBuf, Blame>,
-    pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
+    pub statuses: HashMap<RepoPath, FileStatus>,
     pub current_branch_name: Option<String>,
     pub branches: HashSet<String>,
 }
@@ -312,7 +311,7 @@ impl FakeGitRepositoryState {
             event_emitter,
             index_contents: Default::default(),
             blames: Default::default(),
-            worktree_statuses: Default::default(),
+            statuses: Default::default(),
             current_branch_name: Default::default(),
             branches: Default::default(),
         }
@@ -349,20 +348,14 @@ impl GitRepository for FakeGitRepository {
         let state = self.state.lock();
 
         let mut entries = state
-            .worktree_statuses
+            .statuses
             .iter()
-            .filter_map(|(repo_path, status_worktree)| {
+            .filter_map(|(repo_path, status)| {
                 if path_prefixes
                     .iter()
                     .any(|path_prefix| repo_path.0.starts_with(path_prefix))
                 {
-                    Some((
-                        repo_path.to_owned(),
-                        GitStatusPair {
-                            index_status: None,
-                            worktree_status: Some(*status_worktree),
-                        },
-                    ))
+                    Some((repo_path.to_owned(), *status))
                 } else {
                     None
                 }
@@ -461,51 +454,6 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum GitFileStatus {
-    Added,
-    Modified,
-    // TODO conflicts should be represented by the GitStatusPair
-    Conflict,
-    Deleted,
-    Untracked,
-}
-
-impl GitFileStatus {
-    pub fn merge(
-        this: Option<GitFileStatus>,
-        other: Option<GitFileStatus>,
-        prefer_other: bool,
-    ) -> Option<GitFileStatus> {
-        if prefer_other {
-            return other;
-        }
-
-        match (this, other) {
-            (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => {
-                Some(GitFileStatus::Conflict)
-            }
-            (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => {
-                Some(GitFileStatus::Modified)
-            }
-            (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => {
-                Some(GitFileStatus::Added)
-            }
-            _ => None,
-        }
-    }
-
-    pub fn from_byte(byte: u8) -> Option<Self> {
-        match byte {
-            b'M' => Some(GitFileStatus::Modified),
-            b'A' => Some(GitFileStatus::Added),
-            b'D' => Some(GitFileStatus::Deleted),
-            b'?' => Some(GitFileStatus::Untracked),
-            _ => None,
-        }
-    }
-}
-
 pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
     LazyLock::new(|| RepoPath(Path::new("").into()));
 

crates/git/src/status.rs 🔗

@@ -1,34 +1,316 @@
-use crate::repository::{GitFileStatus, RepoPath};
+use crate::repository::RepoPath;
 use anyhow::{anyhow, Result};
+use serde::{Deserialize, Serialize};
 use std::{path::Path, process::Stdio, sync::Arc};
+use util::ResultExt;
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct GitStatusPair {
-    // Not both `None`.
-    pub index_status: Option<GitFileStatus>,
-    pub worktree_status: Option<GitFileStatus>,
-}
-
-impl GitStatusPair {
-    pub fn is_staged(&self) -> Option<bool> {
-        match (self.index_status, self.worktree_status) {
-            (Some(_), None) => Some(true),
-            (None, Some(_)) => Some(false),
-            (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false),
-            (Some(_), Some(_)) => None,
-            (None, None) => unreachable!(),
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum FileStatus {
+    Untracked,
+    Ignored,
+    Unmerged(UnmergedStatus),
+    Tracked(TrackedStatus),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct UnmergedStatus {
+    pub first_head: UnmergedStatusCode,
+    pub second_head: UnmergedStatusCode,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum UnmergedStatusCode {
+    Added,
+    Deleted,
+    Updated,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct TrackedStatus {
+    pub index_status: StatusCode,
+    pub worktree_status: StatusCode,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum StatusCode {
+    Modified,
+    TypeChanged,
+    Added,
+    Deleted,
+    Renamed,
+    Copied,
+    Unmodified,
+}
+
+impl From<UnmergedStatus> for FileStatus {
+    fn from(value: UnmergedStatus) -> Self {
+        FileStatus::Unmerged(value)
+    }
+}
+
+impl From<TrackedStatus> for FileStatus {
+    fn from(value: TrackedStatus) -> Self {
+        FileStatus::Tracked(value)
+    }
+}
+
+impl FileStatus {
+    pub const fn worktree(worktree_status: StatusCode) -> Self {
+        FileStatus::Tracked(TrackedStatus {
+            index_status: StatusCode::Unmodified,
+            worktree_status,
+        })
+    }
+
+    /// Generate a FileStatus Code from a byte pair, as described in
+    /// https://git-scm.com/docs/git-status#_output
+    ///
+    /// NOTE: That instead of '', we use ' ' to denote no change
+    fn from_bytes(bytes: [u8; 2]) -> anyhow::Result<Self> {
+        let status = match bytes {
+            [b'?', b'?'] => FileStatus::Untracked,
+            [b'!', b'!'] => FileStatus::Ignored,
+            [b'A', b'A'] => UnmergedStatus {
+                first_head: UnmergedStatusCode::Added,
+                second_head: UnmergedStatusCode::Added,
+            }
+            .into(),
+            [b'D', b'D'] => UnmergedStatus {
+                first_head: UnmergedStatusCode::Added,
+                second_head: UnmergedStatusCode::Added,
+            }
+            .into(),
+            [x, b'U'] => UnmergedStatus {
+                first_head: UnmergedStatusCode::from_byte(x)?,
+                second_head: UnmergedStatusCode::Updated,
+            }
+            .into(),
+            [b'U', y] => UnmergedStatus {
+                first_head: UnmergedStatusCode::Updated,
+                second_head: UnmergedStatusCode::from_byte(y)?,
+            }
+            .into(),
+            [x, y] => TrackedStatus {
+                index_status: StatusCode::from_byte(x)?,
+                worktree_status: StatusCode::from_byte(y)?,
+            }
+            .into(),
+        };
+        Ok(status)
+    }
+
+    pub fn is_staged(self) -> Option<bool> {
+        match self {
+            FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
+                Some(false)
+            }
+            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
+                (StatusCode::Unmodified, _) => Some(false),
+                (_, StatusCode::Unmodified) => Some(true),
+                _ => None,
+            },
         }
     }
 
-    // TODO reconsider uses of this
-    pub fn combined(&self) -> GitFileStatus {
-        self.index_status.or(self.worktree_status).unwrap()
+    pub fn is_conflicted(self) -> bool {
+        match self {
+            FileStatus::Unmerged { .. } => true,
+            _ => false,
+        }
+    }
+
+    pub fn is_ignored(self) -> bool {
+        match self {
+            FileStatus::Ignored => true,
+            _ => false,
+        }
+    }
+
+    pub fn is_modified(self) -> bool {
+        match self {
+            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
+                (StatusCode::Modified, _) | (_, StatusCode::Modified) => true,
+                _ => false,
+            },
+            _ => false,
+        }
+    }
+
+    pub fn is_created(self) -> bool {
+        match self {
+            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
+                (StatusCode::Added, _) | (_, StatusCode::Added) => true,
+                _ => false,
+            },
+            _ => false,
+        }
+    }
+
+    pub fn is_deleted(self) -> bool {
+        match self {
+            FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
+                (StatusCode::Deleted, _) | (_, StatusCode::Deleted) => true,
+                _ => false,
+            },
+            _ => false,
+        }
+    }
+
+    pub fn is_untracked(self) -> bool {
+        match self {
+            FileStatus::Untracked => true,
+            _ => false,
+        }
+    }
+
+    pub fn summary(self) -> GitSummary {
+        match self {
+            FileStatus::Ignored => GitSummary::UNCHANGED,
+            FileStatus::Untracked => GitSummary::UNTRACKED,
+            FileStatus::Unmerged(_) => GitSummary::CONFLICT,
+            FileStatus::Tracked(TrackedStatus {
+                index_status,
+                worktree_status,
+            }) => index_status.summary() + worktree_status.summary(),
+        }
+    }
+}
+
+impl StatusCode {
+    fn from_byte(byte: u8) -> anyhow::Result<Self> {
+        match byte {
+            b'M' => Ok(StatusCode::Modified),
+            b'T' => Ok(StatusCode::TypeChanged),
+            b'A' => Ok(StatusCode::Added),
+            b'D' => Ok(StatusCode::Deleted),
+            b'R' => Ok(StatusCode::Renamed),
+            b'C' => Ok(StatusCode::Copied),
+            b' ' => Ok(StatusCode::Unmodified),
+            _ => Err(anyhow!("Invalid status code: {byte}")),
+        }
+    }
+
+    fn summary(self) -> GitSummary {
+        match self {
+            StatusCode::Modified | StatusCode::TypeChanged => GitSummary::MODIFIED,
+            StatusCode::Added => GitSummary::ADDED,
+            StatusCode::Deleted => GitSummary::DELETED,
+            StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
+                GitSummary::UNCHANGED
+            }
+        }
+    }
+}
+
+impl UnmergedStatusCode {
+    fn from_byte(byte: u8) -> anyhow::Result<Self> {
+        match byte {
+            b'A' => Ok(UnmergedStatusCode::Added),
+            b'D' => Ok(UnmergedStatusCode::Deleted),
+            b'U' => Ok(UnmergedStatusCode::Updated),
+            _ => Err(anyhow!("Invalid unmerged status code: {byte}")),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
+pub struct GitSummary {
+    pub added: usize,
+    pub modified: usize,
+    pub conflict: usize,
+    pub untracked: usize,
+    pub deleted: usize,
+}
+
+impl GitSummary {
+    pub const ADDED: Self = Self {
+        added: 1,
+        ..Self::UNCHANGED
+    };
+
+    pub const MODIFIED: Self = Self {
+        modified: 1,
+        ..Self::UNCHANGED
+    };
+
+    pub const CONFLICT: Self = Self {
+        conflict: 1,
+        ..Self::UNCHANGED
+    };
+
+    pub const DELETED: Self = Self {
+        deleted: 1,
+        ..Self::UNCHANGED
+    };
+
+    pub const UNTRACKED: Self = Self {
+        untracked: 1,
+        ..Self::UNCHANGED
+    };
+
+    pub const UNCHANGED: Self = Self {
+        added: 0,
+        modified: 0,
+        conflict: 0,
+        untracked: 0,
+        deleted: 0,
+    };
+}
+
+impl From<FileStatus> for GitSummary {
+    fn from(status: FileStatus) -> Self {
+        status.summary()
+    }
+}
+
+impl sum_tree::Summary for GitSummary {
+    type Context = ();
+
+    fn zero(_: &Self::Context) -> Self {
+        Default::default()
+    }
+
+    fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
+        *self += *rhs;
+    }
+}
+
+impl std::ops::Add<Self> for GitSummary {
+    type Output = Self;
+
+    fn add(mut self, rhs: Self) -> Self {
+        self += rhs;
+        self
+    }
+}
+
+impl std::ops::AddAssign for GitSummary {
+    fn add_assign(&mut self, rhs: Self) {
+        self.added += rhs.added;
+        self.modified += rhs.modified;
+        self.conflict += rhs.conflict;
+        self.untracked += rhs.untracked;
+        self.deleted += rhs.deleted;
+    }
+}
+
+impl std::ops::Sub for GitSummary {
+    type Output = GitSummary;
+
+    fn sub(self, rhs: Self) -> Self::Output {
+        GitSummary {
+            added: self.added - rhs.added,
+            modified: self.modified - rhs.modified,
+            conflict: self.conflict - rhs.conflict,
+            untracked: self.untracked - rhs.untracked,
+            deleted: self.deleted - rhs.deleted,
+        }
     }
 }
 
 #[derive(Clone)]
 pub struct GitStatus {
-    pub entries: Arc<[(RepoPath, GitStatusPair)]>,
+    pub entries: Arc<[(RepoPath, FileStatus)]>,
 }
 
 impl GitStatus {
@@ -77,20 +359,10 @@ impl GitStatus {
                     return None;
                 };
                 let path = &entry[3..];
-                let status = entry[0..2].as_bytes();
-                let index_status = GitFileStatus::from_byte(status[0]);
-                let worktree_status = GitFileStatus::from_byte(status[1]);
-                if (index_status, worktree_status) == (None, None) {
-                    return None;
-                }
+                let status = entry[0..2].as_bytes().try_into().unwrap();
+                let status = FileStatus::from_bytes(status).log_err()?;
                 let path = RepoPath(Path::new(path).into());
-                Some((
-                    path,
-                    GitStatusPair {
-                        index_status,
-                        worktree_status,
-                    },
-                ))
+                Some((path, status))
             })
             .collect::<Vec<_>>();
         entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));

crates/git_ui/src/git_panel.rs 🔗

@@ -8,8 +8,7 @@ use anyhow::{Context as _, Result};
 use db::kvp::KEY_VALUE_STORE;
 use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorSettings, ShowScrollbar};
-use git::repository::{GitFileStatus, RepoPath};
-use git::status::GitStatusPair;
+use git::{repository::RepoPath, status::FileStatus};
 use gpui::*;
 use language::Buffer;
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -72,7 +71,7 @@ pub struct GitListEntry {
     depth: usize,
     display_name: String,
     repo_path: RepoPath,
-    status: GitStatusPair,
+    status: FileStatus,
     is_staged: Option<bool>,
 }
 
@@ -665,7 +664,7 @@ impl GitPanel {
             .skip(range.start)
             .take(range.end - range.start)
         {
-            let status = entry.status.clone();
+            let status = entry.status;
             let filename = entry
                 .repo_path
                 .file_name()
@@ -1072,22 +1071,23 @@ impl GitPanel {
         let repo_path = entry_details.repo_path.clone();
         let selected = self.selected_entry == Some(ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
-        // TODO revisit, maybe use a different status here?
-        let status = entry_details.status.combined();
+        let status = entry_details.status;
 
         let mut label_color = cx.theme().colors().text;
         if status_style == StatusStyle::LabelColor {
-            label_color = match status {
-                GitFileStatus::Added => cx.theme().status().created,
-                GitFileStatus::Modified => cx.theme().status().modified,
-                GitFileStatus::Conflict => cx.theme().status().conflict,
-                GitFileStatus::Deleted => cx.theme().colors().text_disabled,
-                // TODO: Should we even have this here?
-                GitFileStatus::Untracked => cx.theme().colors().text_placeholder,
+            label_color = if status.is_conflicted() {
+                cx.theme().status().conflict
+            } else if status.is_modified() {
+                cx.theme().status().modified
+            } else if status.is_deleted() {
+                cx.theme().colors().text_disabled
+            } else {
+                cx.theme().status().created
             }
         }
 
-        let path_color = matches!(status, GitFileStatus::Deleted)
+        let path_color = status
+            .is_deleted()
             .then_some(cx.theme().colors().text_disabled)
             .unwrap_or(cx.theme().colors().text_muted);
 
@@ -1175,7 +1175,7 @@ impl GitPanel {
             .child(
                 h_flex()
                     .text_color(label_color)
-                    .when(status == GitFileStatus::Deleted, |this| this.line_through())
+                    .when(status.is_deleted(), |this| this.line_through())
                     .when_some(repo_path.parent(), |this, parent| {
                         let parent_str = parent.to_string_lossy();
                         if !parent_str.is_empty() {

crates/git_ui/src/git_ui.rs 🔗

@@ -2,7 +2,8 @@ use ::settings::Settings;
 use collections::HashMap;
 use futures::channel::mpsc;
 use futures::StreamExt as _;
-use git::repository::{GitFileStatus, GitRepository, RepoPath};
+use git::repository::{GitRepository, RepoPath};
+use git::status::FileStatus;
 use git_panel_settings::GitPanelSettings;
 use gpui::{actions, AppContext, Hsla, Model};
 use project::{Project, WorktreeId};
@@ -223,17 +224,15 @@ const REMOVED_COLOR: Hsla = Hsla {
 };
 
 // TODO: Add updated status colors to theme
-pub fn git_status_icon(status: GitFileStatus) -> impl IntoElement {
-    match status {
-        GitFileStatus::Added | GitFileStatus::Untracked => {
-            Icon::new(IconName::SquarePlus).color(Color::Custom(ADDED_COLOR))
-        }
-        GitFileStatus::Modified => {
-            Icon::new(IconName::SquareDot).color(Color::Custom(MODIFIED_COLOR))
-        }
-        GitFileStatus::Conflict => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)),
-        GitFileStatus::Deleted => {
-            Icon::new(IconName::SquareMinus).color(Color::Custom(REMOVED_COLOR))
-        }
-    }
+pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
+    let (icon_name, color) = if status.is_conflicted() {
+        (IconName::Warning, REMOVED_COLOR)
+    } else if status.is_deleted() {
+        (IconName::SquareMinus, REMOVED_COLOR)
+    } else if status.is_modified() {
+        (IconName::SquareDot, MODIFIED_COLOR)
+    } else {
+        (IconName::SquarePlus, ADDED_COLOR)
+    };
+    Icon::new(icon_name).color(Color::Custom(color))
 }

crates/image_viewer/src/image_viewer.rs 🔗

@@ -2,18 +2,17 @@ use std::path::PathBuf;
 
 use anyhow::Context as _;
 use editor::items::entry_git_aware_label_color;
+use file_icons::FileIcons;
 use gpui::{
     canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, EventEmitter,
     FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ObjectFit, ParentElement,
     Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
 };
 use persistence::IMAGE_VIEWER;
-use theme::Theme;
-use ui::prelude::*;
-
-use file_icons::FileIcons;
 use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath};
 use settings::Settings;
+use theme::Theme;
+use ui::prelude::*;
 use util::paths::PathExt;
 use workspace::{
     item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
@@ -101,7 +100,9 @@ impl Item for ImageView {
             let git_status = self
                 .project
                 .read(cx)
-                .project_path_git_status(&project_path, cx);
+                .project_path_git_status(&project_path, cx)
+                .map(|status| status.summary())
+                .unwrap_or_default();
 
             self.project
                 .read(cx)

crates/outline_panel/src/outline_panel.rs 🔗

@@ -1982,7 +1982,7 @@ impl OutlinePanel {
         let is_expanded = !self
             .collapsed_entries
             .contains(&CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id));
-        let color = entry_git_aware_label_color(None, false, is_active);
+        let color = entry_label_color(is_active);
         let icon = if has_outlines {
             FileIcons::get_chevron_icon(is_expanded, cx)
                 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
@@ -2086,7 +2086,7 @@ impl OutlinePanel {
             }) => {
                 let name = self.entry_name(worktree_id, entry, cx);
                 let color =
-                    entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active);
+                    entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active);
                 let icon = if settings.file_icons {
                     FileIcons::get_icon(&entry.path, cx)
                         .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
@@ -2114,7 +2114,7 @@ impl OutlinePanel {
                     directory.entry.id,
                 ));
                 let color = entry_git_aware_label_color(
-                    directory.entry.git_status,
+                    directory.entry.git_summary,
                     directory.entry.is_ignored,
                     is_active,
                 );
@@ -2210,7 +2210,8 @@ impl OutlinePanel {
             let git_status = folded_dir
                 .entries
                 .first()
-                .and_then(|entry| entry.git_status);
+                .map(|entry| entry.git_summary)
+                .unwrap_or_default();
             let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
             let icon = if settings.folder_icons {
                 FileIcons::get_folder_icon(is_expanded, cx)
@@ -2556,7 +2557,10 @@ impl OutlinePanel {
                             match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() {
                                 Some(entry) => {
                                     let entry = GitEntry {
-                                        git_status: worktree.status_for_file(&entry.path),
+                                        git_summary: worktree
+                                            .status_for_file(&entry.path)
+                                            .map(|status| status.summary())
+                                            .unwrap_or_default(),
                                         entry,
                                     };
                                     let mut traversal = worktree

crates/project/src/project.rs 🔗

@@ -39,10 +39,7 @@ use futures::{
 pub use image_store::{ImageItem, ImageStore};
 use image_store::{ImageItemEvent, ImageStoreEvent};
 
-use git::{
-    blame::Blame,
-    repository::{GitFileStatus, GitRepository},
-};
+use git::{blame::Blame, repository::GitRepository, status::FileStatus};
 use gpui::{
     AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context as _, EventEmitter, Hsla,
     Model, ModelContext, SharedString, Task, WeakModel, WindowContext,
@@ -1449,7 +1446,7 @@ impl Project {
         &self,
         project_path: &ProjectPath,
         cx: &AppContext,
-    ) -> Option<GitFileStatus> {
+    ) -> Option<FileStatus> {
         self.worktree_for_id(project_path.worktree_id, cx)
             .and_then(|worktree| worktree.read(cx).status_for_file(&project_path.path))
     }

crates/project_panel/src/project_panel.rs 🔗

@@ -15,7 +15,7 @@ use editor::{
     Editor, EditorEvent, EditorSettings, ShowScrollbar,
 };
 use file_icons::FileIcons;
-use git::repository::GitFileStatus;
+use git::status::GitSummary;
 use gpui::{
     actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
     AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
@@ -145,7 +145,7 @@ struct EntryDetails {
     is_cut: bool,
     filename_text_color: Color,
     diagnostic_severity: Option<DiagnosticSeverity>,
-    git_status: Option<GitFileStatus>,
+    git_status: GitSummary,
     is_private: bool,
     worktree_id: WorktreeId,
     canonical_path: Option<Box<Path>>,
@@ -1584,9 +1584,7 @@ impl ProjectPanel {
                         }
                     }))
                     && entry.is_file()
-                    && entry
-                        .git_status
-                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
+                    && entry.git_summary.modified > 0
             },
             cx,
         );
@@ -1664,9 +1662,7 @@ impl ProjectPanel {
                         }
                     }))
                     && entry.is_file()
-                    && entry
-                        .git_status
-                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
+                    && entry.git_summary.modified > 0
             },
             cx,
         );
@@ -2417,7 +2413,7 @@ impl ProjectPanel {
                             char_bag: entry.char_bag,
                             is_fifo: entry.is_fifo,
                         },
-                        git_status: entry.git_status,
+                        git_summary: entry.git_summary,
                     });
                 }
                 let worktree_abs_path = worktree.read(cx).abs_path();
@@ -2815,7 +2811,9 @@ impl ProjectPanel {
                         .collect()
                 });
                 for entry in visible_worktree_entries[entry_range].iter() {
-                    let status = git_status_setting.then_some(entry.git_status).flatten();
+                    let status = git_status_setting
+                        .then_some(entry.git_summary)
+                        .unwrap_or_default();
                     let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
                     let icon = match entry.kind {
                         EntryKind::File => {

crates/proto/proto/zed.proto 🔗

@@ -1784,7 +1784,9 @@ message RepositoryEntry {
 
 message StatusEntry {
     string repo_path = 1;
-    GitStatus status = 2;
+    // Can be removed once collab's min version is >=0.171.0.
+    GitStatus simple_status = 2;
+    GitFileStatus status = 3;
 }
 
 enum GitStatus {
@@ -1792,6 +1794,31 @@ enum GitStatus {
     Modified = 1;
     Conflict = 2;
     Deleted = 3;
+    Updated = 4;
+    TypeChanged = 5;
+    Renamed = 6;
+    Copied = 7;
+    Unmodified = 8;
+}
+
+message GitFileStatus {
+    oneof variant {
+        Untracked untracked = 1;
+        Ignored ignored = 2;
+        Unmerged unmerged = 3;
+        Tracked tracked = 4;
+    }
+
+    message Untracked {}
+    message Ignored {}
+    message Unmerged {
+        GitStatus first_head = 1;
+        GitStatus second_head = 2;
+    }
+    message Tracked {
+        GitStatus index_status = 1;
+        GitStatus worktree_status = 2;
+    }
 }
 
 message BufferState {

crates/tab_switcher/src/tab_switcher.rs 🔗

@@ -362,7 +362,10 @@ impl PickerDelegate for TabSwitcherDelegate {
                         .and_then(|path| {
                             let project = self.project.read(cx);
                             let entry = project.entry_for_path(path, cx)?;
-                            let git_status = project.project_path_git_status(path, cx);
+                            let git_status = project
+                                .project_path_git_status(path, cx)
+                                .map(|status| status.summary())
+                                .unwrap_or_default();
                             Some((entry, git_status))
                         })
                         .map(|(entry, git_status)| {

crates/worktree/src/worktree.rs 🔗

@@ -18,11 +18,12 @@ use futures::{
     FutureExt as _, Stream, StreamExt,
 };
 use fuzzy::CharBag;
-use git::GitHostingProviderRegistry;
 use git::{
-    repository::{GitFileStatus, GitRepository, RepoPath},
-    status::GitStatusPair,
-    COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE,
+    repository::{GitRepository, RepoPath},
+    status::{
+        FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
+    },
+    GitHostingProviderRegistry, COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE,
 };
 use gpui::{
     AppContext, AsyncAppContext, BackgroundExecutor, Context, EventEmitter, Model, ModelContext,
@@ -239,10 +240,7 @@ impl RepositoryEntry {
             updated_statuses: self
                 .statuses_by_path
                 .iter()
-                .map(|entry| proto::StatusEntry {
-                    repo_path: entry.repo_path.to_string_lossy().to_string(),
-                    status: status_pair_to_proto(entry.status.clone()),
-                })
+                .map(|entry| entry.to_proto())
                 .collect(),
             removed_statuses: Default::default(),
         }
@@ -266,7 +264,7 @@ impl RepositoryEntry {
                             current_new_entry = new_statuses.next();
                         }
                         Ordering::Equal => {
-                            if new_entry.combined_status() != old_entry.combined_status() {
+                            if new_entry.status != old_entry.status {
                                 updated_statuses.push(new_entry.to_proto());
                             }
                             current_old_entry = old_statuses.next();
@@ -2361,13 +2359,13 @@ impl Snapshot {
         Some(removed_entry.path)
     }
 
-    pub fn status_for_file(&self, path: impl AsRef<Path>) -> Option<GitFileStatus> {
+    pub fn status_for_file(&self, path: impl AsRef<Path>) -> Option<FileStatus> {
         let path = path.as_ref();
         self.repository_for_path(path).and_then(|repo| {
             let repo_path = repo.relativize(path).unwrap();
             repo.statuses_by_path
                 .get(&PathKey(repo_path.0), &())
-                .map(|entry| entry.combined_status())
+                .map(|entry| entry.status)
         })
     }
 
@@ -3633,41 +3631,41 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc<Path>, GitRepositoryChange)]>;
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct StatusEntry {
     pub repo_path: RepoPath,
-    pub status: GitStatusPair,
+    pub status: FileStatus,
 }
 
 impl StatusEntry {
-    // TODO revisit uses of this
-    pub fn combined_status(&self) -> GitFileStatus {
-        self.status.combined()
-    }
-
-    pub fn index_status(&self) -> Option<GitFileStatus> {
-        self.status.index_status
-    }
-
-    pub fn worktree_status(&self) -> Option<GitFileStatus> {
-        self.status.worktree_status
-    }
-
     pub fn is_staged(&self) -> Option<bool> {
         self.status.is_staged()
     }
 
     fn to_proto(&self) -> proto::StatusEntry {
+        let simple_status = match self.status {
+            FileStatus::Ignored | FileStatus::Untracked => proto::GitStatus::Added as i32,
+            FileStatus::Unmerged { .. } => proto::GitStatus::Conflict as i32,
+            FileStatus::Tracked(TrackedStatus {
+                index_status,
+                worktree_status,
+            }) => tracked_status_to_proto(if worktree_status != StatusCode::Unmodified {
+                worktree_status
+            } else {
+                index_status
+            }),
+        };
         proto::StatusEntry {
             repo_path: self.repo_path.to_proto(),
-            status: status_pair_to_proto(self.status.clone()),
+            simple_status,
+            status: Some(status_to_proto(self.status)),
         }
     }
 }
 
 impl TryFrom<proto::StatusEntry> for StatusEntry {
     type Error = anyhow::Error;
+
     fn try_from(value: proto::StatusEntry) -> Result<Self, Self::Error> {
         let repo_path = RepoPath(Path::new(&value.repo_path).into());
-        let status = status_pair_from_proto(value.status)
-            .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?;
+        let status = status_from_proto(value.simple_status, value.status)?;
         Ok(Self { repo_path, status })
     }
 }
@@ -3734,43 +3732,13 @@ impl sum_tree::KeyedItem for RepositoryEntry {
     }
 }
 
-impl sum_tree::Summary for GitStatuses {
-    type Context = ();
-
-    fn zero(_: &Self::Context) -> Self {
-        Default::default()
-    }
-
-    fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
-        *self += *rhs;
-    }
-}
-
 impl sum_tree::Item for StatusEntry {
-    type Summary = PathSummary<GitStatuses>;
+    type Summary = PathSummary<GitSummary>;
 
     fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
         PathSummary {
             max_path: self.repo_path.0.clone(),
-            item_summary: match self.combined_status() {
-                GitFileStatus::Added => GitStatuses {
-                    added: 1,
-                    ..Default::default()
-                },
-                GitFileStatus::Modified => GitStatuses {
-                    modified: 1,
-                    ..Default::default()
-                },
-                GitFileStatus::Conflict => GitStatuses {
-                    conflict: 1,
-                    ..Default::default()
-                },
-                GitFileStatus::Deleted => Default::default(),
-                GitFileStatus::Untracked => GitStatuses {
-                    untracked: 1,
-                    ..Default::default()
-                },
-            },
+            item_summary: self.status.summary(),
         }
     }
 }
@@ -3783,69 +3751,12 @@ impl sum_tree::KeyedItem for StatusEntry {
     }
 }
 
-#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
-pub struct GitStatuses {
-    added: usize,
-    modified: usize,
-    conflict: usize,
-    untracked: usize,
-}
-
-impl GitStatuses {
-    pub fn to_status(&self) -> Option<GitFileStatus> {
-        if self.conflict > 0 {
-            Some(GitFileStatus::Conflict)
-        } else if self.modified > 0 {
-            Some(GitFileStatus::Modified)
-        } else if self.added > 0 || self.untracked > 0 {
-            Some(GitFileStatus::Added)
-        } else {
-            None
-        }
-    }
-}
-
-impl std::ops::Add<Self> for GitStatuses {
-    type Output = Self;
-
-    fn add(self, rhs: Self) -> Self {
-        GitStatuses {
-            added: self.added + rhs.added,
-            modified: self.modified + rhs.modified,
-            conflict: self.conflict + rhs.conflict,
-            untracked: self.untracked + rhs.untracked,
-        }
-    }
-}
-
-impl std::ops::AddAssign for GitStatuses {
-    fn add_assign(&mut self, rhs: Self) {
-        self.added += rhs.added;
-        self.modified += rhs.modified;
-        self.conflict += rhs.conflict;
-        self.untracked += rhs.untracked;
-    }
-}
-
-impl std::ops::Sub for GitStatuses {
-    type Output = GitStatuses;
-
-    fn sub(self, rhs: Self) -> Self::Output {
-        GitStatuses {
-            added: self.added - rhs.added,
-            modified: self.modified - rhs.modified,
-            conflict: self.conflict - rhs.conflict,
-            untracked: self.untracked - rhs.untracked,
-        }
-    }
-}
-
-impl<'a> sum_tree::Dimension<'a, PathSummary<GitStatuses>> for GitStatuses {
+impl<'a> sum_tree::Dimension<'a, PathSummary<GitSummary>> for GitSummary {
     fn zero(_cx: &()) -> Self {
         Default::default()
     }
 
-    fn add_summary(&mut self, summary: &'a PathSummary<GitStatuses>, _: &()) {
+    fn add_summary(&mut self, summary: &'a PathSummary<GitSummary>, _: &()) {
         *self += summary.item_summary
     }
 }
@@ -4851,7 +4762,7 @@ impl BackgroundScanner {
 
                     changed_path_statuses.push(Edit::Insert(StatusEntry {
                         repo_path: repo_path.clone(),
-                        status: status.clone(),
+                        status: *status,
                     }));
                 }
 
@@ -5280,7 +5191,7 @@ impl BackgroundScanner {
             new_entries_by_path.insert_or_replace(
                 StatusEntry {
                     repo_path: repo_path.clone(),
-                    status: status.clone(),
+                    status: *status,
                 },
                 &(),
             );
@@ -5695,14 +5606,14 @@ impl<'a> Default for TraversalProgress<'a> {
 #[derive(Debug, Clone, Copy)]
 pub struct GitEntryRef<'a> {
     pub entry: &'a Entry,
-    pub git_status: Option<GitFileStatus>,
+    pub git_summary: GitSummary,
 }
 
 impl<'a> GitEntryRef<'a> {
     pub fn to_owned(&self) -> GitEntry {
         GitEntry {
             entry: self.entry.clone(),
-            git_status: self.git_status,
+            git_summary: self.git_summary,
         }
     }
 }
@@ -5724,14 +5635,14 @@ impl<'a> AsRef<Entry> for GitEntryRef<'a> {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct GitEntry {
     pub entry: Entry,
-    pub git_status: Option<GitFileStatus>,
+    pub git_summary: GitSummary,
 }
 
 impl GitEntry {
     pub fn to_ref(&self) -> GitEntryRef {
         GitEntryRef {
             entry: &self.entry,
-            git_status: self.git_status,
+            git_summary: self.git_summary,
         }
     }
 }
@@ -5753,7 +5664,7 @@ impl AsRef<Entry> for GitEntry {
 /// Walks the worktree entries and their associated git statuses.
 pub struct GitTraversal<'a> {
     traversal: Traversal<'a>,
-    current_entry_status: Option<GitFileStatus>,
+    current_entry_summary: Option<GitSummary>,
     repo_location: Option<(
         &'a RepositoryEntry,
         Cursor<'a, StatusEntry, PathProgress<'a>>,
@@ -5762,7 +5673,7 @@ pub struct GitTraversal<'a> {
 
 impl<'a> GitTraversal<'a> {
     fn synchronize_statuses(&mut self, reset: bool) {
-        self.current_entry_status = None;
+        self.current_entry_summary = None;
 
         let Some(entry) = self.traversal.cursor.item() else {
             return;
@@ -5787,14 +5698,16 @@ impl<'a> GitTraversal<'a> {
         if entry.is_dir() {
             let mut statuses = statuses.clone();
             statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &());
-            let summary: GitStatuses =
+            let summary =
                 statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &());
 
-            self.current_entry_status = summary.to_status();
+            self.current_entry_summary = Some(summary);
         } else if entry.is_file() {
             // For a file entry, park the cursor on the corresponding status
             if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) {
-                self.current_entry_status = Some(statuses.item().unwrap().combined_status());
+                self.current_entry_summary = Some(statuses.item().unwrap().status.into());
+            } else {
+                self.current_entry_summary = Some(GitSummary::zero(&()));
             }
         }
     }
@@ -5830,10 +5743,9 @@ impl<'a> GitTraversal<'a> {
     }
 
     pub fn entry(&self) -> Option<GitEntryRef<'a>> {
-        Some(GitEntryRef {
-            entry: self.traversal.cursor.item()?,
-            git_status: self.current_entry_status,
-        })
+        let entry = self.traversal.cursor.item()?;
+        let git_summary = self.current_entry_summary.unwrap_or_default();
+        Some(GitEntryRef { entry, git_summary })
     }
 }
 
@@ -5884,7 +5796,7 @@ impl<'a> Traversal<'a> {
     pub fn with_git_statuses(self) -> GitTraversal<'a> {
         let mut this = GitTraversal {
             traversal: self,
-            current_entry_status: None,
+            current_entry_summary: None,
             repo_location: None,
         };
         this.synchronize_statuses(true);
@@ -6003,10 +5915,10 @@ impl<'a, 'b, S: Summary> SeekTarget<'a, PathSummary<S>, TraversalProgress<'a>> f
     }
 }
 
-impl<'a, 'b> SeekTarget<'a, PathSummary<GitStatuses>, (TraversalProgress<'a>, GitStatuses)>
+impl<'a, 'b> SeekTarget<'a, PathSummary<GitSummary>, (TraversalProgress<'a>, GitSummary)>
     for PathTarget<'b>
 {
-    fn cmp(&self, cursor_location: &(TraversalProgress<'a>, GitStatuses), _: &()) -> Ordering {
+    fn cmp(&self, cursor_location: &(TraversalProgress<'a>, GitSummary), _: &()) -> Ordering {
         self.cmp_path(&cursor_location.0.max_path)
     }
 }
@@ -6159,28 +6071,135 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry {
     }
 }
 
-// TODO pass the status pair all the way through
-fn status_pair_from_proto(proto: i32) -> Option<GitStatusPair> {
-    let proto = proto::GitStatus::from_i32(proto)?;
-    let worktree_status = match proto {
-        proto::GitStatus::Added => GitFileStatus::Added,
-        proto::GitStatus::Modified => GitFileStatus::Modified,
-        proto::GitStatus::Conflict => GitFileStatus::Conflict,
-        proto::GitStatus::Deleted => GitFileStatus::Deleted,
+fn status_from_proto(
+    simple_status: i32,
+    status: Option<proto::GitFileStatus>,
+) -> anyhow::Result<FileStatus> {
+    use proto::git_file_status::Variant;
+
+    let Some(variant) = status.and_then(|status| status.variant) else {
+        let code = proto::GitStatus::from_i32(simple_status)
+            .ok_or_else(|| anyhow!("Invalid git status code: {simple_status}"))?;
+        let result = match code {
+            proto::GitStatus::Added => TrackedStatus {
+                worktree_status: StatusCode::Added,
+                index_status: StatusCode::Unmodified,
+            }
+            .into(),
+            proto::GitStatus::Modified => TrackedStatus {
+                worktree_status: StatusCode::Modified,
+                index_status: StatusCode::Unmodified,
+            }
+            .into(),
+            proto::GitStatus::Conflict => UnmergedStatus {
+                first_head: UnmergedStatusCode::Updated,
+                second_head: UnmergedStatusCode::Updated,
+            }
+            .into(),
+            proto::GitStatus::Deleted => TrackedStatus {
+                worktree_status: StatusCode::Deleted,
+                index_status: StatusCode::Unmodified,
+            }
+            .into(),
+            _ => return Err(anyhow!("Invalid code for simple status: {simple_status}")),
+        };
+        return Ok(result);
+    };
+
+    let result = match variant {
+        Variant::Untracked(_) => FileStatus::Untracked,
+        Variant::Ignored(_) => FileStatus::Ignored,
+        Variant::Unmerged(unmerged) => {
+            let [first_head, second_head] =
+                [unmerged.first_head, unmerged.second_head].map(|head| {
+                    let code = proto::GitStatus::from_i32(head)
+                        .ok_or_else(|| anyhow!("Invalid git status code: {head}"))?;
+                    let result = match code {
+                        proto::GitStatus::Added => UnmergedStatusCode::Added,
+                        proto::GitStatus::Updated => UnmergedStatusCode::Updated,
+                        proto::GitStatus::Deleted => UnmergedStatusCode::Deleted,
+                        _ => return Err(anyhow!("Invalid code for unmerged status: {code:?}")),
+                    };
+                    Ok(result)
+                });
+            let [first_head, second_head] = [first_head?, second_head?];
+            UnmergedStatus {
+                first_head,
+                second_head,
+            }
+            .into()
+        }
+        Variant::Tracked(tracked) => {
+            let [index_status, worktree_status] = [tracked.index_status, tracked.worktree_status]
+                .map(|status| {
+                    let code = proto::GitStatus::from_i32(status)
+                        .ok_or_else(|| anyhow!("Invalid git status code: {status}"))?;
+                    let result = match code {
+                        proto::GitStatus::Modified => StatusCode::Modified,
+                        proto::GitStatus::TypeChanged => StatusCode::TypeChanged,
+                        proto::GitStatus::Added => StatusCode::Added,
+                        proto::GitStatus::Deleted => StatusCode::Deleted,
+                        proto::GitStatus::Renamed => StatusCode::Renamed,
+                        proto::GitStatus::Copied => StatusCode::Copied,
+                        proto::GitStatus::Unmodified => StatusCode::Unmodified,
+                        _ => return Err(anyhow!("Invalid code for tracked status: {code:?}")),
+                    };
+                    Ok(result)
+                });
+            let [index_status, worktree_status] = [index_status?, worktree_status?];
+            TrackedStatus {
+                index_status,
+                worktree_status,
+            }
+            .into()
+        }
+    };
+    Ok(result)
+}
+
+fn status_to_proto(status: FileStatus) -> proto::GitFileStatus {
+    use proto::git_file_status::{Tracked, Unmerged, Variant};
+
+    let variant = match status {
+        FileStatus::Untracked => Variant::Untracked(Default::default()),
+        FileStatus::Ignored => Variant::Ignored(Default::default()),
+        FileStatus::Unmerged(UnmergedStatus {
+            first_head,
+            second_head,
+        }) => Variant::Unmerged(Unmerged {
+            first_head: unmerged_status_to_proto(first_head),
+            second_head: unmerged_status_to_proto(second_head),
+        }),
+        FileStatus::Tracked(TrackedStatus {
+            index_status,
+            worktree_status,
+        }) => Variant::Tracked(Tracked {
+            index_status: tracked_status_to_proto(index_status),
+            worktree_status: tracked_status_to_proto(worktree_status),
+        }),
     };
-    Some(GitStatusPair {
-        index_status: None,
-        worktree_status: Some(worktree_status),
-    })
-}
-
-fn status_pair_to_proto(status: GitStatusPair) -> i32 {
-    match status.combined() {
-        GitFileStatus::Added => proto::GitStatus::Added as i32,
-        GitFileStatus::Modified => proto::GitStatus::Modified as i32,
-        GitFileStatus::Conflict => proto::GitStatus::Conflict as i32,
-        GitFileStatus::Deleted => proto::GitStatus::Deleted as i32,
-        GitFileStatus::Untracked => proto::GitStatus::Added as i32, // TODO
+    proto::GitFileStatus {
+        variant: Some(variant),
+    }
+}
+
+fn unmerged_status_to_proto(code: UnmergedStatusCode) -> i32 {
+    match code {
+        UnmergedStatusCode::Added => proto::GitStatus::Added as _,
+        UnmergedStatusCode::Deleted => proto::GitStatus::Deleted as _,
+        UnmergedStatusCode::Updated => proto::GitStatus::Updated as _,
+    }
+}
+
+fn tracked_status_to_proto(code: StatusCode) -> i32 {
+    match code {
+        StatusCode::Added => proto::GitStatus::Added as _,
+        StatusCode::Deleted => proto::GitStatus::Deleted as _,
+        StatusCode::Modified => proto::GitStatus::Modified as _,
+        StatusCode::Renamed => proto::GitStatus::Renamed as _,
+        StatusCode::TypeChanged => proto::GitStatus::TypeChanged as _,
+        StatusCode::Copied => proto::GitStatus::Copied as _,
+        StatusCode::Unmodified => proto::GitStatus::Unmodified as _,
     }
 }
 

crates/worktree/src/worktree_tests.rs 🔗

@@ -4,7 +4,12 @@ use crate::{
 };
 use anyhow::Result;
 use fs::{FakeFs, Fs, RealFs, RemoveOptions};
-use git::{repository::GitFileStatus, GITIGNORE};
+use git::{
+    status::{
+        FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
+    },
+    GITIGNORE,
+};
 use gpui::{BorrowAppContext, ModelContext, Task, TestAppContext};
 use parking_lot::Mutex;
 use postage::stream::Stream;
@@ -738,7 +743,10 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
 
     fs.set_status_for_repo_via_working_copy_change(
         Path::new("/root/tree/.git"),
-        &[(Path::new("tracked-dir/tracked-file2"), GitFileStatus::Added)],
+        &[(
+            Path::new("tracked-dir/tracked-file2"),
+            FileStatus::worktree(StatusCode::Added),
+        )],
     );
 
     fs.create_file(
@@ -766,7 +774,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
         assert_entry_git_state(
             tree,
             "tracked-dir/tracked-file2",
-            Some(GitFileStatus::Added),
+            Some(StatusCode::Added),
             false,
         );
         assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file2", None, false);
@@ -822,14 +830,14 @@ async fn test_update_gitignore(cx: &mut TestAppContext) {
 
     fs.set_status_for_repo_via_working_copy_change(
         Path::new("/root/.git"),
-        &[(Path::new("b.txt"), GitFileStatus::Added)],
+        &[(Path::new("b.txt"), FileStatus::worktree(StatusCode::Added))],
     );
 
     cx.executor().run_until_parked();
     cx.read(|cx| {
         let tree = tree.read(cx);
         assert_entry_git_state(tree, "a.xml", None, true);
-        assert_entry_git_state(tree, "b.txt", Some(GitFileStatus::Added), false);
+        assert_entry_git_state(tree, "b.txt", Some(StatusCode::Added), false);
     });
 }
 
@@ -1492,7 +1500,10 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
     // detected.
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/.git"),
-        &[(Path::new("b/c.txt"), GitFileStatus::Modified)],
+        &[(
+            Path::new("b/c.txt"),
+            FileStatus::worktree(StatusCode::Modified),
+        )],
     );
     cx.executor().run_until_parked();
 
@@ -1501,9 +1512,9 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new(""), Some(GitFileStatus::Modified)),
-            (Path::new("a.txt"), None),
-            (Path::new("b/c.txt"), Some(GitFileStatus::Modified)),
+            (Path::new(""), GitSummary::MODIFIED),
+            (Path::new("a.txt"), GitSummary::UNCHANGED),
+            (Path::new("b/c.txt"), GitSummary::MODIFIED),
         ],
     );
 }
@@ -2142,6 +2153,11 @@ fn random_filename(rng: &mut impl Rng) -> String {
         .collect()
 }
 
+const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
+    first_head: UnmergedStatusCode::Updated,
+    second_head: UnmergedStatusCode::Updated,
+});
+
 #[gpui::test]
 async fn test_rename_work_directory(cx: &mut TestAppContext) {
     init_test(cx);
@@ -2183,11 +2199,11 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
         assert_eq!(repo.path.as_ref(), Path::new("projects/project1"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/a")),
-            Some(GitFileStatus::Modified)
+            Some(FileStatus::worktree(StatusCode::Modified)),
         );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/b")),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
     });
 
@@ -2204,11 +2220,11 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
         assert_eq!(repo.path.as_ref(), Path::new("projects/project2"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/a")),
-            Some(GitFileStatus::Modified)
+            Some(FileStatus::worktree(StatusCode::Modified)),
         );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/b")),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
     });
 }
@@ -2387,11 +2403,11 @@ async fn test_file_status(cx: &mut TestAppContext) {
 
         assert_eq!(
             snapshot.status_for_file(project_path.join(B_TXT)),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
         assert_eq!(
             snapshot.status_for_file(project_path.join(F_TXT)),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
     });
 
@@ -2405,7 +2421,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(
             snapshot.status_for_file(project_path.join(A_TXT)),
-            Some(GitFileStatus::Modified)
+            Some(FileStatus::worktree(StatusCode::Modified)),
         );
     });
 
@@ -2421,7 +2437,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(
             snapshot.status_for_file(project_path.join(F_TXT)),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
         assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
         assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
@@ -2443,11 +2459,11 @@ async fn test_file_status(cx: &mut TestAppContext) {
         assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
         assert_eq!(
             snapshot.status_for_file(project_path.join(B_TXT)),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
         assert_eq!(
             snapshot.status_for_file(project_path.join(E_TXT)),
-            Some(GitFileStatus::Modified)
+            Some(FileStatus::worktree(StatusCode::Modified)),
         );
     });
 
@@ -2482,7 +2498,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(
             snapshot.status_for_file(project_path.join(renamed_dir_name).join(RENAMED_FILE)),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
     });
 
@@ -2506,7 +2522,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
                     .join(Path::new(renamed_dir_name))
                     .join(RENAMED_FILE)
             ),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked),
         );
     });
 }
@@ -2559,11 +2575,14 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
         assert_eq!(entries.len(), 3);
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified));
+        assert_eq!(
+            entries[0].status,
+            FileStatus::worktree(StatusCode::Modified)
+        );
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
-        assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked));
+        assert_eq!(entries[1].status, FileStatus::Untracked);
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted));
+        assert_eq!(entries[2].status, FileStatus::worktree(StatusCode::Deleted));
     });
 
     std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
@@ -2581,14 +2600,20 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
         std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified));
+        assert_eq!(
+            entries[0].status,
+            FileStatus::worktree(StatusCode::Modified)
+        );
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
-        assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked));
+        assert_eq!(entries[1].status, FileStatus::Untracked);
         // Status updated
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
-        assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified));
+        assert_eq!(
+            entries[2].status,
+            FileStatus::worktree(StatusCode::Modified)
+        );
         assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted));
+        assert_eq!(entries[3].status, FileStatus::worktree(StatusCode::Deleted));
     });
 
     git_add("a.txt", &repo);
@@ -2621,7 +2646,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
             &entries
         );
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted));
+        assert_eq!(entries[0].status, FileStatus::worktree(StatusCode::Deleted));
     });
 }
 
@@ -2692,7 +2717,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
         assert_eq!(snapshot.status_for_file("c.txt"), None);
         assert_eq!(
             snapshot.status_for_file("d/e.txt"),
-            Some(GitFileStatus::Untracked)
+            Some(FileStatus::Untracked)
         );
     });
 
@@ -2744,17 +2769,20 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
         &[
-            (Path::new("x2.txt"), GitFileStatus::Modified),
-            (Path::new("z.txt"), GitFileStatus::Added),
+            (
+                Path::new("x2.txt"),
+                FileStatus::worktree(StatusCode::Modified),
+            ),
+            (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/y/.git"),
-        &[(Path::new("y1.txt"), GitFileStatus::Conflict)],
+        &[(Path::new("y1.txt"), CONFLICT)],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(Path::new("z2.txt"), GitFileStatus::Added)],
+        &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))],
     );
 
     let tree = Worktree::local(
@@ -2780,25 +2808,25 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
 
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt"));
-    assert_eq!(entry.git_status, None);
+    assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt"));
-    assert_eq!(entry.git_status, Some(GitFileStatus::Modified));
+    assert_eq!(entry.git_summary, GitSummary::MODIFIED);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt"));
-    assert_eq!(entry.git_status, Some(GitFileStatus::Conflict));
+    assert_eq!(entry.git_summary, GitSummary::CONFLICT);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt"));
-    assert_eq!(entry.git_status, None);
+    assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("x/z.txt"));
-    assert_eq!(entry.git_status, Some(GitFileStatus::Added));
+    assert_eq!(entry.git_summary, GitSummary::ADDED);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt"));
-    assert_eq!(entry.git_status, None);
+    assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
     let entry = traversal.next().unwrap();
     assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt"));
-    assert_eq!(entry.git_status, Some(GitFileStatus::Added));
+    assert_eq!(entry.git_summary, GitSummary::ADDED);
 }
 
 #[gpui::test]
@@ -2834,9 +2862,15 @@ async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/.git"),
         &[
-            (Path::new("a/b/c1.txt"), GitFileStatus::Added),
-            (Path::new("a/d/e2.txt"), GitFileStatus::Modified),
-            (Path::new("g/h2.txt"), GitFileStatus::Conflict),
+            (
+                Path::new("a/b/c1.txt"),
+                FileStatus::worktree(StatusCode::Added),
+            ),
+            (
+                Path::new("a/d/e2.txt"),
+                FileStatus::worktree(StatusCode::Modified),
+            ),
+            (Path::new("g/h2.txt"), CONFLICT),
         ],
     );
 
@@ -2859,52 +2893,58 @@ async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new(""), Some(GitFileStatus::Conflict)),
-            (Path::new("g"), Some(GitFileStatus::Conflict)),
-            (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
+            (
+                Path::new(""),
+                GitSummary::CONFLICT + GitSummary::MODIFIED + GitSummary::ADDED,
+            ),
+            (Path::new("g"), GitSummary::CONFLICT),
+            (Path::new("g/h2.txt"), GitSummary::CONFLICT),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new(""), Some(GitFileStatus::Conflict)),
-            (Path::new("a"), Some(GitFileStatus::Modified)),
-            (Path::new("a/b"), Some(GitFileStatus::Added)),
-            (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
-            (Path::new("a/b/c2.txt"), None),
-            (Path::new("a/d"), Some(GitFileStatus::Modified)),
-            (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("f"), None),
-            (Path::new("f/no-status.txt"), None),
-            (Path::new("g"), Some(GitFileStatus::Conflict)),
-            (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
+            (
+                Path::new(""),
+                GitSummary::CONFLICT + GitSummary::ADDED + GitSummary::MODIFIED,
+            ),
+            (Path::new("a"), GitSummary::ADDED + GitSummary::MODIFIED),
+            (Path::new("a/b"), GitSummary::ADDED),
+            (Path::new("a/b/c1.txt"), GitSummary::ADDED),
+            (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
+            (Path::new("a/d"), GitSummary::MODIFIED),
+            (Path::new("a/d/e2.txt"), GitSummary::MODIFIED),
+            (Path::new("f"), GitSummary::UNCHANGED),
+            (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
+            (Path::new("g"), GitSummary::CONFLICT),
+            (Path::new("g/h2.txt"), GitSummary::CONFLICT),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("a/b"), Some(GitFileStatus::Added)),
-            (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
-            (Path::new("a/b/c2.txt"), None),
-            (Path::new("a/d"), Some(GitFileStatus::Modified)),
-            (Path::new("a/d/e1.txt"), None),
-            (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("f"), None),
-            (Path::new("f/no-status.txt"), None),
-            (Path::new("g"), Some(GitFileStatus::Conflict)),
+            (Path::new("a/b"), GitSummary::ADDED),
+            (Path::new("a/b/c1.txt"), GitSummary::ADDED),
+            (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
+            (Path::new("a/d"), GitSummary::MODIFIED),
+            (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
+            (Path::new("a/d/e2.txt"), GitSummary::MODIFIED),
+            (Path::new("f"), GitSummary::UNCHANGED),
+            (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
+            (Path::new("g"), GitSummary::CONFLICT),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
-            (Path::new("a/b/c2.txt"), None),
-            (Path::new("a/d/e1.txt"), None),
-            (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("f/no-status.txt"), None),
+            (Path::new("a/b/c1.txt"), GitSummary::ADDED),
+            (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
+            (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
+            (Path::new("a/d/e2.txt"), GitSummary::MODIFIED),
+            (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
         ],
     );
 }
@@ -2937,18 +2977,24 @@ async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext
 
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
-        &[(Path::new("x1.txt"), GitFileStatus::Added)],
+        &[(Path::new("x1.txt"), FileStatus::worktree(StatusCode::Added))],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/y/.git"),
         &[
-            (Path::new("y1.txt"), GitFileStatus::Conflict),
-            (Path::new("y2.txt"), GitFileStatus::Modified),
+            (Path::new("y1.txt"), CONFLICT),
+            (
+                Path::new("y2.txt"),
+                FileStatus::worktree(StatusCode::Modified),
+            ),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(Path::new("z2.txt"), GitFileStatus::Modified)],
+        &[(
+            Path::new("z2.txt"),
+            FileStatus::worktree(StatusCode::Modified),
+        )],
     );
 
     let tree = Worktree::local(
@@ -2971,48 +3017,48 @@ async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x"), Some(GitFileStatus::Added)),
-            (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
+            (Path::new("x"), GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::ADDED),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("y"), Some(GitFileStatus::Conflict)),
-            (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)),
-            (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)),
+            (Path::new("y"), GitSummary::CONFLICT + GitSummary::MODIFIED),
+            (Path::new("y/y1.txt"), GitSummary::CONFLICT),
+            (Path::new("y/y2.txt"), GitSummary::MODIFIED),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("z"), Some(GitFileStatus::Modified)),
-            (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)),
+            (Path::new("z"), GitSummary::MODIFIED),
+            (Path::new("z/z2.txt"), GitSummary::MODIFIED),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x"), Some(GitFileStatus::Added)),
-            (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
+            (Path::new("x"), GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::ADDED),
         ],
     );
 
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x"), Some(GitFileStatus::Added)),
-            (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
-            (Path::new("x/x2.txt"), None),
-            (Path::new("y"), Some(GitFileStatus::Conflict)),
-            (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)),
-            (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("z"), Some(GitFileStatus::Modified)),
-            (Path::new("z/z1.txt"), None),
-            (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)),
+            (Path::new("x"), GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::ADDED),
+            (Path::new("x/x2.txt"), GitSummary::UNCHANGED),
+            (Path::new("y"), GitSummary::CONFLICT + GitSummary::MODIFIED),
+            (Path::new("y/y1.txt"), GitSummary::CONFLICT),
+            (Path::new("y/y2.txt"), GitSummary::MODIFIED),
+            (Path::new("z"), GitSummary::MODIFIED),
+            (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
+            (Path::new("z/z2.txt"), GitSummary::MODIFIED),
         ],
     );
 }
@@ -3047,18 +3093,21 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
         &[
-            (Path::new("x2.txt"), GitFileStatus::Modified),
-            (Path::new("z.txt"), GitFileStatus::Added),
+            (
+                Path::new("x2.txt"),
+                FileStatus::worktree(StatusCode::Modified),
+            ),
+            (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/y/.git"),
-        &[(Path::new("y1.txt"), GitFileStatus::Conflict)],
+        &[(Path::new("y1.txt"), CONFLICT)],
     );
 
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(Path::new("z2.txt"), GitFileStatus::Added)],
+        &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))],
     );
 
     let tree = Worktree::local(
@@ -3082,17 +3131,17 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x/y"), Some(GitFileStatus::Conflict)), // the y git repository has conflict file in it, and so should have a conflict status
-            (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y2.txt"), None),
+            (Path::new("x/y"), GitSummary::CONFLICT),
+            (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
+            (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
         ],
     );
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("z"), Some(GitFileStatus::Added)),
-            (Path::new("z/z1.txt"), None),
-            (Path::new("z/z2.txt"), Some(GitFileStatus::Added)),
+            (Path::new("z"), GitSummary::ADDED),
+            (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
+            (Path::new("z/z2.txt"), GitSummary::ADDED),
         ],
     );
 
@@ -3100,9 +3149,9 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x"), Some(GitFileStatus::Modified)),
-            (Path::new("x/y"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
+            (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED),
+            (Path::new("x/y"), GitSummary::CONFLICT),
+            (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
         ],
     );
 
@@ -3110,13 +3159,13 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new("x"), Some(GitFileStatus::Modified)),
-            (Path::new("x/x1.txt"), None),
-            (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("x/y"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y2.txt"), None),
-            (Path::new("x/z.txt"), Some(GitFileStatus::Added)),
+            (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
+            (Path::new("x/x2.txt"), GitSummary::MODIFIED),
+            (Path::new("x/y"), GitSummary::CONFLICT),
+            (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
+            (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
+            (Path::new("x/z.txt"), GitSummary::ADDED),
         ],
     );
 
@@ -3124,9 +3173,9 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new(""), None),
-            (Path::new("x"), Some(GitFileStatus::Modified)),
-            (Path::new("x/x1.txt"), None),
+            (Path::new(""), GitSummary::UNCHANGED),
+            (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
         ],
     );
 
@@ -3134,17 +3183,17 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     check_git_statuses(
         &snapshot,
         &[
-            (Path::new(""), None),
-            (Path::new("x"), Some(GitFileStatus::Modified)),
-            (Path::new("x/x1.txt"), None),
-            (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)),
-            (Path::new("x/y"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
-            (Path::new("x/y/y2.txt"), None),
-            (Path::new("x/z.txt"), Some(GitFileStatus::Added)),
-            (Path::new("z"), Some(GitFileStatus::Added)),
-            (Path::new("z/z1.txt"), None),
-            (Path::new("z/z2.txt"), Some(GitFileStatus::Added)),
+            (Path::new(""), GitSummary::UNCHANGED),
+            (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED),
+            (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
+            (Path::new("x/x2.txt"), GitSummary::MODIFIED),
+            (Path::new("x/y"), GitSummary::CONFLICT),
+            (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
+            (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
+            (Path::new("x/z.txt"), GitSummary::ADDED),
+            (Path::new("z"), GitSummary::ADDED),
+            (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
+            (Path::new("z/z2.txt"), GitSummary::ADDED),
         ],
     );
 }
@@ -3173,7 +3222,7 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
 }
 
 #[track_caller]
-fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, Option<GitFileStatus>)]) {
+fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) {
     let mut traversal = snapshot
         .traverse_from_path(true, true, false, "".as_ref())
         .with_git_statuses();
@@ -3182,8 +3231,8 @@ fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, Option<G
         .map(|&(path, _)| {
             let git_entry = traversal
                 .find(|git_entry| &*git_entry.path == path)
-                .expect("Traversal has no entry for {path:?}");
-            (path, git_entry.git_status)
+                .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
+            (path, git_entry.git_summary)
         })
         .collect::<Vec<_>>();
     assert_eq!(found_statuses, expected_statuses);
@@ -3330,14 +3379,21 @@ fn init_test(cx: &mut gpui::TestAppContext) {
 fn assert_entry_git_state(
     tree: &Worktree,
     path: &str,
-    git_status: Option<GitFileStatus>,
+    worktree_status: Option<StatusCode>,
     is_ignored: bool,
 ) {
     let entry = tree.entry_for_path(path).expect("entry {path} not found");
+    let status = tree.status_for_file(Path::new(path));
+    let expected = worktree_status.map(|worktree_status| {
+        TrackedStatus {
+            worktree_status,
+            index_status: StatusCode::Unmodified,
+        }
+        .into()
+    });
     assert_eq!(
-        tree.status_for_file(Path::new(path)),
-        git_status,
-        "expected {path} to have git status: {git_status:?}"
+        status, expected,
+        "expected {path} to have git status: {expected:?}"
     );
     assert_eq!(
         entry.is_ignored, is_ignored,

script/zed-local 🔗

@@ -21,6 +21,7 @@ OPTIONS
   -2, -3, -4, ...   Spawn multiple Zed instances, with their windows tiled.
   --top             Arrange the Zed windows so they take up the top half of the screen.
   --stable          Use stable Zed release installed on local machine for all instances (except for the first one).
+  --preview         Like --stable, but uses the locally-installed preview release instead.
 `.trim();
 
 const { spawn, execSync, execFileSync } = require("child_process");
@@ -48,6 +49,7 @@ let instanceCount = 1;
 let isReleaseMode = false;
 let isTop = false;
 let othersOnStable = false;
+let othersOnPreview = false;
 let isStateful = false;
 
 const args = process.argv.slice(2);
@@ -68,6 +70,8 @@ while (args.length > 0) {
     process.exit(0);
   } else if (arg === "--stable") {
     othersOnStable = true;
+  } else if (arg === "--preview") {
+    othersOnPreview = true;
   } else {
     break;
   }
@@ -172,6 +176,8 @@ setTimeout(() => {
     let binaryPath = zedBinary;
     if (i != 0 && othersOnStable) {
       binaryPath = "/Applications/Zed.app/Contents/MacOS/zed";
+    } else if (i != 0 && othersOnPreview) {
+      binaryPath = "/Applications/Zed Preview.app/Contents/MacOS/zed";
     }
     spawn(binaryPath, i == 0 ? args : [], {
       stdio: "inherit",