feat(cli): add managed worktree workflow

This commit is contained in:
Felipe Coury
2026-05-06 19:44:04 -03:00
parent f32c496144
commit 5a6efcf183
27 changed files with 2157 additions and 14 deletions
+16
View File
@@ -2211,6 +2211,7 @@ dependencies = [
"codex-utils-cli",
"codex-utils-path",
"codex-windows-sandbox",
"codex-worktree",
"libc",
"owo-colors",
"predicates",
@@ -2678,6 +2679,7 @@ dependencies = [
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-oss",
"codex-worktree",
"core_test_support",
"libc",
"opentelemetry",
@@ -3713,6 +3715,7 @@ dependencies = [
"codex-utils-sleep-inhibitor",
"codex-utils-string",
"codex-windows-sandbox",
"codex-worktree",
"color-eyre",
"cpal",
"crossterm",
@@ -4021,6 +4024,19 @@ dependencies = [
"winres",
]
[[package]]
name = "codex-worktree"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-utils-absolute-path",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
+2
View File
@@ -74,6 +74,7 @@ members = [
"otel",
"tui",
"tools",
"worktree",
"v8-poc",
"utils/absolute-path",
"utils/cargo-bin",
@@ -206,6 +207,7 @@ codex-thread-store = { path = "thread-store" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-uds = { path = "uds" }
codex-worktree = { path = "worktree" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
codex-utils-approval-presets = { path = "utils/approval-presets" }
codex-utils-cache = { path = "utils/cache" }
+1
View File
@@ -49,6 +49,7 @@ codex-state = { workspace = true }
codex-stdio-to-uds = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-tui = { workspace = true }
codex-worktree = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path = { workspace = true }
libc = { workspace = true }
+388
View File
@@ -34,8 +34,18 @@ use codex_tui::ExitReason;
use codex_tui::UpdateAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::SharedCliOptions;
use codex_utils_cli::WorktreeDirtyCliArg;
use codex_worktree::DirtyPolicy;
use codex_worktree::WorktreeInfo;
use codex_worktree::WorktreeListQuery;
use codex_worktree::WorktreeRemoveRequest;
use codex_worktree::WorktreeRequest;
use codex_worktree::WorktreeResolution;
use owo_colors::OwoColorize;
use std::fs;
use std::io::IsTerminal;
use std::path::Path;
use std::path::PathBuf;
use supports_color::Stream;
@@ -160,6 +170,9 @@ enum Subcommand {
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
Fork(ForkCommand),
/// Manage Codex-managed Git worktrees.
Worktree(WorktreeCli),
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
@@ -330,6 +343,70 @@ struct ForkCommand {
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex worktree")]
struct WorktreeCli {
#[command(subcommand)]
subcommand: WorktreeSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum WorktreeSubcommand {
/// List Codex-managed worktrees for the current repository.
List(WorktreeListCommand),
/// Print the workspace path for a managed worktree.
Path(WorktreePathCommand),
/// Remove a Codex-managed worktree.
Remove(WorktreeRemoveCommand),
/// Remove stale Codex-managed worktree metadata.
Prune(WorktreePruneCommand),
}
#[derive(Debug, Args)]
struct WorktreeListCommand {
/// Include managed worktrees from all repositories.
#[arg(long = "all", default_value_t = false)]
all: bool,
/// Print machine-readable JSON.
#[arg(long = "json", default_value_t = false)]
json: bool,
}
#[derive(Debug, Args)]
struct WorktreePathCommand {
/// Managed worktree name or slug.
name: String,
}
#[derive(Debug, Args)]
struct WorktreeRemoveCommand {
/// Managed worktree name, slug, or absolute path.
name_or_path: String,
/// Remove even if the worktree is dirty.
#[arg(long = "force", short = 'f', default_value_t = false)]
force: bool,
/// Delete the associated branch after removing the worktree.
#[arg(long = "delete-branch", default_value_t = false)]
delete_branch: bool,
}
#[derive(Debug, Args)]
struct WorktreePruneCommand {
/// Show stale entries without deleting anything.
#[arg(long = "dry-run", default_value_t = false)]
dry_run: bool,
/// Print machine-readable JSON.
#[arg(long = "json", default_value_t = false)]
json: bool,
}
#[derive(Debug, Parser)]
struct SandboxArgs {
#[command(subcommand)]
@@ -666,6 +743,244 @@ async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Res
}
}
fn resolve_worktree_options_for_tui(
cli: &mut TuiCli,
remote: Option<&str>,
remote_auth_token_env: Option<&str>,
) -> anyhow::Result<Option<WorktreeResolution>> {
resolve_worktree_options_for_shared_cli(&mut cli.shared, remote, remote_auth_token_env)
}
fn resolve_worktree_options_for_shared_cli(
shared: &mut SharedCliOptions,
remote: Option<&str>,
remote_auth_token_env: Option<&str>,
) -> anyhow::Result<Option<WorktreeResolution>> {
let Some(branch) = shared.worktree.take() else {
return Ok(None);
};
if remote.is_some() || remote_auth_token_env.is_some() {
anyhow::bail!("--worktree is not supported with remote app-server sessions yet");
}
let codex_home = find_codex_home()?.to_path_buf();
let source_cwd = shared.cwd.clone().unwrap_or(std::env::current_dir()?);
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home,
source_cwd,
branch,
base_ref: shared.worktree_base.take(),
dirty_policy: dirty_policy_from_cli(shared.worktree_dirty),
})?;
shared.cwd = Some(resolution.info.workspace_cwd.clone());
#[allow(clippy::print_stderr)]
for warning in &resolution.warnings {
eprintln!("warning: {}", warning.message);
}
Ok(Some(resolution))
}
fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy {
match arg {
WorktreeDirtyCliArg::Fail => DirtyPolicy::Fail,
WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore,
WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked,
WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll,
}
}
fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> {
let codex_home = find_codex_home()?.to_path_buf();
match cli.subcommand {
WorktreeSubcommand::List(command) => {
let source_cwd = if command.all {
None
} else {
Some(std::env::current_dir()?)
};
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
codex_home,
source_cwd,
include_all_repos: command.all,
})?;
print_worktree_list(entries, command.json)?;
}
WorktreeSubcommand::Path(command) => {
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
codex_home,
source_cwd: Some(std::env::current_dir()?),
include_all_repos: false,
})?;
let entry = find_named_worktree(entries, &command.name)?;
println!("{}", entry.workspace_cwd.display());
}
WorktreeSubcommand::Remove(command) => {
let result = codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home,
source_cwd: Some(std::env::current_dir()?),
name_or_path: command.name_or_path,
force: command.force,
delete_branch: command.delete_branch,
})?;
println!("removed {}", result.removed_path.display());
if let Some(branch) = result.deleted_branch {
println!("deleted branch {branch}");
}
}
WorktreeSubcommand::Prune(command) => {
let stale_paths = stale_managed_worktree_dirs(&codex_home)?;
if command.json {
println!("{}", serde_json::to_string_pretty(&stale_paths)?);
} else if stale_paths.is_empty() {
println!("No stale Codex-managed worktree directories found.");
} else {
for path in &stale_paths {
if command.dry_run {
println!("would remove {}", path.display());
} else {
fs::remove_dir_all(path)?;
println!("removed {}", path.display());
}
}
}
}
}
Ok(())
}
fn print_worktree_list(entries: Vec<WorktreeInfo>, json: bool) -> anyhow::Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
let mut rows = Vec::new();
for entry in &entries {
let status = if entry.dirty.is_dirty() {
"dirty"
} else {
"clean"
};
rows.push([
entry.branch.as_deref().unwrap_or(&entry.name).to_string(),
status.to_string(),
worktree_source_label(entry).to_string(),
entry
.owner_thread_id
.as_deref()
.unwrap_or("none")
.to_string(),
entry.workspace_cwd.display().to_string(),
]);
}
let headers = ["BRANCH", "STATUS", "SOURCE", "THREAD", "PATH"];
let mut widths = headers.map(str::len);
for row in &rows {
for (idx, cell) in row.iter().enumerate() {
widths[idx] = widths[idx].max(cell.len());
}
}
println!(
"{branch:<branch_w$} {status:<status_w$} {source:<source_w$} {thread:<thread_w$} {path}",
branch = headers[0],
status = headers[1],
source = headers[2],
thread = headers[3],
path = headers[4],
branch_w = widths[0],
status_w = widths[1],
source_w = widths[2],
thread_w = widths[3],
);
for row in rows {
println!(
"{branch:<branch_w$} {status:<status_w$} {source:<source_w$} {thread:<thread_w$} {path}",
branch = row[0],
status = row[1],
source = row[2],
thread = row[3],
path = row[4],
branch_w = widths[0],
status_w = widths[1],
source_w = widths[2],
thread_w = widths[3],
);
}
Ok(())
}
fn worktree_source_label(entry: &WorktreeInfo) -> &'static str {
match entry.source {
codex_worktree::WorktreeSource::Cli => "cli",
codex_worktree::WorktreeSource::App => "app",
codex_worktree::WorktreeSource::Legacy => "legacy",
codex_worktree::WorktreeSource::Git => "git",
}
}
fn find_named_worktree(entries: Vec<WorktreeInfo>, name: &str) -> anyhow::Result<WorktreeInfo> {
let matches = entries
.into_iter()
.filter(|entry| {
entry.branch.as_deref() == Some(name) || entry.name == name || entry.slug == name
})
.collect::<Vec<_>>();
match matches.as_slice() {
[entry] => Ok(entry.clone()),
[] => anyhow::bail!("no managed worktree named {name}"),
_ => anyhow::bail!("multiple managed worktrees named {name}; pass a path instead"),
}
}
fn stale_managed_worktree_dirs(codex_home: &Path) -> anyhow::Result<Vec<PathBuf>> {
let root = codex_worktree::codex_worktrees_root(codex_home);
if !root.exists() {
return Ok(Vec::new());
}
let mut stale = Vec::new();
for repo_dir in fs::read_dir(&root)? {
let repo_dir = repo_dir?;
if !repo_dir.file_type()?.is_dir() {
continue;
}
for slug_dir in fs::read_dir(repo_dir.path())? {
let slug_dir = slug_dir?;
if !slug_dir.file_type()?.is_dir() {
continue;
}
let mut has_repo_dir = false;
for repo_root in fs::read_dir(slug_dir.path())? {
let repo_root = repo_root?;
if !repo_root.file_type()?.is_dir() {
continue;
}
has_repo_dir = true;
if !repo_root.path().join(".git").exists() || !git_root_is_valid(&repo_root.path())
{
stale.push(repo_root.path());
}
}
if !has_repo_dir {
stale.push(slug_dir.path());
}
}
}
stale.sort();
Ok(stale)
}
fn git_root_is_valid(path: &Path) -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
.is_ok_and(|output| output.status.success())
}
#[derive(Debug, Default, Parser, Clone)]
struct FeatureToggles {
/// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
@@ -774,6 +1089,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
resolve_worktree_options_for_tui(
&mut interactive,
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
)?;
let exit_info = run_interactive_tui(
interactive,
root_remote.clone(),
@@ -792,6 +1112,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
exec_cli
.shared
.inherit_exec_root_options(&interactive.shared);
resolve_worktree_options_for_shared_cli(
&mut exec_cli.shared,
/*remote*/ None,
/*remote_auth_token_env*/ None,
)?;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
@@ -942,6 +1267,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
include_non_interactive,
config_overrides,
);
resolve_worktree_options_for_tui(
&mut interactive,
remote.remote.as_deref().or(root_remote.as_deref()),
remote
.remote_auth_token_env
.as_deref()
.or(root_remote_auth_token_env.as_deref()),
)?;
let exit_info = run_interactive_tui(
interactive,
remote.remote.or(root_remote.clone()),
@@ -968,6 +1301,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
all,
config_overrides,
);
resolve_worktree_options_for_tui(
&mut interactive,
remote.remote.as_deref().or(root_remote.as_deref()),
remote
.remote_auth_token_env
.as_deref()
.or(root_remote_auth_token_env.as_deref()),
)?;
let exit_info = run_interactive_tui(
interactive,
remote.remote.or(root_remote.clone()),
@@ -979,6 +1320,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
handle_app_exit(exit_info)?;
}
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)?;
}
Some(Subcommand::Login(mut login_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
@@ -2045,6 +2394,45 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn top_level_worktree_flags_parse_into_interactive_shared_options() {
let cli = MultitoolCli::try_parse_from([
"codex",
"--worktree",
"parser-fix",
"--worktree-base",
"origin/main",
"--worktree-dirty",
"copy-tracked",
])
.expect("worktree flags should parse");
assert_eq!(cli.interactive.worktree.as_deref(), Some("parser-fix"));
assert_eq!(
cli.interactive.worktree_base.as_deref(),
Some("origin/main")
);
assert_eq!(
cli.interactive.worktree_dirty,
WorktreeDirtyCliArg::CopyTracked
);
}
#[test]
fn worktree_subcommand_parses() {
let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"])
.expect("worktree list should parse");
let Some(Subcommand::Worktree(WorktreeCli {
subcommand: WorktreeSubcommand::List(command),
})) = cli.subcommand
else {
panic!("expected worktree list subcommand");
};
assert!(command.all);
assert!(command.json);
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
+1
View File
@@ -37,6 +37,7 @@ codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
codex-worktree = { workspace = true }
codex-utils-oss = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+20
View File
@@ -257,6 +257,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
sandbox_mode: sandbox_mode_cli_arg,
dangerously_bypass_approvals_and_sandbox,
cwd,
worktree: _,
worktree_base: _,
worktree_dirty: _,
add_dir,
} = shared;
@@ -693,6 +696,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
)
.await
.map_err(anyhow::Error::msg)?;
bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id);
let session_configured =
session_configured_from_thread_resume_response(&response, &config)
.map_err(anyhow::Error::msg)?;
@@ -708,6 +712,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
)
.await
.map_err(anyhow::Error::msg)?;
bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id);
let session_configured =
session_configured_from_thread_start_response(&response, &config)
.map_err(anyhow::Error::msg)?;
@@ -724,6 +729,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
)
.await
.map_err(anyhow::Error::msg)?;
bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id);
let session_configured = session_configured_from_thread_start_response(&response, &config)
.map_err(anyhow::Error::msg)?;
(session_configured.thread_id, session_configured)
@@ -1105,6 +1111,20 @@ fn session_configured_from_thread_resume_response(
)
}
fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) {
match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) {
Ok(Some(_)) => {
if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) {
tracing::warn!(?err, "failed to bind managed worktree to thread");
}
}
Ok(None) => {}
Err(err) => {
tracing::warn!(?err, "failed to resolve managed worktree metadata");
}
}
}
fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget {
match target {
ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges,
+1
View File
@@ -55,6 +55,7 @@ codex-shell-command = { workspace = true }
codex-state = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-utils-approval-presets = { workspace = true }
codex-worktree = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-elapsed = { workspace = true }
+3
View File
@@ -846,6 +846,9 @@ impl App {
if let Some(message) = external_agent_config_migration_message {
chat_widget.add_info_message(message, /*hint*/ None);
}
if let Some(message) = managed_worktree_startup_message(&config) {
chat_widget.add_info_message(message, /*hint*/ None);
}
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
+6
View File
@@ -77,6 +77,12 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &
)));
}
pub(super) fn managed_worktree_startup_message(config: &Config) -> Option<String> {
let label =
crate::worktree_labels::label_for_cwd(config.codex_home.as_path(), config.cwd.as_path())?;
Some(format!("Workspace: {}", label.summary()))
}
pub(super) fn hooks_needing_review_warning(count: usize) -> Option<String> {
match count {
0 => None,
+4
View File
@@ -497,6 +497,10 @@ impl App {
op: &AppCommand,
) -> Result<bool> {
match op {
AppCommand::AddToHistory { text } => {
self.append_message_history_entry(thread_id, text.to_string());
Ok(true)
}
AppCommand::Interrupt => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server.turn_interrupt(thread_id, turn_id).await?;
+7
View File
@@ -106,6 +106,9 @@ pub(crate) enum AppCommand {
ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent,
},
AddToHistory {
text: String,
},
}
impl AppCommand {
@@ -272,6 +275,10 @@ impl AppCommand {
Self::ApproveGuardianDeniedAction { event }
}
pub(crate) fn add_to_history(text: String) -> Self {
Self::AddToHistory { text }
}
pub(crate) fn is_review(&self) -> bool {
matches!(self, Self::Review { .. })
}
+18
View File
@@ -117,6 +117,7 @@ use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
@@ -1328,6 +1329,7 @@ async fn thread_session_state_from_thread_start_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
let permission_profile = permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
@@ -1360,6 +1362,7 @@ async fn thread_session_state_from_thread_resume_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
let permission_profile = permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
@@ -1392,6 +1395,7 @@ async fn thread_session_state_from_thread_fork_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
let permission_profile = permission_profile_from_thread_response(
&response.sandbox,
response.permission_profile.as_ref(),
@@ -1419,6 +1423,20 @@ async fn thread_session_state_from_thread_fork_response(
.await
}
fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) {
match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) {
Ok(Some(_)) => {
if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) {
tracing::warn!(?err, "failed to bind managed worktree to thread");
}
}
Ok(None) => {}
Err(err) => {
tracing::warn!(?err, "failed to resolve managed worktree metadata");
}
}
}
fn permission_profile_from_thread_response(
sandbox: &codex_app_server_protocol::SandboxPolicy,
permission_profile: Option<&codex_app_server_protocol::PermissionProfile>,
+1
View File
@@ -179,6 +179,7 @@ mod tui;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::UpdateAction;
mod worktree_labels;
#[cfg(not(debug_assertions))]
pub use update_action::get_update_action;
mod update_prompt;
+112 -14
View File
@@ -23,6 +23,8 @@ use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::worktree_labels::WorktreeLabel;
use crate::worktree_labels::label_for_cwd;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use chrono::DateTime;
@@ -375,6 +377,7 @@ async fn run_resume_picker_with_launch_context(
app_server,
include_non_interactive,
raw_reasoning_visibility(config),
(!is_remote).then(|| config.codex_home.to_path_buf()),
bg_tx,
),
bg_rx,
@@ -420,6 +423,7 @@ pub async fn run_fork_picker_with_app_server(
app_server,
/*include_non_interactive*/ false,
raw_reasoning_visibility(config),
(!is_remote).then(|| config.codex_home.to_path_buf()),
bg_tx,
),
bg_rx,
@@ -540,6 +544,7 @@ fn spawn_app_server_page_loader(
app_server: AppServerSession,
include_non_interactive: bool,
raw_reasoning_visibility: RawReasoningVisibility,
codex_home: Option<PathBuf>,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
) -> PickerLoader {
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PickerLoadRequest>();
@@ -557,6 +562,7 @@ fn spawn_app_server_page_loader(
request.provider_filter,
request.sort_key,
include_non_interactive,
codex_home.as_deref(),
)
.await;
let _ = bg_tx.send(BackgroundEvent::Page {
@@ -725,6 +731,7 @@ async fn load_app_server_page(
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
codex_home: Option<&Path>,
) -> std::io::Result<PickerPage> {
let response = app_server
.thread_list(thread_list_params(
@@ -742,7 +749,7 @@ async fn load_app_server_page(
rows: response
.data
.into_iter()
.filter_map(row_from_app_server_thread)
.filter_map(|thread| row_from_app_server_thread(thread, codex_home))
.collect(),
next_cursor: response.next_cursor.map(PageCursor::AppServer),
num_scanned_files,
@@ -824,6 +831,7 @@ struct Row {
updated_at: Option<DateTime<Utc>>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
worktree_label: Option<WorktreeLabel>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -844,6 +852,24 @@ impl Row {
self.thread_name.as_deref().unwrap_or(&self.preview)
}
fn display_branch(&self) -> Option<&str> {
self.worktree_label
.as_ref()
.and_then(|label| label.branch.as_deref())
.or(self.git_branch.as_deref())
}
fn display_cwd(&self) -> Option<String> {
let cwd = self
.cwd
.as_ref()
.map(|path| format_directory_display(path, /*max_width*/ None))?;
Some(match self.worktree_label.as_ref() {
Some(label) => format!("{} · {cwd}", label.summary()),
None => cwd,
})
}
fn matches_query(&self, query: &str) -> bool {
if self.preview.to_lowercase().contains(query) {
return true;
@@ -873,6 +899,16 @@ impl Row {
{
return true;
}
if let Some(label) = self.worktree_label.as_ref()
&& (label.name.to_lowercase().contains(query)
|| label.repo_name.to_lowercase().contains(query)
|| label
.branch
.as_ref()
.is_some_and(|branch| branch.to_lowercase().contains(query)))
{
return true;
}
false
}
}
@@ -1793,7 +1829,7 @@ impl PickerState {
}
}
fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
fn row_from_app_server_thread(thread: Thread, codex_home: Option<&Path>) -> Option<Row> {
let thread_id = match ThreadId::from_string(&thread.id) {
Ok(thread_id) => thread_id,
Err(err) => {
@@ -1802,6 +1838,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
}
};
let preview = thread.preview.trim();
let cwd = thread.cwd.to_path_buf();
let worktree_label = codex_home.and_then(|codex_home| label_for_cwd(codex_home, &cwd));
Some(Row {
path: thread.path,
preview: if preview.is_empty() {
@@ -1815,8 +1853,9 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
.map(|dt| dt.with_timezone(&Utc)),
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
cwd: Some(thread.cwd.to_path_buf()),
cwd: Some(cwd),
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
worktree_label,
})
}
@@ -2571,11 +2610,8 @@ fn render_comfortable_session_lines(
let reference = state.relative_time_reference.unwrap_or_else(Utc::now);
let created = format_relative_time(reference, row.created_at);
let updated = format_relative_time(reference, row.updated_at.or(row.created_at));
let branch = row.git_branch.as_deref();
let cwd = row
.cwd
.as_ref()
.map(|path| format_directory_display(path, /*max_width*/ None));
let branch = row.display_branch();
let cwd = row.display_cwd();
let footer_lines = render_footer_lines(
state.sort_key,
&created,
@@ -2973,12 +3009,10 @@ fn render_expanded_session_details(
.map(|path| format_directory_display(path, /*max_width*/ None))
.unwrap_or_else(|| "-".to_string());
let branch = row
.git_branch
.as_ref()
.display_branch()
.map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}"))
.unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch"));
vec![
let mut details = vec![
expanded_detail_line("Session:", &session, width),
expanded_time_detail_line("Created:", reference, row.created_at, width),
expanded_time_detail_line(
@@ -2987,11 +3021,17 @@ fn render_expanded_session_details(
row.updated_at.or(row.created_at),
width,
),
];
if let Some(worktree) = row.worktree_label.as_ref().map(WorktreeLabel::summary) {
details.push(expanded_detail_line("Workspace:", &worktree, width));
}
details.extend([
expanded_detail_line("Directory:", &directory, width),
expanded_detail_line("Branch:", &branch, width),
vec![" │".dim()].into(),
vec![" │ ".dim(), "Conversation:".dim()].into(),
]
]);
details
}
fn render_conversation_preview_lines(
@@ -3263,6 +3303,7 @@ mod tests {
updated_at: Some(timestamp),
cwd: None,
git_branch: None,
worktree_label: None,
}
}
@@ -3309,6 +3350,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
assert_eq!(row.display_preview(), "My session");
@@ -3349,6 +3391,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/tmp/codex-session-picker")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
assert!(row.matches_query("session-picker"));
@@ -3356,6 +3399,37 @@ mod tests {
assert!(row.matches_query(&thread_id.to_string()[..8]));
}
#[test]
fn row_worktree_label_overrides_branch_and_prefixes_cwd() {
let row = Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: None,
cwd: Some(PathBuf::from(
"/Users/felipe.coury/.codex/worktrees/abcd/parser-fix/codex",
)),
git_branch: Some(String::from("main")),
worktree_label: Some(WorktreeLabel {
name: String::from("parser-fix"),
branch: Some(String::from("parser-fix")),
repo_name: String::from("codex"),
dirty: false,
}),
};
assert_eq!(row.display_branch(), Some("parser-fix"));
assert!(
row.display_cwd()
.expect("cwd")
.starts_with("parser-fix · clean · codex · ")
);
assert!(row.matches_query("parser-fix"));
assert!(row.matches_query("codex"));
}
#[test]
fn relative_time_formats_zero_seconds_as_now() {
let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
@@ -3409,6 +3483,7 @@ mod tests {
updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"),
cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")),
git_branch: Some(String::from("codex/raw-scrollback-mode")),
worktree_label: None,
};
let rendered = render_expanded_session_details(&row, &state, /*width*/ 120)
@@ -3615,6 +3690,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/srv/real-project")),
git_branch: None,
worktree_label: None,
};
assert!(state.row_matches_filter(&row));
@@ -3640,6 +3716,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/srv/remote-project")),
git_branch: None,
worktree_label: None,
};
assert!(state.row_matches_filter(&row));
@@ -3671,6 +3748,7 @@ mod tests {
updated_at: Some(now - Duration::seconds(42)),
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
@@ -3681,6 +3759,7 @@ mod tests {
updated_at: Some(now - Duration::minutes(35)),
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: Some(PathBuf::from("/tmp/c.jsonl")),
@@ -3691,6 +3770,7 @@ mod tests {
updated_at: Some(now - Duration::hours(2)),
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.all_rows = rows.clone();
@@ -4119,6 +4199,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4157,6 +4238,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: None,
@@ -4167,6 +4249,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.pending_transcript_open = Some(thread_id);
@@ -4236,6 +4319,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: None,
@@ -4246,6 +4330,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.update_viewport(/*rows*/ 7, /*width*/ 80);
@@ -4301,6 +4386,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4331,6 +4417,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4407,6 +4494,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state.transcript_cells.insert(
thread_id,
@@ -4606,6 +4694,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4645,6 +4734,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4723,6 +4813,7 @@ session_picker_view = "dense"
"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs",
)),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
}
}
@@ -4975,6 +5066,7 @@ session_picker_view = "dense"
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
cwd: Some(PathBuf::from("/tmp/codex")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
let mut state = PickerState::new(
FrameRequester::test_dummy(),
@@ -5044,6 +5136,7 @@ session_picker_view = "dense"
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
cwd: Some(PathBuf::from("/tmp/codex")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
let mut state = PickerState::new(
FrameRequester::test_dummy(),
@@ -5102,6 +5195,7 @@ session_picker_view = "dense"
updated_at: Some(now - Duration::minutes(idx * 5)),
cwd: None,
git_branch: None,
worktree_label: None,
})
.collect();
state.filtered_rows = state.all_rows.clone();
@@ -5154,6 +5248,7 @@ session_picker_view = "dense"
updated_at: Some(now - Duration::minutes(idx * 5)),
cwd: None,
git_branch: None,
worktree_label: None,
})
.collect();
state.filtered_rows = state.all_rows.clone();
@@ -5622,6 +5717,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -5661,6 +5757,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -5704,7 +5801,8 @@ session_picker_view = "dense"
turns: Vec::new(),
};
let row = row_from_app_server_thread(thread).expect("row should be preserved");
let row = row_from_app_server_thread(thread, /*codex_home*/ None)
.expect("row should be preserved");
assert_eq!(row.path, None);
assert_eq!(row.thread_id, Some(thread_id));
+49
View File
@@ -0,0 +1,49 @@
use std::path::Path;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorktreeLabel {
pub(crate) name: String,
pub(crate) branch: Option<String>,
pub(crate) repo_name: String,
pub(crate) dirty: bool,
}
impl WorktreeLabel {
pub(crate) fn summary(&self) -> String {
let mut parts = vec![self.branch.clone().unwrap_or_else(|| self.name.clone())];
parts.push(if self.dirty { "dirty" } else { "clean" }.to_string());
parts.push(self.repo_name.clone());
parts.join(" · ")
}
}
pub(crate) fn label_for_cwd(codex_home: &Path, cwd: &Path) -> Option<WorktreeLabel> {
let info = codex_worktree::resolve_worktree(codex_home, cwd)
.inspect_err(|err| tracing::warn!(?err, "failed to resolve managed worktree label"))
.ok()
.flatten()?;
Some(WorktreeLabel {
name: info.name,
branch: info.branch,
repo_name: info.repo_name,
dirty: info.dirty.is_dirty(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn summary_includes_name_branch_and_repo() {
let label = WorktreeLabel {
name: String::from("parser-fix"),
branch: Some(String::from("parser-fix")),
repo_name: String::from("codex"),
dirty: false,
};
assert_eq!(label.summary(), "parser-fix · clean · codex");
}
}
+2
View File
@@ -3,9 +3,11 @@ mod config_override;
pub(crate) mod format_env_display;
mod sandbox_mode_cli_arg;
mod shared_options;
mod worktree_dirty_cli_arg;
pub use approval_mode_cli_arg::ApprovalModeCliArg;
pub use config_override::CliConfigOverrides;
pub use format_env_display::format_env_display;
pub use sandbox_mode_cli_arg::SandboxModeCliArg;
pub use shared_options::SharedCliOptions;
pub use worktree_dirty_cli_arg::WorktreeDirtyCliArg;
+40
View File
@@ -1,6 +1,7 @@
//! Shared command-line flags used by both interactive and non-interactive Codex entry points.
use crate::SandboxModeCliArg;
use crate::WorktreeDirtyCliArg;
use clap::Args;
use std::path::PathBuf;
@@ -51,6 +52,18 @@ pub struct SharedCliOptions {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Create or reuse a Codex-managed Git worktree for this branch and run from that workspace.
#[arg(long = "worktree", value_name = "BRANCH")]
pub worktree: Option<String>,
/// Base ref for a newly created managed worktree.
#[arg(long = "worktree-base", value_name = "REF")]
pub worktree_base: Option<String>,
/// How to handle uncommitted source checkout changes when creating a worktree.
#[arg(long = "worktree-dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)]
pub worktree_dirty: WorktreeDirtyCliArg,
/// Additional directories that should be writable alongside the primary workspace.
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
pub add_dir: Vec<PathBuf>,
@@ -69,6 +82,9 @@ impl SharedCliOptions {
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
cwd,
worktree,
worktree_base,
worktree_dirty,
add_dir,
} = self;
let Self {
@@ -80,6 +96,9 @@ impl SharedCliOptions {
sandbox_mode: root_sandbox_mode,
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
cwd: root_cwd,
worktree: root_worktree,
worktree_base: root_worktree_base,
worktree_dirty: root_worktree_dirty,
add_dir: root_add_dir,
} = root;
@@ -105,6 +124,15 @@ impl SharedCliOptions {
if cwd.is_none() {
cwd.clone_from(root_cwd);
}
if worktree.is_none() {
worktree.clone_from(root_worktree);
}
if worktree_base.is_none() {
worktree_base.clone_from(root_worktree_base);
}
if *worktree_dirty == WorktreeDirtyCliArg::Fail {
*worktree_dirty = *root_worktree_dirty;
}
if !root_images.is_empty() {
let mut merged_images = root_images.clone();
merged_images.append(images);
@@ -129,6 +157,9 @@ impl SharedCliOptions {
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
cwd,
worktree,
worktree_base,
worktree_dirty,
add_dir,
} = subcommand;
@@ -152,6 +183,15 @@ impl SharedCliOptions {
if let Some(cwd) = cwd {
self.cwd = Some(cwd);
}
if let Some(worktree) = worktree {
self.worktree = Some(worktree);
}
if let Some(worktree_base) = worktree_base {
self.worktree_base = Some(worktree_base);
}
if worktree_dirty != WorktreeDirtyCliArg::Fail {
self.worktree_dirty = worktree_dirty;
}
if !images.is_empty() {
self.images = images;
}
@@ -0,0 +1,11 @@
use clap::ValueEnum;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum WorktreeDirtyCliArg {
#[default]
Fail,
Ignore,
CopyTracked,
CopyAll,
}
+7
View File
@@ -0,0 +1,7 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "worktree",
crate_name = "codex_worktree",
)
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "codex-worktree"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-utils-absolute-path = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+182
View File
@@ -0,0 +1,182 @@
use std::fs;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use crate::git;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyPolicy {
Fail,
Ignore,
CopyTracked,
CopyAll,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirtyState {
pub has_staged_changes: bool,
pub has_unstaged_changes: bool,
pub has_untracked_files: bool,
}
impl DirtyState {
pub fn is_dirty(&self) -> bool {
self.has_staged_changes || self.has_unstaged_changes || self.has_untracked_files
}
}
pub fn dirty_state(root: &Path) -> Result<DirtyState> {
let staged = git::bytes(root, &["diff", "--cached", "--name-only", "-z"])?;
let unstaged = git::bytes(root, &["diff", "--name-only", "-z"])?;
let untracked = git::bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
Ok(DirtyState {
has_staged_changes: !staged.is_empty(),
has_unstaged_changes: !unstaged.is_empty(),
has_untracked_files: !untracked.is_empty(),
})
}
pub fn validate_dirty_policy_before_create(
source_root: &Path,
policy: DirtyPolicy,
) -> Result<Vec<String>> {
let state = dirty_state(source_root)?;
if !state.is_dirty() {
return Ok(Vec::new());
}
match policy {
DirtyPolicy::Fail => bail_for_dirty_source(),
DirtyPolicy::Ignore => Ok(vec![
"source checkout has uncommitted changes; the new worktree was created without them"
.to_string(),
]),
DirtyPolicy::CopyTracked => {
if state.has_untracked_files {
Ok(vec![
"untracked files were left in the source checkout; use --worktree-dirty copy-all to copy them"
.to_string(),
])
} else {
Ok(Vec::new())
}
}
DirtyPolicy::CopyAll => Ok(Vec::new()),
}
}
pub fn apply_dirty_policy_after_create(
source_root: &Path,
worktree_root: &Path,
policy: DirtyPolicy,
) -> Result<()> {
let state = dirty_state(source_root)?;
if !state.is_dirty() {
return Ok(());
}
match policy {
DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()),
DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root),
DirtyPolicy::CopyAll => {
apply_tracked_diff(source_root, worktree_root)?;
copy_untracked_files(source_root, worktree_root)?;
Ok(())
}
}
}
fn bail_for_dirty_source<T>() -> Result<T> {
anyhow::bail!(
"source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, or copy-all"
);
}
fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> {
let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?;
let unstaged = git::bytes(source_root, &["diff", "--binary"])?;
if !staged.is_empty() {
git::status_with_stdin(
worktree_root,
&["apply", "--index", "--binary", "-"],
&staged,
)
.context("failed to apply staged changes to worktree")?;
}
if !unstaged.is_empty() {
git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged)
.context("failed to apply unstaged changes to worktree")?;
}
Ok(())
}
fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> {
let output = git::bytes(
source_root,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?;
for raw_path in output
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
{
let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned());
ensure_safe_relative_path(&relative_path)?;
let source = source_root.join(&relative_path);
let destination = worktree_root.join(&relative_path);
let metadata = fs::symlink_metadata(&source)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if metadata.file_type().is_symlink() {
let target = fs::read_link(&source)?;
create_symlink(&target, &destination)?;
} else if metadata.is_file() {
fs::copy(&source, &destination)?;
}
}
Ok(())
}
fn ensure_safe_relative_path(path: &Path) -> Result<()> {
if path.is_absolute() {
anyhow::bail!(
"refusing to copy absolute untracked path {}",
path.display()
);
}
if path.components().any(|component| {
matches!(component, Component::ParentDir)
|| matches!(component, Component::Normal(value) if value == ".git")
}) {
anyhow::bail!("refusing to copy unsafe untracked path {}", path.display());
}
Ok(())
}
#[cfg(unix)]
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, destination).map_err(Into::into)
}
#[cfg(windows)]
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
std::os::windows::fs::symlink_file(target, destination).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dirty_state_reports_clean_by_default() {
assert!(!DirtyState::default().is_dirty());
}
}
+57
View File
@@ -0,0 +1,57 @@
use std::path::Path;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
pub fn stdout(cwd: &Path, args: &[&str]) -> Result<String> {
let output = output(cwd, args)?;
Ok(String::from_utf8(output)?.trim_end().to_string())
}
pub fn bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
output(cwd, args)
}
pub fn status(cwd: &Path, args: &[&str]) -> Result<()> {
output(cwd, args).map(|_| ())
}
pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> {
let mut child = Command::new("git")
.args(args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn git {}", args.join(" ")))?;
use std::io::Write as _;
child
.stdin
.as_mut()
.context("git stdin unavailable")?
.write_all(stdin)?;
let output = child.wait_with_output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
pub fn output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new("git").args(args).current_dir(cwd).output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(output.stdout)
}
+105
View File
@@ -0,0 +1,105 @@
mod dirty;
mod git;
mod manager;
mod metadata;
mod paths;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
pub use dirty::DirtyPolicy;
pub use dirty::DirtyState;
pub use manager::ensure_worktree;
pub use manager::list_worktrees;
pub use manager::remove_worktree;
pub use manager::resolve_worktree;
pub use metadata::bind_thread;
pub use metadata::read_worktree_metadata;
pub use metadata::write_worktree_metadata;
pub use paths::codex_worktrees_root;
pub use paths::is_managed_worktree_path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRequest {
pub codex_home: PathBuf,
pub source_cwd: PathBuf,
pub branch: String,
pub base_ref: Option<String>,
pub dirty_policy: DirtyPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeResolution {
pub reused: bool,
pub info: WorktreeInfo,
pub warnings: Vec<WorktreeWarning>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeInfo {
pub id: String,
pub name: String,
pub slug: String,
pub source: WorktreeSource,
pub location: WorktreeLocation,
pub repo_name: String,
pub repo_root: PathBuf,
pub common_git_dir: PathBuf,
pub worktree_git_root: PathBuf,
pub workspace_cwd: PathBuf,
pub original_relative_cwd: PathBuf,
pub branch: Option<String>,
pub head: Option<String>,
pub owner_thread_id: Option<String>,
pub metadata_path: PathBuf,
pub dirty: DirtyState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeSource {
Cli,
App,
Legacy,
Git,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeLocation {
Sibling,
CodexHome,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeWarning {
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeListQuery {
pub codex_home: PathBuf,
pub source_cwd: Option<PathBuf>,
pub include_all_repos: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRemoveRequest {
pub codex_home: PathBuf,
pub source_cwd: Option<PathBuf>,
pub name_or_path: String,
pub force: bool,
pub delete_branch: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeRemoveResult {
pub removed_path: PathBuf,
pub deleted_branch: Option<String>,
}
+604
View File
@@ -0,0 +1,604 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use crate::WorktreeInfo;
use crate::WorktreeListQuery;
use crate::WorktreeLocation;
use crate::WorktreeRemoveRequest;
use crate::WorktreeRemoveResult;
use crate::WorktreeRequest;
use crate::WorktreeResolution;
use crate::WorktreeSource;
use crate::WorktreeWarning;
use crate::dirty;
use crate::git;
use crate::metadata;
use crate::metadata::WorktreeMetadata;
use crate::paths;
pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
let repo = SourceRepo::resolve(&req.source_cwd)?;
let branch = req.branch.clone();
let slug = paths::slugify_name(&branch)?;
ensure_safe_branch_name(&repo.root, &branch)?;
let worktree_git_root = paths::sibling_worktree_git_root(&repo.root, &branch)?;
let workspace_cwd = worktree_git_root.join(&repo.relative_cwd);
if worktree_git_root.exists() {
let Some(metadata) = metadata::read_worktree_metadata(&worktree_git_root)? else {
anyhow::bail!(
"managed worktree path {} already exists but is not owned by Codex",
worktree_git_root.display()
);
};
ensure_existing_worktree_matches_branch(&worktree_git_root, &metadata, &branch)?;
let info = info_from_existing_worktree(
&req.codex_home,
&worktree_git_root,
Some(branch),
Some(slug),
)?;
return Ok(WorktreeResolution {
reused: true,
info,
warnings: Vec::new(),
});
}
if let Some(path) = branch_checkout_path(&repo.root, &branch)?
&& path != worktree_git_root
{
anyhow::bail!(
"branch {branch} is already checked out at {}; remove that worktree first",
path.display()
);
}
let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?;
let base_ref = req.base_ref.as_deref().unwrap_or("HEAD");
fs::create_dir_all(
worktree_git_root
.parent()
.context("managed worktree path has no parent")?,
)?;
if branch_exists(&repo.root, &branch) {
git::status(
&repo.root,
&[
"worktree",
"add",
&worktree_git_root.to_string_lossy(),
&branch,
],
)?;
} else {
git::status(
&repo.root,
&[
"worktree",
"add",
"-b",
&branch,
&worktree_git_root.to_string_lossy(),
base_ref,
],
)?;
}
dirty::apply_dirty_policy_after_create(&repo.root, &worktree_git_root, req.dirty_policy)?;
let dirty = dirty::dirty_state(&worktree_git_root)?;
let head = git::stdout(&worktree_git_root, &["rev-parse", "HEAD"]).ok();
let mut info = WorktreeInfo {
id: repo.id.clone(),
name: branch.clone(),
slug,
source: WorktreeSource::Cli,
location: WorktreeLocation::Sibling,
repo_name: repo.repo_name.clone(),
repo_root: repo.root.clone(),
common_git_dir: repo.common_git_dir.clone(),
worktree_git_root: worktree_git_root.clone(),
workspace_cwd,
original_relative_cwd: repo.relative_cwd.clone(),
branch: Some(branch),
head,
owner_thread_id: None,
metadata_path: metadata_path_for_display(&worktree_git_root)?,
dirty,
};
metadata::write_pending_owner_metadata(&worktree_git_root)?;
let worktree_metadata = WorktreeMetadata::from_info(&info, repo.root);
metadata::write_worktree_metadata(&worktree_git_root, &worktree_metadata)?;
info.owner_thread_id = worktree_metadata.owner_thread_id;
Ok(WorktreeResolution {
reused: false,
info,
warnings: warnings
.into_iter()
.map(|message| WorktreeWarning { message })
.collect(),
})
}
pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result<Option<WorktreeInfo>> {
let Ok(root) = git::stdout(cwd, &["rev-parse", "--show-toplevel"]) else {
return Ok(None);
};
let root = PathBuf::from(root);
if !paths::is_managed_worktree_path(&root, codex_home)
&& metadata::read_worktree_metadata(&root)?.is_none()
{
return Ok(None);
}
Ok(Some(info_from_existing_worktree(
codex_home, &root, /*fallback_name*/ None, /*fallback_slug*/ None,
)?))
}
pub fn list_worktrees(query: WorktreeListQuery) -> Result<Vec<WorktreeInfo>> {
let repo_filter = if query.include_all_repos {
None
} else {
let source_cwd = query
.source_cwd
.as_ref()
.context("source cwd is required unless include_all_repos is true")?;
Some(SourceRepo::resolve(source_cwd)?)
};
let mut entries = Vec::new();
if let Some(repo_filter) = repo_filter.as_ref() {
for worktree in parse_worktree_list(&git::stdout(
&repo_filter.root,
&["worktree", "list", "--porcelain"],
)?) {
let Ok(info) = info_from_existing_worktree(
&query.codex_home,
worktree.path.as_path(),
worktree.branch.clone(),
worktree
.branch
.as_deref()
.and_then(|branch| paths::slugify_name(branch).ok()),
) else {
continue;
};
if worktree_matches_repo(&info, repo_filter) {
entries.push(info);
}
}
}
let root = paths::codex_worktrees_root(&query.codex_home);
if root.exists() {
for worktree_root in discover_codex_home_worktree_roots(&root)? {
let Ok(info) = info_from_existing_worktree(
&query.codex_home,
worktree_root.as_path(),
/*fallback_name*/ None,
/*fallback_slug*/ None,
) else {
continue;
};
if let Some(repo_filter) = repo_filter.as_ref()
&& !worktree_matches_repo(&info, repo_filter)
{
continue;
}
entries.push(info);
}
}
let mut unique_entries = Vec::new();
for entry in entries {
if unique_entries.iter().any(|existing: &WorktreeInfo| {
paths_match(&existing.worktree_git_root, &entry.worktree_git_root)
}) {
continue;
}
unique_entries.push(entry);
}
let mut entries = unique_entries;
entries.sort_by(|a, b| {
display_branch_or_name(a)
.cmp(display_branch_or_name(b))
.then_with(|| a.worktree_git_root.cmp(&b.worktree_git_root))
});
Ok(entries)
}
fn discover_codex_home_worktree_roots(root: &Path) -> Result<Vec<PathBuf>> {
let mut roots = Vec::new();
for parent in fs::read_dir(root)? {
let parent = parent?;
if !parent.file_type()?.is_dir() {
continue;
}
let parent_path = parent.path();
if is_git_root(&parent_path) {
roots.push(parent_path);
continue;
}
for child in fs::read_dir(parent_path)? {
let child = child?;
if !child.file_type()?.is_dir() {
continue;
}
let child_path = child.path();
if is_git_root(&child_path) {
roots.push(child_path);
continue;
}
for grandchild in fs::read_dir(child_path)? {
let grandchild = grandchild?;
if !grandchild.file_type()?.is_dir() {
continue;
}
let grandchild_path = grandchild.path();
if is_git_root(&grandchild_path) {
roots.push(grandchild_path);
}
}
}
}
roots.sort();
roots.dedup();
Ok(roots)
}
fn is_git_root(path: &Path) -> bool {
path.join(".git").exists()
}
fn worktree_matches_repo(info: &WorktreeInfo, repo: &SourceRepo) -> bool {
info.id == repo.id || paths_match(&info.common_git_dir, &repo.common_git_dir)
}
fn paths_match(a: &Path, b: &Path) -> bool {
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
a == b
}
pub fn remove_worktree(req: WorktreeRemoveRequest) -> Result<WorktreeRemoveResult> {
let target = target_worktree_path(&req)?;
let metadata = metadata::read_worktree_metadata(&target)?
.context("refusing to remove a worktree not managed by Codex")?;
let dirty = dirty::dirty_state(&target)?;
if dirty.is_dirty() && !req.force {
anyhow::bail!(
"refusing to remove dirty worktree {}; use --force to override",
target.display()
);
}
let branch = current_branch(&target)?;
let mut args = vec!["worktree", "remove"];
if req.force {
args.push("--force");
}
let target_arg = target.to_string_lossy();
args.push(&target_arg);
let primary_root = primary_worktree_root(&target)?;
git::status(&primary_root, &args)?;
let mut deleted_branch = None;
if req.delete_branch
&& let Some(branch) = branch
{
if req.force {
git::status(&primary_root, &["branch", "-D", &branch])?;
} else {
git::status(&primary_root, &["branch", "-d", &branch])?;
}
deleted_branch = Some(branch);
}
if metadata.location == WorktreeLocation::CodexHome
&& let Some(parent) = metadata.worktree_git_root.parent()
&& parent.exists()
&& parent.read_dir()?.next().is_none()
{
fs::remove_dir(parent)?;
}
Ok(WorktreeRemoveResult {
removed_path: target,
deleted_branch,
})
}
fn ensure_existing_worktree_matches_branch(
worktree_git_root: &Path,
metadata: &WorktreeMetadata,
requested_branch: &str,
) -> Result<()> {
if metadata.branch.as_deref() == Some(requested_branch) || metadata.name == requested_branch {
return Ok(());
}
if current_branch(worktree_git_root)?.as_deref() == Some(requested_branch) {
return Ok(());
}
anyhow::bail!(
"managed worktree path {} is already used by {}; choose a different branch name",
worktree_git_root.display(),
metadata.branch.as_deref().unwrap_or(metadata.name.as_str())
)
}
fn target_worktree_path(req: &WorktreeRemoveRequest) -> Result<PathBuf> {
let raw = PathBuf::from(&req.name_or_path);
if raw.is_absolute() {
return Ok(raw);
}
let entries = list_worktrees(WorktreeListQuery {
codex_home: req.codex_home.clone(),
source_cwd: req.source_cwd.clone(),
include_all_repos: req.source_cwd.is_none(),
})?;
let matches = entries
.into_iter()
.filter(|entry| {
entry.branch.as_deref() == Some(req.name_or_path.as_str())
|| entry.name == req.name_or_path
|| entry.slug == req.name_or_path
})
.collect::<Vec<_>>();
match matches.as_slice() {
[entry] => Ok(entry.worktree_git_root.clone()),
[] => anyhow::bail!("no managed worktree named {}", req.name_or_path),
_ => anyhow::bail!(
"multiple managed worktrees named {}; pass a path instead",
req.name_or_path
),
}
}
fn info_from_existing_worktree(
codex_home: &Path,
worktree_git_root: &Path,
fallback_name: Option<String>,
fallback_slug: Option<String>,
) -> Result<WorktreeInfo> {
let metadata = metadata::read_worktree_metadata(worktree_git_root)?;
let root = git::stdout(worktree_git_root, &["rev-parse", "--show-toplevel"])
.map(PathBuf::from)
.unwrap_or_else(|_| worktree_git_root.to_path_buf());
let common_git_dir = git::stdout(worktree_git_root, &["rev-parse", "--git-common-dir"])
.map(|value| absolutize(worktree_git_root, Path::new(&value)))
.unwrap_or_else(|_| PathBuf::new());
let branch = current_branch(worktree_git_root)?;
let head = git::stdout(worktree_git_root, &["rev-parse", "HEAD"]).ok();
let dirty = dirty::dirty_state(worktree_git_root).unwrap_or_default();
let (source, location) = classify_worktree(codex_home, worktree_git_root, metadata.as_ref());
let repo_name = root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
let id = metadata
.as_ref()
.map(|metadata| metadata.repo_id.clone())
.unwrap_or_else(|| {
root.strip_prefix(paths::codex_worktrees_root(codex_home))
.ok()
.and_then(|path| path.components().next())
.map(|component| component.as_os_str().to_string_lossy().to_string())
.unwrap_or_default()
});
let name = metadata
.as_ref()
.map(|metadata| metadata.name.clone())
.or(fallback_name)
.or_else(|| branch.clone())
.unwrap_or_else(|| repo_name.clone());
let slug = metadata
.as_ref()
.map(|metadata| metadata.slug.clone())
.or(fallback_slug)
.unwrap_or_else(|| paths::slugify_name(&name).unwrap_or_else(|_| name.clone()));
let workspace_cwd = metadata
.as_ref()
.map(|metadata| metadata.workspace_cwd.clone())
.unwrap_or_else(|| root.clone());
let original_relative_cwd = metadata
.as_ref()
.map(|metadata| metadata.original_relative_cwd.clone())
.unwrap_or_default();
Ok(WorktreeInfo {
id,
name,
slug,
source,
location,
repo_name,
repo_root: root,
common_git_dir,
worktree_git_root: worktree_git_root.to_path_buf(),
workspace_cwd,
original_relative_cwd,
branch,
head,
owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id),
metadata_path: metadata_path_for_display(worktree_git_root)?,
dirty,
})
}
fn classify_worktree(
codex_home: &Path,
worktree_git_root: &Path,
metadata: Option<&WorktreeMetadata>,
) -> (WorktreeSource, WorktreeLocation) {
if let Some(metadata) = metadata {
return (metadata.source, metadata.location);
}
if paths::is_managed_worktree_path(worktree_git_root, codex_home) {
return (WorktreeSource::App, WorktreeLocation::CodexHome);
}
(WorktreeSource::Git, WorktreeLocation::External)
}
fn display_branch_or_name(info: &WorktreeInfo) -> &str {
info.branch.as_deref().unwrap_or(&info.name)
}
struct SourceRepo {
root: PathBuf,
relative_cwd: PathBuf,
common_git_dir: PathBuf,
repo_name: String,
id: String,
}
impl SourceRepo {
fn resolve(source_cwd: &Path) -> Result<Self> {
let source_cwd = source_cwd
.canonicalize()
.unwrap_or_else(|_| source_cwd.to_path_buf());
let root = PathBuf::from(git::stdout(&source_cwd, &["rev-parse", "--show-toplevel"])?);
let root = root.canonicalize().unwrap_or(root);
let common_git_dir_raw = git::stdout(&source_cwd, &["rev-parse", "--git-common-dir"])?;
let common_git_dir = absolutize(&source_cwd, Path::new(&common_git_dir_raw))
.canonicalize()
.unwrap_or_else(|_| absolutize(&source_cwd, Path::new(&common_git_dir_raw)));
let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok();
let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref());
let repo_name = root
.file_name()
.context("repository root has no directory name")?
.to_string_lossy()
.to_string();
let relative_cwd = source_cwd
.strip_prefix(&root)
.unwrap_or_else(|_| Path::new(""))
.to_path_buf();
Ok(Self {
root,
relative_cwd,
common_git_dir,
repo_name,
id,
})
}
}
fn branch_checkout_path(root: &Path, branch: &str) -> Result<Option<PathBuf>> {
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
Ok(worktrees
.into_iter()
.find_map(|entry| (entry.branch.as_deref() == Some(branch)).then_some(entry.path)))
}
fn branch_exists(root: &Path, branch: &str) -> bool {
git::status(
root,
&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
.is_ok()
}
fn ensure_safe_branch_name(root: &Path, branch: &str) -> Result<()> {
if branch.trim().is_empty() {
anyhow::bail!("branch name must not be empty");
}
git::status(root, &["check-ref-format", "--branch", branch]).context("invalid branch name")
}
fn current_branch(root: &Path) -> Result<Option<String>> {
let output = std::process::Command::new("git")
.args(["symbolic-ref", "--quiet", "--short", "HEAD"])
.current_dir(root)
.output()?;
if output.status.success() {
let branch = String::from_utf8(output.stdout)?.trim().to_string();
Ok((!branch.is_empty()).then_some(branch))
} else {
Ok(None)
}
}
fn primary_worktree_root(root: &Path) -> Result<PathBuf> {
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
worktrees
.into_iter()
.next()
.map(|entry| entry.path)
.context("git did not report a primary worktree")
}
fn metadata_path_for_display(worktree_path: &Path) -> Result<PathBuf> {
let path = git::stdout(
worktree_path,
&["rev-parse", "--git-path", "codex-worktree.json"],
)?;
Ok(absolutize(worktree_path, Path::new(&path)))
}
fn absolutize(cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
#[derive(Debug, PartialEq, Eq)]
struct GitWorktreeEntry {
path: PathBuf,
branch: Option<String>,
}
fn parse_worktree_list(output: &str) -> Vec<GitWorktreeEntry> {
let mut entries = Vec::new();
let mut path = None;
let mut branch = None;
for line in output.lines().chain(std::iter::once("")) {
if line.is_empty() {
if let Some(path) = path.take() {
entries.push(GitWorktreeEntry {
path,
branch: branch.take(),
});
}
continue;
}
if let Some(raw_path) = line.strip_prefix("worktree ") {
path = Some(PathBuf::from(raw_path));
} else if let Some(raw_branch) = line.strip_prefix("branch ") {
branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string());
}
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parse_worktree_list_preserves_branches() {
let entries = parse_worktree_list(
"worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo.wt\nHEAD def\nbranch refs/heads/codex/demo\n\n",
);
assert_eq!(
entries,
vec![
GitWorktreeEntry {
path: PathBuf::from("/repo"),
branch: Some("main".to_string())
},
GitWorktreeEntry {
path: PathBuf::from("/repo.wt"),
branch: Some("codex/demo".to_string())
}
]
);
}
}
+157
View File
@@ -0,0 +1,157 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use crate::WorktreeInfo;
use crate::WorktreeLocation;
use crate::WorktreeSource;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeThreadMetadata {
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_thread_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeMetadata {
pub version: u32,
pub manager: String,
pub backend: String,
#[serde(default = "default_source")]
pub source: WorktreeSource,
#[serde(default = "default_location")]
pub location: WorktreeLocation,
pub id: String,
pub name: String,
pub slug: String,
pub branch: Option<String>,
pub repo_id: String,
pub repo_name: String,
pub source_repo_root: PathBuf,
pub original_relative_cwd: PathBuf,
pub worktree_git_root: PathBuf,
pub workspace_cwd: PathBuf,
pub created_at: i64,
pub updated_at: i64,
pub owner_thread_id: Option<String>,
pub tmux_session: Option<String>,
}
impl WorktreeMetadata {
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
let now = unix_seconds();
Self {
version: 1,
manager: "codex-cli".to_string(),
backend: "git".to_string(),
source: info.source,
location: info.location,
id: info.id.clone(),
name: info.name.clone(),
slug: info.slug.clone(),
branch: info.branch.clone(),
repo_id: info.id.clone(),
repo_name: info.repo_name.clone(),
source_repo_root,
original_relative_cwd: info.original_relative_cwd.clone(),
worktree_git_root: info.worktree_git_root.clone(),
workspace_cwd: info.workspace_cwd.clone(),
created_at: now,
updated_at: now,
owner_thread_id: info.owner_thread_id.clone(),
tmux_session: None,
}
}
}
fn default_source() -> WorktreeSource {
WorktreeSource::Legacy
}
fn default_location() -> WorktreeLocation {
WorktreeLocation::CodexHome
}
pub fn read_worktree_metadata(worktree_path: &Path) -> Result<Option<WorktreeMetadata>> {
let path = metadata_path(worktree_path, "codex-worktree.json")?;
read_json_if_exists(&path)
}
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
let path = metadata_path(worktree_path, "codex-worktree.json")?;
write_json(&path, metadata)
}
pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?;
let git_root = PathBuf::from(git_root);
let owner = WorktreeThreadMetadata {
version: 1,
owner_thread_id: Some(thread_id.to_string()),
};
let owner_path = metadata_path(&git_root, "codex-thread.json")?;
write_json(&owner_path, &owner)?;
if let Some(mut metadata) = read_worktree_metadata(&git_root)? {
metadata.owner_thread_id = Some(thread_id.to_string());
metadata.updated_at = unix_seconds();
write_worktree_metadata(&git_root, &metadata)?;
}
Ok(())
}
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
let metadata = WorktreeThreadMetadata {
version: 1,
owner_thread_id: None,
};
let path = metadata_path(worktree_path, "codex-thread.json")?;
write_json(&path, &metadata)
}
fn read_json_if_exists<T>(path: &Path) -> Result<Option<T>>
where
T: serde::de::DeserializeOwned,
{
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
Ok(Some(serde_json::from_str(&contents)?))
}
fn write_json<T>(path: &Path, value: &T) -> Result<()>
where
T: serde::Serialize,
{
let contents = serde_json::to_string_pretty(value)?;
fs::write(path, contents)?;
Ok(())
}
fn metadata_path(worktree_path: &Path, name: &str) -> Result<PathBuf> {
let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?;
let path = PathBuf::from(path);
Ok(if path.is_absolute() {
path
} else {
worktree_path.join(path)
})
}
fn unix_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
}
+105
View File
@@ -0,0 +1,105 @@
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use sha2::Digest;
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
codex_home.join("worktrees")
}
pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let root = codex_worktrees_root(codex_home)
.canonicalize()
.unwrap_or_else(|_| codex_worktrees_root(codex_home));
path.starts_with(root)
}
pub fn slugify_name(name: &str) -> Result<String> {
let slug = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.take(12)
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
anyhow::bail!("worktree name must contain at least one ASCII letter or digit");
}
Ok(slug)
}
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
let sanitized = branch.replace(['/', '\\'], "-");
if sanitized.trim().is_empty() {
anyhow::bail!("branch name must produce a non-empty worktree path segment");
}
Ok(sanitized)
}
pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(common_git_dir.to_string_lossy().as_bytes());
if let Some(origin_url) = origin_url {
hasher.update(b"\0");
hasher.update(origin_url.as_bytes());
}
let digest = hasher.finalize();
digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
let repo_name = repo_root
.file_name()
.context("source repository root has no directory name")?;
let parent = repo_root
.parent()
.context("source repository root has no parent directory")?;
let sanitized_branch = sanitize_branch_for_path(branch)?;
let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch);
Ok(parent.join(dirname))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn slugify_name_keeps_short_ascii_slug() -> Result<()> {
assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests");
Ok(())
}
#[test]
fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> {
assert_eq!(
sanitize_branch_for_path("feature/auth\\windows")?,
"feature-auth-windows"
);
Ok(())
}
#[test]
fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> {
assert_eq!(
sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?,
PathBuf::from("/Users/me/code/codex.feature-auth")
);
Ok(())
}
}
+239
View File
@@ -0,0 +1,239 @@
use std::fs;
use std::path::Path;
use std::process::Command;
use codex_worktree::DirtyPolicy;
use codex_worktree::WorktreeListQuery;
use codex_worktree::WorktreeLocation;
use codex_worktree::WorktreeRemoveRequest;
use codex_worktree::WorktreeRequest;
use codex_worktree::WorktreeSource;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn creates_reuses_lists_and_removes_managed_worktree() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
fs::create_dir(fixture.repo.path().join("codex-rs"))?;
fs::write(fixture.repo.path().join("codex-rs/README.md"), "subdir\n")?;
run_git(fixture.repo.path(), &["add", "codex-rs/README.md"])?;
run_git(fixture.repo.path(), &["commit", "-m", "add subdir"])?;
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().join("codex-rs"),
branch: "parser-fix".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})?;
assert!(!resolution.reused);
assert_eq!(resolution.info.name, "parser-fix");
assert_eq!(resolution.info.slug, "parser-fix");
assert_eq!(resolution.info.branch.as_deref(), Some("parser-fix"));
assert_eq!(resolution.info.source, WorktreeSource::Cli);
assert_eq!(resolution.info.location, WorktreeLocation::Sibling);
let canonical_repo = fixture.repo.path().canonicalize()?;
assert_eq!(
resolution.info.worktree_git_root,
canonical_repo.with_file_name(format!(
"{}.parser-fix",
canonical_repo.file_name().unwrap().to_string_lossy()
))
);
assert_eq!(
resolution.info.workspace_cwd,
resolution.info.worktree_git_root.join("codex-rs")
);
assert!(resolution.info.workspace_cwd.exists());
assert!(
git(
&resolution.info.worktree_git_root,
&["rev-parse", "--git-path", "codex-worktree.json"]
)
.map(|path| resolution.info.worktree_git_root.join(path).exists())
.unwrap_or(false)
);
let reused = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().join("codex-rs"),
branch: "parser-fix".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})?;
assert!(reused.reused);
assert_eq!(
reused.info.worktree_git_root,
resolution.info.worktree_git_root
);
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: Some(fixture.repo.path().to_path_buf()),
include_all_repos: false,
})?;
assert_eq!(
entries
.iter()
.filter(|entry| entry.source == WorktreeSource::Cli)
.map(|entry| entry.branch.as_deref())
.collect::<Vec<_>>(),
vec![Some("parser-fix")]
);
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: Some(fixture.repo.path().to_path_buf()),
name_or_path: "parser-fix".to_string(),
force: false,
delete_branch: false,
})?;
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
assert!(!removed.removed_path.exists());
Ok(())
}
#[test]
fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?;
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
fs::write(
fixture.repo.path().join("unstaged.txt"),
"unstaged changed\n",
)?;
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().to_path_buf(),
branch: "copy-dirty".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::CopyTracked,
})?;
assert_eq!(
git(
&resolution.info.worktree_git_root,
&["diff", "--cached", "--name-only"]
)?,
"staged.txt"
);
assert_eq!(
git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?,
"unstaged.txt"
);
Ok(())
}
#[test]
fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().to_path_buf(),
branch: "feature/foo".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})?;
let err = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().to_path_buf(),
branch: "feature-foo".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})
.expect_err("sanitized branch path collision should fail");
assert!(
err.to_string().contains("already used by feature/foo"),
"{err:#}"
);
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: Some(fixture.repo.path().to_path_buf()),
name_or_path: "feature/foo".to_string(),
force: false,
delete_branch: false,
})?;
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
Ok(())
}
#[test]
fn list_includes_app_style_worktrees_without_cli_metadata() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
let app_worktree = fixture.codex_home.path().join("worktrees/7f6e/repo");
fs::create_dir_all(app_worktree.parent().expect("app worktree parent"))?;
run_git(
fixture.repo.path(),
&[
"worktree",
"add",
app_worktree.to_str().expect("utf-8 path"),
"HEAD",
],
)?;
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: Some(fixture.repo.path().to_path_buf()),
include_all_repos: false,
})?;
let canonical_app_worktree = app_worktree.canonicalize()?;
assert_eq!(
entries
.iter()
.filter(|entry| entry.source == WorktreeSource::App)
.map(|entry| (entry.name.as_str(), entry.worktree_git_root.as_path()))
.collect::<Vec<_>>(),
vec![("repo", canonical_app_worktree.as_path())]
);
Ok(())
}
struct GitFixture {
repo: TempDir,
codex_home: TempDir,
}
impl GitFixture {
fn new() -> anyhow::Result<Self> {
let repo = TempDir::new()?;
let codex_home = TempDir::new()?;
run_git(repo.path(), &["init", "-b", "main"])?;
run_git(repo.path(), &["config", "user.email", "codex@example.com"])?;
run_git(repo.path(), &["config", "user.name", "Codex"])?;
fs::write(repo.path().join("staged.txt"), "staged original\n")?;
fs::write(repo.path().join("unstaged.txt"), "unstaged original\n")?;
run_git(repo.path(), &["add", "."])?;
run_git(repo.path(), &["commit", "-m", "initial"])?;
Ok(Self { repo, codex_home })
}
}
fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> {
let output = Command::new("git").args(args).current_dir(cwd).output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn git(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("git").args(args).current_dir(cwd).output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
}