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:
parent
ef54b58346
commit
724c935196
24 changed files with 1626 additions and 184 deletions
|
@ -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))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue