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
560
crates/project/src/git_store/conflict_set.rs
Normal file
560
crates/project/src/git_store/conflict_set.rs
Normal file
|
@ -0,0 +1,560 @@
|
|||
use gpui::{App, Context, Entity, EventEmitter};
|
||||
use std::{cmp::Ordering, ops::Range, sync::Arc};
|
||||
use text::{Anchor, BufferId, OffsetRangeExt as _};
|
||||
|
||||
pub struct ConflictSet {
|
||||
pub has_conflict: bool,
|
||||
pub snapshot: ConflictSetSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConflictSetUpdate {
|
||||
pub buffer_range: Option<Range<Anchor>>,
|
||||
pub old_range: Range<usize>,
|
||||
pub new_range: Range<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConflictSetSnapshot {
|
||||
pub buffer_id: BufferId,
|
||||
pub conflicts: Arc<[ConflictRegion]>,
|
||||
}
|
||||
|
||||
impl ConflictSetSnapshot {
|
||||
pub fn conflicts_in_range(
|
||||
&self,
|
||||
range: Range<Anchor>,
|
||||
buffer: &text::BufferSnapshot,
|
||||
) -> &[ConflictRegion] {
|
||||
let start_ix = self
|
||||
.conflicts
|
||||
.binary_search_by(|conflict| {
|
||||
conflict
|
||||
.range
|
||||
.end
|
||||
.cmp(&range.start, buffer)
|
||||
.then(Ordering::Greater)
|
||||
})
|
||||
.unwrap_err();
|
||||
let end_ix = start_ix
|
||||
+ self.conflicts[start_ix..]
|
||||
.binary_search_by(|conflict| {
|
||||
conflict
|
||||
.range
|
||||
.start
|
||||
.cmp(&range.end, buffer)
|
||||
.then(Ordering::Less)
|
||||
})
|
||||
.unwrap_err();
|
||||
&self.conflicts[start_ix..end_ix]
|
||||
}
|
||||
|
||||
pub fn compare(&self, other: &Self, buffer: &text::BufferSnapshot) -> ConflictSetUpdate {
|
||||
let common_prefix_len = self
|
||||
.conflicts
|
||||
.iter()
|
||||
.zip(other.conflicts.iter())
|
||||
.take_while(|(old, new)| old == new)
|
||||
.count();
|
||||
let common_suffix_len = self.conflicts[common_prefix_len..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(other.conflicts[common_prefix_len..].iter().rev())
|
||||
.take_while(|(old, new)| old == new)
|
||||
.count();
|
||||
let old_conflicts =
|
||||
&self.conflicts[common_prefix_len..(self.conflicts.len() - common_suffix_len)];
|
||||
let new_conflicts =
|
||||
&other.conflicts[common_prefix_len..(other.conflicts.len() - common_suffix_len)];
|
||||
let old_range = common_prefix_len..(common_prefix_len + old_conflicts.len());
|
||||
let new_range = common_prefix_len..(common_prefix_len + new_conflicts.len());
|
||||
let start = match (old_conflicts.first(), new_conflicts.first()) {
|
||||
(None, None) => None,
|
||||
(None, Some(conflict)) => Some(conflict.range.start),
|
||||
(Some(conflict), None) => Some(conflict.range.start),
|
||||
(Some(first), Some(second)) => Some(first.range.start.min(&second.range.start, buffer)),
|
||||
};
|
||||
let end = match (old_conflicts.last(), new_conflicts.last()) {
|
||||
(None, None) => None,
|
||||
(None, Some(conflict)) => Some(conflict.range.end),
|
||||
(Some(first), None) => Some(first.range.end),
|
||||
(Some(first), Some(second)) => Some(first.range.end.max(&second.range.end, buffer)),
|
||||
};
|
||||
ConflictSetUpdate {
|
||||
buffer_range: start.zip(end).map(|(start, end)| start..end),
|
||||
old_range,
|
||||
new_range,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictRegion {
|
||||
pub range: Range<Anchor>,
|
||||
pub ours: Range<Anchor>,
|
||||
pub theirs: Range<Anchor>,
|
||||
pub base: Option<Range<Anchor>>,
|
||||
}
|
||||
|
||||
impl ConflictRegion {
|
||||
pub fn resolve(
|
||||
&self,
|
||||
buffer: Entity<language::Buffer>,
|
||||
ranges: &[Range<Anchor>],
|
||||
cx: &mut App,
|
||||
) {
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let mut deletions = Vec::new();
|
||||
let empty = "";
|
||||
let outer_range = self.range.to_offset(&buffer_snapshot);
|
||||
let mut offset = outer_range.start;
|
||||
for kept_range in ranges {
|
||||
let kept_range = kept_range.to_offset(&buffer_snapshot);
|
||||
if kept_range.start > offset {
|
||||
deletions.push((offset..kept_range.start, empty));
|
||||
}
|
||||
offset = kept_range.end;
|
||||
}
|
||||
if outer_range.end > offset {
|
||||
deletions.push((offset..outer_range.end, empty));
|
||||
}
|
||||
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(deletions, None, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictSet {
|
||||
pub fn new(buffer_id: BufferId, has_conflict: bool, _: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
has_conflict,
|
||||
snapshot: ConflictSetSnapshot {
|
||||
buffer_id,
|
||||
conflicts: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_has_conflict(&mut self, has_conflict: bool, cx: &mut Context<Self>) -> bool {
|
||||
if has_conflict != self.has_conflict {
|
||||
self.has_conflict = has_conflict;
|
||||
if !self.has_conflict {
|
||||
cx.emit(ConflictSetUpdate {
|
||||
buffer_range: None,
|
||||
old_range: 0..self.snapshot.conflicts.len(),
|
||||
new_range: 0..0,
|
||||
});
|
||||
self.snapshot.conflicts = Default::default();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> ConflictSetSnapshot {
|
||||
self.snapshot.clone()
|
||||
}
|
||||
|
||||
pub fn set_snapshot(
|
||||
&mut self,
|
||||
snapshot: ConflictSetSnapshot,
|
||||
update: ConflictSetUpdate,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.snapshot = snapshot;
|
||||
cx.emit(update);
|
||||
}
|
||||
|
||||
pub fn parse(buffer: &text::BufferSnapshot) -> ConflictSetSnapshot {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let mut line_pos = 0;
|
||||
let mut lines = buffer.text_for_range(0..buffer.len()).lines();
|
||||
|
||||
let mut conflict_start: Option<usize> = None;
|
||||
let mut ours_start: Option<usize> = None;
|
||||
let mut ours_end: Option<usize> = None;
|
||||
let mut base_start: Option<usize> = None;
|
||||
let mut base_end: Option<usize> = None;
|
||||
let mut theirs_start: Option<usize> = None;
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
let line_end = line_pos + line.len();
|
||||
|
||||
if line.starts_with("<<<<<<< ") {
|
||||
// If we see a new conflict marker while already parsing one,
|
||||
// abandon the previous one and start a new one
|
||||
conflict_start = Some(line_pos);
|
||||
ours_start = Some(line_end + 1);
|
||||
} else if line.starts_with("||||||| ")
|
||||
&& conflict_start.is_some()
|
||||
&& ours_start.is_some()
|
||||
{
|
||||
ours_end = Some(line_pos);
|
||||
base_start = Some(line_end + 1);
|
||||
} else if line.starts_with("=======")
|
||||
&& conflict_start.is_some()
|
||||
&& ours_start.is_some()
|
||||
{
|
||||
// Set ours_end if not already set (would be set if we have base markers)
|
||||
if ours_end.is_none() {
|
||||
ours_end = Some(line_pos);
|
||||
} else if base_start.is_some() {
|
||||
base_end = Some(line_pos);
|
||||
}
|
||||
theirs_start = Some(line_end + 1);
|
||||
} else if line.starts_with(">>>>>>> ")
|
||||
&& conflict_start.is_some()
|
||||
&& ours_start.is_some()
|
||||
&& ours_end.is_some()
|
||||
&& theirs_start.is_some()
|
||||
{
|
||||
let theirs_end = line_pos;
|
||||
let conflict_end = line_end + 1;
|
||||
|
||||
let range = buffer.anchor_after(conflict_start.unwrap())
|
||||
..buffer.anchor_before(conflict_end);
|
||||
let ours = buffer.anchor_after(ours_start.unwrap())
|
||||
..buffer.anchor_before(ours_end.unwrap());
|
||||
let theirs =
|
||||
buffer.anchor_after(theirs_start.unwrap())..buffer.anchor_before(theirs_end);
|
||||
|
||||
let base = base_start
|
||||
.zip(base_end)
|
||||
.map(|(start, end)| buffer.anchor_after(start)..buffer.anchor_before(end));
|
||||
|
||||
conflicts.push(ConflictRegion {
|
||||
range,
|
||||
ours,
|
||||
theirs,
|
||||
base,
|
||||
});
|
||||
|
||||
conflict_start = None;
|
||||
ours_start = None;
|
||||
ours_end = None;
|
||||
base_start = None;
|
||||
base_end = None;
|
||||
theirs_start = None;
|
||||
}
|
||||
|
||||
line_pos = line_end + 1;
|
||||
}
|
||||
|
||||
ConflictSetSnapshot {
|
||||
conflicts: conflicts.into(),
|
||||
buffer_id: buffer.remote_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ConflictSetUpdate> for ConflictSet {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::mpsc;
|
||||
|
||||
use crate::{Project, project_settings::ProjectSettings};
|
||||
|
||||
use super::*;
|
||||
use fs::FakeFs;
|
||||
use git::status::{UnmergedStatus, UnmergedStatusCode};
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use language::language_settings::AllLanguageSettings;
|
||||
use serde_json::json;
|
||||
use settings::Settings as _;
|
||||
use text::{Buffer, BufferId, ToOffset as _};
|
||||
use unindent::Unindent as _;
|
||||
use util::path;
|
||||
use worktree::WorktreeSettings;
|
||||
|
||||
#[test]
|
||||
fn test_parse_conflicts_in_buffer() {
|
||||
// Create a buffer with conflict markers
|
||||
let test_content = r#"
|
||||
This is some text before the conflict.
|
||||
<<<<<<< HEAD
|
||||
This is our version
|
||||
=======
|
||||
This is their version
|
||||
>>>>>>> branch-name
|
||||
|
||||
Another conflict:
|
||||
<<<<<<< HEAD
|
||||
Our second change
|
||||
||||||| merged common ancestors
|
||||
Original content
|
||||
=======
|
||||
Their second change
|
||||
>>>>>>> branch-name
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
let buffer_id = BufferId::new(1).unwrap();
|
||||
let buffer = Buffer::new(0, buffer_id, test_content);
|
||||
let snapshot = buffer.snapshot();
|
||||
|
||||
let conflict_snapshot = ConflictSet::parse(&snapshot);
|
||||
assert_eq!(conflict_snapshot.conflicts.len(), 2);
|
||||
|
||||
let first = &conflict_snapshot.conflicts[0];
|
||||
assert!(first.base.is_none());
|
||||
let our_text = snapshot
|
||||
.text_for_range(first.ours.clone())
|
||||
.collect::<String>();
|
||||
let their_text = snapshot
|
||||
.text_for_range(first.theirs.clone())
|
||||
.collect::<String>();
|
||||
assert_eq!(our_text, "This is our version\n");
|
||||
assert_eq!(their_text, "This is their version\n");
|
||||
|
||||
let second = &conflict_snapshot.conflicts[1];
|
||||
assert!(second.base.is_some());
|
||||
let our_text = snapshot
|
||||
.text_for_range(second.ours.clone())
|
||||
.collect::<String>();
|
||||
let their_text = snapshot
|
||||
.text_for_range(second.theirs.clone())
|
||||
.collect::<String>();
|
||||
let base_text = snapshot
|
||||
.text_for_range(second.base.as_ref().unwrap().clone())
|
||||
.collect::<String>();
|
||||
assert_eq!(our_text, "Our second change\n");
|
||||
assert_eq!(their_text, "Their second change\n");
|
||||
assert_eq!(base_text, "Original content\n");
|
||||
|
||||
// Test conflicts_in_range
|
||||
let range = snapshot.anchor_before(0)..snapshot.anchor_before(snapshot.len());
|
||||
let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
|
||||
assert_eq!(conflicts_in_range.len(), 2);
|
||||
|
||||
// Test with a range that includes only the first conflict
|
||||
let first_conflict_end = conflict_snapshot.conflicts[0].range.end;
|
||||
let range = snapshot.anchor_before(0)..first_conflict_end;
|
||||
let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
|
||||
assert_eq!(conflicts_in_range.len(), 1);
|
||||
|
||||
// Test with a range that includes only the second conflict
|
||||
let second_conflict_start = conflict_snapshot.conflicts[1].range.start;
|
||||
let range = second_conflict_start..snapshot.anchor_before(snapshot.len());
|
||||
let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
|
||||
assert_eq!(conflicts_in_range.len(), 1);
|
||||
|
||||
// Test with a range that doesn't include any conflicts
|
||||
let range = buffer.anchor_after(first_conflict_end.to_offset(&buffer) + 1)
|
||||
..buffer.anchor_before(second_conflict_start.to_offset(&buffer) - 1);
|
||||
let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
|
||||
assert_eq!(conflicts_in_range.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_conflict_markers() {
|
||||
// Create a buffer with nested conflict markers
|
||||
let test_content = r#"
|
||||
This is some text before the conflict.
|
||||
<<<<<<< HEAD
|
||||
This is our version
|
||||
<<<<<<< HEAD
|
||||
This is a nested conflict marker
|
||||
=======
|
||||
This is their version in a nested conflict
|
||||
>>>>>>> branch-nested
|
||||
=======
|
||||
This is their version
|
||||
>>>>>>> branch-name
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
let buffer_id = BufferId::new(1).unwrap();
|
||||
let buffer = Buffer::new(0, buffer_id, test_content.to_string());
|
||||
let snapshot = buffer.snapshot();
|
||||
|
||||
let conflict_snapshot = ConflictSet::parse(&snapshot);
|
||||
|
||||
assert_eq!(conflict_snapshot.conflicts.len(), 1);
|
||||
|
||||
// The conflict should have our version, their version, but no base
|
||||
let conflict = &conflict_snapshot.conflicts[0];
|
||||
assert!(conflict.base.is_none());
|
||||
|
||||
// Check that the nested conflict was detected correctly
|
||||
let our_text = snapshot
|
||||
.text_for_range(conflict.ours.clone())
|
||||
.collect::<String>();
|
||||
assert_eq!(our_text, "This is a nested conflict marker\n");
|
||||
let their_text = snapshot
|
||||
.text_for_range(conflict.theirs.clone())
|
||||
.collect::<String>();
|
||||
assert_eq!(their_text, "This is their version in a nested conflict\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflicts_in_range() {
|
||||
// Create a buffer with conflict markers
|
||||
let test_content = r#"
|
||||
one
|
||||
<<<<<<< HEAD1
|
||||
two
|
||||
=======
|
||||
three
|
||||
>>>>>>> branch1
|
||||
four
|
||||
five
|
||||
<<<<<<< HEAD2
|
||||
six
|
||||
=======
|
||||
seven
|
||||
>>>>>>> branch2
|
||||
eight
|
||||
nine
|
||||
<<<<<<< HEAD3
|
||||
ten
|
||||
=======
|
||||
eleven
|
||||
>>>>>>> branch3
|
||||
twelve
|
||||
<<<<<<< HEAD4
|
||||
thirteen
|
||||
=======
|
||||
fourteen
|
||||
>>>>>>> branch4
|
||||
fifteen
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
let buffer_id = BufferId::new(1).unwrap();
|
||||
let buffer = Buffer::new(0, buffer_id, test_content.clone());
|
||||
let snapshot = buffer.snapshot();
|
||||
|
||||
let conflict_snapshot = ConflictSet::parse(&snapshot);
|
||||
assert_eq!(conflict_snapshot.conflicts.len(), 4);
|
||||
|
||||
let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap();
|
||||
let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
|
||||
assert_eq!(
|
||||
conflict_snapshot.conflicts_in_range(range, &snapshot),
|
||||
&conflict_snapshot.conflicts[1..=2]
|
||||
);
|
||||
|
||||
let range = test_content.find("one").unwrap()..test_content.find("<<<<<<< HEAD2").unwrap();
|
||||
let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
|
||||
assert_eq!(
|
||||
conflict_snapshot.conflicts_in_range(range, &snapshot),
|
||||
&conflict_snapshot.conflicts[0..=1]
|
||||
);
|
||||
|
||||
let range =
|
||||
test_content.find("eight").unwrap() - 1..test_content.find(">>>>>>> branch3").unwrap();
|
||||
let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
|
||||
assert_eq!(
|
||||
conflict_snapshot.conflicts_in_range(range, &snapshot),
|
||||
&conflict_snapshot.conflicts[1..=2]
|
||||
);
|
||||
|
||||
let range = test_content.find("thirteen").unwrap() - 1..test_content.len();
|
||||
let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
|
||||
assert_eq!(
|
||||
conflict_snapshot.conflicts_in_range(range, &snapshot),
|
||||
&conflict_snapshot.conflicts[3..=3]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
env_logger::try_init().ok();
|
||||
cx.update(|cx| {
|
||||
settings::init(cx);
|
||||
WorktreeSettings::register(cx);
|
||||
ProjectSettings::register(cx);
|
||||
AllLanguageSettings::register(cx);
|
||||
});
|
||||
let initial_text = "
|
||||
one
|
||||
two
|
||||
three
|
||||
four
|
||||
five
|
||||
"
|
||||
.unindent();
|
||||
let fs = FakeFs::new(executor);
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".git": {},
|
||||
"a.txt": initial_text,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||
let (git_store, buffer) = project.update(cx, |project, cx| {
|
||||
(
|
||||
project.git_store().clone(),
|
||||
project.open_local_buffer(path!("/project/a.txt"), cx),
|
||||
)
|
||||
});
|
||||
let buffer = buffer.await.unwrap();
|
||||
let conflict_set = git_store.update(cx, |git_store, cx| {
|
||||
git_store.open_conflict_set(buffer.clone(), cx)
|
||||
});
|
||||
let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
|
||||
let _conflict_set_subscription = cx.update(|cx| {
|
||||
cx.subscribe(&conflict_set, move |_, event, _| {
|
||||
events_tx.send(event.clone()).ok();
|
||||
})
|
||||
});
|
||||
let conflicts_snapshot = conflict_set.update(cx, |conflict_set, _| conflict_set.snapshot());
|
||||
assert!(conflicts_snapshot.conflicts.is_empty());
|
||||
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
[
|
||||
(4..4, "<<<<<<< HEAD\n"),
|
||||
(14..14, "=======\nTWO\n>>>>>>> branch\n"),
|
||||
],
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
events_rx.try_recv().expect_err(
|
||||
"no conflicts should be registered as long as the file's status is unchanged",
|
||||
);
|
||||
|
||||
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
||||
state.unmerged_paths.insert(
|
||||
"a.txt".into(),
|
||||
UnmergedStatus {
|
||||
first_head: UnmergedStatusCode::Updated,
|
||||
second_head: UnmergedStatusCode::Updated,
|
||||
},
|
||||
);
|
||||
// Cause the repository to emit MergeHeadsChanged.
|
||||
state.merge_head_shas = vec!["abc".into(), "def".into()]
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
let update = events_rx
|
||||
.try_recv()
|
||||
.expect("status change should trigger conflict parsing");
|
||||
assert_eq!(update.old_range, 0..0);
|
||||
assert_eq!(update.new_range, 0..1);
|
||||
|
||||
let conflict = conflict_set.update(cx, |conflict_set, _| {
|
||||
conflict_set.snapshot().conflicts[0].clone()
|
||||
});
|
||||
cx.update(|cx| {
|
||||
conflict.resolve(buffer.clone(), &[conflict.theirs.clone()], cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
let update = events_rx
|
||||
.try_recv()
|
||||
.expect("conflicts should be removed after resolution");
|
||||
assert_eq!(update.old_range, 0..1);
|
||||
assert_eq!(update.new_range, 0..0);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue