mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
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:
committed by
copyberry
parent
10aca93f18
commit
a2a9a43476
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user