Split conflicts into their own section (#24324)

Conrad Irwin created

Co-Authored-By: Mikayla <mikayla@zed.dev>

Release Notes:

- N/A

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql            |  1 
crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql |  2 
crates/collab/src/db/queries/projects.rs                                  | 11 
crates/collab/src/db/queries/rooms.rs                                     |  8 
crates/collab/src/db/tables/worktree_repository.rs                        |  2 
crates/git/src/repository.rs                                              | 18 
crates/git/src/status.rs                                                  |  6 
crates/git_ui/src/git_panel.rs                                            | 68 
crates/git_ui/src/project_diff.rs                                         | 31 
crates/project/src/git.rs                                                 |  6 
crates/proto/proto/zed.proto                                              |  1 
crates/sum_tree/src/tree_map.rs                                           |  2 
crates/worktree/src/worktree.rs                                           | 57 
13 files changed, 171 insertions(+), 42 deletions(-)

Detailed changes

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

@@ -100,6 +100,7 @@ CREATE TABLE "worktree_repositories" (
     "branch" VARCHAR,
     "scan_id" INTEGER NOT NULL,
     "is_deleted" BOOL NOT NULL,
+    "current_merge_conflicts" VARCHAR,
     PRIMARY KEY(project_id, worktree_id, work_directory_id),
     FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE,
     FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE

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

@@ -333,6 +333,9 @@ impl Database {
                         scan_id: ActiveValue::set(update.scan_id as i64),
                         branch: ActiveValue::set(repository.branch.clone()),
                         is_deleted: ActiveValue::set(false),
+                        current_merge_conflicts: ActiveValue::Set(Some(
+                            serde_json::to_string(&repository.current_merge_conflicts).unwrap(),
+                        )),
                     },
                 ))
                 .on_conflict(
@@ -769,6 +772,13 @@ impl Database {
                         updated_statuses.push(db_status_to_proto(status_entry)?);
                     }
 
+                    let current_merge_conflicts = db_repository_entry
+                        .current_merge_conflicts
+                        .as_ref()
+                        .map(|conflicts| serde_json::from_str(&conflicts))
+                        .transpose()?
+                        .unwrap_or_default();
+
                     worktree.repository_entries.insert(
                         db_repository_entry.work_directory_id as u64,
                         proto::RepositoryEntry {
@@ -776,6 +786,7 @@ impl Database {
                             branch: db_repository_entry.branch,
                             updated_statuses,
                             removed_statuses: Vec::new(),
+                            current_merge_conflicts,
                         },
                     );
                 }

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

@@ -736,11 +736,19 @@ impl Database {
                             }
                         }
 
+                        let current_merge_conflicts = db_repository
+                            .current_merge_conflicts
+                            .as_ref()
+                            .map(|conflicts| serde_json::from_str(&conflicts))
+                            .transpose()?
+                            .unwrap_or_default();
+
                         worktree.updated_repositories.push(proto::RepositoryEntry {
                             work_directory_id: db_repository.work_directory_id as u64,
                             branch: db_repository.branch,
                             updated_statuses,
                             removed_statuses,
+                            current_merge_conflicts,
                         });
                     }
                 }

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

