diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 600bc10c7b..f7bf773c64 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4844,6 +4844,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-worktree" +version = "0.0.0" +dependencies = [ + "anyhow", + "dunce", + "pretty_assertions", + "serde_json", + "tempfile", +] + [[package]] name = "color-eyre" version = "0.6.5" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ea9d044146..45bbc3642c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -98,6 +98,7 @@ members = [ "tools", "v8-poc", "websocket-client", + "worktree", "workload-identity", "utils/absolute-path", "utils/audio", diff --git a/codex-rs/worktree/BUILD.bazel b/codex-rs/worktree/BUILD.bazel new file mode 100644 index 0000000000..32ffdba592 --- /dev/null +++ b/codex-rs/worktree/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "worktree", + crate_name = "codex_worktree", +) diff --git a/codex-rs/worktree/Cargo.toml b/codex-rs/worktree/Cargo.toml new file mode 100644 index 0000000000..3bd0ebaf8f --- /dev/null +++ b/codex-rs/worktree/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-worktree" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[lib] +doctest = false +test = false + +[dependencies] +anyhow = { workspace = true } +dunce = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs new file mode 100644 index 0000000000..6478c08994 --- /dev/null +++ b/codex-rs/worktree/src/lib.rs @@ -0,0 +1,4 @@ +mod settings; + +pub use settings::DEFAULT_WORKTREE_KEEP_COUNT; +pub use settings::WorktreeSettings; diff --git a/codex-rs/worktree/src/settings.rs b/codex-rs/worktree/src/settings.rs new file mode 100644 index 0000000000..4a2fb83ea5 --- /dev/null +++ b/codex-rs/worktree/src/settings.rs @@ -0,0 +1,83 @@ +//! Parses and validates worktree settings from the existing `[desktop]` config. +//! +//! Resolves the managed worktree root and the automatic-cleanup and retention +//! settings without introducing a separate configuration format. + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +const WORKTREE_ROOT: &str = "git-worktree-root"; +const AUTO_CLEANUP: &str = "worktree-auto-cleanup-enabled"; +const KEEP_COUNT: &str = "worktree-keep-count"; + +pub const DEFAULT_WORKTREE_KEEP_COUNT: usize = 15; + +/// Effective host-local settings already understood by Codex Desktop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorktreeSettings { + pub root: PathBuf, + pub auto_cleanup_enabled: bool, + pub keep_count: usize, +} + +impl WorktreeSettings { + /// Resolves existing `[desktop]` values without introducing another config format. + pub fn from_desktop_config( + codex_home: &Path, + desktop: Option<&HashMap>, + ) -> Result { + let root = match desktop.and_then(|settings| settings.get(WORKTREE_ROOT)) { + None | Some(Value::Null) => codex_home.join("worktrees"), + Some(value) => { + let configured = value + .as_str() + .context("desktop.git-worktree-root must be a string")? + .trim(); + if configured.is_empty() { + codex_home.join("worktrees") + } else { + let path = PathBuf::from(configured); + if !path.is_absolute() { + bail!("desktop.git-worktree-root must be an absolute path"); + } + path + } + } + }; + + let auto_cleanup_enabled = desktop + .and_then(|settings| settings.get(AUTO_CLEANUP)) + .map(|value| { + value + .as_bool() + .context("desktop.worktree-auto-cleanup-enabled must be a boolean") + }) + .transpose()? + .unwrap_or(true); + + let keep_count = desktop + .and_then(|settings| settings.get(KEEP_COUNT)) + .map(|value| { + let count = value + .as_u64() + .context("desktop.worktree-keep-count must be a positive integer")?; + if count == 0 { + bail!("desktop.worktree-keep-count must be a positive integer"); + } + usize::try_from(count).context("desktop.worktree-keep-count is too large") + }) + .transpose()? + .unwrap_or(DEFAULT_WORKTREE_KEEP_COUNT); + + Ok(Self { + root: dunce::simplified(&root).to_path_buf(), + auto_cleanup_enabled, + keep_count, + }) + } +} diff --git a/codex-rs/worktree/tests/settings.rs b/codex-rs/worktree/tests/settings.rs new file mode 100644 index 0000000000..bb4726867d --- /dev/null +++ b/codex-rs/worktree/tests/settings.rs @@ -0,0 +1,76 @@ +#![allow(clippy::expect_used)] + +use codex_worktree::WorktreeSettings; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::collections::HashMap; + +fn desktop_config(entries: [(&str, Value); 3]) -> HashMap { + entries + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect() +} + +#[test] +fn default_settings_match_desktop_worktree_defaults() { + let codex_home = tempfile::tempdir().expect("create Codex home"); + + let settings = WorktreeSettings::from_desktop_config(codex_home.path(), /*desktop*/ None) + .expect("load default worktree settings"); + + assert_eq!( + settings, + WorktreeSettings { + root: codex_home.path().join("worktrees"), + auto_cleanup_enabled: true, + keep_count: 15, + } + ); +} + +#[test] +fn desktop_settings_use_existing_root_and_retention_keys() { + let codex_home = tempfile::tempdir().expect("create Codex home"); + let custom_root = codex_home.path().join("custom-managed-worktrees"); + let desktop = desktop_config([ + ( + "git-worktree-root", + json!(custom_root.to_string_lossy().into_owned()), + ), + ("worktree-auto-cleanup-enabled", json!(false)), + ("worktree-keep-count", json!(4)), + ]); + + let settings = WorktreeSettings::from_desktop_config(codex_home.path(), Some(&desktop)) + .expect("load existing Desktop worktree settings"); + + assert_eq!( + settings, + WorktreeSettings { + root: custom_root, + auto_cleanup_enabled: false, + keep_count: 4, + } + ); +} + +#[test] +fn desktop_settings_reject_invalid_root_and_retention_values() { + let codex_home = tempfile::tempdir().expect("create Codex home"); + let invalid_configs = [ + HashMap::from([("git-worktree-root".to_owned(), json!("relative/worktrees"))]), + HashMap::from([("worktree-auto-cleanup-enabled".to_owned(), json!("yes"))]), + HashMap::from([("worktree-keep-count".to_owned(), json!(0))]), + HashMap::from([("worktree-keep-count".to_owned(), json!(-1))]), + HashMap::from([("worktree-keep-count".to_owned(), json!(1.5))]), + ]; + + for desktop in invalid_configs { + assert!( + WorktreeSettings::from_desktop_config(codex_home.path(), Some(&desktop)).is_err(), + "invalid Desktop worktree settings were accepted: {desktop:?}", + ); + } +}