Highlight merge conflicts and provide for resolving them (#28065)

TODO:

- [x] Make it work in the project diff:
  - [x] Support non-singleton buffers
  - [x] Adjust excerpt boundaries to show full conflicts
- [x] Write tests for conflict-related events and state management
- [x] Prevent hunk buttons from appearing inside conflicts
- [x] Make sure it works over SSH, collab
- [x] Allow separate theming of markers

Bonus:

- [ ] Count of conflicts in toolbar
- [ ] Keyboard-driven navigation and resolution
- [ ] ~~Inlay hints to contextualize "ours"/"theirs"~~

Release Notes:

- Implemented initial support for resolving merge conflicts.

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Cole Miller 2025-04-23 12:38:46 -04:00 committed by GitHub
parent ef54b58346
commit 724c935196
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1626 additions and 184 deletions

View file

@ -1,3 +1,4 @@
mod conflict_set;
pub mod git_traversal;
use crate::{
@ -10,11 +11,12 @@ use askpass::AskPassDelegate;
use buffer_diff::{BufferDiff, BufferDiffEvent};
use client::ProjectId;
use collections::HashMap;
pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate};
use fs::Fs;
use futures::{
FutureExt as _, StreamExt as _,
FutureExt, StreamExt as _,
channel::{mpsc, oneshot},
future::{self, Shared},
future::{self, Shared, try_join_all},
};
use git::{
BuildPermalinkParams, GitHostingProviderRegistry, WORK_DIRECTORY_REPO_PATH,
@ -74,7 +76,7 @@ pub struct GitStore {
#[allow(clippy::type_complexity)]
loading_diffs:
HashMap<(BufferId, DiffKind), Shared<Task<Result<Entity<BufferDiff>, Arc<anyhow::Error>>>>>,
diffs: HashMap<BufferId, Entity<BufferDiffState>>,
diffs: HashMap<BufferId, Entity<BufferGitState>>,
shared_diffs: HashMap<proto::PeerId, HashMap<BufferId, SharedDiffs>>,
_subscriptions: Vec<Subscription>,
}
@ -85,12 +87,15 @@ struct SharedDiffs {
uncommitted: Option<Entity<BufferDiff>>,
}
struct BufferDiffState {
struct BufferGitState {
unstaged_diff: Option<WeakEntity<BufferDiff>>,
uncommitted_diff: Option<WeakEntity<BufferDiff>>,
conflict_set: Option<WeakEntity<ConflictSet>>,
recalculate_diff_task: Option<Task<Result<()>>>,
reparse_conflict_markers_task: Option<Task<Result<()>>>,
language: Option<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
conflict_updated_futures: Vec<oneshot::Sender<()>>,
recalculating_tx: postage::watch::Sender<bool>,
/// These operation counts are used to ensure that head and index text
@ -224,17 +229,26 @@ impl sum_tree::KeyedItem for StatusEntry {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RepositoryId(pub u64);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MergeDetails {
pub conflicted_paths: TreeSet<RepoPath>,
pub message: Option<SharedString>,
pub apply_head: Option<CommitDetails>,
pub cherry_pick_head: Option<CommitDetails>,
pub merge_heads: Vec<CommitDetails>,
pub rebase_head: Option<CommitDetails>,
pub revert_head: Option<CommitDetails>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepositorySnapshot {
pub id: RepositoryId,
pub merge_message: Option<SharedString>,
pub statuses_by_path: SumTree<StatusEntry>,
pub work_directory_abs_path: Arc<Path>,
pub branch: Option<Branch>,
pub head_commit: Option<CommitDetails>,
pub merge_conflicts: TreeSet<RepoPath>,
pub merge_head_shas: Vec<SharedString>,
pub scan_id: u64,
pub merge: MergeDetails,
}
type JobId = u64;
@ -297,6 +311,7 @@ pub enum GitStoreEvent {
RepositoryRemoved(RepositoryId),
IndexWriteError(anyhow::Error),
JobsUpdated,
ConflictsUpdated,
}
impl EventEmitter<RepositoryEvent> for Repository {}
@ -681,10 +696,11 @@ impl GitStore {
let text_snapshot = buffer.text_snapshot();
this.loading_diffs.remove(&(buffer_id, kind));
let git_store = cx.weak_entity();
let diff_state = this
.diffs
.entry(buffer_id)
.or_insert_with(|| cx.new(|_| BufferDiffState::default()));
.or_insert_with(|| cx.new(|_| BufferGitState::new(git_store)));
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
@ -737,6 +753,62 @@ impl GitStore {
diff_state.read(cx).uncommitted_diff.as_ref()?.upgrade()
}
pub fn open_conflict_set(
&mut self,
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Entity<ConflictSet> {
log::debug!("open conflict set");
let buffer_id = buffer.read(cx).remote_id();
if let Some(git_state) = self.diffs.get(&buffer_id) {
if let Some(conflict_set) = git_state
.read(cx)
.conflict_set
.as_ref()
.and_then(|weak| weak.upgrade())
{
let conflict_set = conflict_set.clone();
let buffer_snapshot = buffer.read(cx).text_snapshot();
git_state.update(cx, |state, cx| {
let _ = state.reparse_conflict_markers(buffer_snapshot, cx);
});
return conflict_set;
}
}
let is_unmerged = self
.repository_and_path_for_buffer_id(buffer_id, cx)
.map_or(false, |(repo, path)| {
repo.read(cx)
.snapshot
.merge
.conflicted_paths
.contains(&path)
});
let git_store = cx.weak_entity();
let buffer_git_state = self
.diffs
.entry(buffer_id)
.or_insert_with(|| cx.new(|_| BufferGitState::new(git_store)));
let conflict_set = cx.new(|cx| ConflictSet::new(buffer_id, is_unmerged, cx));
self._subscriptions
.push(cx.subscribe(&conflict_set, |_, _, _, cx| {
cx.emit(GitStoreEvent::ConflictsUpdated);
}));
buffer_git_state.update(cx, |state, cx| {
state.conflict_set = Some(conflict_set.downgrade());
let buffer_snapshot = buffer.read(cx).text_snapshot();
let _ = state.reparse_conflict_markers(buffer_snapshot, cx);
});
conflict_set
}
pub fn project_path_git_status(
&self,
project_path: &ProjectPath,
@ -1079,6 +1151,35 @@ impl GitStore {
cx: &mut Context<Self>,
) {
let id = repo.read(cx).id;
let merge_conflicts = repo.read(cx).snapshot.merge.conflicted_paths.clone();
for (buffer_id, diff) in self.diffs.iter() {
if let Some((buffer_repo, repo_path)) =
self.repository_and_path_for_buffer_id(*buffer_id, cx)
{
if buffer_repo == repo {
diff.update(cx, |diff, cx| {
if let Some(conflict_set) = &diff.conflict_set {
let conflict_status_changed =
conflict_set.update(cx, |conflict_set, cx| {
let has_conflict = merge_conflicts.contains(&repo_path);
conflict_set.set_has_conflict(has_conflict, cx)
})?;
if conflict_status_changed {
let buffer_store = self.buffer_store.read(cx);
if let Some(buffer) = buffer_store.get(*buffer_id) {
let _ = diff.reparse_conflict_markers(
buffer.read(cx).text_snapshot(),
cx,
);
}
}
}
anyhow::Ok(())
})
.ok();
}
}
}
cx.emit(GitStoreEvent::RepositoryUpdated(
id,
event.clone(),
@ -1218,9 +1319,15 @@ impl GitStore {
if let Some(diff_state) = self.diffs.get_mut(&buffer.read(cx).remote_id()) {
let buffer = buffer.read(cx).text_snapshot();
diff_state.update(cx, |diff_state, cx| {
diff_state.recalculate_diffs(buffer, cx);
futures.extend(diff_state.wait_for_recalculation());
diff_state.recalculate_diffs(buffer.clone(), cx);
futures.extend(diff_state.wait_for_recalculation().map(FutureExt::boxed));
});
futures.push(diff_state.update(cx, |diff_state, cx| {
diff_state
.reparse_conflict_markers(buffer, cx)
.map(|_| {})
.boxed()
}));
}
}
async move {
@ -2094,13 +2201,86 @@ impl GitStore {
}
}
impl BufferDiffState {
impl BufferGitState {
fn new(_git_store: WeakEntity<GitStore>) -> Self {
Self {
unstaged_diff: Default::default(),
uncommitted_diff: Default::default(),
recalculate_diff_task: Default::default(),
language: Default::default(),
language_registry: Default::default(),
recalculating_tx: postage::watch::channel_with(false).0,
hunk_staging_operation_count: 0,
hunk_staging_operation_count_as_of_write: 0,
head_text: Default::default(),
index_text: Default::default(),
head_changed: Default::default(),
index_changed: Default::default(),
language_changed: Default::default(),
conflict_updated_futures: Default::default(),
conflict_set: Default::default(),
reparse_conflict_markers_task: Default::default(),
}
}
fn buffer_language_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
self.language = buffer.read(cx).language().cloned();
self.language_changed = true;
let _ = self.recalculate_diffs(buffer.read(cx).text_snapshot(), cx);
}
fn reparse_conflict_markers(
&mut self,
buffer: text::BufferSnapshot,
cx: &mut Context<Self>,
) -> oneshot::Receiver<()> {
let (tx, rx) = oneshot::channel();
let Some(conflict_set) = self
.conflict_set
.as_ref()
.and_then(|conflict_set| conflict_set.upgrade())
else {
return rx;
};
let old_snapshot = conflict_set.read_with(cx, |conflict_set, _| {
if conflict_set.has_conflict {
Some(conflict_set.snapshot())
} else {
None
}
});
if let Some(old_snapshot) = old_snapshot {
self.conflict_updated_futures.push(tx);
self.reparse_conflict_markers_task = Some(cx.spawn(async move |this, cx| {
let (snapshot, changed_range) = cx
.background_spawn(async move {
let new_snapshot = ConflictSet::parse(&buffer);
let changed_range = old_snapshot.compare(&new_snapshot, &buffer);
(new_snapshot, changed_range)
})
.await;
this.update(cx, |this, cx| {
if let Some(conflict_set) = &this.conflict_set {
conflict_set
.update(cx, |conflict_set, cx| {
conflict_set.set_snapshot(snapshot, changed_range, cx);
})
.ok();
}
let futures = std::mem::take(&mut this.conflict_updated_futures);
for tx in futures {
tx.send(()).ok();
}
})
}))
}
rx
}
fn unstaged_diff(&self) -> Option<Entity<BufferDiff>> {
self.unstaged_diff.as_ref().and_then(|set| set.upgrade())
}
@ -2335,26 +2515,6 @@ impl BufferDiffState {
}
}
impl Default for BufferDiffState {
fn default() -> Self {
Self {
unstaged_diff: Default::default(),
uncommitted_diff: Default::default(),
recalculate_diff_task: Default::default(),
language: Default::default(),
language_registry: Default::default(),
recalculating_tx: postage::watch::channel_with(false).0,
hunk_staging_operation_count: 0,
hunk_staging_operation_count_as_of_write: 0,
head_text: Default::default(),
index_text: Default::default(),
head_changed: Default::default(),
index_changed: Default::default(),
language_changed: Default::default(),
}
}
}
fn make_remote_delegate(
this: Entity<GitStore>,
project_id: u64,
@ -2397,14 +2557,12 @@ impl RepositorySnapshot {
fn empty(id: RepositoryId, work_directory_abs_path: Arc<Path>) -> Self {
Self {
id,
merge_message: None,
statuses_by_path: Default::default(),
work_directory_abs_path,
branch: None,
head_commit: None,
merge_conflicts: Default::default(),
merge_head_shas: Default::default(),
scan_id: 0,
merge: Default::default(),
}
}
@ -2419,7 +2577,8 @@ impl RepositorySnapshot {
.collect(),
removed_statuses: Default::default(),
current_merge_conflicts: self
.merge_conflicts
.merge
.conflicted_paths
.iter()
.map(|repo_path| repo_path.to_proto())
.collect(),
@ -2480,7 +2639,8 @@ impl RepositorySnapshot {
updated_statuses,
removed_statuses,
current_merge_conflicts: self
.merge_conflicts
.merge
.conflicted_paths
.iter()
.map(|path| path.as_ref().to_proto())
.collect(),
@ -2515,7 +2675,7 @@ impl RepositorySnapshot {
}
pub fn has_conflict(&self, repo_path: &RepoPath) -> bool {
self.merge_conflicts.contains(repo_path)
self.merge.conflicted_paths.contains(repo_path)
}
/// This is the name that will be displayed in the repository selector for this repository.
@ -2529,7 +2689,77 @@ impl RepositorySnapshot {
}
}
impl MergeDetails {
async fn load(
backend: &Arc<dyn GitRepository>,
status: &SumTree<StatusEntry>,
prev_snapshot: &RepositorySnapshot,
) -> Result<(MergeDetails, bool)> {
fn sha_eq<'a>(
l: impl IntoIterator<Item = &'a CommitDetails>,
r: impl IntoIterator<Item = &'a CommitDetails>,
) -> bool {
l.into_iter()
.map(|commit| &commit.sha)
.eq(r.into_iter().map(|commit| &commit.sha))
}
let merge_heads = try_join_all(
backend
.merge_head_shas()
.into_iter()
.map(|sha| backend.show(sha)),
)
.await?;
let cherry_pick_head = backend.show("CHERRY_PICK_HEAD".into()).await.ok();
let rebase_head = backend.show("REBASE_HEAD".into()).await.ok();
let revert_head = backend.show("REVERT_HEAD".into()).await.ok();
let apply_head = backend.show("APPLY_HEAD".into()).await.ok();
let message = backend.merge_message().await.map(SharedString::from);
let merge_heads_changed = !sha_eq(
merge_heads.as_slice(),
prev_snapshot.merge.merge_heads.as_slice(),
) || !sha_eq(
cherry_pick_head.as_ref(),
prev_snapshot.merge.cherry_pick_head.as_ref(),
) || !sha_eq(
apply_head.as_ref(),
prev_snapshot.merge.apply_head.as_ref(),
) || !sha_eq(
rebase_head.as_ref(),
prev_snapshot.merge.rebase_head.as_ref(),
) || !sha_eq(
revert_head.as_ref(),
prev_snapshot.merge.revert_head.as_ref(),
);
let conflicted_paths = if merge_heads_changed {
TreeSet::from_ordered_entries(
status
.iter()
.filter(|entry| entry.status.is_conflicted())
.map(|entry| entry.repo_path.clone()),
)
} else {
prev_snapshot.merge.conflicted_paths.clone()
};
let details = MergeDetails {
conflicted_paths,
message,
apply_head,
cherry_pick_head,
merge_heads,
rebase_head,
revert_head,
};
Ok((details, merge_heads_changed))
}
}
impl Repository {
pub fn snapshot(&self) -> RepositorySnapshot {
self.snapshot.clone()
}
fn local(
id: RepositoryId,
work_directory_abs_path: Arc<Path>,
@ -3731,7 +3961,7 @@ impl Repository {
.as_ref()
.map(proto_to_commit_details);
self.snapshot.merge_conflicts = conflicted_paths;
self.snapshot.merge.conflicted_paths = conflicted_paths;
let edits = update
.removed_statuses
@ -4321,16 +4551,6 @@ async fn compute_snapshot(
let branches = backend.branches().await?;
let branch = branches.into_iter().find(|branch| branch.is_head);
let statuses = backend.status(&[WORK_DIRECTORY_REPO_PATH.clone()]).await?;
let merge_message = backend
.merge_message()
.await
.and_then(|msg| Some(msg.lines().nth(0)?.to_owned().into()));
let merge_head_shas = backend
.merge_head_shas()
.into_iter()
.map(SharedString::from)
.collect();
let statuses_by_path = SumTree::from_iter(
statuses
.entries
@ -4341,47 +4561,36 @@ async fn compute_snapshot(
}),
&(),
);
let (merge_details, merge_heads_changed) =
MergeDetails::load(&backend, &statuses_by_path, &prev_snapshot).await?;
let merge_head_shas_changed = merge_head_shas != prev_snapshot.merge_head_shas;
if merge_head_shas_changed
if merge_heads_changed
|| branch != prev_snapshot.branch
|| statuses_by_path != prev_snapshot.statuses_by_path
{
events.push(RepositoryEvent::Updated { full_scan: true });
}
let mut current_merge_conflicts = TreeSet::default();
for (repo_path, status) in statuses.entries.iter() {
if status.is_conflicted() {
current_merge_conflicts.insert(repo_path.clone());
}
}
// Cache merge conflict paths so they don't change from staging/unstaging,
// until the merge heads change (at commit time, etc.).
let mut merge_conflicts = prev_snapshot.merge_conflicts.clone();
if merge_head_shas_changed {
merge_conflicts = current_merge_conflicts;
if merge_heads_changed {
events.push(RepositoryEvent::MergeHeadsChanged);
}
// Useful when branch is None in detached head state
let head_commit = match backend.head_sha() {
Some(head_sha) => backend.show(head_sha).await.ok(),
Some(head_sha) => backend.show(head_sha).await.log_err(),
None => None,
};
let snapshot = RepositorySnapshot {
id,
merge_message,
statuses_by_path,
work_directory_abs_path,
scan_id: prev_snapshot.scan_id + 1,
branch,
head_commit,
merge_conflicts,
merge_head_shas,
merge: merge_details,
};
Ok((snapshot, events))