Add managed worktree creation (#42196)

## What changed

- Add `WorktreeManager::create` to create detached, Desktop-compatible worktrees from `HEAD` or an explicit base while preserving the source working-directory path.
- Isolate worktree Git operations from inherited repository selectors, hooks, filesystem monitors, and configured content filters.
- Validate the destination working directory and roll back incomplete worktrees and empty allocation buckets on failure.

## Testing

- Cover layout, base selection, annotated tags, nested working directories, Git environment isolation, filter suppression, source checkout preservation, unsafe symlinks, and creation rollback.

GitOrigin-RevId: bf172c3ff4268dab603d00a1d547485fcd0de368
This commit is contained in:
Benjamin Carlsson
2026-09-02 03:46:24 +00:00
committed by copyberry
parent 5a0419edb5
commit 798833fe97
9 changed files with 856 additions and 10 deletions
+1
View File
@@ -4993,6 +4993,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"uuid",
]
[[package]]
+1
View File
@@ -47,6 +47,7 @@ pub use info::get_head_commit_hash;
pub use info::git_diff_to_remote;
pub use info::local_git_branches;
pub use info::recent_commits;
pub use operations::git_config_override_env;
pub use platform::create_symlink;
pub use status::get_has_changes_in_repo;
pub use trust::resolve_root_git_project_for_trust;
+16
View File
@@ -10,6 +10,22 @@ use crate::GitToolingError;
const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
/// Encodes Git overrides without treating equals signs in keys as separators.
pub fn git_config_override_env(
overrides: impl IntoIterator<Item = (String, String)>,
) -> Vec<(String, String)> {
let overrides: Vec<_> = overrides.into_iter().collect();
if overrides.is_empty() {
return Vec::new();
}
let mut environment = vec![("GIT_CONFIG_COUNT".to_owned(), overrides.len().to_string())];
for (index, (key, value)) in overrides.into_iter().enumerate() {
environment.push((format!("GIT_CONFIG_KEY_{index}"), key));
environment.push((format!("GIT_CONFIG_VALUE_{index}"), value));
}
environment
}
pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> {
match run_git_for_stdout(
path,
+1 -1
View File
@@ -9,7 +9,6 @@ workspace = true
[lib]
doctest = false
test = false
[dependencies]
anyhow = { workspace = true }
@@ -19,6 +18,7 @@ dunce = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+97 -9
View File
@@ -1,35 +1,76 @@
//! Executes Git metadata queries and decodes native filesystem paths.
//! Runs isolated Git commands and decodes native filesystem paths.
//!
//! Commands remove inherited repository selectors and per-command Git config,
//! select the repository from the supplied working directory, and disable hooks
//! and filesystem monitoring.
//!
//! Filesystem monitoring is disabled uniformly, including Git's built-in daemon,
//! rather than probing support. These command-local overrides avoid running
//! repository-configured helpers without changing persistent Git settings.
//!
//! Working-tree operations also disable configured clean, smudge, and process filters.
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_git_utils::SAFE_BARE_REPOSITORY_CONFIG;
use codex_git_utils::git_config_override_env;
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
#[derive(Clone, Copy)]
pub(crate) enum GitOperation {
Metadata,
WorkingTree,
}
pub(crate) fn git_output(
cwd: &Path,
operation: GitOperation,
args: impl IntoIterator<Item = impl AsRef<OsStr>>,
) -> Result<Output> {
let mut command = base_git_command(cwd);
if matches!(operation, GitOperation::WorkingTree) {
command.envs(git_config_override_env(
configured_filters(cwd)?.into_iter().flat_map(|filter| {
[
(format!("filter.{filter}.process"), String::new()),
(format!("filter.{filter}.clean"), String::new()),
(format!("filter.{filter}.smudge"), String::new()),
(format!("filter.{filter}.required"), "false".to_owned()),
]
}),
));
}
let output = command.args(args).output().context("failed to start git")?;
ensure_git_success(&output)?;
Ok(output)
}
pub(crate) fn git_stdout<I, S>(cwd: &Path, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = git_output(cwd, GitOperation::WorkingTree, args)?;
let stdout = String::from_utf8(output.stdout).context("git output is not valid UTF-8")?;
Ok(stdout.trim().to_owned())
}
pub(crate) fn git_path<I, S>(cwd: &Path, args: I) -> Result<PathBuf>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = base_git_command(cwd)
.args(args)
.output()
.context("failed to start git")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git command failed: {}", stderr.trim());
}
let output = git_output(cwd, GitOperation::Metadata, args)?;
let bytes = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout);
#[cfg(windows)]
let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes);
@@ -59,6 +100,7 @@ fn base_git_command(cwd: &Path) -> Command {
// repository from cwd, and install only our own per-command config below.
for name in [
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
@@ -93,3 +135,49 @@ fn base_git_command(cwd: &Path) -> Command {
scrub_non_inheritable_env_vars(&mut command);
command
}
fn configured_filters(cwd: &Path) -> Result<BTreeSet<String>> {
let output = base_git_command(cwd)
.args([
"config",
"--null",
"--name-only",
"--get-regexp",
"^filter\\.",
])
.output()
.context("failed to inspect Git filter configuration")?;
if !output.status.success() {
if output.status.code() == Some(1) {
return Ok(BTreeSet::new());
}
ensure_git_success(&output)?;
}
let mut filters = BTreeSet::new();
let names = String::from_utf8(output.stdout).context("Git filter names are not valid UTF-8")?;
for name in names.split('\0') {
if let Some((driver, attribute)) = name
.strip_prefix("filter.")
.and_then(|name| name.rsplit_once('.'))
&& !driver.is_empty()
&& matches!(attribute, "clean" | "smudge" | "process" | "required")
{
filters.insert(driver.to_owned());
}
}
Ok(filters)
}
fn ensure_git_success(output: &Output) -> Result<()> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git command failed: {}", stderr.trim())
}
#[cfg(test)]
#[path = "git_tests.rs"]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! Tests Git output handling and isolation from configured hooks and filters.
use std::fs;
use std::path::Path;
use std::process::Command;
#[cfg(unix)]
use anyhow::Context;
use anyhow::Result;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::GitOperation;
use super::configured_filters;
use super::git_output;
use super::git_stdout;
fn setup_git(cwd: &Path, args: &[&str]) -> Result<()> {
let output = Command::new("git").current_dir(cwd).args(args).output()?;
if !output.status.success() {
anyhow::bail!(
"git setup failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
fn repository() -> Result<(TempDir, std::path::PathBuf)> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("repository");
fs::create_dir(&root)?;
setup_git(&root, &["init", "-b", "main"])?;
setup_git(&root, &["config", "user.name", "Codex Test"])?;
setup_git(&root, &["config", "user.email", "codex@example.invalid"])?;
fs::write(root.join("tracked.txt"), "tracked\n")?;
setup_git(&root, &["add", "tracked.txt"])?;
setup_git(&root, &["commit", "--no-gpg-sign", "-m", "initial"])?;
Ok((directory, root))
}
#[test]
fn git_stdout_returns_trimmed_output() -> Result<()> {
let (_directory, root) = repository()?;
assert_eq!(git_stdout(&root, ["branch", "--show-current"])?, "main");
Ok(())
}
#[test]
fn git_output_reports_nonzero_exit_status() -> Result<()> {
let (_directory, root) = repository()?;
let error = git_output(
&root,
GitOperation::WorkingTree,
["rev-parse", "--verify", "missing-ref"],
)
.expect_err("unknown revision should fail");
assert!(error.to_string().contains("git command failed"));
Ok(())
}
#[test]
fn discovers_filter_drivers_with_dotted_names() -> Result<()> {
let (_directory, root) = repository()?;
setup_git(
&root,
&["config", "filter.some.nested.driver.smudge", "cat"],
)?;
setup_git(&root, &["config", "filter.simple.required", "true"])?;
setup_git(&root, &["config", "filter.x=y.process", "cat"])?;
let filters = configured_filters(&root)?;
assert!(filters.contains("some.nested.driver"));
assert!(filters.contains("simple"));
assert!(filters.contains("x=y"));
Ok(())
}
#[cfg(unix)]
#[test]
fn checkout_does_not_execute_configured_hooks_fsmonitor_or_filters() -> Result<()> {
let (directory, root) = repository()?;
fs::write(root.join(".gitattributes"), "*.txt filter=x=y -text\n")?;
setup_git(&root, &["add", ".gitattributes"])?;
setup_git(&root, &["commit", "--no-gpg-sign", "-m", "attributes"])?;
let hooks = directory.path().join("hooks");
fs::create_dir(&hooks)?;
let hook_marker = directory.path().join("hook-executed");
let hook = hooks.join("post-checkout");
write_marker_script(&hook, &hook_marker)?;
let fsmonitor_marker = directory.path().join("fsmonitor-executed");
let fsmonitor = directory.path().join("fsmonitor");
write_marker_script(&fsmonitor, &fsmonitor_marker)?;
let filter_marker = directory.path().join("filter-executed");
let filter = directory.path().join("filter");
write_marker_script(&filter, &filter_marker)?;
setup_git(
&root,
&[
"config",
"core.hooksPath",
hooks.to_str().context("hooks path")?,
],
)?;
setup_git(
&root,
&[
"config",
"core.fsmonitor",
fsmonitor.to_str().context("fsmonitor path")?,
],
)?;
for attribute in ["clean", "smudge", "process"] {
setup_git(
&root,
&[
"config",
&format!("filter.x=y.{attribute}"),
filter.to_str().context("filter path")?,
],
)?;
}
setup_git(&root, &["config", "filter.x=y.required", "true"])?;
let checkout = directory.path().join("checkout");
git_output(
&root,
GitOperation::WorkingTree,
[
"worktree",
"add",
"--detach",
checkout.to_str().context("checkout path")?,
"HEAD",
],
)?;
git_output(
&checkout,
GitOperation::WorkingTree,
["status", "--porcelain"],
)?;
assert!(!hook_marker.exists(), "repository hook was executed");
assert!(!fsmonitor_marker.exists(), "fsmonitor helper was executed");
assert!(!filter_marker.exists(), "attribute filter was executed");
assert_eq!(
fs::read_to_string(checkout.join("tracked.txt"))?,
"tracked\n"
);
Ok(())
}
#[cfg(unix)]
fn write_marker_script(path: &Path, marker: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let marker = marker.to_str().context("marker path is not valid UTF-8")?;
fs::write(
path,
format!("#!/bin/sh\nprintf executed > '{marker}'\ncat\n"),
)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
Ok(())
}
+155
View File
@@ -1,11 +1,19 @@
mod git;
mod metadata;
mod paths;
mod settings;
use crate::git::GitOperation;
use crate::git::git_output;
use crate::git::git_path;
use crate::git::git_stdout;
use crate::paths::allocate_worktree_root;
use crate::paths::remove_empty_bucket;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use serde::Serialize;
use std::ffi::OsStr;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
@@ -13,6 +21,25 @@ use std::path::PathBuf;
pub use settings::DEFAULT_WORKTREE_KEEP_COUNT;
pub use settings::WorktreeSettings;
/// A Desktop-compatible checkout and the cwd that should be used to start its thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ManagedWorktree {
pub root: PathBuf,
pub cwd: PathBuf,
pub source_root: PathBuf,
pub source_cwd: PathBuf,
pub head_sha: String,
/// A UTF-8 branch label, when available; absence does not imply detached HEAD.
pub branch: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWorktree {
pub source_cwd: PathBuf,
pub base: Option<String>,
}
/// Creates and identifies worktrees using the existing Codex Desktop contract.
#[derive(Clone, Debug)]
pub struct WorktreeManager {
@@ -29,6 +56,112 @@ impl WorktreeManager {
&self.settings
}
pub fn create(&self, request: &CreateWorktree) -> Result<ManagedWorktree> {
if !self.settings.root.is_absolute() {
bail!("managed worktree root must be an absolute path");
}
let source_cwd = dunce::canonicalize(&request.source_cwd)
.with_context(|| format!("cannot resolve {}", request.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 repository_name = source_root
.file_name()
.context("repository root has no directory name")?;
let revision = format!("{}^{{commit}}", request.base.as_deref().unwrap_or("HEAD"));
let head_sha = git_stdout(
&source_root,
[
"rev-parse",
"--verify",
"--end-of-options",
revision.as_str(),
],
)?;
let root = allocate_worktree_root(&self.settings.root, repository_name)?;
let result = git_output(
&source_root,
GitOperation::WorkingTree,
[
OsStr::new("worktree"),
OsStr::new("add"),
OsStr::new("--detach"),
OsStr::new("--no-checkout"),
root.as_os_str(),
OsStr::new(&head_sha),
],
);
if let Err(error) = result {
remove_empty_bucket(&root);
return Err(error).context("cannot create managed worktree");
}
// Write only the destination's config, without changing shared settings or
// enabling worktreeConfig for the source repository.
let result = git_path(
&root,
[
"rev-parse",
"--path-format=absolute",
"--git-path",
"config.worktree",
],
)
.and_then(|config| {
git_output(
&root,
GitOperation::Metadata,
[
OsStr::new("config"),
OsStr::new("--file"),
config.as_os_str(),
OsStr::new("core.worktree"),
root.as_os_str(),
],
)?;
// Discover destination-only filters before materializing files, and pin
// the working tree even when per-worktree configuration is disabled.
git_output(
&root,
GitOperation::WorkingTree,
[
"--work-tree=.",
"reset",
"--hard",
"--no-recurse-submodules",
head_sha.as_str(),
],
)
});
if let Err(error) = result {
remove_worktree(&source_root, &root)
.context("cannot roll back an incomplete managed worktree")?;
return Err(error).context("cannot populate managed worktree");
}
let cwd = root.join(relative_cwd);
if !is_safe_worktree_cwd(&root, &cwd) {
remove_worktree(&source_root, &root)
.context("cannot roll back a worktree missing its working directory")?;
bail!(
"requested base does not contain a safe working directory {}",
relative_cwd.display()
);
}
Ok(ManagedWorktree {
cwd,
root,
source_root,
source_cwd,
head_sha,
branch: None,
})
}
pub fn bind_thread(&self, checkout: &Path, thread_id: &str) -> Result<()> {
let checkout = self.managed_checkout(checkout)?;
metadata::bind_thread(&checkout, thread_id)
@@ -57,6 +190,28 @@ impl WorktreeManager {
}
}
fn remove_worktree(source_root: &Path, root: &Path) -> Result<()> {
let result = git_output(
source_root,
GitOperation::WorkingTree,
[
OsStr::new("worktree"),
OsStr::new("remove"),
OsStr::new("--force"),
root.as_os_str(),
],
);
remove_empty_bucket(root);
result.map(|_| ())
}
fn is_safe_worktree_cwd(root: &Path, cwd: &Path) -> bool {
let Ok(root) = dunce::canonicalize(root) else {
return false;
};
dunce::canonicalize(cwd).is_ok_and(|resolved| resolved.is_dir() && resolved.starts_with(&root))
}
fn linked_worktree_common_dir(checkout: &Path) -> Result<PathBuf> {
if checkout != repository_root(checkout)? {
bail!("{} is not a worktree root", checkout.display());
+46
View File
@@ -0,0 +1,46 @@
//! Allocates Desktop-compatible worktree buckets and removes empty buckets.
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
pub(crate) fn allocate_worktree_root(root: &Path, repository_name: &OsStr) -> Result<PathBuf> {
fs::create_dir_all(root)
.with_context(|| format!("cannot create worktree root {}", root.display()))?;
#[cfg(target_os = "macos")]
{
let marker = root.join(".metadata_never_index");
fs::write(&marker, b"").with_context(|| {
format!("cannot disable Spotlight indexing at {}", marker.display())
})?;
}
for _ in 0..=u16::MAX {
let identifier = Uuid::new_v4().simple().to_string();
let bucket = root.join(&identifier[..4]);
match fs::create_dir(&bucket) {
Ok(()) => return Ok(bucket.join(repository_name)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(error).with_context(|| {
format!("cannot create worktree bucket {}", bucket.display())
});
}
}
}
bail!("all managed worktree identifiers are in use")
}
pub(crate) fn remove_empty_bucket(checkout: &Path) {
if let Some(bucket) = checkout.parent() {
let _ = fs::remove_dir(bucket);
}
}
+371
View File
@@ -1,5 +1,7 @@
#![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;
@@ -66,6 +68,17 @@ fn commit(repository: &Path, message: &str) {
);
}
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)
@@ -175,3 +188,361 @@ fn thread_binding_writes_the_exact_desktop_owner_schema_into_git_metadata() {
}),
);
}
#[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",
);
}