mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Add worktree startup helpers
This commit is contained in:
@@ -29,6 +29,8 @@ pub enum GitToolingError {
|
||||
#[error("failed to process path inside worktree")]
|
||||
PathPrefix(#[from] std::path::StripPrefixError),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Walkdir(#[from] WalkdirError),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
@@ -9,6 +9,7 @@ mod info;
|
||||
mod operations;
|
||||
mod platform;
|
||||
mod worktree;
|
||||
mod worktree_prune;
|
||||
|
||||
pub use apply::ApplyGitRequest;
|
||||
pub use apply::ApplyGitResult;
|
||||
@@ -50,8 +51,18 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
pub use worktree::CODEX_MANAGED_WORKTREE_METADATA_FILE;
|
||||
pub use worktree::CodexManagedWorktree;
|
||||
pub use worktree::CodexManagedWorktreeMetadata;
|
||||
pub use worktree::create_codex_managed_worktree;
|
||||
pub use worktree::touch_codex_managed_worktree_metadata;
|
||||
pub use worktree_prune::CodexWorktreePruneCandidate;
|
||||
pub use worktree_prune::CodexWorktreePruneMode;
|
||||
pub use worktree_prune::CodexWorktreePruneOptions;
|
||||
pub use worktree_prune::CodexWorktreePruneReport;
|
||||
pub use worktree_prune::CodexWorktreePruneSkipReason;
|
||||
pub use worktree_prune::CodexWorktreePruneSkipped;
|
||||
pub use worktree_prune::prune_codex_managed_worktrees;
|
||||
|
||||
type CommitID = String;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::hash::Hash;
|
||||
use std::hash::Hasher;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,10 +18,13 @@ use crate::operations::resolve_head;
|
||||
use crate::operations::resolve_repository_root;
|
||||
use crate::operations::run_git_for_status;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
use crate::resolve_root_git_project_for_trust;
|
||||
|
||||
static WORKTREE_BUCKET_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub const CODEX_MANAGED_WORKTREE_MARKER_FILE: &str = "codex-managed";
|
||||
pub const CODEX_MANAGED_WORKTREE_METADATA_FILE: &str = "codex-worktree.json";
|
||||
const CODEX_MANAGED_WORKTREE_METADATA_VERSION: u64 = 1;
|
||||
|
||||
/// Metadata for a detached worktree created under `$CODEX_HOME/worktrees`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -32,6 +36,20 @@ pub struct CodexManagedWorktree {
|
||||
pub worktree_workspace_root: PathBuf,
|
||||
pub starting_ref: String,
|
||||
pub marker_path: PathBuf,
|
||||
pub metadata_path: PathBuf,
|
||||
pub metadata: CodexManagedWorktreeMetadata,
|
||||
}
|
||||
|
||||
/// Persisted metadata for a Codex-managed worktree checkout.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct CodexManagedWorktreeMetadata {
|
||||
pub version: u64,
|
||||
pub source_repo_root: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub starting_ref: String,
|
||||
pub created_at: u64,
|
||||
pub last_used_at: u64,
|
||||
}
|
||||
|
||||
/// Creates a detached worktree for `source_cwd` and returns the mapped cwd
|
||||
@@ -49,13 +67,14 @@ pub fn create_codex_managed_worktree(
|
||||
let starting_ref = starting_ref_for_repo(source_repo_root.as_path())?;
|
||||
let worktree_git_root = allocate_worktree_root(codex_home, source_repo_root.as_path())?;
|
||||
|
||||
let metadata = build_worktree_metadata(&source_repo_root, &worktree_git_root, &starting_ref);
|
||||
create_worktree_checkout(
|
||||
source_repo_root.as_path(),
|
||||
&worktree_git_root,
|
||||
&starting_ref,
|
||||
)?;
|
||||
let setup_result = setup_worktree_checkout(&worktree_git_root);
|
||||
let (worktree_git_dir, marker_path) = match setup_result {
|
||||
let setup_result = setup_worktree_checkout(&worktree_git_root, &metadata);
|
||||
let (worktree_git_dir, marker_path, metadata_path) = match setup_result {
|
||||
Ok(setup) => setup,
|
||||
Err(err) => {
|
||||
cleanup_worktree_checkout(source_repo_root.as_path(), &worktree_git_root);
|
||||
@@ -76,9 +95,30 @@ pub fn create_codex_managed_worktree(
|
||||
worktree_workspace_root,
|
||||
starting_ref,
|
||||
marker_path,
|
||||
metadata_path,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates `last_used_at` when `cwd` belongs to a Codex-managed worktree.
|
||||
pub fn touch_codex_managed_worktree_metadata(
|
||||
cwd: &Path,
|
||||
) -> Result<Option<CodexManagedWorktreeMetadata>, GitToolingError> {
|
||||
let Ok(worktree_git_dir) = worktree_git_dir(cwd) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let marker_path = worktree_git_dir.join(CODEX_MANAGED_WORKTREE_MARKER_FILE);
|
||||
if !marker_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata_path = worktree_git_dir.join(CODEX_MANAGED_WORKTREE_METADATA_FILE);
|
||||
let mut metadata = read_or_backfill_worktree_metadata(&metadata_path, cwd)?;
|
||||
metadata.last_used_at = unix_timestamp_secs();
|
||||
write_worktree_metadata(&metadata_path, &metadata)?;
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
|
||||
fn starting_ref_for_repo(repo_root: &Path) -> Result<String, GitToolingError> {
|
||||
let branch = run_git_for_stdout(
|
||||
repo_root,
|
||||
@@ -167,10 +207,12 @@ fn create_worktree_checkout(
|
||||
|
||||
fn setup_worktree_checkout(
|
||||
worktree_git_root: &Path,
|
||||
) -> Result<(PathBuf, PathBuf), GitToolingError> {
|
||||
metadata: &CodexManagedWorktreeMetadata,
|
||||
) -> Result<(PathBuf, PathBuf, PathBuf), GitToolingError> {
|
||||
let worktree_git_dir = worktree_git_dir(worktree_git_root)?;
|
||||
let marker_path = write_codex_managed_marker(&worktree_git_dir)?;
|
||||
Ok((worktree_git_dir, marker_path))
|
||||
let metadata_path = write_worktree_metadata_in_git_dir(&worktree_git_dir, metadata)?;
|
||||
Ok((worktree_git_dir, marker_path, metadata_path))
|
||||
}
|
||||
|
||||
fn cleanup_worktree_checkout(source_repo_root: &Path, worktree_git_root: &Path) {
|
||||
@@ -194,7 +236,72 @@ fn write_codex_managed_marker(worktree_git_dir: &Path) -> Result<PathBuf, GitToo
|
||||
Ok(marker_path)
|
||||
}
|
||||
|
||||
fn worktree_git_dir(worktree_git_root: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
fn write_worktree_metadata_in_git_dir(
|
||||
worktree_git_dir: &Path,
|
||||
metadata: &CodexManagedWorktreeMetadata,
|
||||
) -> Result<PathBuf, GitToolingError> {
|
||||
let metadata_path = worktree_git_dir.join(CODEX_MANAGED_WORKTREE_METADATA_FILE);
|
||||
write_worktree_metadata(&metadata_path, metadata)?;
|
||||
Ok(metadata_path)
|
||||
}
|
||||
|
||||
fn write_worktree_metadata(
|
||||
metadata_path: &Path,
|
||||
metadata: &CodexManagedWorktreeMetadata,
|
||||
) -> Result<(), GitToolingError> {
|
||||
let metadata_json = serde_json::to_vec_pretty(metadata)?;
|
||||
fs::write(metadata_path, metadata_json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn read_or_backfill_worktree_metadata(
|
||||
metadata_path: &Path,
|
||||
cwd: &Path,
|
||||
) -> Result<CodexManagedWorktreeMetadata, GitToolingError> {
|
||||
match fs::read(metadata_path) {
|
||||
Ok(metadata_json) => Ok(serde_json::from_slice(&metadata_json)?),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => backfill_worktree_metadata(cwd),
|
||||
Err(err) => Err(GitToolingError::Io(err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn backfill_worktree_metadata(cwd: &Path) -> Result<CodexManagedWorktreeMetadata, GitToolingError> {
|
||||
let worktree_git_root = resolve_repository_root(cwd)?;
|
||||
let source_repo_root = resolve_root_git_project_for_trust(worktree_git_root.as_path())
|
||||
.unwrap_or_else(|| worktree_git_root.clone());
|
||||
// Legacy marker-only worktrees do not record the original checkout ref, so
|
||||
// fail closed by leaving `starting_ref` unknown instead of inferring HEAD.
|
||||
Ok(build_worktree_metadata(
|
||||
&source_repo_root,
|
||||
&worktree_git_root,
|
||||
"",
|
||||
))
|
||||
}
|
||||
|
||||
fn build_worktree_metadata(
|
||||
source_repo_root: &Path,
|
||||
worktree_git_root: &Path,
|
||||
starting_ref: &str,
|
||||
) -> CodexManagedWorktreeMetadata {
|
||||
let timestamp = unix_timestamp_secs();
|
||||
CodexManagedWorktreeMetadata {
|
||||
version: CODEX_MANAGED_WORKTREE_METADATA_VERSION,
|
||||
source_repo_root: source_repo_root.to_path_buf(),
|
||||
worktree_git_root: worktree_git_root.to_path_buf(),
|
||||
starting_ref: starting_ref.to_string(),
|
||||
created_at: timestamp,
|
||||
last_used_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub(crate) fn worktree_git_dir(worktree_git_root: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let git_dir = run_git_for_stdout(
|
||||
worktree_git_root,
|
||||
vec![OsString::from("rev-parse"), OsString::from("--git-dir")],
|
||||
@@ -211,12 +318,15 @@ fn worktree_git_dir(worktree_git_root: &Path) -> Result<PathBuf, GitToolingError
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CODEX_MANAGED_WORKTREE_MARKER_FILE;
|
||||
use super::CODEX_MANAGED_WORKTREE_METADATA_FILE;
|
||||
use super::CodexManagedWorktree;
|
||||
use super::CodexManagedWorktreeMetadata;
|
||||
use super::allocate_worktree_root;
|
||||
use super::cleanup_worktree_checkout;
|
||||
use super::create_codex_managed_worktree;
|
||||
use super::create_worktree_checkout;
|
||||
use super::starting_ref_for_repo;
|
||||
use super::touch_codex_managed_worktree_metadata;
|
||||
use crate::GitToolingError;
|
||||
#[cfg(unix)]
|
||||
use crate::platform::create_symlink;
|
||||
@@ -306,6 +416,23 @@ mod tests {
|
||||
.worktree_git_dir
|
||||
.join(CODEX_MANAGED_WORKTREE_MARKER_FILE)
|
||||
);
|
||||
assert_eq!(
|
||||
result.metadata_path,
|
||||
result
|
||||
.worktree_git_dir
|
||||
.join(CODEX_MANAGED_WORKTREE_METADATA_FILE)
|
||||
);
|
||||
assert_eq!(
|
||||
result.metadata,
|
||||
CodexManagedWorktreeMetadata {
|
||||
version: 1,
|
||||
source_repo_root: expected_repo_root,
|
||||
worktree_git_root: result.worktree_git_root.clone(),
|
||||
starting_ref: "main".to_string(),
|
||||
created_at: result.metadata.created_at,
|
||||
last_used_at: result.metadata.created_at,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -359,6 +486,33 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_codex_managed_worktree_writes_and_touches_metadata() -> Result<(), GitToolingError> {
|
||||
let (_temp, _repo, nested) = create_repo_with_nested_cwd();
|
||||
let codex_home = tempdir().expect("codex home");
|
||||
|
||||
let result = create_codex_managed_worktree(&nested, codex_home.path())?;
|
||||
let mut metadata: CodexManagedWorktreeMetadata =
|
||||
serde_json::from_slice(&fs::read(&result.metadata_path)?)?;
|
||||
assert_eq!(metadata, result.metadata);
|
||||
|
||||
metadata.last_used_at = 1;
|
||||
fs::write(&result.metadata_path, serde_json::to_vec_pretty(&metadata)?)?;
|
||||
|
||||
let touched = touch_codex_managed_worktree_metadata(&result.worktree_workspace_root)?
|
||||
.expect("managed worktree metadata");
|
||||
|
||||
assert_eq!(touched.created_at, result.metadata.created_at);
|
||||
assert!(touched.last_used_at > 1);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<CodexManagedWorktreeMetadata>(&fs::read(
|
||||
&result.metadata_path
|
||||
)?)?,
|
||||
touched
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_worktree_checkout_removes_worktree_registration() -> Result<(), GitToolingError> {
|
||||
let (_temp, repo, _nested) = create_repo_with_nested_cwd();
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::GitToolingError;
|
||||
use crate::operations::run_git_for_status;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
use crate::worktree::CODEX_MANAGED_WORKTREE_MARKER_FILE;
|
||||
use crate::worktree::CODEX_MANAGED_WORKTREE_METADATA_FILE;
|
||||
use crate::worktree::CodexManagedWorktreeMetadata;
|
||||
use crate::worktree::read_or_backfill_worktree_metadata;
|
||||
use crate::worktree::worktree_git_dir;
|
||||
|
||||
/// Filters and deletion behavior for pruning Codex-managed worktrees.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWorktreePruneOptions {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_repo_root: Option<PathBuf>,
|
||||
pub created_before: Option<u64>,
|
||||
pub last_used_before: Option<u64>,
|
||||
pub mode: CodexWorktreePruneMode,
|
||||
}
|
||||
|
||||
/// Controls whether prune removes matching worktrees or only reports them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodexWorktreePruneMode {
|
||||
DryRun,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// One Codex-managed worktree selected by the prune scanner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWorktreePruneCandidate {
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub metadata_path: PathBuf,
|
||||
pub metadata: CodexManagedWorktreeMetadata,
|
||||
}
|
||||
|
||||
/// Result of a prune scan over `$CODEX_HOME/worktrees`.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWorktreePruneReport {
|
||||
pub pruned: Vec<CodexWorktreePruneCandidate>,
|
||||
pub kept: Vec<CodexWorktreePruneCandidate>,
|
||||
pub skipped: Vec<CodexWorktreePruneSkipped>,
|
||||
}
|
||||
|
||||
/// A managed worktree was discovered but not deleted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWorktreePruneSkipped {
|
||||
pub candidate: CodexWorktreePruneCandidate,
|
||||
pub reason: CodexWorktreePruneSkipReason,
|
||||
}
|
||||
|
||||
/// Why a managed worktree was not safe to prune.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodexWorktreePruneSkipReason {
|
||||
DirtyWorktree,
|
||||
LocalCommits,
|
||||
MissingStartingRef,
|
||||
}
|
||||
|
||||
/// Scans and optionally removes Codex-managed worktrees that match `options`.
|
||||
pub fn prune_codex_managed_worktrees(
|
||||
options: &CodexWorktreePruneOptions,
|
||||
) -> Result<CodexWorktreePruneReport, GitToolingError> {
|
||||
let mut report = CodexWorktreePruneReport::default();
|
||||
let candidates = discover_codex_managed_worktrees(options.codex_home.as_path())?;
|
||||
for candidate in candidates {
|
||||
if !worktree_matches_prune_filters(&candidate, options)? {
|
||||
report.kept.push(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
match classify_prune_candidate(&candidate) {
|
||||
Ok(None) => {
|
||||
if options.mode == CodexWorktreePruneMode::Delete {
|
||||
delete_prune_candidate(&candidate)?;
|
||||
}
|
||||
report.pruned.push(candidate);
|
||||
}
|
||||
Ok(Some(reason)) => {
|
||||
report
|
||||
.skipped
|
||||
.push(CodexWorktreePruneSkipped { candidate, reason });
|
||||
}
|
||||
Err(_) => {
|
||||
report.skipped.push(CodexWorktreePruneSkipped {
|
||||
candidate,
|
||||
reason: CodexWorktreePruneSkipReason::MissingStartingRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn discover_codex_managed_worktrees(
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<CodexWorktreePruneCandidate>, GitToolingError> {
|
||||
let worktrees_root = codex_home.join("worktrees");
|
||||
let mut candidates = Vec::new();
|
||||
let Ok(bucket_entries) = fs::read_dir(&worktrees_root) else {
|
||||
return Ok(candidates);
|
||||
};
|
||||
|
||||
for bucket_entry in bucket_entries {
|
||||
let bucket_entry = bucket_entry?;
|
||||
if !bucket_entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for worktree_entry in fs::read_dir(bucket_entry.path())? {
|
||||
let worktree_entry = worktree_entry?;
|
||||
if !worktree_entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let worktree_git_root = worktree_entry.path();
|
||||
let Ok(worktree_git_dir) = worktree_git_dir(&worktree_git_root) else {
|
||||
continue;
|
||||
};
|
||||
if !worktree_git_dir
|
||||
.join(CODEX_MANAGED_WORKTREE_MARKER_FILE)
|
||||
.exists()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let metadata_path = worktree_git_dir.join(CODEX_MANAGED_WORKTREE_METADATA_FILE);
|
||||
let metadata = read_or_backfill_worktree_metadata(&metadata_path, &worktree_git_root)?;
|
||||
candidates.push(CodexWorktreePruneCandidate {
|
||||
worktree_git_root,
|
||||
metadata_path,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn worktree_matches_prune_filters(
|
||||
candidate: &CodexWorktreePruneCandidate,
|
||||
options: &CodexWorktreePruneOptions,
|
||||
) -> Result<bool, GitToolingError> {
|
||||
if let Some(source_repo_root) = options.source_repo_root.as_deref()
|
||||
&& candidate.metadata.source_repo_root != source_repo_root.canonicalize()?
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(created_before) = options.created_before
|
||||
&& candidate.metadata.created_at >= created_before
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(last_used_before) = options.last_used_before
|
||||
&& candidate.metadata.last_used_at >= last_used_before
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn classify_prune_candidate(
|
||||
candidate: &CodexWorktreePruneCandidate,
|
||||
) -> Result<Option<CodexWorktreePruneSkipReason>, GitToolingError> {
|
||||
if candidate.metadata.starting_ref.is_empty() {
|
||||
return Ok(Some(CodexWorktreePruneSkipReason::MissingStartingRef));
|
||||
}
|
||||
|
||||
let status = run_git_for_stdout(
|
||||
candidate.worktree_git_root.as_path(),
|
||||
vec![
|
||||
OsString::from("status"),
|
||||
OsString::from("--porcelain"),
|
||||
OsString::from("--untracked-files=all"),
|
||||
],
|
||||
/*env*/ None,
|
||||
)?;
|
||||
if !status.is_empty() {
|
||||
return Ok(Some(CodexWorktreePruneSkipReason::DirtyWorktree));
|
||||
}
|
||||
|
||||
let local_commits = run_git_for_stdout(
|
||||
candidate.worktree_git_root.as_path(),
|
||||
vec![
|
||||
OsString::from("rev-list"),
|
||||
OsString::from("--max-count=1"),
|
||||
OsString::from(format!("{}..HEAD", candidate.metadata.starting_ref)),
|
||||
],
|
||||
/*env*/ None,
|
||||
)?;
|
||||
if !local_commits.is_empty() {
|
||||
return Ok(Some(CodexWorktreePruneSkipReason::LocalCommits));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn delete_prune_candidate(candidate: &CodexWorktreePruneCandidate) -> Result<(), GitToolingError> {
|
||||
run_git_for_status(
|
||||
candidate.metadata.source_repo_root.as_path(),
|
||||
vec![
|
||||
OsString::from("worktree"),
|
||||
OsString::from("remove"),
|
||||
OsString::from("--force"),
|
||||
OsString::from(candidate.worktree_git_root.as_os_str()),
|
||||
],
|
||||
/*env*/ None,
|
||||
)?;
|
||||
run_git_for_status(
|
||||
candidate.metadata.source_repo_root.as_path(),
|
||||
vec![OsString::from("worktree"), OsString::from("prune")],
|
||||
/*env*/ None,
|
||||
)?;
|
||||
let _ = fs::remove_dir_all(&candidate.worktree_git_root);
|
||||
if let Some(bucket_path) = candidate.worktree_git_root.parent() {
|
||||
let _ = fs::remove_dir(bucket_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CodexWorktreePruneCandidate;
|
||||
use super::CodexWorktreePruneMode;
|
||||
use super::CodexWorktreePruneOptions;
|
||||
use super::CodexWorktreePruneSkipReason;
|
||||
use super::prune_codex_managed_worktrees;
|
||||
use crate::CodexManagedWorktreeMetadata;
|
||||
use crate::create_codex_managed_worktree;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn prune_codex_managed_worktrees_filters_by_repo_and_timestamps_and_skips_dirty_worktrees() {
|
||||
let (_first_temp, first_repo, first_nested) = create_repo_with_nested_cwd();
|
||||
let (_second_temp, _second_repo, second_nested) = create_repo_with_nested_cwd();
|
||||
let codex_home = tempdir().expect("codex home");
|
||||
|
||||
let old_clean = create_codex_managed_worktree(&first_nested, codex_home.path())
|
||||
.expect("old clean worktree");
|
||||
let old_dirty = create_codex_managed_worktree(&first_nested, codex_home.path())
|
||||
.expect("old dirty worktree");
|
||||
let new_clean = create_codex_managed_worktree(&first_nested, codex_home.path())
|
||||
.expect("new clean worktree");
|
||||
let other_repo =
|
||||
create_codex_managed_worktree(&second_nested, codex_home.path()).expect("other repo");
|
||||
|
||||
rewrite_metadata_timestamp(&old_clean.metadata_path, 10, 20);
|
||||
rewrite_metadata_timestamp(&old_dirty.metadata_path, 10, 20);
|
||||
rewrite_metadata_timestamp(&new_clean.metadata_path, 100, 200);
|
||||
rewrite_metadata_timestamp(&other_repo.metadata_path, 10, 20);
|
||||
fs::write(old_dirty.worktree_git_root.join("dirty.txt"), "dirty\n")
|
||||
.expect("dirty worktree");
|
||||
|
||||
let report = prune_codex_managed_worktrees(&CodexWorktreePruneOptions {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
source_repo_root: Some(first_repo),
|
||||
created_before: Some(50),
|
||||
last_used_before: Some(50),
|
||||
mode: CodexWorktreePruneMode::Delete,
|
||||
})
|
||||
.expect("prune worktrees");
|
||||
|
||||
assert_eq!(
|
||||
sorted_worktree_paths(&report.pruned),
|
||||
vec![old_clean.worktree_git_root.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skipped| (skipped.candidate.worktree_git_root.clone(), skipped.reason))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(
|
||||
old_dirty.worktree_git_root.clone(),
|
||||
CodexWorktreePruneSkipReason::DirtyWorktree
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
sorted_worktree_paths(&report.kept),
|
||||
sorted_paths(vec![
|
||||
new_clean.worktree_git_root.clone(),
|
||||
other_repo.worktree_git_root.clone()
|
||||
])
|
||||
);
|
||||
assert!(!old_clean.worktree_git_root.exists());
|
||||
assert!(old_dirty.worktree_git_root.exists());
|
||||
assert!(new_clean.worktree_git_root.exists());
|
||||
assert!(other_repo.worktree_git_root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_codex_managed_worktrees_skips_marker_only_legacy_worktrees() {
|
||||
let (_temp, _repo, nested) = create_repo_with_nested_cwd();
|
||||
let codex_home = tempdir().expect("codex home");
|
||||
let legacy =
|
||||
create_codex_managed_worktree(&nested, codex_home.path()).expect("legacy worktree");
|
||||
fs::remove_file(&legacy.metadata_path).expect("remove metadata sidecar");
|
||||
|
||||
let report = prune_codex_managed_worktrees(&CodexWorktreePruneOptions {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
source_repo_root: None,
|
||||
created_before: None,
|
||||
last_used_before: None,
|
||||
mode: CodexWorktreePruneMode::Delete,
|
||||
})
|
||||
.expect("prune worktrees");
|
||||
|
||||
assert_eq!(report.pruned, Vec::new());
|
||||
assert_eq!(report.kept, Vec::new());
|
||||
assert_eq!(report.skipped.len(), 1);
|
||||
assert_eq!(
|
||||
report.skipped[0].candidate.worktree_git_root,
|
||||
legacy.worktree_git_root
|
||||
);
|
||||
assert_eq!(
|
||||
report.skipped[0].reason,
|
||||
CodexWorktreePruneSkipReason::MissingStartingRef
|
||||
);
|
||||
assert!(legacy.worktree_git_root.exists());
|
||||
}
|
||||
|
||||
fn create_repo_with_nested_cwd() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let repo = temp.path().join("repo");
|
||||
let nested = repo.join("nested").join("path");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
init_test_repo(&repo);
|
||||
fs::write(repo.join("README.md"), "hello\n").expect("write file");
|
||||
fs::write(nested.join("marker.txt"), "nested\n").expect("write nested file");
|
||||
commit(&repo, "initial");
|
||||
(temp, repo, nested)
|
||||
}
|
||||
|
||||
fn init_test_repo(repo_path: &Path) {
|
||||
run_git_in(repo_path, &["init", "--initial-branch=main"]);
|
||||
run_git_in(repo_path, &["config", "core.autocrlf", "false"]);
|
||||
run_git_in(repo_path, &["config", "user.name", "Tester"]);
|
||||
run_git_in(repo_path, &["config", "user.email", "test@example.com"]);
|
||||
}
|
||||
|
||||
fn commit(repo_path: &Path, message: &str) {
|
||||
run_git_in(repo_path, &["add", "."]);
|
||||
run_git_in(
|
||||
repo_path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn run_git_in(repo_path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(repo_path)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git command");
|
||||
assert!(status.success(), "git command failed: {args:?}");
|
||||
}
|
||||
|
||||
fn rewrite_metadata_timestamp(metadata_path: &Path, created_at: u64, last_used_at: u64) {
|
||||
let mut metadata: CodexManagedWorktreeMetadata =
|
||||
serde_json::from_slice(&fs::read(metadata_path).expect("read metadata"))
|
||||
.expect("parse metadata");
|
||||
metadata.created_at = created_at;
|
||||
metadata.last_used_at = last_used_at;
|
||||
fs::write(
|
||||
metadata_path,
|
||||
serde_json::to_vec_pretty(&metadata).expect("serialize metadata"),
|
||||
)
|
||||
.expect("write metadata");
|
||||
}
|
||||
|
||||
fn sorted_worktree_paths(candidates: &[CodexWorktreePruneCandidate]) -> Vec<PathBuf> {
|
||||
sorted_paths(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.worktree_git_root.clone())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sorted_paths(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user