diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e217b70249..f9c9089a83 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4993,6 +4993,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "uuid", ] [[package]] diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index ec02057ce9..2bcd233648 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -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; diff --git a/codex-rs/git-utils/src/operations.rs b/codex-rs/git-utils/src/operations.rs index f03b5450d7..ba3196da58 100644 --- a/codex-rs/git-utils/src/operations.rs +++ b/codex-rs/git-utils/src/operations.rs @@ -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, +) -> 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, diff --git a/codex-rs/worktree/Cargo.toml b/codex-rs/worktree/Cargo.toml index fff8bb81c2..648c5d1079 100644 --- a/codex-rs/worktree/Cargo.toml +++ b/codex-rs/worktree/Cargo.toml @@ -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 } diff --git a/codex-rs/worktree/src/git.rs b/codex-rs/worktree/src/git.rs index cab518112a..18b8863317 100644 --- a/codex-rs/worktree/src/git.rs +++ b/codex-rs/worktree/src/git.rs @@ -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>, +) -> Result { + 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(cwd: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + 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(cwd: &Path, args: I) -> Result where I: IntoIterator, S: AsRef, { - 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> { + 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; diff --git a/codex-rs/worktree/src/git_tests.rs b/codex-rs/worktree/src/git_tests.rs new file mode 100644 index 0000000000..805409c5f6 --- /dev/null +++ b/codex-rs/worktree/src/git_tests.rs @@ -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(()) +} diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 2605d62579..17ce30be88 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateWorktree { + pub source_cwd: PathBuf, + pub base: Option, +} + /// 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 { + 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 { if checkout != repository_root(checkout)? { bail!("{} is not a worktree root", checkout.display()); diff --git a/codex-rs/worktree/src/paths.rs b/codex-rs/worktree/src/paths.rs new file mode 100644 index 0000000000..d9943e83ed --- /dev/null +++ b/codex-rs/worktree/src/paths.rs @@ -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 { + 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); + } +} diff --git a/codex-rs/worktree/tests/worktree.rs b/codex-rs/worktree/tests/worktree.rs index 8f90575163..5d2fdafda0 100644 --- a/codex-rs/worktree/tests/worktree.rs +++ b/codex-rs/worktree/tests/worktree.rs @@ -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 { + 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", + ); +}