Files
zed/crates/project/tests/integration/git_store.rs
T
Smit Barmase 3122f37ff1 git_ui_core: Fix worktree creation for bare repositories (#63351)
Follow-up https://github.com/zed-industries/zed/pull/55153
Fixes
https://github.com/zed-industries/zed/discussions/54553#discussioncomment-17091833

When a repository stores its Git data in `~/work/zed/.bare`, creating a
linked worktree from Zed is broken in two visible ways:

- Zed creates `random-code` at:
```
~/work/worktrees/.bare/random-code/.bare
```
instead of:
```
~/work/worktrees/zed/random-code/zed
```

- Because the checkout ends in `.bare`, Zed displays `.bare` as its
worktree name in the title bar, worktree picker, and agent thread
sidebar. Every worktree Zed creates from that repository gets the same
`.bare` label instead of its generated name.

This problem is specific to the path Zed creates. Worktrees using either
of these layouts are not malformed:
```
~/work/worktrees/zed/random-code/zed
~/work/zed/some-worktree
```

https://github.com/zed-industries/zed/pull/55153 introduced repository
identities for bare repositories. This PR uses that identity in the
remaining worktree naming and creation paths.

There are three repository layouts to account for:

- In a normal repository, the common Git directory is `~/work/zed/.git`,
so Zed derives the main worktree at `~/work/zed`. Existing behavior
remains unchanged.

- With `~/work/zed/.bare`, Git has no main worktree. A checkout named
main is still a linked worktree. Zed now treats `~/work/zed` as the
repository identity, creates new worktrees under `~/work/worktrees/zed`,
and displays their generated names.

- With a bare repository named `~/work/foo.git`, `foo.git` is the
repository identity. Its worktree path remains
`~/work/worktrees/foo.git/random-code/foo.git`. Zed uses foo.git to
derive the worktree name random-code, while displaying the repository
itself as foo.

Existing malformed worktrees such as
`~/work/worktrees/.bare/random-code/.bare` are not moved and may
continue to display `.bare`.

Broken:

<p>
<img width="238" height="58" alt="image"
src="https://github.com/user-attachments/assets/1504e4d0-c34a-471f-8e76-c5ec062b1d77"
/>
</p>
<p>
<img width="274" height="49" alt="image"
src="https://github.com/user-attachments/assets/13266ca9-4e64-426c-b03c-e23c924a3597"
/>
</p>
<p>
<img width="574" height="85" alt="image"
src="https://github.com/user-attachments/assets/0fca58c7-5be7-4d36-9067-066d0b80caf8"
/>
</p>

Release Notes:

- Fixed linked worktree names and creation paths for bare Git
repositories.
2026-08-28 12:15:55 +00:00

2542 lines
81 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)
})
.await;
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)
})
.await;
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)
})
.await;
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::from_unix_str("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::Unix).is_ok());
// Valid: subdirectory
assert!(
worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Unix).is_ok()
);
assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Unix).is_ok());
// Invalid: just ".." would resolve back to the working directory itself
let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Unix).unwrap_err();
assert!(err.to_string().contains("must not be \"..\""));
// Invalid: ".." with trailing separators
let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Unix).unwrap_err();
assert!(err.to_string().contains("must not be \"..\""));
let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Unix).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::Unix).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::Unix).unwrap_err();
assert!(err.to_string().contains("relative path"));
// Invalid: "/" is absolute on Unix
let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Unix).unwrap_err();
assert!(err.to_string().contains("relative path"));
// Invalid: "///" is absolute
let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Unix).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::Unix)
.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::Unix).unwrap();
assert_eq!(
directory,
PathBuf::from("/home/user/dev/worktrees/lsp-tests")
);
}
#[gpui::test]
async fn test_new_worktree_paths_use_bare_repository_identity(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/zed"),
json!({
".bare": {
"worktrees": {
"main": {
"commondir": "../..",
},
},
},
"main": {
".git": "gitdir: /zed/.bare/worktrees/main",
"file.txt": "content",
},
}),
)
.await;
let project = Project::test(fs, [path!("/zed/main").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 default_path = repository.read_with(cx, |repository, _| {
repository
.path_for_new_linked_worktree("plum-warbler", "../worktrees")
.unwrap()
});
let repository_relative_path = repository.read_with(cx, |repository, _| {
repository
.path_for_new_linked_worktree("plum-warbler", "worktrees")
.unwrap()
});
assert_eq!(
default_path,
PathBuf::from(path!("/worktrees/zed/plum-warbler/zed"))
);
assert_eq!(
repository_relative_path,
PathBuf::from(path!("/zed/worktrees/plum-warbler/zed"))
);
}
#[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);
}
#[gpui::test]
async fn test_remove_worktree_uses_bare_repository_identity_for_managed_parent_directories(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/zed"),
json!({
".bare": {
"worktrees": {
"main": {
"commondir": "../..",
},
},
},
"main": {
".git": "gitdir: /zed/.bare/worktrees/main",
"file.txt": "content",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/zed/main").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 = repository.read_with(cx, |repository, _| {
repository
.path_for_new_linked_worktree("feature/nested", "../worktrees")
.unwrap()
});
let worktree_parent = PathBuf::from(path!("/worktrees/zed/feature/nested"));
let worktree_intermediate_parent = PathBuf::from(path!("/worktrees/zed/feature"));
let worktree_base = PathBuf::from(path!("/worktrees/zed"));
assert_eq!(
worktree_path,
PathBuf::from(path!("/worktrees/zed/feature/nested/zed"))
);
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 path::PathStyle;
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), PathStyle::local()),
Path::new(expected),
"identity path for common_dir {common_dir:?} should be {expected:?}"
);
}
}
#[test]
fn test_windows_remote_identity_path() {
let examples = [
(r"C:\Users\zed\.git", r"C:\Users\zed"),
(r"C:\Users\project\.bare", r"C:\Users\project"),
(r"C:\Users\zed.git", r"C:\Users\zed.git"),
(r"C:\Users\zed", r"C:\Users\zed"),
];
for (common_dir, expected) in examples {
assert_eq!(
repo_identity_path(Path::new(common_dir), PathStyle::Windows),
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:?}"
);
}
}
}
mod repository_activation_tests {
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use fs::{FakeFs, Fs, RemoveOptions};
use gpui::{BorrowAppContext, Entity, TestAppContext};
use serde_json::json;
use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore};
use util::{
path,
rel_path::{RelPath, rel_path},
};
use crate::{Project, ProjectPath};
#[gpui::test]
async fn test_deep_repositories_park_in_unmanaged_root(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"repo1": {
".git": {},
"a.txt": ""
},
"nested": {
"deep": {
"repo2": {
".git": {},
"src": {
"b.txt": ""
}
}
}
}
}),
)
.await;
assert_repositories(
&project,
cx,
&[path!("/root/repo1")],
&[path!("/root/nested/deep/repo2")],
);
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(
worktree
.entry_for_path(rel_path("nested/deep/repo2/src/b.txt"))
.map(|entry| entry.path.as_ref()),
Some(rel_path("nested/deep/repo2/src/b.txt"))
);
});
}
#[gpui::test]
async fn test_managed_root_never_parks(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
".git": {},
"vendor": {
"x": {
".git": {},
"a.txt": ""
}
}
}),
)
.await;
assert_repositories(
&project,
cx,
&[path!("/root"), path!("/root/vendor/x")],
&[],
);
}
#[gpui::test]
async fn test_parked_repository_activates_on_buffer_open(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"nested": {
"outer": {
".git": {},
"inner": {
".git": {},
"a.txt": ""
},
"b.txt": ""
}
}
}),
)
.await;
assert_repositories(
&project,
cx,
&[],
&[
path!("/root/nested/outer"),
path!("/root/nested/outer/inner"),
],
);
let _buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/nested/outer/inner/a.txt"), cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
assert_repositories(
&project,
cx,
&[
path!("/root/nested/outer"),
path!("/root/nested/outer/inner"),
],
&[],
);
}
#[gpui::test]
async fn test_parked_repository_activates_on_save_as(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"a": {
"repo": {
".git": {},
"x.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[path!("/root/a/repo")]);
let buffer = project
.update(cx, |project, cx| project.create_buffer(None, false, cx))
.await
.unwrap();
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
project
.update(cx, |project, cx| {
project.save_buffer_as(
buffer,
ProjectPath {
worktree_id,
path: rel_path("a/repo/new.txt").into(),
},
cx,
)
})
.await
.unwrap();
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
}
#[gpui::test]
async fn test_buffer_open_beyond_scan_horizon_activates_repository(cx: &mut TestAppContext) {
init_test(cx);
set_file_scan_depth(cx, 2);
let (_fs, project) = build_project(
cx,
json!({
"a": {
"b": {
"repo": {
".git": {},
"src": {
"deep": {
"f.txt": ""
},
"g.txt": ""
},
"docs": {
"h.txt": ""
}
}
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[]);
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(worktree.entry_for_path(rel_path("a/b/repo")), None);
});
let _buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/a/b/repo/src/deep/f.txt"), cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/a/b/repo")], &[]);
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
for path in [
"a/b/repo/src/deep/f.txt",
"a/b/repo/src/g.txt",
"a/b/repo/docs/h.txt",
] {
assert_eq!(
worktree
.entry_for_path(rel_path(path))
.map(|entry| entry.path.as_ref()),
Some(rel_path(path))
);
}
});
}
#[gpui::test]
async fn test_project_settings_disable_parking(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"a": {
"repo": {
".git": {},
"x.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[path!("/root/a/repo")]);
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store
.set_local_settings(
worktree_id,
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
LocalSettingsKind::Settings,
Some(r#"{ "file_scan_depth": 0 }"#),
cx,
)
.unwrap();
});
});
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
}
#[gpui::test]
async fn test_open_buffer_prevents_parking_late_discovered_repository(cx: &mut TestAppContext) {
init_test(cx);
let (fs, project) = build_project(
cx,
json!({
"nested": {
"repo": {
"a.txt": ""
}
}
}),
)
.await;
let _buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/nested/repo/a.txt"), cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
fs.create_dir(Path::new(path!("/root/nested/repo/.git")))
.await
.unwrap();
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
}
#[gpui::test]
async fn test_parked_repository_dropped_when_dot_git_removed(cx: &mut TestAppContext) {
init_test(cx);
let (fs, project) = build_project(
cx,
json!({
"nested": {
"repo": {
".git": {},
"a.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
fs.remove_dir(
Path::new(path!("/root/nested/repo/.git")),
RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await
.unwrap();
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[], &[]);
}
#[gpui::test]
async fn test_repository_shared_between_two_worktrees(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"nested": {
"repo": {
".git": {},
"a.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
let worktree = project
.update(cx, |project, cx| {
project.worktree_store().update(cx, |worktree_store, cx| {
worktree_store.create_worktree(path!("/root/nested/repo"), true, cx)
})
})
.await
.unwrap();
worktree
.read_with(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
project.update(cx, |project, cx| {
project.git_store().update(cx, |git_store, cx| {
git_store.activate_all_parked_repositories(cx);
});
});
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
let second_worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
project.update(cx, |project, cx| {
project.worktree_store().update(cx, |worktree_store, cx| {
worktree_store.remove_worktree(second_worktree_id, cx);
})
});
drop(worktree);
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
}
#[gpui::test]
async fn test_nested_repository_inside_active_repository_not_parked(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"x": {
".git": {},
"vendor": {
"sub": {
".git": {},
"f.txt": ""
}
}
}
}),
)
.await;
assert_repositories(
&project,
cx,
&[path!("/root/x"), path!("/root/x/vendor/sub")],
&[],
);
}
#[gpui::test]
async fn test_active_repository_survives_file_scan_depth_tightening(cx: &mut TestAppContext) {
init_test(cx);
set_file_scan_depth(cx, 0);
let (_fs, project) = build_project(
cx,
json!({
"a": {
"repo": {
".git": {},
"x.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
set_file_scan_depth(cx, 1);
cx.executor().run_until_parked();
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(worktree.entry_for_path(rel_path("a/repo/x.txt")), None);
});
}
#[gpui::test]
async fn test_open_buffer_survives_file_scan_depth_tightening(cx: &mut TestAppContext) {
init_test(cx);
let (_fs, project) = build_project(
cx,
json!({
"junk": {
"a": {
"b": {
"deep.txt": "content",
"sibling.txt": ""
}
}
}
}),
)
.await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/junk/a/b/deep.txt"), cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
let disk_state_before =
buffer.read_with(cx, |buffer, _| buffer.file().unwrap().disk_state());
set_file_scan_depth(cx, 1);
cx.executor().run_until_parked();
buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.file().unwrap().disk_state(), disk_state_before);
});
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(
worktree
.entry_for_path(rel_path("junk/a/b/deep.txt"))
.map(|entry| entry.path.as_ref()),
Some(rel_path("junk/a/b/deep.txt"))
);
});
}
#[gpui::test]
async fn test_git_init_at_root_activates_parked_repositories(cx: &mut TestAppContext) {
init_test(cx);
let (fs, project) = build_project(
cx,
json!({
"nested": {
"repo": {
".git": {},
"a.txt": ""
}
}
}),
)
.await;
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
fs.create_dir(Path::new(path!("/root/.git"))).await.unwrap();
cx.executor().run_until_parked();
assert_repositories(
&project,
cx,
&[path!("/root"), path!("/root/nested/repo")],
&[],
);
}
fn init_test(cx: &mut TestAppContext) {
zlog::init_test();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
async fn build_project(
cx: &mut TestAppContext,
tree: serde_json::Value,
) -> (Arc<FakeFs>, Entity<Project>) {
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(path!("/root"), tree).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
cx.executor().run_until_parked();
(fs, project)
}
#[track_caller]
fn assert_repositories(
project: &Entity<Project>,
cx: &mut TestAppContext,
expected_active: &[&str],
expected_parked: &[&str],
) {
let (mut active, mut parked) = project.read_with(cx, |project, cx| {
(
project
.repositories(cx)
.values()
.map(|repository| repository.read(cx).work_directory_abs_path.to_path_buf())
.collect::<Vec<_>>(),
project
.git_store()
.read(cx)
.parked_repositories()
.iter()
.map(|parked| parked.work_directory_abs_path().to_path_buf())
.collect::<Vec<_>>(),
)
});
active.sort();
parked.sort();
assert_eq!(
active,
expected_active
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
);
assert_eq!(
parked,
expected_parked
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
);
}
fn set_file_scan_depth(cx: &mut TestAppContext, depth: u32) {
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_depth = Some(depth);
});
});
});
}
}