Add worktree startup helpers

This commit is contained in:
starr-openai
2026-04-03 17:57:59 -07:00
parent eb462e8f76
commit cab5fd2f11
11 changed files with 751 additions and 6 deletions
+2
View File
@@ -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),
+11
View File
@@ -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;
+159 -5
View File
@@ -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();
+396
View File
@@ -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
}
}