mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-25 06:24:56 +00:00
Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #54641 Release Notes: - Fixed creating git worktrees in WSL remote projects from Windows. ## Summary - Preserve the repository `PathStyle` when constructing git worktree paths. - Avoid using local OS path separators when creating worktrees for remote Posix projects such as WSL. - Keep worktree archive path checks aligned with the same path-style-aware worktree directory calculation. ## Root Cause Remote repository paths are stored as `PathBuf`s, but `PathBuf::join` and related path operations use the client OS separator. On Windows clients connected to WSL, this could turn a remote Linux path into a mixed path like `/home/<user>/\home\<user>\dev\worktrees\...`, causing worktree creation and opening to fail. ## Validation - `cargo fmt --all --check` - `git diff --check` - `cargo test -p project test_new_worktree_path_uses_posix_style_for_remote_paths` - `cargo test -p project test_worktree_directory_uses_remote_path_style` - `cargo test -p project test_join_path_for_style_uses_remote_separator` --------- Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
1788 lines
59 KiB
Rust
1788 lines
59 KiB
Rust
mod conflict_set_tests {
|
|
use std::sync::mpsc;
|
|
|
|
use crate::Project;
|
|
|
|
use fs::FakeFs;
|
|
use git::{
|
|
repository::{RepoPath, repo_path},
|
|
status::{UnmergedStatus, UnmergedStatusCode},
|
|
};
|
|
use gpui::{BackgroundExecutor, TestAppContext};
|
|
use project::git_store::*;
|
|
use serde_json::json;
|
|
use text::{Buffer, BufferId, OffsetRangeExt, Point, ReplicaId, ToOffset as _};
|
|
use unindent::Unindent as _;
|
|
use util::{path, rel_path::rel_path};
|
|
|
|
#[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(ReplicaId::LOCAL, 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());
|
|
assert_eq!(first.ours_branch_name.as_ref(), "HEAD");
|
|
assert_eq!(first.theirs_branch_name.as_ref(), "branch-name");
|
|
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());
|
|
assert_eq!(second.ours_branch_name.as_ref(), "HEAD");
|
|
assert_eq!(second.theirs_branch_name.as_ref(), "branch-name");
|
|
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_next_offset(&buffer))
|
|
..buffer.anchor_before(second_conflict_start.to_previous_offset(&buffer));
|
|
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(ReplicaId::LOCAL, buffer_id, test_content);
|
|
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());
|
|
assert_eq!(conflict.ours_branch_name.as_ref(), "HEAD");
|
|
assert_eq!(conflict.theirs_branch_name.as_ref(), "branch-nested");
|
|
|
|
// 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_conflict_markers_at_eof() {
|
|
let test_content = r#"
|
|
<<<<<<< ours
|
|
=======
|
|
This is their version
|
|
>>>>>>> "#
|
|
.unindent();
|
|
let buffer_id = BufferId::new(1).unwrap();
|
|
let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
|
|
let snapshot = buffer.snapshot();
|
|
|
|
let conflict_snapshot = ConflictSet::parse(&snapshot);
|
|
assert_eq!(conflict_snapshot.conflicts.len(), 1);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
|
|
"ours"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
|
|
"Origin" // default branch name if there is none
|
|
);
|
|
}
|
|
|
|
#[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(ReplicaId::LOCAL, buffer_id, test_content.clone());
|
|
let snapshot = buffer.snapshot();
|
|
|
|
let conflict_snapshot = ConflictSet::parse(&snapshot);
|
|
assert_eq!(conflict_snapshot.conflicts.len(), 4);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
|
|
"HEAD1"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
|
|
"branch1"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[1].ours_branch_name.as_ref(),
|
|
"HEAD2"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[1].theirs_branch_name.as_ref(),
|
|
"branch2"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[2].ours_branch_name.as_ref(),
|
|
"HEAD3"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[2].theirs_branch_name.as_ref(),
|
|
"branch3"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[3].ours_branch_name.as_ref(),
|
|
"HEAD4"
|
|
);
|
|
assert_eq!(
|
|
conflict_snapshot.conflicts[3].theirs_branch_name.as_ref(),
|
|
"branch4"
|
|
);
|
|
|
|
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) {
|
|
zlog::init_test();
|
|
cx.update(|cx| {
|
|
settings::init(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.read_with(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(
|
|
repo_path("a.txt"),
|
|
UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
},
|
|
);
|
|
// Cause the repository to update cached conflicts
|
|
state.refs.insert("MERGE_HEAD".into(), "123".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.read_with(cx, |conflict_set, _| {
|
|
conflict_set.snapshot().conflicts[0].clone()
|
|
});
|
|
cx.update(|cx| {
|
|
conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), 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);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_conflict_updates_without_merge_head(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
zlog::init_test();
|
|
cx.update(|cx| {
|
|
settings::init(cx);
|
|
});
|
|
|
|
let initial_text = "
|
|
zero
|
|
<<<<<<< HEAD
|
|
one
|
|
=======
|
|
two
|
|
>>>>>>> Stashed Changes
|
|
three
|
|
"
|
|
.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),
|
|
)
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.unmerged_paths.insert(
|
|
RepoPath::from_rel_path(rel_path("a.txt")),
|
|
UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
},
|
|
)
|
|
})
|
|
.unwrap();
|
|
|
|
let buffer = buffer.await.unwrap();
|
|
|
|
// Open the conflict set for a file that currently has conflicts.
|
|
let conflict_set = git_store.update(cx, |git_store, cx| {
|
|
git_store.open_conflict_set(buffer.clone(), cx)
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
conflict_set.update(cx, |conflict_set, cx| {
|
|
let conflict_range = conflict_set.snapshot().conflicts[0]
|
|
.range
|
|
.to_point(buffer.read(cx));
|
|
assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
|
|
});
|
|
|
|
// Simulate the conflict being removed by e.g. staging the file.
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.unmerged_paths.remove(&repo_path("a.txt"))
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
conflict_set.update(cx, |conflict_set, _| {
|
|
assert!(!conflict_set.has_conflict);
|
|
assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
|
|
});
|
|
|
|
// Simulate the conflict being re-added.
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.unmerged_paths.insert(
|
|
repo_path("a.txt"),
|
|
UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
},
|
|
)
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
conflict_set.update(cx, |conflict_set, cx| {
|
|
let conflict_range = conflict_set.snapshot().conflicts[0]
|
|
.range
|
|
.to_point(buffer.read(cx));
|
|
assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_conflict_updates_with_delayed_merge_head_conflicts(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
zlog::init_test();
|
|
cx.update(|cx| {
|
|
settings::init(cx);
|
|
});
|
|
|
|
let initial_text = "
|
|
one
|
|
two
|
|
three
|
|
four
|
|
"
|
|
.unindent();
|
|
|
|
let conflicted_text = "
|
|
one
|
|
<<<<<<< HEAD
|
|
two
|
|
=======
|
|
TWO
|
|
>>>>>>> branch
|
|
three
|
|
four
|
|
"
|
|
.unindent();
|
|
|
|
let resolved_text = "
|
|
one
|
|
TWO
|
|
three
|
|
four
|
|
"
|
|
.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();
|
|
})
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
events_rx
|
|
.try_recv()
|
|
.expect_err("conflict set should start empty");
|
|
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.refs.insert("MERGE_HEAD".into(), "123".into())
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
events_rx
|
|
.try_recv()
|
|
.expect_err("merge head without conflicted paths should not publish conflicts");
|
|
conflict_set.update(cx, |conflict_set, _| {
|
|
assert!(!conflict_set.has_conflict);
|
|
assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
|
|
});
|
|
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.set_text(conflicted_text.clone(), cx);
|
|
});
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.unmerged_paths.insert(
|
|
repo_path("a.txt"),
|
|
UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
},
|
|
);
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
let update = events_rx
|
|
.try_recv()
|
|
.expect("conflicts should appear once conflicted paths are visible");
|
|
assert_eq!(update.old_range, 0..0);
|
|
assert_eq!(update.new_range, 0..1);
|
|
conflict_set.update(cx, |conflict_set, cx| {
|
|
assert!(conflict_set.has_conflict);
|
|
let conflict_range = conflict_set.snapshot().conflicts[0]
|
|
.range
|
|
.to_point(buffer.read(cx));
|
|
assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
|
|
});
|
|
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.set_text(resolved_text.clone(), cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
let update = events_rx
|
|
.try_recv()
|
|
.expect("resolved buffer text should clear visible conflict markers");
|
|
assert_eq!(update.old_range, 0..1);
|
|
assert_eq!(update.new_range, 0..0);
|
|
conflict_set.update(cx, |conflict_set, _| {
|
|
assert!(conflict_set.has_conflict);
|
|
assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
|
|
});
|
|
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.refs.insert("MERGE_HEAD".into(), "456".into());
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
events_rx.try_recv().expect_err(
|
|
"merge-head change without unmerged-path changes should not emit marker updates",
|
|
);
|
|
conflict_set.update(cx, |conflict_set, _| {
|
|
assert!(conflict_set.has_conflict);
|
|
assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
|
|
});
|
|
|
|
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
|
state.unmerged_paths.remove(&repo_path("a.txt"));
|
|
state.refs.remove("MERGE_HEAD");
|
|
})
|
|
.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
let update = events_rx.try_recv().expect(
|
|
"status catch-up should emit a no-op update when clearing stale conflict state",
|
|
);
|
|
assert_eq!(update.old_range, 0..0);
|
|
assert_eq!(update.new_range, 0..0);
|
|
assert!(update.buffer_range.is_none());
|
|
conflict_set.update(cx, |conflict_set, _| {
|
|
assert!(!conflict_set.has_conflict);
|
|
assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
|
|
});
|
|
}
|
|
}
|
|
|
|
mod git_traversal {
|
|
use std::{path::Path, time::Duration};
|
|
|
|
use collections::HashMap;
|
|
use project::{
|
|
Project,
|
|
git_store::{RepositoryId, RepositorySnapshot},
|
|
};
|
|
|
|
use fs::FakeFs;
|
|
use git::status::{
|
|
FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
|
|
};
|
|
use gpui::TestAppContext;
|
|
use project::GitTraversal;
|
|
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use util::{
|
|
path,
|
|
rel_path::{RelPath, rel_path},
|
|
};
|
|
|
|
const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
});
|
|
const ADDED: GitSummary = GitSummary {
|
|
index: TrackedSummary::ADDED,
|
|
count: 1,
|
|
..GitSummary::UNCHANGED
|
|
};
|
|
const MODIFIED: GitSummary = GitSummary {
|
|
index: TrackedSummary::MODIFIED,
|
|
count: 1,
|
|
..GitSummary::UNCHANGED
|
|
};
|
|
|
|
#[gpui::test]
|
|
async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"x": {
|
|
".git": {},
|
|
"x1.txt": "foo",
|
|
"x2.txt": "bar",
|
|
"y": {
|
|
".git": {},
|
|
"y1.txt": "baz",
|
|
"y2.txt": "qux"
|
|
},
|
|
"z.txt": "sneaky..."
|
|
},
|
|
"z": {
|
|
".git": {},
|
|
"z1.txt": "quux",
|
|
"z2.txt": "quuux"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/x/.git")),
|
|
&[
|
|
("x2.txt", StatusCode::Modified.index()),
|
|
("z.txt", StatusCode::Added.index()),
|
|
],
|
|
);
|
|
fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/z/.git")),
|
|
&[("z2.txt", StatusCode::Added.index())],
|
|
);
|
|
|
|
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
|
|
(
|
|
project.git_store().read(cx).repo_snapshots(cx),
|
|
project.worktrees(cx).next().unwrap().read(cx).snapshot(),
|
|
)
|
|
});
|
|
|
|
let traversal = GitTraversal::new(
|
|
&repo_snapshots,
|
|
worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()),
|
|
);
|
|
let entries = traversal
|
|
.map(|entry| (entry.path.clone(), entry.git_summary))
|
|
.collect::<Vec<_>>();
|
|
pretty_assertions::assert_eq!(
|
|
entries,
|
|
[
|
|
(rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
|
|
(rel_path("x/x2.txt").into(), MODIFIED),
|
|
(rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
|
|
(rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
|
|
(rel_path("x/z.txt").into(), ADDED),
|
|
(rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
|
|
(rel_path("z/z2.txt").into(), ADDED),
|
|
]
|
|
)
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"x": {
|
|
".git": {},
|
|
"x1.txt": "foo",
|
|
"x2.txt": "bar",
|
|
"y": {
|
|
".git": {},
|
|
"y1.txt": "baz",
|
|
"y2.txt": "qux"
|
|
},
|
|
"z.txt": "sneaky..."
|
|
},
|
|
"z": {
|
|
".git": {},
|
|
"z1.txt": "quux",
|
|
"z2.txt": "quuux"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/x/.git")),
|
|
&[
|
|
("x2.txt", StatusCode::Modified.index()),
|
|
("z.txt", StatusCode::Added.index()),
|
|
],
|
|
);
|
|
fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/z/.git")),
|
|
&[("z2.txt", StatusCode::Added.index())],
|
|
);
|
|
|
|
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
|
|
(
|
|
project.git_store().read(cx).repo_snapshots(cx),
|
|
project.worktrees(cx).next().unwrap().read(cx).snapshot(),
|
|
)
|
|
});
|
|
|
|
// Sanity check the propagation for x/y and z
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("x/y", GitSummary::CONFLICT),
|
|
("x/y/y1.txt", GitSummary::CONFLICT),
|
|
("x/y/y2.txt", GitSummary::UNCHANGED),
|
|
],
|
|
);
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("z", ADDED),
|
|
("z/z1.txt", GitSummary::UNCHANGED),
|
|
("z/z2.txt", ADDED),
|
|
],
|
|
);
|
|
|
|
// Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("x", MODIFIED + ADDED),
|
|
("x/y", GitSummary::CONFLICT),
|
|
("x/y/y1.txt", GitSummary::CONFLICT),
|
|
],
|
|
);
|
|
|
|
// Sanity check everything around it
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("x", MODIFIED + ADDED),
|
|
("x/x1.txt", GitSummary::UNCHANGED),
|
|
("x/x2.txt", MODIFIED),
|
|
("x/y", GitSummary::CONFLICT),
|
|
("x/y/y1.txt", GitSummary::CONFLICT),
|
|
("x/y/y2.txt", GitSummary::UNCHANGED),
|
|
("x/z.txt", ADDED),
|
|
],
|
|
);
|
|
|
|
// Test the other fundamental case, transitioning from git repository to non-git repository
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("", GitSummary::UNCHANGED),
|
|
("x", MODIFIED + ADDED),
|
|
("x/x1.txt", GitSummary::UNCHANGED),
|
|
],
|
|
);
|
|
|
|
// And all together now
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("", GitSummary::UNCHANGED),
|
|
("x", MODIFIED + ADDED),
|
|
("x/x1.txt", GitSummary::UNCHANGED),
|
|
("x/x2.txt", MODIFIED),
|
|
("x/y", GitSummary::CONFLICT),
|
|
("x/y/y1.txt", GitSummary::CONFLICT),
|
|
("x/y/y2.txt", GitSummary::UNCHANGED),
|
|
("x/z.txt", ADDED),
|
|
("z", ADDED),
|
|
("z/z1.txt", GitSummary::UNCHANGED),
|
|
("z/z2.txt", ADDED),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_traversal_simple(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"a": {
|
|
"b": {
|
|
"c1.txt": "",
|
|
"c2.txt": "",
|
|
},
|
|
"d": {
|
|
"e1.txt": "",
|
|
"e2.txt": "",
|
|
"e3.txt": "",
|
|
}
|
|
},
|
|
"f": {
|
|
"no-status.txt": ""
|
|
},
|
|
"g": {
|
|
"h1.txt": "",
|
|
"h2.txt": ""
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/.git")),
|
|
&[
|
|
("a/b/c1.txt", StatusCode::Added.index()),
|
|
("a/d/e2.txt", StatusCode::Modified.index()),
|
|
("g/h2.txt", CONFLICT),
|
|
],
|
|
);
|
|
|
|
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
|
|
(
|
|
project.git_store().read(cx).repo_snapshots(cx),
|
|
project.worktrees(cx).next().unwrap().read(cx).snapshot(),
|
|
)
|
|
});
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("", GitSummary::CONFLICT + MODIFIED + ADDED),
|
|
("g", GitSummary::CONFLICT),
|
|
("g/h2.txt", GitSummary::CONFLICT),
|
|
],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("", GitSummary::CONFLICT + ADDED + MODIFIED),
|
|
("a", ADDED + MODIFIED),
|
|
("a/b", ADDED),
|
|
("a/b/c1.txt", ADDED),
|
|
("a/b/c2.txt", GitSummary::UNCHANGED),
|
|
("a/d", MODIFIED),
|
|
("a/d/e2.txt", MODIFIED),
|
|
("f", GitSummary::UNCHANGED),
|
|
("f/no-status.txt", GitSummary::UNCHANGED),
|
|
("g", GitSummary::CONFLICT),
|
|
("g/h2.txt", GitSummary::CONFLICT),
|
|
],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("a/b", ADDED),
|
|
("a/b/c1.txt", ADDED),
|
|
("a/b/c2.txt", GitSummary::UNCHANGED),
|
|
("a/d", MODIFIED),
|
|
("a/d/e1.txt", GitSummary::UNCHANGED),
|
|
("a/d/e2.txt", MODIFIED),
|
|
("f", GitSummary::UNCHANGED),
|
|
("f/no-status.txt", GitSummary::UNCHANGED),
|
|
("g", GitSummary::CONFLICT),
|
|
],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("a/b/c1.txt", ADDED),
|
|
("a/b/c2.txt", GitSummary::UNCHANGED),
|
|
("a/d/e1.txt", GitSummary::UNCHANGED),
|
|
("a/d/e2.txt", MODIFIED),
|
|
("f/no-status.txt", GitSummary::UNCHANGED),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"x": {
|
|
".git": {},
|
|
"x1.txt": "foo",
|
|
"x2.txt": "bar"
|
|
},
|
|
"y": {
|
|
".git": {},
|
|
"y1.txt": "baz",
|
|
"y2.txt": "qux"
|
|
},
|
|
"z": {
|
|
".git": {},
|
|
"z1.txt": "quux",
|
|
"z2.txt": "quuux"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/x/.git")),
|
|
&[("x1.txt", StatusCode::Added.index())],
|
|
);
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/y/.git")),
|
|
&[
|
|
("y1.txt", CONFLICT),
|
|
("y2.txt", StatusCode::Modified.index()),
|
|
],
|
|
);
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/z/.git")),
|
|
&[("z2.txt", StatusCode::Modified.index())],
|
|
);
|
|
|
|
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
|
|
(
|
|
project.git_store().read(cx).repo_snapshots(cx),
|
|
project.worktrees(cx).next().unwrap().read(cx).snapshot(),
|
|
)
|
|
});
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[("x", ADDED), ("x/x1.txt", ADDED)],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("y", GitSummary::CONFLICT + MODIFIED),
|
|
("y/y1.txt", GitSummary::CONFLICT),
|
|
("y/y2.txt", MODIFIED),
|
|
],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[("x", ADDED), ("x/x1.txt", ADDED)],
|
|
);
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("x", ADDED),
|
|
("x/x1.txt", ADDED),
|
|
("x/x2.txt", GitSummary::UNCHANGED),
|
|
("y", GitSummary::CONFLICT + MODIFIED),
|
|
("y/y1.txt", GitSummary::CONFLICT),
|
|
("y/y2.txt", MODIFIED),
|
|
("z", MODIFIED),
|
|
("z/z1.txt", GitSummary::UNCHANGED),
|
|
("z/z2.txt", MODIFIED),
|
|
],
|
|
);
|
|
}
|
|
|
|
fn init_test(cx: &mut gpui::TestAppContext) {
|
|
zlog::init_test();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
// Create a worktree with a git directory.
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"a.txt": "",
|
|
"b": {
|
|
"c.txt": "",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
fs.set_head_and_index_for_repo(
|
|
path!("/root/.git").as_ref(),
|
|
&[("a.txt", "".into()), ("b/c.txt", "".into())],
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
|
|
let tree = project.worktrees(cx).next().unwrap().read(cx);
|
|
(
|
|
tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
|
|
tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
|
|
)
|
|
});
|
|
|
|
// Regression test: after the directory is scanned, touch the git repo's
|
|
// working directory, bumping its mtime. That directory keeps its project
|
|
// entry id after the directories are re-scanned.
|
|
fs.touch_path(path!("/root")).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
|
|
let tree = project.worktrees(cx).next().unwrap().read(cx);
|
|
(
|
|
tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
|
|
tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
|
|
)
|
|
});
|
|
assert_eq!(new_entry_ids, old_entry_ids);
|
|
assert_ne!(new_mtimes, old_mtimes);
|
|
|
|
// Regression test: changes to the git repository should still be
|
|
// detected.
|
|
fs.set_head_for_repo(
|
|
path!("/root/.git").as_ref(),
|
|
&[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
|
|
"deadbeef",
|
|
);
|
|
cx.executor().run_until_parked();
|
|
cx.executor().advance_clock(Duration::from_secs(1));
|
|
|
|
let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
|
|
(
|
|
project.git_store().read(cx).repo_snapshots(cx),
|
|
project.worktrees(cx).next().unwrap().read(cx).snapshot(),
|
|
)
|
|
});
|
|
|
|
check_git_statuses(
|
|
&repo_snapshots,
|
|
&worktree_snapshot,
|
|
&[
|
|
("", MODIFIED),
|
|
("a.txt", GitSummary::UNCHANGED),
|
|
("b/c.txt", MODIFIED),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[track_caller]
|
|
fn check_git_statuses(
|
|
repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
|
|
worktree_snapshot: &worktree::Snapshot,
|
|
expected_statuses: &[(&str, GitSummary)],
|
|
) {
|
|
let mut traversal = GitTraversal::new(
|
|
repo_snapshots,
|
|
worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
|
|
);
|
|
let found_statuses = expected_statuses
|
|
.iter()
|
|
.map(|&(path, _)| {
|
|
let git_entry = traversal
|
|
.find(|git_entry| git_entry.path.as_ref() == rel_path(path))
|
|
.unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
|
|
(path, git_entry.git_summary)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
pretty_assertions::assert_eq!(found_statuses, expected_statuses);
|
|
}
|
|
}
|
|
|
|
mod git_worktrees {
|
|
use fs::{FakeFs, Fs};
|
|
use gpui::TestAppContext;
|
|
use project::worktrees_directory_for_repo;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::path::{Path, PathBuf};
|
|
use util::{path, paths::PathStyle};
|
|
|
|
fn init_test(cx: &mut gpui::TestAppContext) {
|
|
zlog::init_test();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_worktree_directory() {
|
|
let work_dir = Path::new("/code/my-project");
|
|
|
|
// Valid: sibling
|
|
assert!(worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).is_ok());
|
|
|
|
// Valid: subdirectory
|
|
assert!(
|
|
worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Posix).is_ok()
|
|
);
|
|
assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Posix).is_ok());
|
|
|
|
// Invalid: just ".." would resolve back to the working directory itself
|
|
let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("must not be \"..\""));
|
|
|
|
// Invalid: ".." with trailing separators
|
|
let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("must not be \"..\""));
|
|
let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("must not be \"..\""));
|
|
|
|
// Invalid: empty string would resolve to the working directory itself
|
|
let err = worktrees_directory_for_repo(work_dir, "", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("must not be empty"));
|
|
|
|
// Invalid: absolute path
|
|
let err =
|
|
worktrees_directory_for_repo(work_dir, "/tmp/worktrees", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("relative path"));
|
|
|
|
// Invalid: "/" is absolute on Unix
|
|
let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("relative path"));
|
|
|
|
// Invalid: "///" is absolute
|
|
let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Posix).unwrap_err();
|
|
assert!(err.to_string().contains("relative path"));
|
|
|
|
// Invalid: escapes too far up
|
|
let err =
|
|
worktrees_directory_for_repo(work_dir, "../../other-project/wt", PathStyle::Posix)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("outside"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_worktree_directory_uses_remote_path_style() {
|
|
let work_dir = Path::new("/home/user/dev/lsp-tests");
|
|
|
|
let directory =
|
|
worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap();
|
|
|
|
assert_eq!(
|
|
directory,
|
|
PathBuf::from("/home/user/dev/worktrees/lsp-tests")
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project.repositories(cx).values().next().unwrap().clone()
|
|
});
|
|
|
|
let worktrees = cx
|
|
.update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(worktrees.len(), 1);
|
|
assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
|
|
|
|
let worktrees_directory = PathBuf::from(path!("/root"));
|
|
let worktree_1_directory = worktrees_directory.join("feature-branch");
|
|
cx.update(|cx| {
|
|
repository.update(cx, |repository, _| {
|
|
repository.create_worktree(
|
|
git::repository::CreateWorktreeTarget::NewBranch {
|
|
branch_name: "feature-branch".to_string(),
|
|
base_sha: Some("abc123".to_string()),
|
|
},
|
|
worktree_1_directory.clone(),
|
|
)
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let worktrees = cx
|
|
.update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(worktrees.len(), 2);
|
|
assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
|
|
assert_eq!(worktrees[1].path, worktree_1_directory);
|
|
assert_eq!(
|
|
worktrees[1].ref_name,
|
|
Some("refs/heads/feature-branch".into())
|
|
);
|
|
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
|
|
|
|
let worktree_2_directory = worktrees_directory.join("bugfix-branch");
|
|
cx.update(|cx| {
|
|
repository.update(cx, |repository, _| {
|
|
repository.create_worktree(
|
|
git::repository::CreateWorktreeTarget::NewBranch {
|
|
branch_name: "bugfix-branch".to_string(),
|
|
base_sha: None,
|
|
},
|
|
worktree_2_directory.clone(),
|
|
)
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
// List worktrees — should now have main + two created
|
|
let worktrees = cx
|
|
.update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(worktrees.len(), 3);
|
|
|
|
let worktree_1 = worktrees
|
|
.iter()
|
|
.find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
|
|
.expect("should find feature-branch worktree");
|
|
assert_eq!(worktree_1.path, worktree_1_directory);
|
|
|
|
let worktree_2 = worktrees
|
|
.iter()
|
|
.find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
|
|
.expect("should find bugfix-branch worktree");
|
|
assert_eq!(worktree_2.path, worktree_2_directory);
|
|
assert_eq!(worktree_2.sha.as_ref(), "fake-sha");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remove_worktree_removes_managed_parent_directories(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project.repositories(cx).values().next().unwrap().clone()
|
|
});
|
|
|
|
let worktree_path = PathBuf::from(path!("/worktrees/root/feature/nested/root"));
|
|
let worktree_parent = PathBuf::from(path!("/worktrees/root/feature/nested"));
|
|
let worktree_intermediate_parent = PathBuf::from(path!("/worktrees/root/feature"));
|
|
let worktree_base = PathBuf::from(path!("/worktrees/root"));
|
|
|
|
cx.update(|cx| {
|
|
repository.update(cx, |repository, _| {
|
|
repository.create_worktree(
|
|
git::repository::CreateWorktreeTarget::NewBranch {
|
|
branch_name: "feature/nested".to_string(),
|
|
base_sha: Some("abc123".to_string()),
|
|
},
|
|
worktree_path.clone(),
|
|
)
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert!(Fs::is_dir(fs.as_ref(), &worktree_path).await);
|
|
assert!(Fs::is_dir(fs.as_ref(), &worktree_parent).await);
|
|
assert!(Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
|
|
assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
|
|
|
|
cx.update(|cx| {
|
|
repository.update(cx, |repository, _| {
|
|
repository.remove_worktree(worktree_path.clone(), false)
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
assert!(!Fs::is_dir(fs.as_ref(), &worktree_path).await);
|
|
assert!(!Fs::is_dir(fs.as_ref(), &worktree_parent).await);
|
|
assert!(!Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
|
|
assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
|
|
}
|
|
|
|
use crate::Project;
|
|
}
|
|
|
|
mod trust_tests {
|
|
use collections::HashSet;
|
|
use fs::FakeFs;
|
|
use gpui::TestAppContext;
|
|
use project::trusted_worktrees::*;
|
|
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use util::path;
|
|
|
|
use crate::Project;
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
zlog::init_test();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
".git": {},
|
|
"a.txt": "hello",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Create project without trust system — repos should default to untrusted.
|
|
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project.repositories(cx).values().next().unwrap().clone()
|
|
});
|
|
|
|
repository.read_with(cx, |repo, _| {
|
|
assert!(
|
|
!repo.is_trusted(),
|
|
"repository should default to untrusted when no trust system is initialized"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
".git": {},
|
|
"a.txt": "hello",
|
|
"sub": {
|
|
".git": {},
|
|
"b.txt": "world",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
init(DbTrustedPaths::default(), cx);
|
|
});
|
|
|
|
let project =
|
|
Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
|
|
let worktree_id = worktree_store.read_with(cx, |store, cx| {
|
|
store.worktrees().next().unwrap().read(cx).id()
|
|
});
|
|
|
|
let repos = project.read_with(cx, |project, cx| {
|
|
project
|
|
.repositories(cx)
|
|
.values()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
});
|
|
assert_eq!(repos.len(), 2, "should have two repositories");
|
|
for repo in &repos {
|
|
repo.read_with(cx, |repo, _| {
|
|
assert!(
|
|
!repo.is_trusted(),
|
|
"all repos should be untrusted initially"
|
|
);
|
|
});
|
|
}
|
|
|
|
let trusted_worktrees = cx
|
|
.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
|
|
trusted_worktrees.update(cx, |store, cx| {
|
|
store.trust(
|
|
&worktree_store,
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
cx.executor().run_until_parked();
|
|
|
|
for repo in &repos {
|
|
repo.read_with(cx, |repo, _| {
|
|
assert!(
|
|
repo.is_trusted(),
|
|
"all repos should be trusted after worktree is trusted"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
".git": {},
|
|
"a.txt": "hello",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
project::trusted_worktrees::init(DbTrustedPaths::default(), cx);
|
|
});
|
|
|
|
let project =
|
|
Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
|
|
let worktree_id = worktree_store.read_with(cx, |store, cx| {
|
|
store.worktrees().next().unwrap().read(cx).id()
|
|
});
|
|
|
|
let repository = project.read_with(cx, |project, cx| {
|
|
project.repositories(cx).values().next().unwrap().clone()
|
|
});
|
|
|
|
repository.read_with(cx, |repo, _| {
|
|
assert!(!repo.is_trusted(), "repository should start untrusted");
|
|
});
|
|
|
|
let trusted_worktrees = cx
|
|
.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
|
|
|
|
trusted_worktrees.update(cx, |store, cx| {
|
|
store.trust(
|
|
&worktree_store,
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
cx.executor().run_until_parked();
|
|
|
|
repository.read_with(cx, |repo, _| {
|
|
assert!(
|
|
repo.is_trusted(),
|
|
"repository should be trusted after worktree is trusted"
|
|
);
|
|
});
|
|
|
|
trusted_worktrees.update(cx, |store, cx| {
|
|
store.restrict(
|
|
worktree_store.downgrade(),
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
cx.executor().run_until_parked();
|
|
|
|
repository.read_with(cx, |repo, _| {
|
|
assert!(
|
|
!repo.is_trusted(),
|
|
"repository should be untrusted after worktree is restricted"
|
|
);
|
|
});
|
|
|
|
trusted_worktrees.update(cx, |store, cx| {
|
|
store.trust(
|
|
&worktree_store,
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
cx.executor().run_until_parked();
|
|
|
|
repository.read_with(cx, |repo, _| {
|
|
assert!(
|
|
repo.is_trusted(),
|
|
"repository should be trusted again after second trust"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
mod resolve_worktree_tests {
|
|
use fs::FakeFs;
|
|
use gpui::TestAppContext;
|
|
use project::{
|
|
git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name,
|
|
repo_identity_path,
|
|
};
|
|
use serde_json::json;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
// Set up a main repo with a worktree entry
|
|
fs.insert_tree(
|
|
"/main-repo",
|
|
json!({
|
|
".git": {
|
|
"worktrees": {
|
|
"feature": {
|
|
"commondir": "../../",
|
|
"HEAD": "ref: refs/heads/feature"
|
|
}
|
|
}
|
|
},
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
// Set up a worktree checkout pointing back to the main repo
|
|
fs.insert_tree(
|
|
"/worktree-checkout",
|
|
json!({
|
|
".git": "gitdir: /main-repo/.git/worktrees/feature",
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let result =
|
|
resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await;
|
|
assert_eq!(result, Some(PathBuf::from("/main-repo")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/repo",
|
|
json!({
|
|
".git": {},
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await;
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_git_worktree_bare_repo_identity_path(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/monty/.bare",
|
|
json!({
|
|
"worktrees": {
|
|
"feature-a": {
|
|
"commondir": "../../",
|
|
"HEAD": "ref: refs/heads/feature-a"
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
fs.insert_tree(
|
|
"/monty/feature-a",
|
|
json!({
|
|
".git": "gitdir: /monty/.bare/worktrees/feature-a",
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let result =
|
|
resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/monty/feature-a")).await;
|
|
assert_eq!(result, Some(PathBuf::from("/monty")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/plain",
|
|
json!({
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await;
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
|
|
let result =
|
|
resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await;
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_repo_identity_path() {
|
|
let examples = [
|
|
// Normal checkout: `.git` starts with `.`, so parent is the worktree
|
|
("/home/bob/zed/.git", "/home/bob/zed"),
|
|
// Bare clone named `.bare`: starts with `.`, so parent is the project dir
|
|
("/repos/project/.bare", "/repos/project"),
|
|
// Bare clone with `.git` extension: does not start with `.`, kept as-is
|
|
("/repos/zed.git", "/repos/zed.git"),
|
|
// Bare clone with arbitrary plain name: kept as-is
|
|
("/repos/project", "/repos/project"),
|
|
];
|
|
for (common_dir, expected) in examples {
|
|
assert_eq!(
|
|
repo_identity_path(Path::new(common_dir)),
|
|
Path::new(expected),
|
|
"identity path for common_dir {common_dir:?} should be {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_linked_worktree_short_name() {
|
|
let examples = [
|
|
(
|
|
"/home/bob/zed",
|
|
"/home/bob/worktrees/olivetti/zed",
|
|
Some("olivetti".into()),
|
|
),
|
|
("/home/bob/zed", "/home/bob/zed2", Some("zed2".into())),
|
|
(
|
|
"/home/bob/zed",
|
|
"/home/bob/worktrees/zed/selectric",
|
|
Some("selectric".into()),
|
|
),
|
|
("/home/bob/zed", "/home/bob/zed", None),
|
|
];
|
|
for (main_worktree_path, linked_worktree_path, expected) in examples {
|
|
let short_name = linked_worktree_short_name(
|
|
Path::new(main_worktree_path),
|
|
Path::new(linked_worktree_path),
|
|
);
|
|
assert_eq!(
|
|
short_name, expected,
|
|
"short name for {linked_worktree_path:?}, linked worktree of {main_worktree_path:?}, should be {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|