List managed worktrees for a repository (#42366)

## What changed

- Add `WorktreeManager::list` to return managed linked worktrees associated
  with the repository containing a requested working directory.
- Preserve the requested repository-relative working directory in each result
  and sort results by worktree root.
- Exclude primary and unrelated checkouts, paths outside the managed layout,
  unsafe working directories, aliases, and stale or mismatched Git
  registrations.

## Testing

Add coverage for repository filtering, native paths, nested working
directories, stale registrations, aliases, and layout validation.

GitOrigin-RevId: 385133914bd601e6c72858166853a1d42f7722ee
This commit is contained in:
Benjamin Carlsson
2026-09-02 20:14:04 +00:00
committed by copyberry
parent 10aca93f18
commit a2a9a43476
2 changed files with 440 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ mod settings;
use crate::git::GitOperation;
use crate::git::git_output;
use crate::git::git_path;
use crate::git::git_path_from_bytes;
use crate::git::git_stdout;
use crate::paths::allocate_worktree_root;
use crate::paths::remove_empty_bucket;
@@ -14,6 +15,7 @@ use anyhow::Result;
use anyhow::bail;
use serde::Serialize;
use std::ffi::OsStr;
use std::fs;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
@@ -162,6 +164,112 @@ impl WorktreeManager {
})
}
pub fn list(&self, source_cwd: &Path) -> Result<Vec<ManagedWorktree>> {
let source_cwd = dunce::canonicalize(source_cwd)
.with_context(|| format!("cannot resolve {}", source_cwd.display()))?;
let source_root = repository_root(&source_cwd)?;
let relative_cwd = source_cwd
.strip_prefix(&source_root)
.context("working directory is outside the repository root")?;
let managed_root =
dunce::canonicalize(&self.settings.root).unwrap_or_else(|_| self.settings.root.clone());
let source_common_dir = resolve_git_path(&source_root, "--git-common-dir")?;
let output = git_output(
&source_cwd,
GitOperation::Metadata,
["worktree", "list", "--porcelain", "-z"],
)?;
let fields = output
.stdout
.split(|byte| *byte == b'\0')
.collect::<Vec<_>>();
let mut worktrees = Vec::new();
for entry in fields.split(|field| field.is_empty()) {
let mut root = None;
let mut head_sha = None;
let mut branch = None;
for field in entry {
if let Some(path) = field.strip_prefix(b"worktree ") {
root = Some(git_path_from_bytes(path)?);
} else if let Some(head) = field.strip_prefix(b"HEAD ") {
head_sha = Some(
std::str::from_utf8(head)
.context("worktree HEAD is not valid UTF-8")?
.to_owned(),
);
} else if let Some(name) = field.strip_prefix(b"branch ") {
branch = Some(name.strip_prefix(b"refs/heads/").unwrap_or(name));
}
}
let Some(root) = root else {
continue;
};
let Some(bucket) = root.parent() else {
continue;
};
// Keep aliases from changing which registration supplies the metadata.
if ![root.as_path(), bucket].into_iter().all(|path| {
fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
}) {
continue;
}
let canonical_root = dunce::canonicalize(&root).unwrap_or_else(|_| root.clone());
let linked_git_file = fs::symlink_metadata(canonical_root.join(".git"))
.is_ok_and(|metadata| metadata.file_type().is_file());
if !has_managed_layout(&managed_root, &canonical_root) || !linked_git_file {
continue;
}
if linked_worktree_common_dir(&canonical_root).ok().as_ref() != Some(&source_common_dir)
{
continue;
}
// A different linked checkout can occupy a stale registration's path.
// Require its administration directory to point back to this checkout.
let Ok(git_dir) = resolve_git_path(&canonical_root, "--git-dir") else {
continue;
};
let Ok(backlink) = fs::read(git_dir.join("gitdir")) else {
continue;
};
let backlink = backlink.strip_suffix(b"\n").unwrap_or(&backlink);
#[cfg(windows)]
let backlink = backlink.strip_suffix(b"\r").unwrap_or(backlink);
let Ok(backlink) = git_path_from_bytes(backlink) else {
continue;
};
let Ok(backlink) = dunce::canonicalize(git_dir.join(backlink)) else {
continue;
};
let Ok(git_file) = dunce::canonicalize(canonical_root.join(".git")) else {
continue;
};
if backlink != git_file {
continue;
}
let cwd = root.join(relative_cwd);
if !is_safe_worktree_cwd(&canonical_root, &cwd) {
continue;
}
let head_sha = head_sha
.with_context(|| format!("managed worktree {} has no HEAD", root.display()))?;
worktrees.push(ManagedWorktree {
cwd,
root,
source_root: source_root.clone(),
source_cwd: source_cwd.clone(),
head_sha,
branch: branch.and_then(|name| std::str::from_utf8(name).ok().map(str::to_owned)),
});
}
worktrees.sort_by(|left, right| left.root.cmp(&right.root));
Ok(worktrees)
}
pub fn bind_thread(&self, checkout: &Path, thread_id: &str) -> Result<()> {
let checkout = self.managed_checkout(checkout)?;
metadata::bind_thread(&checkout, thread_id)

View File

@@ -546,3 +546,335 @@ fn creation_fails_for_a_directory_outside_a_git_repository() {
"managed worktree creation requires a Git repository",
);
}
#[test]
fn listing_rejects_a_primary_checkout_even_when_its_path_matches_the_layout() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let primary = manager.settings().root.join("a1b2").join("ordinary");
initialize_repository(&primary);
assert!(
manager
.list(&primary)
.expect("list managed worktrees for primary checkout")
.is_empty(),
"a primary checkout under a matching path is not a managed linked worktree",
);
let git_dir = fixture.codex_home.join("separate-git-dir");
run_git(
&primary,
&[
"init",
"--separate-git-dir",
git_dir.to_str().expect("UTF-8 fixture path"),
],
);
assert!(primary.join(".git").is_file());
assert!(
manager
.list(&primary)
.expect("fixture operation succeeds")
.is_empty()
);
}
#[test]
fn listing_skips_worktrees_without_a_safe_source_working_directory() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let source_cwd = fixture.repository.join("nested/component");
let valid =
create_worktree(&manager, &source_cwd, /*base*/ None).expect("fixture operation succeeds");
let missing =
create_worktree(&manager, &source_cwd, /*base*/ None).expect("fixture operation succeeds");
let file =
create_worktree(&manager, &source_cwd, /*base*/ None).expect("fixture operation succeeds");
fs::remove_dir_all(&missing.cwd).expect("fixture operation succeeds");
fs::remove_dir_all(&file.cwd).expect("fixture operation succeeds");
fs::write(&file.cwd, "not a directory").expect("fixture operation succeeds");
#[cfg(unix)]
{
let escaping = create_worktree(&manager, &source_cwd, /*base*/ None)
.expect("fixture operation succeeds");
fs::remove_dir_all(&escaping.cwd).expect("fixture operation succeeds");
std::os::unix::fs::symlink(&source_cwd, &escaping.cwd).expect("fixture operation succeeds");
}
assert_eq!(
manager
.list(&source_cwd)
.expect("fixture operation succeeds"),
vec![valid]
);
}
#[test]
fn listing_rejects_a_stale_registration_reused_by_another_repository() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let stale = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("fixture operation succeeds");
fs::remove_dir_all(&stale.root).expect("fixture operation succeeds");
let other = fixture.codex_home.join("other-repository");
initialize_repository(&other);
run_git(
&other,
&[
"worktree",
"add",
"--detach",
stale.root.to_str().expect("UTF-8 fixture path"),
"HEAD",
],
);
assert!(
manager
.list(&fixture.repository)
.expect("fixture operation succeeds")
.is_empty()
);
assert_eq!(
manager
.list(&other)
.expect("fixture operation succeeds")
.len(),
1
);
}
#[test]
fn listing_rejects_a_stale_registration_reused_by_the_same_repository() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let original = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
fs::write(fixture.repository.join("second.txt"), "second commit\n")
.expect("write second revision");
run_git(&fixture.repository, &["add", "second.txt"]);
commit(&fixture.repository, "second commit");
let current = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
let stale = manager.settings().root.join("a1b2/project");
let moved = manager.settings().root.join("c3d4/project");
// Use ordinary Git checkouts: manager.create pins core.worktree to its path.
for (path, revision) in [(&stale, &original), (&moved, &current)] {
fs::create_dir_all(path.parent().expect("worktree bucket"))
.expect("create worktree bucket");
run_git(
&fixture.repository,
&[
"worktree",
"add",
"--detach",
path.to_str().expect("UTF-8 fixture path"),
revision,
],
);
}
let valid = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("create unaffected worktree");
fs::remove_dir_all(&stale).expect("remove stale checkout");
fs::rename(&moved, &stale).expect("reuse stale path without repairing registration");
assert_eq!(
manager.list(&fixture.repository).expect("list worktrees"),
vec![valid],
);
}
#[cfg(unix)]
#[test]
fn listing_rejects_checkout_and_bucket_aliases_but_allows_a_root_alias() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let stale = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("create stale checkout");
fs::write(fixture.repository.join("second.txt"), "second commit\n")
.expect("write second revision");
run_git(&fixture.repository, &["add", "second.txt"]);
commit(&fixture.repository, "second commit");
let valid = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("create valid checkout");
let expected = vec![valid.clone()];
fs::remove_dir_all(&stale.root).expect("remove stale checkout");
std::os::unix::fs::symlink(&valid.root, &stale.root).expect("alias stale checkout");
assert_eq!(
manager.list(&fixture.repository).expect("list worktrees"),
expected,
);
fs::remove_file(&stale.root).expect("remove checkout alias");
let stale_bucket = stale.root.parent().expect("stale worktree bucket");
fs::remove_dir(stale_bucket).expect("remove stale bucket");
std::os::unix::fs::symlink(
valid.root.parent().expect("valid worktree bucket"),
stale_bucket,
)
.expect("alias stale bucket");
assert_eq!(
manager.list(&fixture.repository).expect("list worktrees"),
expected,
);
let aliased_root = fixture.codex_home.join("aliased-worktrees");
std::os::unix::fs::symlink(&manager.settings().root, &aliased_root)
.expect("alias managed root");
let aliased_manager = WorktreeManager::new(WorktreeSettings {
root: aliased_root,
..manager.settings().clone()
});
assert_eq!(
aliased_manager
.list(&fixture.repository)
.expect("list worktrees through managed-root alias"),
expected,
);
}
#[cfg(unix)]
#[test]
fn listing_preserves_inventory_when_a_branch_name_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let worktree = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("fixture operation succeeds");
let branch = b"refs/heads/non-utf8-\xff";
// Packed refs can represent these names even on filesystems that reject
// non-UTF-8 filenames, including APFS.
let mut packed_ref = format!("{} ", worktree.head_sha).into_bytes();
packed_ref.extend_from_slice(branch);
packed_ref.push(b'\n');
fs::write(fixture.repository.join(".git/packed-refs"), packed_ref)
.expect("fixture operation succeeds");
let output = Command::new("git")
.current_dir(&worktree.root)
.args(["symbolic-ref", "HEAD"])
.arg(std::ffi::OsStr::from_bytes(branch))
.output()
.expect("fixture operation succeeds");
assert!(output.status.success());
assert_eq!(
manager
.list(&fixture.repository)
.expect("fixture operation succeeds"),
vec![worktree]
);
// The primary checkout is not managed, but its metadata is parsed too.
fs::write(
fixture.repository.join(".git/HEAD"),
[b"ref: ".as_slice(), branch, b"\n"].concat(),
)
.expect("fixture operation succeeds");
assert_eq!(
manager
.list(&fixture.repository)
.expect("fixture operation succeeds")
.len(),
1
);
}
#[test]
fn listing_only_includes_managed_worktrees_for_the_requested_repository() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let first = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("create first worktree");
let second = create_worktree(&manager, &fixture.repository, /*base*/ None)
.expect("create second worktree");
let other_repository = fixture.codex_home.join("other-project");
initialize_repository(&other_repository);
let unrelated = create_worktree(&manager, &other_repository, /*base*/ None)
.expect("create unrelated repository worktree");
let listed = manager
.list(&fixture.repository)
.expect("list managed worktrees for source repository");
let listed_roots: Vec<&Path> = listed.iter().map(|entry| entry.root.as_path()).collect();
assert_eq!(listed_roots.len(), 2);
assert!(listed_roots.contains(&first.root.as_path()));
assert!(listed_roots.contains(&second.root.as_path()));
assert!(!listed_roots.contains(&unrelated.root.as_path()));
let unrelated_list = manager
.list(&other_repository)
.expect("list managed worktrees for the other repository");
assert_eq!(unrelated_list.len(), 1);
assert_eq!(unrelated_list[0].root, unrelated.root);
}
#[test]
fn listing_ignores_worktrees_outside_the_desktop_bucket_layout() {
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
let root = manager.settings().root.join("scratch").join("project");
fs::create_dir_all(root.parent().expect("scratch worktree parent"))
.expect("create scratch worktree parent");
run_git(
&fixture.repository,
&[
"worktree",
"add",
"--detach",
root.to_str().expect("UTF-8 scratch worktree"),
"HEAD",
],
);
assert!(
manager
.list(&fixture.repository)
.expect("list managed worktrees")
.is_empty(),
"non-Desktop worktrees must never enter the managed inventory",
);
}
#[cfg(unix)]
#[test]
fn creation_listing_and_thread_binding_preserve_native_repository_paths() {
use std::ffi::OsString;
#[cfg(not(target_os = "macos"))]
use std::os::unix::ffi::OsStringExt;
let fixture = RepositoryFixture::new();
let manager = fixture.manager();
for name in [
OsString::from("project with trailing space "),
OsString::from("project-東京"),
// APFS only supports creating filenames with valid UTF-8.
#[cfg(not(target_os = "macos"))]
OsString::from_vec(b"project-\xff".to_vec()),
] {
let repository = fixture.codex_home.join(&name);
initialize_repository(&repository);
let worktree = create_worktree(&manager, &repository, /*base*/ None)
.expect("create managed worktree with a native repository path");
assert_eq!(worktree.source_root, repository);
assert_eq!(worktree.root.file_name(), Some(name.as_os_str()));
let listed = manager
.list(&repository)
.expect("list managed worktrees with a native repository path");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].root, worktree.root);
manager
.bind_thread(&worktree.root, "native-path-thread")
.expect("bind managed worktree with a native repository path");
assert_eq!(
manager
.owner(&worktree.root)
.expect("read owner with a native repository path"),
Some("native-path-thread".to_owned()),
);
}
}