@@ -13,6 +13,8 @@ pub struct Model {
     pub scan_id: i64,
     pub branch: Option<String>,
     pub is_deleted: bool,
+    // JSON array typed string
+    pub current_merge_conflicts: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/git/src/repository.rs 🔗

@@ -46,6 +46,8 @@ pub trait GitRepository: Send + Sync {
     /// Returns the SHA of the current HEAD.
     fn head_sha(&self) -> Option<String>;
 
+    fn merge_head_shas(&self) -> Vec<String>;
+
     /// Returns the list of git statuses, sorted by path
     fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
 
@@ -162,6 +164,18 @@ impl GitRepository for RealGitRepository {
         Some(self.repository.lock().head().ok()?.target()?.to_string())
     }
 
+    fn merge_head_shas(&self) -> Vec<String> {
+        let mut shas = Vec::default();
+        self.repository
+            .lock()
+            .mergehead_foreach(|oid| {
+                shas.push(oid.to_string());
+                true
+            })
+            .ok();
+        shas
+    }
+
     fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
         let working_directory = self
             .repository
@@ -387,6 +401,10 @@ impl GitRepository for FakeGitRepository {
         None
     }
 
+    fn merge_head_shas(&self) -> Vec<String> {
+        vec![]
+    }
+
     fn dot_git_dir(&self) -> PathBuf {
         let state = self.state.lock();
         state.dot_git_dir.clone()

crates/git/src/status.rs 🔗

@@ -134,7 +134,11 @@ impl FileStatus {
     }
 
     pub fn has_changes(&self) -> bool {
-        self.is_modified() || self.is_created() || self.is_deleted() || self.is_untracked()
+        self.is_modified()
+            || self.is_created()
+            || self.is_deleted()
+            || self.is_untracked()
+            || self.is_conflicted()
     }
 
     pub fn is_modified(self) -> bool {

crates/git_ui/src/git_panel.rs 🔗

@@ -76,30 +76,29 @@ struct SerializedGitPanel {
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 enum Section {
+    Conflict,
     Tracked,
     New,
 }
 
-impl Section {
-    pub fn contains(&self, status: FileStatus) -> bool {
-        match self {
-            Section::Tracked => !status.is_created(),
-            Section::New => status.is_created(),
-        }
-    }
-}
-
 #[derive(Debug, PartialEq, Eq, Clone)]
 struct GitHeaderEntry {
     header: Section,
 }
 
 impl GitHeaderEntry {
-    pub fn contains(&self, status_entry: &GitStatusEntry) -> bool {
-        self.header.contains(status_entry.status)
+    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
+        let this = &self.header;
+        let status = status_entry.status;
+        match this {
+            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
+            Section::Tracked => !status.is_created(),
+            Section::New => status.is_created(),
+        }
     }
     pub fn title(&self) -> &'static str {
         match self.header {
+            Section::Conflict => "Conflicts",
             Section::Tracked => "Changed",
             Section::New => "New",
         }
@@ -160,6 +159,8 @@ pub struct GitPanel {
     commit_task: Task<Result<()>>,
     commit_pending: bool,
 
+    conflicted_staged_count: usize,
+    conflicted_count: usize,
     tracked_staged_count: usize,
     tracked_count: usize,
     new_staged_count: usize,
@@ -276,6 +277,8 @@ impl GitPanel {
                 commit_editor,
                 project,
                 workspace,
+                conflicted_count: 0,
+                conflicted_staged_count: 0,
                 tracked_staged_count: 0,
                 tracked_count: 0,
                 new_staged_count: 0,
@@ -577,12 +580,13 @@ impl GitPanel {
             }
             GitListEntry::Header(section) => {
                 let goal_staged_state = !self.header_state(section.header).selected();
+                let repository = active_repository.read(cx);
                 let entries = self
                     .entries
                     .iter()
                     .filter_map(|entry| entry.status_entry())
                     .filter(|status_entry| {
-                        section.contains(&status_entry)
+                        section.contains(&status_entry, repository)
                             && status_entry.is_staged != Some(goal_staged_state)
                     })
                     .map(|status_entry| status_entry.repo_path.clone())
@@ -601,7 +605,8 @@ impl GitPanel {
         });
         let repo_paths = repo_paths.clone();
         let active_repository = active_repository.clone();
-        self.update_counts();
+        let repository = active_repository.read(cx);
+        self.update_counts(repository);
         cx.notify();
 
         cx.spawn({
@@ -740,8 +745,7 @@ impl GitPanel {
             .iter()
             .filter_map(|entry| entry.status_entry())
             .filter(|status_entry| {
-                Section::Tracked.contains(status_entry.status)
-                    && !status_entry.is_staged.unwrap_or(false)
+                !status_entry.status.is_created() && !status_entry.is_staged.unwrap_or(false)
             })
             .map(|status_entry| status_entry.repo_path.clone())
             .collect::<Vec<_>>();
@@ -909,6 +913,7 @@ impl GitPanel {
         self.entries_by_path.clear();
         let mut changed_entries = Vec::new();
         let mut new_entries = Vec::new();
+        let mut conflict_entries = Vec::new();
 
         let Some(repo) = self.active_repository.as_ref() else {
             // Just clear entries if no repository is active.
@@ -925,6 +930,7 @@ impl GitPanel {
             let (depth, difference) =
                 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
 
+            let is_conflict = repo.has_conflict(&entry.repo_path);
             let is_new = entry.status.is_created();
             let is_staged = entry.status.is_staged();
 
@@ -955,7 +961,9 @@ impl GitPanel {
                 is_staged,
             };
 
-            if is_new {
+            if is_conflict {
+                conflict_entries.push(entry);
+            } else if is_new {
                 new_entries.push(entry);
             } else {
                 changed_entries.push(entry);
@@ -963,9 +971,21 @@ impl GitPanel {
         }
 
         // Sort entries by path to maintain consistent order
+        conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
         changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
         new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
 
+        if conflict_entries.len() > 0 {
+            self.entries.push(GitListEntry::Header(GitHeaderEntry {
+                header: Section::Conflict,
+            }));
+            self.entries.extend(
+                conflict_entries
+                    .into_iter()
+                    .map(GitListEntry::GitStatusEntry),
+            );
+        }
+
         if changed_entries.len() > 0 {
             self.entries.push(GitListEntry::Header(GitHeaderEntry {
                 header: Section::Tracked,
@@ -990,14 +1010,16 @@ impl GitPanel {
                     .insert(status_entry.repo_path.clone(), ix);
             }
         }
-        self.update_counts();
+        self.update_counts(repo);
 
         self.select_first_entry_if_none(cx);
 
         cx.notify();
     }
 
-    fn update_counts(&mut self) {
+    fn update_counts(&mut self, repo: &Repository) {
+        self.conflicted_count = 0;
+        self.conflicted_staged_count = 0;
         self.new_count = 0;
         self.tracked_count = 0;
         self.new_staged_count = 0;
@@ -1006,7 +1028,12 @@ impl GitPanel {
             let Some(status_entry) = entry.status_entry() else {
                 continue;
             };
-            if status_entry.status.is_created() {
+            if repo.has_conflict(&status_entry.repo_path) {
+                self.conflicted_count += 1;
+                if self.entry_appears_staged(status_entry) != Some(false) {
+                    self.conflicted_staged_count += 1;
+                }
+            } else if status_entry.status.is_created() {
                 self.new_count += 1;
                 if self.entry_is_staged(status_entry) != Some(false) {
                     self.new_staged_count += 1;
@@ -1041,6 +1068,7 @@ impl GitPanel {
         let (staged_count, count) = match header_type {
             Section::New => (self.new_staged_count, self.new_count),
             Section::Tracked => (self.tracked_staged_count, self.tracked_count),
+            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
         };
         if staged_count == 0 {
             ToggleState::Unselected
@@ -1467,7 +1495,7 @@ impl GitPanel {
             self.header_state(header.header)
         } else {
             match header.header {
-                Section::Tracked => ToggleState::Selected,
+                Section::Tracked | Section::Conflict => ToggleState::Selected,
                 Section::New => ToggleState::Unselected,
             }
         };

crates/git_ui/src/project_diff.rs 🔗

@@ -46,8 +46,9 @@ struct DiffBuffer {
     change_set: Entity<BufferChangeSet>,
 }
 
-const CHANGED_NAMESPACE: &'static str = "0";
-const ADDED_NAMESPACE: &'static str = "1";
+const CONFLICT_NAMESPACE: &'static str = "0";
+const TRACKED_NAMESPACE: &'static str = "1";
+const NEW_NAMESPACE: &'static str = "2";
 
 impl ProjectDiff {
     pub(crate) fn register(
@@ -174,19 +175,25 @@ impl ProjectDiff {
         let Some(git_repo) = self.git_state.read(cx).active_repository() else {
             return;
         };
+        let repo = git_repo.read(cx);
 
-        let Some(path) = git_repo
-            .read(cx)
+        let Some(abs_path) = repo
             .repo_path_to_project_path(&entry.repo_path)
             .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
         else {
             return;
         };
-        let path_key = if entry.status.is_created() {
-            PathKey::namespaced(ADDED_NAMESPACE, &path)
+
+        let namespace = if repo.has_conflict(&entry.repo_path) {
+            CONFLICT_NAMESPACE
+        } else if entry.status.is_created() {
+            NEW_NAMESPACE
         } else {
-            PathKey::namespaced(CHANGED_NAMESPACE, &path)
+            TRACKED_NAMESPACE
         };
+
+        let path_key = PathKey::namespaced(namespace, &abs_path);
+
         self.scroll_to_path(path_key, window, cx)
     }
 
@@ -259,12 +266,14 @@ impl ProjectDiff {
                 let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
                     continue;
                 };
-                // Craft some artificial paths so that created entries will appear last.
-                let path_key = if entry.status.is_created() {
-                    PathKey::namespaced(ADDED_NAMESPACE, &abs_path)
+                let namespace = if repo.has_conflict(&entry.repo_path) {
+                    CONFLICT_NAMESPACE
+                } else if entry.status.is_created() {
+                    NEW_NAMESPACE
                 } else {
-                    PathKey::namespaced(CHANGED_NAMESPACE, &abs_path)
+                    TRACKED_NAMESPACE
                 };
+                let path_key = PathKey::namespaced(namespace, &abs_path);
 
                 previous_paths.remove(&path_key);
                 let load_buffer = self

crates/project/src/git.rs 🔗

@@ -336,6 +336,12 @@ impl Repository {
         self.repository_entry.status()
     }
 
+    pub fn has_conflict(&self, path: &RepoPath) -> bool {
+        self.repository_entry
+            .current_merge_conflicts
+            .contains(&path)
+    }
+
     pub fn repo_path_to_project_path(&self, path: &RepoPath) -> Option<ProjectPath> {
         let path = self.repository_entry.unrelativize(path)?;
         Some((self.worktree_id, path).into())

crates/proto/proto/zed.proto 🔗

@@ -1800,6 +1800,7 @@ message RepositoryEntry {
     optional string branch = 2;
     repeated StatusEntry updated_statuses = 3;
     repeated string removed_statuses = 4;
+    repeated string current_merge_conflicts = 5;
 }
 
 message StatusEntry {

crates/sum_tree/src/tree_map.rs 🔗

@@ -32,7 +32,7 @@ impl<'a, K> Default for MapKeyRef<'a, K> {
     }
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct TreeSet<K>(TreeMap<K, ()>)
 where
     K: Clone + Ord;

crates/worktree/src/worktree.rs 🔗

@@ -178,7 +178,7 @@ pub struct Snapshot {
     completed_scan_id: usize,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct RepositoryEntry {
     /// The git status entries for this repository.
     /// Note that the paths on this repository are relative to the git work directory.
@@ -203,6 +203,7 @@ pub struct RepositoryEntry {
     work_directory_id: ProjectEntryId,
     pub work_directory: WorkDirectory,
     pub(crate) branch: Option<Arc<str>>,
+    pub current_merge_conflicts: TreeSet<RepoPath>,
 }
 
 impl Deref for RepositoryEntry {
@@ -256,6 +257,11 @@ impl RepositoryEntry {
                 .map(|entry| entry.to_proto())
                 .collect(),
             removed_statuses: Default::default(),
+            current_merge_conflicts: self
+                .current_merge_conflicts
+                .iter()
+                .map(|repo_path| repo_path.to_proto())
+                .collect(),
         }
     }
 
@@ -306,6 +312,11 @@ impl RepositoryEntry {
             branch: self.branch.as_ref().map(|branch| branch.to_string()),
             updated_statuses,
             removed_statuses,
+            current_merge_conflicts: self
+                .current_merge_conflicts
+                .iter()
+                .map(RepoPath::to_proto)
+                .collect(),
         }
     }
 }
@@ -456,6 +467,7 @@ struct BackgroundScannerState {
 
 #[derive(Debug, Clone)]
 pub struct LocalRepositoryEntry {
+    pub(crate) work_directory_id: ProjectEntryId,
     pub(crate) work_directory: WorkDirectory,
     pub(crate) git_dir_scan_id: usize,
     pub(crate) status_scan_id: usize,
@@ -465,6 +477,7 @@ pub struct LocalRepositoryEntry {
     pub(crate) dot_git_dir_abs_path: Arc<Path>,
     /// Absolute path to the .git file, if we're in a git worktree.
     pub(crate) dot_git_worktree_abs_path: Option<Arc<Path>>,
+    pub current_merge_head_shas: Vec<String>,
 }
 
 impl sum_tree::Item for LocalRepositoryEntry {
@@ -2520,6 +2533,13 @@ impl Snapshot {
         for repository in update.updated_repositories {
             let work_directory_id = ProjectEntryId::from_proto(repository.work_directory_id);
             if let Some(work_dir_entry) = self.entry_for_id(work_directory_id) {
+                let conflicted_paths = TreeSet::from_ordered_entries(
+                    repository
+                        .current_merge_conflicts
+                        .into_iter()
+                        .map(|path| RepoPath(Path::new(&path).into())),
+                );
+
                 if self
                     .repositories
                     .contains(&PathKey(work_dir_entry.path.clone()), &())
@@ -2539,6 +2559,7 @@ impl Snapshot {
                         .update(&PathKey(work_dir_entry.path.clone()), &(), |repo| {
                             repo.branch = repository.branch.map(Into::into);
                             repo.statuses_by_path.edit(edits, &());
+                            repo.current_merge_conflicts = conflicted_paths
                         });
                 } else {
                     let statuses = SumTree::from_iter(
@@ -2561,6 +2582,7 @@ impl Snapshot {
                             },
                             branch: repository.branch.map(Into::into),
                             statuses_by_path: statuses,
+                            current_merge_conflicts: conflicted_paths,
                         },
                         &(),
                     );
@@ -3363,17 +3385,20 @@ impl BackgroundScannerState {
                 work_directory: work_directory.clone(),
                 branch: repository.branch_name().map(Into::into),
                 statuses_by_path: Default::default(),
+                current_merge_conflicts: Default::default(),
             },
             &(),
         );
 
         let local_repository = LocalRepositoryEntry {
+            work_directory_id: work_dir_id,
             work_directory: work_directory.clone(),
             git_dir_scan_id: 0,
             status_scan_id: 0,
             repo_ptr: repository.clone(),
             dot_git_dir_abs_path: actual_dot_git_dir_abs_path,
             dot_git_worktree_abs_path,
+            current_merge_head_shas: Default::default(),
         };
 
         self.snapshot
@@ -5127,11 +5152,11 @@ impl BackgroundScanner {
                         .snapshot
                         .git_repositories
                         .iter()
-                        .find_map(|(entry_id, repo)| {
+                        .find_map(|(_, repo)| {
                             if repo.dot_git_dir_abs_path.as_ref() == &dot_git_dir
                                 || repo.dot_git_worktree_abs_path.as_deref() == Some(&dot_git_dir)
                             {
-                                Some((*entry_id, repo.clone()))
+                                Some(repo.clone())
                             } else {
                                 None
                             }
@@ -5148,13 +5173,13 @@ impl BackgroundScanner {
                             None => continue,
                         }
                     }
-                    Some((entry_id, local_repository)) => {
+                    Some(local_repository) => {
                         if local_repository.git_dir_scan_id == scan_id {
                             continue;
                         }
                         let Some(work_dir) = state
                             .snapshot
-                            .entry_for_id(entry_id)
+                            .entry_for_id(local_repository.work_directory_id)
                             .map(|entry| entry.path.clone())
                         else {
                             continue;
@@ -5163,10 +5188,13 @@ impl BackgroundScanner {
                         let branch = local_repository.repo_ptr.branch_name();
                         local_repository.repo_ptr.reload_index();
 
-                        state.snapshot.git_repositories.update(&entry_id, |entry| {
-                            entry.git_dir_scan_id = scan_id;
-                            entry.status_scan_id = scan_id;
-                        });
+                        state.snapshot.git_repositories.update(
+                            &local_repository.work_directory_id,
+                            |entry| {
+                                entry.git_dir_scan_id = scan_id;
+                                entry.status_scan_id = scan_id;
+                            },
+                        );
                         state.snapshot.snapshot.repositories.update(
                             &PathKey(work_dir.clone()),
                             &(),
@@ -5260,6 +5288,11 @@ impl BackgroundScanner {
                     return;
                 };
 
+                let merge_head_shas = local_repository.repo().merge_head_shas();
+                if merge_head_shas != local_repository.current_merge_head_shas {
+                    mem::take(&mut repository.current_merge_conflicts);
+                }
+
                 let mut new_entries_by_path = SumTree::new(&());
                 for (repo_path, status) in statuses.entries.iter() {
                     let project_path = repository.work_directory.unrelativize(repo_path);
@@ -5283,6 +5316,12 @@ impl BackgroundScanner {
                     .snapshot
                     .repositories
                     .insert_or_replace(repository, &());
+                state.snapshot.git_repositories.update(
+                    &local_repository.work_directory_id,
+                    |entry| {
+                        entry.current_merge_head_shas = merge_head_shas;
+                    },
+                );
 
                 util::extend_sorted(
                     &mut state.changed_paths,