mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
5a0419edb5
commit
798833fe97
Generated
+1
@@ -4993,6 +4993,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user