From cab5fd2f116ed2bba2a33e3a735246511557c545 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 3 Apr 2026 17:57:59 -0700 Subject: [PATCH] Add worktree startup helpers --- codex-rs/Cargo.lock | 2 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 14 + codex-rs/cli/src/worktree_cmd.rs | 134 ++++++++ codex-rs/exec/src/lib.rs | 15 + codex-rs/git-utils/Cargo.toml | 1 + codex-rs/git-utils/src/errors.rs | 2 + codex-rs/git-utils/src/lib.rs | 11 + codex-rs/git-utils/src/worktree.rs | 164 +++++++++- codex-rs/git-utils/src/worktree_prune.rs | 396 +++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 17 +- 11 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 codex-rs/cli/src/worktree_cmd.rs create mode 100644 codex-rs/git-utils/src/worktree_prune.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4eb2c3e5f2..153b393bd6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1628,6 +1628,7 @@ dependencies = [ "codex-exec", "codex-execpolicy", "codex-features", + "codex-git-utils", "codex-login", "codex-mcp-server", "codex-protocol", @@ -2144,6 +2145,7 @@ dependencies = [ "regex", "schemars 0.8.22", "serde", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6788d5f9e7..a15a2d0233 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -31,6 +31,7 @@ codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 082965b3d2..e195118903 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -39,10 +39,13 @@ mod app_cmd; #[cfg(target_os = "macos")] mod desktop_app; mod mcp_cmd; +mod worktree_cmd; #[cfg(not(windows))] mod wsl_paths; use crate::mcp_cmd::McpCli; +use crate::worktree_cmd::WorktreeCli; +use crate::worktree_cmd::run_worktree_command; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -150,6 +153,9 @@ enum Subcommand { /// Inspect feature flags. Features(FeaturesCli), + + /// Manage Codex-created local git worktrees. + Worktree(WorktreeCli), } #[derive(Debug, Parser)] @@ -1028,6 +1034,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { disable_feature_in_config(&interactive, &feature).await?; } }, + Some(Subcommand::Worktree(worktree_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "worktree", + )?; + run_worktree_command(worktree_cli)?; + } } Ok(()) diff --git a/codex-rs/cli/src/worktree_cmd.rs b/codex-rs/cli/src/worktree_cmd.rs new file mode 100644 index 0000000000..34b25e902a --- /dev/null +++ b/codex-rs/cli/src/worktree_cmd.rs @@ -0,0 +1,134 @@ +use clap::Parser; +use codex_core::config::find_codex_home; +use codex_git_utils::CodexWorktreePruneCandidate; +use codex_git_utils::CodexWorktreePruneMode; +use codex_git_utils::CodexWorktreePruneOptions; +use codex_git_utils::CodexWorktreePruneSkipReason; +use codex_git_utils::CodexWorktreePruneSkipped; +use codex_git_utils::prune_codex_managed_worktrees; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub(crate) struct WorktreeCli { + #[command(subcommand)] + pub(crate) subcommand: WorktreeSubcommand, +} + +#[derive(Debug, Parser)] +pub(crate) enum WorktreeSubcommand { + /// Remove old Codex-managed local git worktrees. + Prune(WorktreePruneCommand), +} + +#[derive(Debug, Parser)] +pub(crate) struct WorktreePruneCommand { + /// Only prune worktrees created from this source repository. + #[arg(long = "repo", value_name = "DIR")] + repo: Option, + + /// Only prune worktrees created before this Unix timestamp (seconds). + #[arg(long = "created-before", value_name = "UNIX_SECONDS")] + created_before: Option, + + /// Only prune worktrees last used before this Unix timestamp (seconds). + #[arg(long = "last-used-before", value_name = "UNIX_SECONDS")] + last_used_before: Option, + + /// Print matching worktrees without deleting them. + #[arg(long = "dry-run", default_value_t = false)] + dry_run: bool, +} + +pub(crate) fn run_worktree_command(worktree_cli: WorktreeCli) -> anyhow::Result<()> { + match worktree_cli.subcommand { + WorktreeSubcommand::Prune(command) => run_worktree_prune_command(command), + } +} + +fn run_worktree_prune_command(command: WorktreePruneCommand) -> anyhow::Result<()> { + let options = CodexWorktreePruneOptions { + codex_home: find_codex_home()?, + source_repo_root: command.repo, + created_before: command.created_before, + last_used_before: command.last_used_before, + mode: if command.dry_run { + CodexWorktreePruneMode::DryRun + } else { + CodexWorktreePruneMode::Delete + }, + }; + + let report = prune_codex_managed_worktrees(&options)?; + for candidate in &report.pruned { + print_candidate("pruned", candidate); + } + for skipped in &report.skipped { + print_skipped_candidate(skipped); + } + for candidate in &report.kept { + print_candidate("kept", candidate); + } + println!( + "summary: pruned={} skipped={} kept={}", + report.pruned.len(), + report.skipped.len(), + report.kept.len() + ); + Ok(()) +} + +fn print_candidate(action: &str, candidate: &CodexWorktreePruneCandidate) { + println!( + "{action} {} repo={} created_at={} last_used_at={}", + candidate.worktree_git_root.display(), + candidate.metadata.source_repo_root.display(), + candidate.metadata.created_at, + candidate.metadata.last_used_at + ); +} + +fn print_skipped_candidate(skipped: &CodexWorktreePruneSkipped) { + let reason = match skipped.reason { + CodexWorktreePruneSkipReason::DirtyWorktree => "dirty-worktree", + CodexWorktreePruneSkipReason::LocalCommits => "local-commits", + CodexWorktreePruneSkipReason::MissingStartingRef => "missing-starting-ref", + }; + println!( + "skipped {} reason={} repo={} created_at={} last_used_at={}", + skipped.candidate.worktree_git_root.display(), + reason, + skipped.candidate.metadata.source_repo_root.display(), + skipped.candidate.metadata.created_at, + skipped.candidate.metadata.last_used_at + ); +} + +#[cfg(test)] +mod tests { + use super::WorktreeCli; + use super::WorktreeSubcommand; + use clap::Parser; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn parse_worktree_prune_filters() { + let cli = WorktreeCli::parse_from([ + "codex-worktree", + "prune", + "--repo", + "/tmp/repo", + "--created-before", + "123", + "--last-used-before", + "456", + "--dry-run", + ]); + + let WorktreeSubcommand::Prune(command) = cli.subcommand; + assert_eq!(command.repo, Some(PathBuf::from("/tmp/repo"))); + assert_eq!(command.created_before, Some(123)); + assert_eq!(command.last_used_before, Some(456)); + assert!(command.dry_run); + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b4fe948ff3..cde8aaa49a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -70,6 +70,7 @@ use codex_git_utils::CodexManagedWorktree; use codex_git_utils::GitToolingError; use codex_git_utils::create_codex_managed_worktree; use codex_git_utils::get_git_repo_root; +use codex_git_utils::touch_codex_managed_worktree_metadata; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; use codex_protocol::config_types::SandboxMode; @@ -196,6 +197,9 @@ fn resolve_startup_cwd( }; let Some(worktree_creator) = worktree_creator else { + if let Err(err) = touch_codex_managed_worktree_metadata(config_cwd.as_path()) { + warn!(?err, "failed to refresh Codex-managed worktree metadata"); + } return Ok(StartupCwd { resolved_cwd: requested_cwd, config_cwd, @@ -1709,6 +1713,7 @@ fn build_review_request(args: &ReviewArgs) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_git_utils::CodexManagedWorktreeMetadata; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::config_types::ApprovalsReviewer; use opentelemetry::trace::TraceContextExt; @@ -1785,6 +1790,7 @@ mod tests { let worktree_git_root = codex_home.join("worktrees/fake/project"); let worktree_git_dir = worktree_git_root.join(".git"); let marker_path = worktree_git_dir.join("codex-managed"); + let metadata_path = worktree_git_dir.join("codex-worktree.json"); Ok(CodexManagedWorktree { source_cwd: source_cwd.to_path_buf(), source_repo_root: source_cwd.to_path_buf(), @@ -1793,6 +1799,15 @@ mod tests { worktree_workspace_root: worktree_git_root.join("nested/path"), starting_ref: "main".to_string(), marker_path, + metadata_path, + metadata: CodexManagedWorktreeMetadata { + version: 1, + source_repo_root: source_cwd.to_path_buf(), + worktree_git_root, + starting_ref: "main".to_string(), + created_at: 1, + last_used_at: 1, + }, }) }), ) diff --git a/codex-rs/git-utils/Cargo.toml b/codex-rs/git-utils/Cargo.toml index 7ecc72dfa9..7a389f9383 100644 --- a/codex-rs/git-utils/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -15,6 +15,7 @@ once_cell = { workspace = true } regex = "1" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "process", "rt", "time"] } diff --git a/codex-rs/git-utils/src/errors.rs b/codex-rs/git-utils/src/errors.rs index 41f7b6df49..634ba0796e 100644 --- a/codex-rs/git-utils/src/errors.rs +++ b/codex-rs/git-utils/src/errors.rs @@ -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), diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index d74a4de239..a97dce3f2c 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -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; diff --git a/codex-rs/git-utils/src/worktree.rs b/codex-rs/git-utils/src/worktree.rs index b8c5c5340a..c8d7acaeff 100644 --- a/codex-rs/git-utils/src/worktree.rs +++ b/codex-rs/git-utils/src/worktree.rs @@ -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, 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 { 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 Result { +fn write_worktree_metadata_in_git_dir( + worktree_git_dir: &Path, + metadata: &CodexManagedWorktreeMetadata, +) -> Result { + 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 { + 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 { + 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 { 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 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::(&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(); diff --git a/codex-rs/git-utils/src/worktree_prune.rs b/codex-rs/git-utils/src/worktree_prune.rs new file mode 100644 index 0000000000..ab0f55402b --- /dev/null +++ b/codex-rs/git-utils/src/worktree_prune.rs @@ -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, + pub created_before: Option, + pub last_used_before: Option, + 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, + pub kept: Vec, + pub skipped: Vec, +} + +/// 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 { + 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, 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 { + 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, 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![( + 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 { + sorted_paths( + candidates + .iter() + .map(|candidate| candidate.worktree_git_root.clone()) + .collect(), + ) + } + + fn sorted_paths(mut paths: Vec) -> Vec { + paths.sort(); + paths + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 709da092a4..a459c440f7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -43,6 +43,7 @@ use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_git_utils::CodexManagedWorktree; use codex_git_utils::GitToolingError; use codex_git_utils::create_codex_managed_worktree; +use codex_git_utils::touch_codex_managed_worktree_metadata; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -606,6 +607,9 @@ fn resolve_startup_cwd( }; let Some(worktree_creator) = worktree_creator else { + if let Err(err) = touch_codex_managed_worktree_metadata(config_cwd.as_path()) { + warn!(?err, "failed to refresh Codex-managed worktree metadata"); + } return Ok(StartupCwd { cwd: requested_cwd, config_cwd, @@ -1692,6 +1696,7 @@ mod tests { use codex_core::config::ConfigOverrides; use codex_core::config::ProjectConfig; use codex_features::Feature; + use codex_git_utils::CodexManagedWorktreeMetadata; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -1759,7 +1764,7 @@ mod tests { StartupCwd { cwd: Some(cwd.path().to_path_buf()), config_cwd: AbsolutePathBuf::from_absolute_path( - &cwd.path().canonicalize().expect("canonicalize cwd") + cwd.path().canonicalize().expect("canonicalize cwd") ) .expect("absolute cwd"), } @@ -1778,6 +1783,7 @@ mod tests { let worktree_git_root = codex_home.join("worktrees/fake/project"); let worktree_git_dir = worktree_git_root.join(".git"); let marker_path = worktree_git_dir.join("codex-managed"); + let metadata_path = worktree_git_dir.join("codex-worktree.json"); Ok(CodexManagedWorktree { source_cwd: source_cwd.to_path_buf(), source_repo_root: source_cwd.to_path_buf(), @@ -1786,6 +1792,15 @@ mod tests { worktree_workspace_root: worktree_git_root.join("nested/path"), starting_ref: "main".to_string(), marker_path, + metadata_path, + metadata: CodexManagedWorktreeMetadata { + version: 1, + source_repo_root: source_cwd.to_path_buf(), + worktree_git_root, + starting_ref: "main".to_string(), + created_at: 1, + last_used_at: 1, + }, }) }), )