diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d3913c0a..d8655e3b 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -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) -> IdeResult { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 9b4dda80..1391f618 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -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/`. 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); diff --git a/crates/ide-host/tests/vcs_worktrees.rs b/crates/ide-host/tests/vcs_worktrees.rs new file mode 100644 index 00000000..57304d18 --- /dev/null +++ b/crates/ide-host/tests/vcs_worktrees.rs @@ -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, 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, + 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:?}"); +} diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 44dbce5b..011cab69 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -277,6 +277,15 @@ impl DirState { self.watch_tree(path, true, &mut discovered); } } + // VCS metadata directories (`.git`, `.git/worktrees/`) 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>> = 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();