mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(vcs): discover worktrees created while the project is open (#930)
A linked worktree created below the project while it was open never joined the repository set: its files had no status entries or gutter marks until the project was reopened. A linked worktree's `.git` is a gitfile and its HEAD lives under `<main>/.git/worktrees/<name>/`. The watcher classified both as non-metadata, `.git/worktrees` was never watched, and the repository rescan only reacted to `<repo>/.git/HEAD`. The watcher now reports the `.git` path and `.git/worktrees/**` (without `.lock` files) as VCS changes and watches `.git/worktrees`; the rescan accepts a worktree's `.git`; and the Add/Remove Worktree commands refresh the repository set directly, so both the in-IDE and the external `git worktree` paths are covered. Fixes #927. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
co-authored by
a-rookie-of-C-language
parent
00e717d8fd
commit
53e76e29c6
@@ -729,7 +729,13 @@ pub async fn vcs_add_worktree(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
let result = project
|
||||
.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()));
|
||||
// A worktree below the project is a new repository: rescan so its files
|
||||
// join the status and gutter queries (the watcher picks the gitfile up
|
||||
// too, but only as a debounced event later).
|
||||
project.refresh_repositories();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -743,7 +749,12 @@ pub async fn vcs_remove_worktree(
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
blocking(move || {
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
|
||||
|
||||
@@ -325,15 +325,35 @@ impl Host {
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
}
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
// A repository was cloned / initialised / removed below the project:
|
||||
// its `.git/HEAD` appeared or went. A linked worktree's `.git` is a
|
||||
// plain gitfile, and that file is the only marker written inside the
|
||||
// worktree itself, so the `.git` path counts too.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let dir = if p.ends_with(".git/HEAD") {
|
||||
p.parent().and_then(Path::parent)
|
||||
} else if p.ends_with(".git") {
|
||||
p.parent()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
known.iter().any(|r| r == dir) != p.is_file()
|
||||
let Some(dir) = dir else { return false };
|
||||
known.iter().any(|r| r == dir) != p.exists()
|
||||
});
|
||||
// A linked worktree was registered or unregistered in the main
|
||||
// gitdir: `git worktree add` / `remove` creates or deletes
|
||||
// `.git/worktrees/<name>`. Its own gitfile is written before the
|
||||
// watcher can watch the new directory, so this entry is what
|
||||
// Linux per-directory watchers see.
|
||||
git_dirs |= batch.vcs.iter().any(|p| {
|
||||
let rest: Vec<_> = p
|
||||
.components()
|
||||
.skip_while(|c| c.as_os_str() != ".git")
|
||||
.skip(1) // the `.git` component itself
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
|
||||
});
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, Project, ops::vcs};
|
||||
use std::{path::PathBuf, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
async fn wait_for_repos(project: &Arc<Project>, count: usize) {
|
||||
for _ in 0..200 {
|
||||
if project.repos().all().len() >= count {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
panic!("expected {} repositories, found {}", count, project.repos().all().len());
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
root: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "test@example.com"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "original"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
wait_for_repos(&project, 1).await;
|
||||
Self { host, project, root, _temp: temp }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_project("main");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn command_added_worktree_joins_the_repository_set() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
|
||||
let roots = f.project.repos().roots();
|
||||
assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}");
|
||||
assert_eq!(roots.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_worktree_leaves_the_repository_set() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
|
||||
assert_eq!(f.project.repos().all().len(), 2);
|
||||
vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap();
|
||||
assert_eq!(f.project.repos().all().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watcher_picks_up_a_worktree_added_below_the_project() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("watched");
|
||||
git(&f.root, &["worktree", "add", "watched", "-b", "watched"]);
|
||||
wait_for_repos(&f.project, 2).await;
|
||||
let roots = f.project.repos().roots();
|
||||
assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}");
|
||||
}
|
||||
@@ -277,6 +277,15 @@ impl DirState {
|
||||
self.watch_tree(path, true, &mut discovered);
|
||||
}
|
||||
}
|
||||
// VCS metadata directories (`.git`, `.git/worktrees/<name>`) need
|
||||
// watches too: a worktree registered in the main gitdir writes its
|
||||
// entry below `.git/worktrees`, and the events in a gitdir created
|
||||
// during this batch would otherwise never be seen.
|
||||
for path in &batch.vcs {
|
||||
if !self.watched.lock().contains(path) && path.symlink_metadata().is_ok_and(|m| m.is_dir()) {
|
||||
self.watch_tree(path, false, &mut discovered);
|
||||
}
|
||||
}
|
||||
for path in &batch.removed {
|
||||
self.unwatch_tree(path);
|
||||
}
|
||||
@@ -415,15 +424,29 @@ impl EventFilter {
|
||||
let idx = comps.iter().position(|c| *c == ".git")?;
|
||||
let rest = &comps[idx + 1..];
|
||||
Some(match rest {
|
||||
[one] => *one == "HEAD" || *one == "index" || *one == "packed-refs",
|
||||
[first, ..] => *first == "refs" && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock")),
|
||||
[] => false,
|
||||
// `refs` and `worktrees` are directories whose creation is itself a
|
||||
// registration event (`git init`, the first `git worktree add`);
|
||||
// they must surface even though the depth below them is handled
|
||||
// separately.
|
||||
[one] => matches!(one.to_str(), Some("HEAD" | "index" | "packed-refs" | "refs" | "worktrees" | "info")),
|
||||
[first, ..] => {
|
||||
let metadata = *first == "refs"
|
||||
// Linked-worktree gitdirs: a worktree added or removed below
|
||||
// the project writes its HEAD here.
|
||||
|| *first == "worktrees";
|
||||
metadata && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock"))
|
||||
}
|
||||
// The `.git` path itself: a linked worktree's `.git` is a plain
|
||||
// file (gitfile), and it is the only marker a new worktree writes
|
||||
// inside its own directory.
|
||||
[] => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a directory deserves its own watch in per-directory mode: not
|
||||
/// excluded or git-ignored; inside `.git` only the directory itself and
|
||||
/// `refs/` (HEAD, index and refs are what VCS refresh needs).
|
||||
/// excluded or git-ignored; inside `.git` the directory itself, `refs/`
|
||||
/// and `worktrees/` (HEAD, index, refs and worktree metadata are what VCS
|
||||
/// refresh needs).
|
||||
pub fn should_watch_dir(&self, dir: &Path) -> bool {
|
||||
let Ok(rel) = dir.strip_prefix(&self.root) else { return false };
|
||||
if rel.as_os_str().is_empty() {
|
||||
@@ -435,6 +458,7 @@ impl EventFilter {
|
||||
return match rest.as_slice() {
|
||||
[] => true,
|
||||
[first, ..] if *first == "refs" => true,
|
||||
[first, ..] if *first == "worktrees" => true,
|
||||
[only] => *only == "info",
|
||||
_ => false,
|
||||
};
|
||||
@@ -690,7 +714,18 @@ mod tests {
|
||||
assert_eq!(g(".git/refs/heads/main"), Some(true));
|
||||
assert_eq!(g(".git/refs/heads/main.lock"), Some(false));
|
||||
assert_eq!(g(".git/objects/aa/bb"), Some(false));
|
||||
assert_eq!(g(".git"), Some(false));
|
||||
// The `.git` path itself: a repo dir, or a linked worktree's gitfile.
|
||||
assert_eq!(g(".git"), Some(true));
|
||||
assert_eq!(g("wt/.git"), Some(true));
|
||||
// Linked-worktree gitdir metadata below the main repository.
|
||||
assert_eq!(g(".git/worktrees/wt/HEAD"), Some(true));
|
||||
assert_eq!(g(".git/worktrees/wt/index.lock"), Some(false));
|
||||
// The depth-1 directories themselves: `git init` or the first
|
||||
// `git worktree add` creates them in an already-watched gitdir.
|
||||
assert_eq!(g(".git/refs"), Some(true));
|
||||
assert_eq!(g(".git/worktrees"), Some(true));
|
||||
assert_eq!(g(".git/info"), Some(true));
|
||||
assert_eq!(g(".git/objects"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -744,6 +779,9 @@ mod tests {
|
||||
assert!(!w("build") && w("src/build"));
|
||||
assert!(w(".git") && w(".git/refs") && w(".git/refs/heads") && w(".git/info"));
|
||||
assert!(!w(".git/objects") && !w(".git/objects/ab") && !w(".git/logs"));
|
||||
// Linked-worktree gitdirs are watched so worktrees added below the
|
||||
// project while it is open are discovered.
|
||||
assert!(w(".git/worktrees") && w(".git/worktrees/wt") && w(".git/worktrees/wt/rebase-merge"));
|
||||
assert!(!filter.should_watch_dir(Path::new("/elsewhere")));
|
||||
// Excludes are replaced in place (Watcher::set_excludes).
|
||||
*filter.excludes.write() = ExcludeMatcher::new(&["gen".into()]);
|
||||
@@ -861,6 +899,51 @@ mod tests {
|
||||
assert_eq!(batches.lock().len(), n, "no events after drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_directory_mode_follows_worktree_registrations() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for d in ["wt", ".git/refs/heads"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
// The worktree's gitfile is written before any watch on the worktree
|
||||
// directory could register it — the same window a real
|
||||
// `git worktree add` runs into.
|
||||
std::fs::write(root.join("wt/.git"), "gitdir: ../.git/worktrees/wt\n").unwrap();
|
||||
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
|
||||
let sink: SharedSink = Arc::new(|_e: IdeEvent| {});
|
||||
let watcher = {
|
||||
let batches = batches.clone();
|
||||
Watcher::start_with(
|
||||
&root,
|
||||
Vec::new(),
|
||||
sink,
|
||||
Box::new(move |b| batches.lock().push(b.clone())),
|
||||
WatchMode::PerDirectory,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
// Registering the worktree in the main gitdir surfaces as VCS metadata
|
||||
// (the deepest directory itself may be created before its parent gains
|
||||
// a watch, so `.git/worktrees` is the event that reliably fires)...
|
||||
std::fs::create_dir_all(root.join(".git/worktrees/wt")).unwrap();
|
||||
assert!(
|
||||
wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees")))),
|
||||
"{:?}",
|
||||
batches.lock()
|
||||
);
|
||||
// ...and the registered gitdir gains a watch, so writes inside it are
|
||||
// seen even though nothing reported the directory itself.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
std::fs::write(root.join(".git/worktrees/wt/HEAD"), "ref: refs/heads/topic\n").unwrap();
|
||||
assert!(
|
||||
wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees/wt/HEAD")))),
|
||||
"{:?}",
|
||||
batches.lock()
|
||||
);
|
||||
drop(watcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_mode_reports_the_roots_own_entries_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user