Files
zed/crates/project/tests/integration/git_store.rs
T
Kirill Bulatov 18be72fd68 Make file scanner less eager in non-git-tracked directory trees (#62583)
Fixes https://github.com/zed-industries/zed/issues/35780
Collab schema migration PR:
https://github.com/zed-industries/cloud/pull/3422
The corresponding database schema migration has been created in the
Cloud repo and applied to the production database.

Before, Zed scanned each and every entry in the tree down from the
directory it was opened in, except gitignored files and scan exclusions.
The approach is unchanged, if Zed detects it was open inside a git
repository: e.g. the directory open in Zed contains `.git` directory.

For the rest of the projects, 2 optimizations are made:

* Limit the depth of file scan traversal.
Now, `file_scan_depth` (default `5`) restricts Zed from traversing any
directory that has same number or more segments in its file path.


Such directories behave similar to gitignored directories: their
contents is not available in file finder, project search and project
panel, but can be lazily traversed when the directory is expanded (e.g.
project panel expands it or a nested file is open by path via terminal,
etc.)

To indicate that to the users, a status entry is shown firs time the
limitation is hit in the project:

<img width="858" height="133" alt="image"
src="https://github.com/user-attachments/assets/7da6cfbb-98b4-4cc3-bf2a-8902a9597a15"
/>

* During the scan, any git repositories that are not direct children of
the directory open in Zed (depth >= 2), are traversed and indexed
normally, but their git metadata is never fetched eagerly.

Only when Zed opens a buffer from that repo the git metadata is fetched
and applied.

All that combined now uses a way more moderate amount of CPU and RAM
when opening `~`:

<img width="1717" height="368" alt="Screenshot 2026-08-13 at 17 43 53"
src="https://github.com/user-attachments/assets/ec83e2a9-f7cc-452b-8eb7-af158284ca4e"
/>

File scan inclusions and exclusions are considered still for such
projects.
Set `file_scan_depth` to `0` to enable old behavior.
The setting is supported in the project settings, so custom values can
be set based on the project's structure.

---

Release Notes:

- Fixed Zed using a lot of memory and CPU in large, non-git-tracked,
directory trees
2026-08-13 17:07:06 +00:00

2394 lines
76 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_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:?}"
);
}
}
}
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);
});
});
});
}
}