diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index dbd63cf679..ba6fdeb929 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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 diff --git a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql b/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql new file mode 100644 index 0000000000..e6e0770bba --- /dev/null +++ b/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql @@ -0,0 +1,2 @@ +ALTER TABLE worktree_repositories +ADD COLUMN current_merge_conflicts VARCHAR NULL; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index fd83cd3da8..2755f12230 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/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, }, ); } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 4a46e79fa2..8c9089dd75 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/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, }); } } diff --git a/crates/collab/src/db/tables/worktree_repository.rs b/crates/collab/src/db/tables/worktree_repository.rs index 6f86ff0c2d..66ff7b7643 100644 --- a/crates/collab/src/db/tables/worktree_repository.rs +++ b/crates/collab/src/db/tables/worktree_repository.rs @@ -13,6 +13,8 @@ pub struct Model { pub scan_id: i64, pub branch: Option, pub is_deleted: bool, + // JSON array typed string + pub current_merge_conflicts: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 50191ea683..58dc9a9dce 100644 --- a/crates/git/src/repository.rs +++ b/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; + fn merge_head_shas(&self) -> Vec; + /// Returns the list of git statuses, sorted by path fn status(&self, path_prefixes: &[RepoPath]) -> Result; @@ -162,6 +164,18 @@ impl GitRepository for RealGitRepository { Some(self.repository.lock().head().ok()?.target()?.to_string()) } + fn merge_head_shas(&self) -> Vec { + 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 { let working_directory = self .repository @@ -387,6 +401,10 @@ impl GitRepository for FakeGitRepository { None } + fn merge_head_shas(&self) -> Vec { + vec![] + } + fn dot_git_dir(&self) -> PathBuf { let state = self.state.lock(); state.dot_git_dir.clone() diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index a882a5a14b..5cd7310b23 100644 --- a/crates/git/src/status.rs +++ b/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 { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 1af585bafa..d47e066f33 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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>, 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::>(); @@ -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, } }; diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 1581d0fc8a..5d2689ed4c 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -46,8 +46,9 @@ struct DiffBuffer { change_set: Entity, } -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 diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index f4ab1791a7..fad10f1ba4 100644 --- a/crates/project/src/git.rs +++ b/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 { let path = self.repository_entry.unrelativize(path)?; Some((self.worktree_id, path).into()) diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 195294fe68..fd10f0d113 100644 --- a/crates/proto/proto/zed.proto +++ b/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 { diff --git a/crates/sum_tree/src/tree_map.rs b/crates/sum_tree/src/tree_map.rs index 9a4d952e93..09274c37c2 100644 --- a/crates/sum_tree/src/tree_map.rs +++ b/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(TreeMap) where K: Clone + Ord; diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 3033a6e9fa..8c3bd04657 100644 --- a/crates/worktree/src/worktree.rs +++ b/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>, + pub current_merge_conflicts: TreeSet, } 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, /// Absolute path to the .git file, if we're in a git worktree. pub(crate) dot_git_worktree_abs_path: Option>, + pub current_merge_head_shas: Vec, } 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,