mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
## What changed - Add the experimental `worktrees` feature and a shared `--worktree` flag for new and forked `codex exec` sessions. - Create each enabled session in a managed Git worktree, use that checkout as the session working directory, and bind the checkout to the new thread. - Share the configured worktree pool with Desktop while leaving automatic cleanup disabled for CLI allocations. - Reject unsupported commands, remote execution, ignored user configuration, ephemeral sessions, and use without the feature enabled before allocating a worktree. ## Testing - Cover flag placement and inheritance, supported and rejected command combinations, worktree allocation and thread ownership, configuration gating, and compatibility with existing worktree-backed sessions. GitOrigin-RevId: 011ff4639b09e8992c50d7b823df23e71798670e
917 lines
32 KiB
Rust
917 lines
32 KiB
Rust
#![allow(clippy::expect_used)]
|
|
|
|
use codex_worktree::CreateWorktree;
|
|
use codex_worktree::ManagedWorktree;
|
|
use codex_worktree::WorktreeManager;
|
|
use codex_worktree::WorktreeSettings;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
struct RepositoryFixture {
|
|
_temp_dir: TempDir,
|
|
codex_home: PathBuf,
|
|
repository: PathBuf,
|
|
}
|
|
|
|
impl RepositoryFixture {
|
|
fn new() -> Self {
|
|
let temp_dir = tempfile::tempdir().expect("create temporary test directory");
|
|
let temp_root = dunce::canonicalize(temp_dir.path()).expect("canonicalize temporary root");
|
|
let codex_home = temp_root.join("codex-home");
|
|
let repository = temp_root.join("project");
|
|
fs::create_dir_all(&codex_home).expect("create Codex home");
|
|
initialize_repository(&repository);
|
|
|
|
Self {
|
|
_temp_dir: temp_dir,
|
|
codex_home,
|
|
repository,
|
|
}
|
|
}
|
|
|
|
fn manager(&self) -> WorktreeManager {
|
|
let settings =
|
|
WorktreeSettings::from_desktop_config(&self.codex_home, /*desktop*/ None)
|
|
.expect("load default worktree settings");
|
|
WorktreeManager::new(settings)
|
|
}
|
|
}
|
|
|
|
fn initialize_repository(repository: &Path) {
|
|
fs::create_dir_all(repository.join("nested/component"))
|
|
.expect("create temporary repository directories");
|
|
run_git(repository, &["init", "--quiet"]);
|
|
fs::write(
|
|
repository.join("README.md"),
|
|
"initial repository contents\n",
|
|
)
|
|
.expect("write tracked repository file");
|
|
fs::write(
|
|
repository.join("nested/component/tracked.txt"),
|
|
"nested tracked file\n",
|
|
)
|
|
.expect("write nested tracked repository file");
|
|
run_git(repository, &["add", "."]);
|
|
commit(repository, "initial commit");
|
|
}
|
|
|
|
fn commit(repository: &Path, message: &str) {
|
|
run_git(
|
|
repository,
|
|
&["commit", "--quiet", "--no-gpg-sign", "-m", message],
|
|
);
|
|
}
|
|
|
|
fn create_worktree(
|
|
manager: &WorktreeManager,
|
|
source_cwd: &Path,
|
|
base: Option<&str>,
|
|
) -> anyhow::Result<ManagedWorktree> {
|
|
manager.create(&CreateWorktree {
|
|
source_cwd: source_cwd.to_path_buf(),
|
|
base: base.map(str::to_owned),
|
|
})
|
|
}
|
|
|
|
fn git_output(repository: &Path, args: &[&str]) -> std::process::Output {
|
|
Command::new("git")
|
|
.current_dir(repository)
|
|
.args([
|
|
"-c",
|
|
"user.name=Codex Worktree Test",
|
|
"-c",
|
|
"user.email=codex-worktree-test@example.invalid",
|
|
"-c",
|
|
"commit.gpgSign=false",
|
|
"-c",
|
|
"tag.gpgSign=false",
|
|
])
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|error| panic!("run git {args:?} in {repository:?}: {error}"))
|
|
}
|
|
|
|
fn run_git(repository: &Path, args: &[&str]) -> String {
|
|
let output = git_output(repository, args);
|
|
assert!(
|
|
output.status.success(),
|
|
"git {args:?} failed in {repository:?}: {}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
String::from_utf8(output.stdout)
|
|
.expect("git output is UTF-8")
|
|
.trim()
|
|
.to_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn cli_creation_shares_default_and_custom_desktop_pools_without_enabling_cleanup() {
|
|
let fixture = RepositoryFixture::new();
|
|
for root in [None, Some(fixture.codex_home.join("custom-desktop"))] {
|
|
let desktop =
|
|
root.map(|root| HashMap::from([("git-worktree-root".to_owned(), json!(root))]));
|
|
let desktop_settings =
|
|
WorktreeSettings::from_desktop_config(&fixture.codex_home, desktop.as_ref())
|
|
.expect("resolve desktop settings");
|
|
let mut cli_desktop = desktop.unwrap_or_default();
|
|
cli_desktop.insert("worktree-auto-cleanup-enabled".to_owned(), json!("unused"));
|
|
cli_desktop.insert("worktree-keep-count".to_owned(), json!(0));
|
|
let manager = WorktreeManager::new(
|
|
WorktreeSettings::for_cli(&fixture.codex_home, Some(&cli_desktop))
|
|
.expect("resolve CLI settings"),
|
|
);
|
|
let worktree = create_worktree(&manager, &fixture.repository, /*base*/ None)
|
|
.expect("create CLI worktree");
|
|
assert!(worktree.root.starts_with(&desktop_settings.root));
|
|
assert_eq!(
|
|
manager.settings(),
|
|
&WorktreeSettings {
|
|
auto_cleanup_enabled: false,
|
|
..desktop_settings.clone()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
WorktreeManager::new(desktop_settings)
|
|
.list(&fixture.repository)
|
|
.expect("list desktop worktrees"),
|
|
vec![worktree],
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn thread_binding_rejects_primary_and_unmanaged_worktrees() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
fs::create_dir_all(&manager.settings().root).expect("create managed root");
|
|
assert!(
|
|
manager
|
|
.bind_thread(&fixture.repository, "not-a-managed-thread")
|
|
.is_err(),
|
|
);
|
|
assert!(
|
|
!fixture.repository.join(".git/codex-thread.json").exists(),
|
|
"primary checkout ownership metadata must not be modified",
|
|
);
|
|
|
|
let unmanaged = manager.settings().root.join("scratch").join("project");
|
|
fs::create_dir_all(unmanaged.parent().expect("unmanaged parent"))
|
|
.expect("create unmanaged parent");
|
|
run_git(
|
|
&fixture.repository,
|
|
&[
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
unmanaged.to_str().expect("UTF-8 unmanaged worktree"),
|
|
"HEAD",
|
|
],
|
|
);
|
|
assert!(manager.bind_thread(&unmanaged, "unmanaged").is_err());
|
|
assert!(manager.owner(&unmanaged).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn thread_binding_writes_the_exact_desktop_owner_schema_into_git_metadata() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let checkout = manager.settings().root.join("a1b2/project");
|
|
fs::create_dir_all(checkout.parent().expect("checkout parent")).expect("create bucket");
|
|
run_git(
|
|
&fixture.repository,
|
|
&[
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
checkout.to_str().expect("UTF-8 checkout"),
|
|
"HEAD",
|
|
],
|
|
);
|
|
let thread_id = "019f1234-5678-7000-8000-000000000001";
|
|
|
|
assert_eq!(manager.owner(&checkout).expect("read initial owner"), None);
|
|
|
|
manager
|
|
.bind_thread(&checkout, thread_id)
|
|
.expect("bind managed worktree to thread");
|
|
|
|
assert_eq!(
|
|
manager.owner(&checkout).expect("read bound owner"),
|
|
Some(thread_id.to_owned()),
|
|
);
|
|
|
|
let metadata_path = checkout.join(run_git(
|
|
&checkout,
|
|
&["rev-parse", "--git-path", "codex-thread.json"],
|
|
));
|
|
let metadata: Value = serde_json::from_slice(
|
|
&fs::read(&metadata_path).expect("read Desktop-compatible worktree owner metadata"),
|
|
)
|
|
.expect("parse Desktop-compatible worktree owner metadata");
|
|
|
|
assert_eq!(
|
|
metadata,
|
|
json!({
|
|
"version": 1,
|
|
"ownerThreadId": thread_id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_uses_desktop_hash_bucket_layout_and_detached_head() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let source_head = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
|
|
let worktree = create_worktree(&manager, &fixture.repository, /*base*/ None)
|
|
.expect("create managed worktree");
|
|
|
|
assert_eq!(
|
|
manager
|
|
.settings()
|
|
.root
|
|
.join(".metadata_never_index")
|
|
.is_file(),
|
|
cfg!(target_os = "macos"),
|
|
);
|
|
assert_eq!(
|
|
worktree.root.parent().and_then(Path::parent),
|
|
Some(fixture.codex_home.join("worktrees").as_path())
|
|
);
|
|
assert_eq!(
|
|
worktree.root.file_name(),
|
|
fixture.repository.file_name(),
|
|
"Desktop expects the repository name underneath the hash bucket",
|
|
);
|
|
|
|
let bucket = worktree
|
|
.root
|
|
.parent()
|
|
.and_then(Path::file_name)
|
|
.and_then(|name| name.to_str())
|
|
.expect("worktree has a UTF-8 hash-bucket directory");
|
|
assert_eq!(bucket.len(), 4);
|
|
assert!(bucket.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
|
|
|
assert_eq!(worktree.cwd, worktree.root);
|
|
assert_eq!(worktree.source_root, fixture.repository);
|
|
assert_eq!(worktree.source_cwd, worktree.source_root);
|
|
assert_eq!(worktree.head_sha, source_head);
|
|
assert_eq!(worktree.branch, None);
|
|
assert_eq!(run_git(&worktree.root, &["rev-parse", "HEAD"]), source_head);
|
|
assert!(
|
|
!git_output(&worktree.root, &["symbolic-ref", "--quiet", "HEAD"])
|
|
.status
|
|
.success(),
|
|
"Desktop-managed worktrees start with a detached HEAD",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_preserves_the_source_working_directory_relative_to_the_repository() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
fs::create_dir_all(&manager.settings().root).expect("precreate managed worktree root");
|
|
let source_cwd = fixture.repository.join("nested/component");
|
|
|
|
let worktree = create_worktree(&manager, &source_cwd, /*base*/ None)
|
|
.expect("create worktree from nested source directory");
|
|
|
|
assert_eq!(worktree.source_root, fixture.repository);
|
|
assert_eq!(worktree.source_cwd, source_cwd);
|
|
assert_eq!(worktree.cwd, worktree.root.join("nested/component"));
|
|
assert!(worktree.cwd.join("tracked.txt").is_file());
|
|
assert_eq!(
|
|
manager
|
|
.settings()
|
|
.root
|
|
.join(".metadata_never_index")
|
|
.is_file(),
|
|
cfg!(target_os = "macos"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_respects_an_explicit_starting_commit() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let initial_commit = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
|
|
fs::write(fixture.repository.join("second.txt"), "second commit\n")
|
|
.expect("write another tracked repository file");
|
|
run_git(&fixture.repository, &["add", "second.txt"]);
|
|
commit(&fixture.repository, "second commit");
|
|
|
|
let worktree = create_worktree(&manager, &fixture.repository, Some(&initial_commit))
|
|
.expect("create worktree from explicit commit");
|
|
|
|
assert_eq!(worktree.head_sha, initial_commit);
|
|
assert_eq!(
|
|
run_git(&worktree.root, &["rev-parse", "HEAD"]),
|
|
initial_commit
|
|
);
|
|
assert!(!worktree.root.join("second.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn creation_peels_annotated_tags_to_the_checked_out_commit() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let commit = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
run_git(
|
|
&fixture.repository,
|
|
&["tag", "-a", "release", "-m", "annotated release"],
|
|
);
|
|
|
|
let worktree = create_worktree(&manager, &fixture.repository, Some("release"))
|
|
.expect("create worktree from annotated tag");
|
|
|
|
assert_eq!(worktree.head_sha, commit);
|
|
assert_eq!(run_git(&worktree.root, &["rev-parse", "HEAD"]), commit);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn creation_rejects_a_working_directory_that_escapes_through_a_base_symlink() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let original = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
let outside = fixture.codex_home.join("outside-worktree");
|
|
fs::create_dir_all(&outside).expect("create outside directory");
|
|
let linked_directory = fixture.repository.join("nested/component");
|
|
fs::remove_dir_all(&linked_directory).expect("remove existing tracked directory");
|
|
std::os::unix::fs::symlink(&outside, &linked_directory)
|
|
.expect("replace tracked directory with an escaping symlink");
|
|
run_git(&fixture.repository, &["add", "-A"]);
|
|
commit(
|
|
&fixture.repository,
|
|
"introduce escaping working-directory symlink",
|
|
);
|
|
let unsafe_revision = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
run_git(&fixture.repository, &["reset", "--hard", &original]);
|
|
|
|
let registered_before = run_git(&fixture.repository, &["worktree", "list", "--porcelain"]);
|
|
|
|
assert!(
|
|
create_worktree(&manager, &linked_directory, Some(&unsafe_revision)).is_err(),
|
|
"an escaping destination symlink must never become the thread cwd",
|
|
);
|
|
assert_eq!(
|
|
run_git(&fixture.repository, &["worktree", "list", "--porcelain"]),
|
|
registered_before
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_ignores_inherited_git_environment() {
|
|
const CHILD_ROOT: &str = "CODEX_WORKTREE_ENV_TEST_ROOT";
|
|
if let Some(root) = std::env::var_os(CHILD_ROOT) {
|
|
let root = PathBuf::from(root);
|
|
let source = root.join("project");
|
|
let source_cwd = source.join("nested/component");
|
|
let settings =
|
|
WorktreeSettings::from_desktop_config(&root.join("codex-home"), /*desktop*/ None)
|
|
.expect("fixture operation succeeds");
|
|
let worktree = create_worktree(
|
|
&WorktreeManager::new(settings),
|
|
&source_cwd,
|
|
/*base*/ None,
|
|
)
|
|
.expect("fixture operation succeeds");
|
|
assert_eq!(worktree.source_root, source);
|
|
assert_eq!(worktree.source_cwd, source_cwd);
|
|
assert_eq!(
|
|
worktree.head_sha,
|
|
std::env::var("CODEX_WORKTREE_ENV_TEST_HEAD").expect("fixture operation succeeds")
|
|
);
|
|
return;
|
|
}
|
|
|
|
let fixture = RepositoryFixture::new();
|
|
let other = fixture.codex_home.join("other-repository");
|
|
initialize_repository(&other);
|
|
fs::write(other.join("other.txt"), "different repository").expect("fixture operation succeeds");
|
|
run_git(&other, &["add", "."]);
|
|
commit(&other, "different repository");
|
|
fs::write(
|
|
fixture.repository.join(".gitattributes"),
|
|
"*.txt filter=review\n",
|
|
)
|
|
.expect("fixture operation succeeds");
|
|
run_git(&fixture.repository, &["add", "."]);
|
|
commit(&fixture.repository, "checkout filter attributes");
|
|
let mut child = Command::new(std::env::current_exe().expect("current test executable"));
|
|
child
|
|
.args(["--exact", "creation_ignores_inherited_git_environment", "--nocapture"])
|
|
.env(CHILD_ROOT, fixture.repository.parent().expect("fixture parent"))
|
|
.env("CODEX_WORKTREE_ENV_TEST_HEAD", run_git(&fixture.repository, &["rev-parse", "HEAD"]))
|
|
.env("GIT_DIR", other.join(".git"))
|
|
.env("GIT_COMMON_DIR", other.join(".git"))
|
|
.env("GIT_CEILING_DIRECTORIES", &fixture.repository)
|
|
.env("GIT_WORK_TREE", &fixture.repository)
|
|
.env("GIT_CONFIG_COUNT", "1")
|
|
.env("GIT_CONFIG_KEY_0", "filter.review.required")
|
|
.env("GIT_CONFIG_VALUE_0", "true")
|
|
.env("GIT_CONFIG_PARAMETERS", "'filter.review.process=codex-worktree-nonexistent-filter' 'filter.review.required=true'");
|
|
let output = child.output().expect("fixture operation succeeds");
|
|
assert!(
|
|
output.status.success(),
|
|
"child failed: {}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_disables_filters_from_destination_conditional_config() {
|
|
let fixture = RepositoryFixture::new();
|
|
fs::write(
|
|
fixture.repository.join(".gitattributes"),
|
|
"*.txt filter=late text eol=lf\n",
|
|
)
|
|
.expect("write checkout attributes");
|
|
run_git(&fixture.repository, &["add", "."]);
|
|
commit(&fixture.repository, "checkout attributes");
|
|
let config = fixture.codex_home.join("destination.gitconfig");
|
|
fs::write(
|
|
&config,
|
|
"[filter \"late\"]\nsmudge = codex-worktree-nonexistent-filter\nrequired = true\n",
|
|
)
|
|
.expect("write conditional filter config");
|
|
let git_dir = fixture.repository.join(".git/worktrees");
|
|
let condition = format!(
|
|
"includeIf.gitdir:{}/.path",
|
|
git_dir.to_string_lossy().replace('\\', "/")
|
|
);
|
|
run_git(
|
|
&fixture.repository,
|
|
&["config", &condition, config.to_str().expect("UTF-8 config")],
|
|
);
|
|
|
|
let worktree = create_worktree(&fixture.manager(), &fixture.repository, /*base*/ None)
|
|
.expect("checkout must not invoke the destination-only filter");
|
|
assert_eq!(
|
|
fs::read_to_string(worktree.root.join("nested/component/tracked.txt"))
|
|
.expect("read checked out file"),
|
|
"nested tracked file\n",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_preserves_dirty_source_with_shared_git_config() {
|
|
let fixture = RepositoryFixture::new();
|
|
initialize_repository(&fixture.repository.join("submodule"));
|
|
run_git(
|
|
&fixture.repository,
|
|
&["submodule", "add", "./submodule", "submodule"],
|
|
);
|
|
commit(&fixture.repository, "add initialized submodule");
|
|
|
|
for (key, value) in [
|
|
(
|
|
"core.worktree",
|
|
fixture.repository.to_str().expect("UTF-8 repository"),
|
|
),
|
|
("extensions.worktreeConfig", "true"),
|
|
("core.autocrlf", "false"),
|
|
("submodule.recurse", "true"),
|
|
] {
|
|
run_git(&fixture.repository, &["config", key, value]);
|
|
}
|
|
let config_path = fixture.repository.join(".git/config");
|
|
let source_config = fs::read(&config_path).expect("read shared Git config");
|
|
let path = "nested/component/tracked.txt";
|
|
let original = fs::read(fixture.repository.join(path)).expect("read committed file");
|
|
let dirty = b"uncommitted source contents";
|
|
fs::write(fixture.repository.join(path), dirty).expect("dirty the source checkout");
|
|
|
|
let worktree = create_worktree(&fixture.manager(), &fixture.repository, /*base*/ None)
|
|
.expect("create without resetting the source checkout");
|
|
|
|
assert_eq!(
|
|
(
|
|
fs::read(fixture.repository.join(path)).expect("read dirty source file"),
|
|
fs::read(worktree.root.join(path)).expect("read new checkout file"),
|
|
fs::read(config_path).expect("reread shared Git config"),
|
|
),
|
|
(dirty.to_vec(), original, source_config),
|
|
);
|
|
assert_eq!(
|
|
(
|
|
PathBuf::from(run_git(&worktree.cwd, &["rev-parse", "--show-toplevel"])),
|
|
run_git(&worktree.cwd, &["status", "--porcelain"]),
|
|
),
|
|
(worktree.root, String::new()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_rolls_back_when_the_base_lacks_the_source_subdirectory() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let older_commit = run_git(&fixture.repository, &["rev-parse", "HEAD"]);
|
|
let source_cwd = fixture.repository.join("introduced/later");
|
|
fs::create_dir_all(&source_cwd).expect("create newly introduced source directory");
|
|
fs::write(source_cwd.join("tracked.txt"), "new directory\n")
|
|
.expect("write newly introduced tracked file");
|
|
run_git(&fixture.repository, &["add", "."]);
|
|
commit(&fixture.repository, "introduce source directory");
|
|
|
|
let registered_before = run_git(&fixture.repository, &["worktree", "list", "--porcelain"]);
|
|
|
|
assert!(
|
|
create_worktree(&manager, &source_cwd, Some(&older_commit)).is_err(),
|
|
"a destination cwd missing from the requested base must fail",
|
|
);
|
|
assert_eq!(
|
|
run_git(&fixture.repository, &["worktree", "list", "--porcelain"]),
|
|
registered_before
|
|
);
|
|
assert_eq!(
|
|
fs::read_dir(&manager.settings().root)
|
|
.expect("read managed root")
|
|
.filter(|entry| {
|
|
entry
|
|
.as_ref()
|
|
.expect("read managed root entry")
|
|
.file_type()
|
|
.expect("read managed root entry type")
|
|
.is_dir()
|
|
})
|
|
.count(),
|
|
0,
|
|
"failed creation must remove its own empty bucket",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_rejects_relative_roots_before_resolving_the_source() {
|
|
let directory = TempDir::new().expect("create temporary directory");
|
|
let source = directory.path().join("missing-source");
|
|
let settings = WorktreeSettings::from_desktop_config(
|
|
Path::new("relative-codex-home"),
|
|
/*desktop*/ None,
|
|
)
|
|
.expect("load settings with a relative default root");
|
|
let error = create_worktree(&WorktreeManager::new(settings), &source, /*base*/ None)
|
|
.expect_err("relative roots must fail before source discovery");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"managed worktree root must be an absolute path",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_fails_for_a_directory_outside_a_git_repository() {
|
|
let fixture = RepositoryFixture::new();
|
|
let manager = fixture.manager();
|
|
let non_repository = fixture.codex_home.join("not-a-repository");
|
|
fs::create_dir_all(&non_repository).expect("create non-repository directory");
|
|
|
|
assert!(
|
|
create_worktree(&manager, &non_repository, /*base*/ None).is_err(),
|
|
"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()),
|
|
);
|
|
}
|
|
}
|