From a2a9a434764b9ff875022c028f636a77656d1abc Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 2 Sep 2026 20:14:04 +0000 Subject: [PATCH] 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 --- codex-rs/worktree/src/lib.rs | 108 +++++++++ codex-rs/worktree/tests/worktree.rs | 332 ++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 17ce30be88..3162bd3c5a 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -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> { + 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::>(); + 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) diff --git a/codex-rs/worktree/tests/worktree.rs b/codex-rs/worktree/tests/worktree.rs index 5d2fdafda0..1c59ba82ec 100644 --- a/codex-rs/worktree/tests/worktree.rs +++ b/codex-rs/worktree/tests/worktree.rs @@ -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, ¤t)] { + 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()), + ); + } +}