Migrate most callers of git-related worktree APIs to use the GitStore (#27225)

This is a pure refactoring PR that goes through all the git-related APIs
exposed by the worktree crate and minimizes their use outside that
crate, migrating callers of those APIs to read from the GitStore
instead. This is to prepare for evacuating git repository state from
worktrees and making the GitStore the new source of truth.

Other drive-by changes:

- `project::git` is now `project::git_store`, for consistency with the
other project stores
- the project panel's test module has been split into its own file

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Cole Miller 2025-03-21 00:10:17 -04:00 committed by GitHub
parent 9134630841
commit cf7d639fbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 6480 additions and 6429 deletions

View file

@ -1,15 +1,12 @@
use crate::{
worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot,
WorkDirectory, Worktree, WorktreeModelHandle,
worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, WorkDirectory,
Worktree, WorktreeModelHandle,
};
use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::{
repository::RepoPath,
status::{
FileStatus, GitSummary, StatusCode, TrackedStatus, TrackedSummary, UnmergedStatus,
UnmergedStatusCode,
},
status::{FileStatus, StatusCode, TrackedStatus},
GITIGNORE,
};
use git2::RepositoryInitOptions;
@ -27,7 +24,6 @@ use std::{
mem,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use util::{path, test::TempTree, ResultExt};
@ -1472,86 +1468,6 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
);
}
#[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(), "".into()), ("b/c.txt".into(), "".into())],
);
cx.run_until_parked();
let tree = Worktree::local(
path!("/root").as_ref(),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
cx.executor().run_until_parked();
let (old_entry_ids, old_mtimes) = tree.read_with(cx, |tree, _| {
(
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) = tree.read_with(cx, |tree, _| {
(
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(), "".into()),
("b/c.txt".into(), "something-else".into()),
],
);
cx.executor().run_until_parked();
cx.executor().advance_clock(Duration::from_secs(1));
let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
check_git_statuses(
&snapshot,
&[
(Path::new(""), MODIFIED),
(Path::new("a.txt"), GitSummary::UNCHANGED),
(Path::new("b/c.txt"), MODIFIED),
],
);
}
#[gpui::test]
async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
init_test(cx);
@ -2196,11 +2112,6 @@ fn random_filename(rng: &mut impl Rng) -> String {
.collect()
}
const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
});
// NOTE:
// This test always fails on Windows, because on Windows, unlike on Unix, you can't rename
// a directory which some program has already open.
@ -2244,7 +2155,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
cx.read(|cx| {
let tree = tree.read(cx);
let repo = tree.repositories().iter().next().unwrap();
let repo = tree.repositories.iter().next().unwrap();
assert_eq!(
repo.work_directory,
WorkDirectory::in_project("projects/project1")
@ -2268,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
cx.read(|cx| {
let tree = tree.read(cx);
let repo = tree.repositories().iter().next().unwrap();
let repo = tree.repositories.iter().next().unwrap();
assert_eq!(
repo.work_directory,
WorkDirectory::in_project("projects/project2")
@ -2529,8 +2440,8 @@ async fn test_file_status(cx: &mut TestAppContext) {
// Check that the right git state is observed on startup
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
assert_eq!(snapshot.repositories().iter().count(), 1);
let repo_entry = snapshot.repositories().iter().next().unwrap();
assert_eq!(snapshot.repositories.iter().count(), 1);
let repo_entry = snapshot.repositories.iter().next().unwrap();
assert_eq!(
repo_entry.work_directory,
WorkDirectory::in_project("project")
@ -2705,7 +2616,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
// Check that the right git state is observed on startup
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
let repo = snapshot.repositories().iter().next().unwrap();
let repo = snapshot.repositories.iter().next().unwrap();
let entries = repo.status().collect::<Vec<_>>();
assert_eq!(entries.len(), 3);
@ -2727,7 +2638,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
let repository = snapshot.repositories().iter().next().unwrap();
let repository = snapshot.repositories.iter().next().unwrap();
let entries = repository.status().collect::<Vec<_>>();
std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
@ -2760,7 +2671,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
let repo = snapshot.repositories().iter().next().unwrap();
let repo = snapshot.repositories.iter().next().unwrap();
let entries = repo.status().collect::<Vec<_>>();
// Deleting an untracked entry, b.txt, should leave no status
@ -2814,7 +2725,7 @@ async fn test_git_status_postprocessing(cx: &mut TestAppContext) {
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
let repo = snapshot.repositories().iter().next().unwrap();
let repo = snapshot.repositories.iter().next().unwrap();
let entries = repo.status().collect::<Vec<_>>();
// `sub` doesn't appear in our computed statuses.
@ -2883,8 +2794,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
// Ensure that the git status is loaded correctly
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
assert_eq!(snapshot.repositories().iter().count(), 1);
let repo = snapshot.repositories().iter().next().unwrap();
assert_eq!(snapshot.repositories.iter().count(), 1);
let repo = snapshot.repositories.iter().next().unwrap();
assert_eq!(
repo.work_directory.canonicalize(),
WorkDirectory::AboveProject {
@ -2913,442 +2824,13 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
assert!(snapshot.repositories().iter().next().is_some());
assert!(snapshot.repositories.iter().next().is_some());
assert_eq!(snapshot.status_for_file("c.txt"), None);
assert_eq!(snapshot.status_for_file("d/e.txt"), None);
});
}
#[gpui::test]
async fn test_traverse_with_git_status(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")),
&[
(Path::new("x2.txt"), StatusCode::Modified.index()),
(Path::new("z.txt"), StatusCode::Added.index()),
],
);
fs.set_status_for_repo(
Path::new(path!("/root/x/y/.git")),
&[(Path::new("y1.txt"), CONFLICT)],
);
fs.set_status_for_repo(
Path::new(path!("/root/z/.git")),
&[(Path::new("z2.txt"), StatusCode::Added.index())],
);
let tree = Worktree::local(
Path::new(path!("/root")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
tree.flush_fs_events(cx).await;
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
cx.executor().run_until_parked();
let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
let mut traversal = snapshot
.traverse_from_path(true, false, true, Path::new("x"))
.with_git_statuses();
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt"));
assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt"));
assert_eq!(entry.git_summary, MODIFIED);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt"));
assert_eq!(entry.git_summary, GitSummary::CONFLICT);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt"));
assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("x/z.txt"));
assert_eq!(entry.git_summary, ADDED);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt"));
assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
let entry = traversal.next().unwrap();
assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt"));
assert_eq!(entry.git_summary, ADDED);
}
#[gpui::test]
async fn test_propagate_git_statuses(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")),
&[
(Path::new("a/b/c1.txt"), StatusCode::Added.index()),
(Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
(Path::new("g/h2.txt"), CONFLICT),
],
);
let tree = Worktree::local(
Path::new(path!("/root")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
cx.executor().run_until_parked();
let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
check_git_statuses(
&snapshot,
&[
(Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED),
(Path::new("g"), GitSummary::CONFLICT),
(Path::new("g/h2.txt"), GitSummary::CONFLICT),
],
);
check_git_statuses(
&snapshot,
&[
(Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED),
(Path::new("a"), ADDED + MODIFIED),
(Path::new("a/b"), ADDED),
(Path::new("a/b/c1.txt"), ADDED),
(Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
(Path::new("a/d"), MODIFIED),
(Path::new("a/d/e2.txt"), MODIFIED),
(Path::new("f"), GitSummary::UNCHANGED),
(Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
(Path::new("g"), GitSummary::CONFLICT),
(Path::new("g/h2.txt"), GitSummary::CONFLICT),
],
);
check_git_statuses(
&snapshot,
&[
(Path::new("a/b"), ADDED),
(Path::new("a/b/c1.txt"), ADDED),
(Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
(Path::new("a/d"), MODIFIED),
(Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
(Path::new("a/d/e2.txt"), MODIFIED),
(Path::new("f"), GitSummary::UNCHANGED),
(Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
(Path::new("g"), GitSummary::CONFLICT),
],
);
check_git_statuses(
&snapshot,
&[
(Path::new("a/b/c1.txt"), ADDED),
(Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
(Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
(Path::new("a/d/e2.txt"), MODIFIED),
(Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
],
);
}
#[gpui::test]
async fn test_propagate_statuses_for_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")),
&[(Path::new("x1.txt"), StatusCode::Added.index())],
);
fs.set_status_for_repo(
Path::new(path!("/root/y/.git")),
&[
(Path::new("y1.txt"), CONFLICT),
(Path::new("y2.txt"), StatusCode::Modified.index()),
],
);
fs.set_status_for_repo(
Path::new(path!("/root/z/.git")),
&[(Path::new("z2.txt"), StatusCode::Modified.index())],
);
let tree = Worktree::local(
Path::new(path!("/root")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
tree.flush_fs_events(cx).await;
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
cx.executor().run_until_parked();
let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
check_git_statuses(
&snapshot,
&[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
);
check_git_statuses(
&snapshot,
&[
(Path::new("y"), GitSummary::CONFLICT + MODIFIED),
(Path::new("y/y1.txt"), GitSummary::CONFLICT),
(Path::new("y/y2.txt"), MODIFIED),
],
);
check_git_statuses(
&snapshot,
&[
(Path::new("z"), MODIFIED),
(Path::new("z/z2.txt"), MODIFIED),
],
);
check_git_statuses(
&snapshot,
&[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
);
check_git_statuses(
&snapshot,
&[
(Path::new("x"), ADDED),
(Path::new("x/x1.txt"), ADDED),
(Path::new("x/x2.txt"), GitSummary::UNCHANGED),
(Path::new("y"), GitSummary::CONFLICT + MODIFIED),
(Path::new("y/y1.txt"), GitSummary::CONFLICT),
(Path::new("y/y2.txt"), MODIFIED),
(Path::new("z"), MODIFIED),
(Path::new("z/z1.txt"), GitSummary::UNCHANGED),
(Path::new("z/z2.txt"), MODIFIED),
],
);
}
#[gpui::test]
async fn test_propagate_statuses_for_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")),
&[
(Path::new("x2.txt"), StatusCode::Modified.index()),
(Path::new("z.txt"), StatusCode::Added.index()),
],
);
fs.set_status_for_repo(
Path::new(path!("/root/x/y/.git")),
&[(Path::new("y1.txt"), CONFLICT)],
);
fs.set_status_for_repo(
Path::new(path!("/root/z/.git")),
&[(Path::new("z2.txt"), StatusCode::Added.index())],
);
let tree = Worktree::local(
Path::new(path!("/root")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
tree.flush_fs_events(cx).await;
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
cx.executor().run_until_parked();
let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
// Sanity check the propagation for x/y and z
check_git_statuses(
&snapshot,
&[
(Path::new("x/y"), GitSummary::CONFLICT),
(Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
(Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
],
);
check_git_statuses(
&snapshot,
&[
(Path::new("z"), ADDED),
(Path::new("z/z1.txt"), GitSummary::UNCHANGED),
(Path::new("z/z2.txt"), ADDED),
],
);
// Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
check_git_statuses(
&snapshot,
&[
(Path::new("x"), MODIFIED + ADDED),
(Path::new("x/y"), GitSummary::CONFLICT),
(Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
],
);
// Sanity check everything around it
check_git_statuses(
&snapshot,
&[
(Path::new("x"), MODIFIED + ADDED),
(Path::new("x/x1.txt"), GitSummary::UNCHANGED),
(Path::new("x/x2.txt"), MODIFIED),
(Path::new("x/y"), GitSummary::CONFLICT),
(Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
(Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
(Path::new("x/z.txt"), ADDED),
],
);
// Test the other fundamental case, transitioning from git repository to non-git repository
check_git_statuses(
&snapshot,
&[
(Path::new(""), GitSummary::UNCHANGED),
(Path::new("x"), MODIFIED + ADDED),
(Path::new("x/x1.txt"), GitSummary::UNCHANGED),
],
);
// And all together now
check_git_statuses(
&snapshot,
&[
(Path::new(""), GitSummary::UNCHANGED),
(Path::new("x"), MODIFIED + ADDED),
(Path::new("x/x1.txt"), GitSummary::UNCHANGED),
(Path::new("x/x2.txt"), MODIFIED),
(Path::new("x/y"), GitSummary::CONFLICT),
(Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
(Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
(Path::new("x/z.txt"), ADDED),
(Path::new("z"), ADDED),
(Path::new("z/z1.txt"), GitSummary::UNCHANGED),
(Path::new("z/z2.txt"), ADDED),
],
);
}
#[gpui::test]
async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) {
init_test(cx);
@ -3403,7 +2885,7 @@ async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) {
);
tree.flush_fs_events(cx).await;
let conflicts = tree.update(cx, |tree, _| {
let entry = tree.git_entries().nth(0).expect("No git entry").clone();
let entry = tree.repositories.first().expect("No git entry").clone();
entry
.current_merge_conflicts
.iter()
@ -3420,7 +2902,7 @@ async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) {
pretty_assertions::assert_eq!(git_status(&repo), collections::HashMap::default());
tree.flush_fs_events(cx).await;
let conflicts = tree.update(cx, |tree, _| {
let entry = tree.git_entries().nth(0).expect("No git entry").clone();
let entry = tree.repositories.first().expect("No git entry").clone();
entry
.current_merge_conflicts
.iter()
@ -3490,34 +2972,6 @@ fn test_unrelativize() {
);
}
#[track_caller]
fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) {
let mut traversal = snapshot
.traverse_from_path(true, true, false, "".as_ref())
.with_git_statuses();
let found_statuses = expected_statuses
.iter()
.map(|&(path, _)| {
let git_entry = traversal
.find(|git_entry| &*git_entry.path == path)
.unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
(path, git_entry.git_summary)
})
.collect::<Vec<_>>();
assert_eq!(found_statuses, expected_statuses);
}
const ADDED: GitSummary = GitSummary {
index: TrackedSummary::ADDED,
count: 1,
..GitSummary::UNCHANGED
};
const MODIFIED: GitSummary = GitSummary {
index: TrackedSummary::MODIFIED,
count: 1,
..GitSummary::UNCHANGED
};
#[track_caller]
fn git_init(path: &Path) -> git2::Repository {
let mut init_opts = RepositoryInitOptions::new();