diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6c90de189b..8cb9e5f9b3 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -5762,56 +5762,57 @@ impl BackgroundScanner { let scan_id = state.snapshot.scan_id; let mut affected_repo_roots = Vec::new(); for dot_git_dir in dot_git_paths { - let existing_repository_entry = - state - .snapshot - .git_repositories - .iter() - .find_map(|(_, repo)| { - let dot_git_dir = SanitizedPath::new(&dot_git_dir); - if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir - || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) - == dot_git_dir - || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir - { - Some(repo.clone()) - } else { - None - } - }); + // Several repositories can share a git directory: a linked worktree's + // commondir is the main checkout's `.git`, so a ref update there must + // refresh every repository that reads from it. + let existing_work_directory_ids = state + .snapshot + .git_repositories + .iter() + .filter_map(|(&work_directory_id, repo)| { + let dot_git_dir = SanitizedPath::new(&dot_git_dir); + if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir + { + Some(work_directory_id) + } else { + None + } + }) + .collect::>(); - match existing_repository_entry { - None => { - let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else { - // A `.git` path outside the worktree root is not - // ours to register. This happens legitimately when - // `.git` is a gitfile pointing outside the worktree - // (linked worktrees and submodules), and also when - // a rescan of a linked worktree's commondir arrives - // after the worktree's repository has already been - // unregistered. - continue; - }; - affected_repo_roots.push(dot_git_dir.parent().unwrap().into()); + if existing_work_directory_ids.is_empty() { + let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else { + // A `.git` path outside the worktree root is not + // ours to register. This happens legitimately when + // `.git` is a gitfile pointing outside the worktree + // (linked worktrees and submodules), and also when + // a rescan of a linked worktree's commondir arrives + // after the worktree's repository has already been + // unregistered. + continue; + }; + affected_repo_roots.push(dot_git_dir.parent().unwrap().into()); + state + .insert_git_repository( + RelPath::new(relative, PathStyle::local()) + .unwrap() + .into_arc(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; + } else { + for work_directory_id in existing_work_directory_ids { state - .insert_git_repository( - RelPath::new(relative, PathStyle::local()) - .unwrap() - .into_arc(), - self.fs.as_ref(), - self.watcher.as_ref(), - ) - .await; - } - Some(local_repository) => { - state.snapshot.git_repositories.update( - &local_repository.work_directory_id, - |entry| { + .snapshot + .git_repositories + .update(&work_directory_id, |entry| { entry.git_dir_scan_id = scan_id; - }, - ); + }); } - }; + } } // Remove any git repositories whose .git entry no longer exists. diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index f63bf7d108..8fb7fe3f81 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -4199,6 +4199,86 @@ async fn test_linked_worktree_gitfile_event_preserves_repo( }); } +#[gpui::test] +async fn test_shared_common_dir_event_updates_all_repositories( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + // A main checkout and one of its linked worktrees can both live inside the + // same project worktree, sharing a common git directory. An event in that + // common directory (e.g. a ref update) must refresh every repository that + // reads from it, not just the first match. + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "main_repo": { + ".git": {}, + "file.txt": "content", + }, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/project/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/project/linked")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let tree = Worktree::local( + path!("/project").as_ref(), + true, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + let mut events = cx.events(&tree); + fs.emit_fs_event( + path!("/project/main_repo/.git/refs/heads/main"), + Some(PathEventKind::Changed), + ); + executor.run_until_parked(); + + let mut updated_work_dirs = Vec::new(); + while let Ok(event) = events.try_recv() { + if let Event::UpdatedGitRepositories(updates) = event { + updated_work_dirs.extend( + updates + .iter() + .filter_map(|update| update.new_work_directory_abs_path.clone()), + ); + } + } + updated_work_dirs.sort(); + assert_eq!( + updated_work_dirs, + [ + Arc::from(Path::new(path!("/project/linked"))), + Arc::from(Path::new(path!("/project/main_repo"))), + ], + "a ref update in the shared common dir should refresh both repositories" + ); +} + #[gpui::test] async fn test_noisy_dot_git_events_do_not_emit_git_repo_update( executor: BackgroundExecutor,