Compare commits

...

4 Commits

Author SHA1 Message Date
Felipe Coury
1b31e12444 feat(tui): create worktrees from slash command 2026-05-07 20:41:34 -03:00
Felipe Coury
4f3955ff91 fix(tui): keep worktree switching responsive 2026-05-06 23:58:02 -03:00
Felipe Coury
250390cb76 feat(tui): add worktree slash command 2026-05-06 21:07:37 -03:00
Felipe Coury
5a6efcf183 feat(cli): add managed worktree workflow 2026-05-06 19:44:04 -03:00
43 changed files with 4076 additions and 30 deletions

16
codex-rs/Cargo.lock generated
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"

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" }

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 }

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,

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"] }

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,

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 }

View File

@@ -201,6 +201,7 @@ mod thread_events;
mod thread_goal_actions;
mod thread_routing;
mod thread_session_state;
mod worktree;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
@@ -846,6 +847,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);

View File

@@ -187,6 +187,65 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreePicker => {
self.open_worktree_picker(tui);
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreeCreatePrompt => {
self.open_worktree_create_prompt();
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreesLoaded { cwd, result } => {
self.on_worktrees_loaded(cwd, result);
tui.frame_requester().schedule_frame();
}
AppEvent::CreateWorktreeAndSwitch {
branch,
base_ref,
dirty_policy,
} => {
self.create_worktree_and_switch(tui, branch, base_ref, dirty_policy);
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreeCreated { cwd, result } => {
self.on_worktree_created(tui, app_server, cwd, result).await;
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktree { target } => {
self.begin_switch_to_worktree_target(tui, target);
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktreeAfterLoading { target } => {
self.switch_to_worktree_target_after_loading(tui, app_server, target)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreeSessionReady {
info,
config,
forked,
warnings,
result,
} => {
self.on_worktree_session_ready(
tui, app_server, info, config, forked, warnings, result,
)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::ShowWorktreePath { target } => {
self.show_worktree_path(target);
tui.frame_requester().schedule_frame();
}
AppEvent::RemoveWorktree {
target,
force,
delete_branch,
confirmed,
} => {
self.remove_worktree(target, force, delete_branch, confirmed);
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
}

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,

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?;

View File

@@ -0,0 +1,760 @@
//! App-layer handlers for the worktree TUI flow.
use codex_protocol::ThreadId;
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 codex_worktree::WorktreeSource;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use super::*;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorktreeSwitchMode {
StartFresh,
Fork(ThreadId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorktreeSessionTransition {
Forked,
Started,
}
impl WorktreeSessionTransition {
fn message_prefix(self) -> &'static str {
match self {
WorktreeSessionTransition::Forked => "Forked into",
WorktreeSessionTransition::Started => "Started session in",
}
}
}
impl App {
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
self.chat_widget
.show_selection_view(crate::worktree::loading_params(
tui.frame_requester(),
self.config.animations,
));
self.fetch_worktrees_for_picker();
}
pub(super) fn open_worktree_create_prompt(&mut self) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"New worktree".to_string(),
"Type a branch name and press Enter".to_string(),
/*initial_text*/ String::new(),
/*context_label*/
Some("Creates a sibling worktree and starts this chat there.".to_string()),
Box::new(move |branch: String| {
tx.send(AppEvent::CreateWorktreeAndSwitch {
branch: branch.trim().to_string(),
base_ref: None,
dirty_policy: None,
});
}),
);
self.chat_widget.show_bottom_pane_view(Box::new(view));
}
pub(super) fn on_worktrees_loaded(
&mut self,
cwd: PathBuf,
result: Result<Vec<WorktreeInfo>, String>,
) {
if cwd.as_path() != self.config.cwd.as_path() {
return;
}
let params = match result {
Ok(entries) if entries.is_empty() => crate::worktree::empty_params(),
Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()),
Err(err) => crate::worktree::error_params(err),
};
self.replace_worktree_view(params);
}
pub(super) fn create_worktree_and_switch(
&mut self,
tui: &mut tui::Tui,
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let dirty_policy = match dirty_policy {
Some(policy) => policy,
None => match codex_worktree::dirty_state(self.config.cwd.as_path()) {
Ok(state) if state.is_dirty() => {
let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref);
self.chat_widget.show_selection_view(params);
return;
}
Ok(_) => DirtyPolicy::Fail,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to inspect source checkout: {err}"));
return;
}
},
};
self.show_worktree_creating_view(tui, branch.clone());
self.spawn_worktree_create_request(WorktreeRequest {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: self.config.cwd.to_path_buf(),
branch,
base_ref,
dirty_policy,
});
}
pub(super) async fn on_worktree_created(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
cwd: PathBuf,
result: Result<WorktreeResolution, String>,
) {
if cwd.as_path() != self.config.cwd.as_path() {
return;
}
let resolution = match result {
Ok(resolution) => resolution,
Err(err) => {
self.show_worktree_error("Failed to create worktree.".to_string(), err);
return;
}
};
let target = resolution
.info
.branch
.clone()
.unwrap_or_else(|| resolution.info.name.clone());
self.show_worktree_switching_view(tui, target);
self.switch_to_worktree_info(
tui,
app_server,
resolution.info,
resolution
.warnings
.into_iter()
.map(|warning| warning.message)
.collect(),
)
.await;
}
pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
self.show_worktree_switching_view(tui, target.clone());
self.defer_switch_to_worktree_target(target);
}
pub(super) async fn switch_to_worktree_target_after_loading(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
target: String,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let entries = match self.list_current_repo_worktrees() {
Ok(entries) => entries,
Err(err) => {
self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string());
return;
}
};
let info = match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => info.clone(),
Err(err) => {
self.show_worktree_error("Failed to switch worktree.".to_string(), err);
return;
}
};
self.switch_to_worktree_info(tui, app_server, info, Vec::new())
.await;
}
pub(super) fn show_worktree_path(&mut self, target: String) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
match self.list_current_repo_worktrees() {
Ok(entries) => match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => {
self.chat_widget.add_info_message(
info.workspace_cwd.display().to_string(),
/*hint*/ None,
);
}
Err(err) => self.chat_widget.add_error_message(err),
},
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to list worktrees: {err}"));
}
}
}
pub(super) fn remove_worktree(
&mut self,
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let entries = match self.list_current_repo_worktrees() {
Ok(entries) => entries,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to list worktrees: {err}"));
return;
}
};
let info = match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => info,
Err(err) => {
self.chat_widget.add_error_message(err);
return;
}
};
if info.source != WorktreeSource::Cli {
let source = crate::worktree::source_label(info.source);
self.chat_widget.add_error_message(format!(
"Refusing to remove {source} worktree '{target}'. Only Codex CLI-managed worktrees can be removed."
));
return;
}
if !confirmed {
let params = crate::worktree::remove_confirmation_params(target, force, delete_branch);
self.chat_widget.show_selection_view(params);
return;
}
match codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
name_or_path: target.clone(),
force,
delete_branch,
}) {
Ok(result) => {
let mut message = format!("Removed worktree {}", result.removed_path.display());
if let Some(branch) = result.deleted_branch {
message.push_str(&format!(" and deleted branch {branch}"));
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to remove worktree: {err}"));
}
}
}
fn list_current_repo_worktrees(&self) -> anyhow::Result<Vec<WorktreeInfo>> {
codex_worktree::list_worktrees(WorktreeListQuery {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
include_all_repos: false,
})
}
fn fetch_worktrees_for_picker(&mut self) {
let query = WorktreeListQuery {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
include_all_repos: false,
};
let cwd = self.config.cwd.to_path_buf();
let app_event_tx = self.app_event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string());
app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result });
});
}
fn spawn_worktree_create_request(&self, request: WorktreeRequest) {
let cwd = request.source_cwd.clone();
let app_event_tx = self.app_event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = codex_worktree::ensure_worktree(request).map_err(|err| err.to_string());
app_event_tx.send(AppEvent::WorktreeCreated { cwd, result });
});
}
async fn switch_to_worktree_info(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
info: WorktreeInfo,
warnings: Vec<String>,
) {
let mut config = match self
.rebuild_config_for_cwd(info.workspace_cwd.clone())
.await
{
Ok(config) => config,
Err(err) => {
self.show_worktree_error(
"Failed to rebuild configuration for worktree.".to_string(),
err.to_string(),
);
return;
}
};
self.apply_runtime_policy_overrides(&mut config);
let mode = self.worktree_switch_mode().await;
self.spawn_worktree_session_request(app_server, info, config, mode, warnings);
tui.frame_requester().schedule_frame();
}
pub(super) async fn on_worktree_session_ready(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
info: WorktreeInfo,
config: Config,
forked: bool,
warnings: Vec<String>,
result: Result<AppServerStartedThread, String>,
) {
match result {
Ok(started) => {
self.shutdown_current_thread(app_server).await;
self.install_worktree_config(tui, config);
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui, app_server, started, /*initial_user_message*/ None,
)
.await
{
self.show_worktree_error(
"Failed to attach to worktree thread.".to_string(),
err.to_string(),
);
} else {
let transition = if forked {
WorktreeSessionTransition::Forked
} else {
WorktreeSessionTransition::Started
};
self.add_worktree_session_message(&info, transition);
for warning in warnings {
self.chat_widget.add_info_message(warning, /*hint*/ None);
}
}
}
Err(err) => {
let summary = if forked {
"Failed to fork current session into worktree."
} else {
"Failed to start session in worktree."
};
self.show_worktree_error(summary.to_string(), err);
}
}
tui.frame_requester().schedule_frame();
}
fn spawn_worktree_session_request(
&self,
app_server: &AppServerSession,
info: WorktreeInfo,
config: Config,
mode: WorktreeSwitchMode,
warnings: Vec<String>,
) {
let request_handle = app_server.request_handle();
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let forked = matches!(mode, WorktreeSwitchMode::Fork(_));
let result = match mode {
WorktreeSwitchMode::Fork(thread_id) => {
crate::app_server_session::fork_thread_with_request_handle(
request_handle,
config.clone(),
thread_id,
remote_cwd_override,
)
.await
}
WorktreeSwitchMode::StartFresh => {
crate::app_server_session::start_thread_with_request_handle(
request_handle,
config.clone(),
remote_cwd_override,
)
.await
}
}
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::WorktreeSessionReady {
info,
config,
forked,
warnings,
result,
});
});
}
fn add_worktree_session_message(
&mut self,
info: &WorktreeInfo,
transition: WorktreeSessionTransition,
) {
let (message, hint) = worktree_session_message(info, transition);
self.chat_widget.add_info_message(message, Some(hint));
}
async fn worktree_switch_mode(&self) -> WorktreeSwitchMode {
let Some(thread_id) = self.current_displayed_thread_id() else {
return WorktreeSwitchMode::StartFresh;
};
if self
.session_for_thread(thread_id)
.await
.as_ref()
.is_some_and(Self::session_has_materialized_rollout)
{
WorktreeSwitchMode::Fork(thread_id)
} else {
WorktreeSwitchMode::StartFresh
}
}
async fn session_for_thread(&self, thread_id: ThreadId) -> Option<ThreadSessionState> {
if self.primary_thread_id == Some(thread_id)
&& let Some(session) = self.primary_session_configured.clone()
{
return Some(session);
}
let channel = self.thread_event_channels.get(&thread_id)?;
let store = channel.store.lock().await;
store.session.clone()
}
fn session_has_materialized_rollout(session: &ThreadSessionState) -> bool {
session
.rollout_path
.as_ref()
.is_some_and(|rollout_path| rollout_path.exists())
}
fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
let params = crate::worktree::switching_params(
target.clone(),
tui.frame_requester(),
self.config.animations,
);
if !self.replace_worktree_view(params) {
self.chat_widget
.show_selection_view(crate::worktree::switching_params(
target,
tui.frame_requester(),
self.config.animations,
));
}
tui.frame_requester().schedule_frame();
}
fn show_worktree_creating_view(&mut self, tui: &mut tui::Tui, branch: String) {
self.chat_widget
.show_selection_view(crate::worktree::creating_params(
branch,
tui.frame_requester(),
self.config.animations,
));
tui.frame_requester().schedule_frame();
}
fn defer_switch_to_worktree_target(&self, target: String) {
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await;
app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target });
});
}
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
self.chat_widget
.replace_selection_view_if_active(crate::worktree::WORKTREE_SELECTION_VIEW_ID, params)
}
fn show_worktree_error(&mut self, summary: String, error: String) {
let params = crate::worktree::error_with_summary_params(summary.clone(), error.clone());
if !self.replace_worktree_view(params) {
self.chat_widget
.add_error_message(format!("{summary} {error}"));
}
}
fn install_worktree_config(&mut self, tui: &mut tui::Tui, config: Config) {
self.config = config;
tui.set_notification_settings(
self.config.tui_notifications.method,
self.config.tui_notifications.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
}
}
fn worktree_session_message(
info: &WorktreeInfo,
transition: WorktreeSessionTransition,
) -> (String, String) {
let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str());
let state = if info.dirty.is_dirty() {
"dirty"
} else {
"clean"
};
let source = crate::worktree::source_label(info.source);
(
format!(
"{} {source} worktree {worktree_name} · {state} · {}",
transition.message_prefix(),
info.repo_name
),
info.workspace_cwd.display().to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::AskForApproval;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_worktree::DirtyState;
use codex_worktree::WorktreeLocation;
use tempfile::TempDir;
#[tokio::test]
async fn worktree_switch_mode_starts_fresh_without_current_thread() {
let app = crate::app::test_support::make_test_app().await;
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::StartFresh
);
}
#[tokio::test]
async fn worktree_switch_mode_starts_fresh_for_unmaterialized_primary_rollout() {
let temp_dir = TempDir::new().expect("temp dir");
let thread_id = ThreadId::new();
let missing_rollout_path = temp_dir.path().join("missing-rollout.jsonl");
let session = test_thread_session(
thread_id,
temp_dir.path().join("repo"),
missing_rollout_path,
);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.primary_session_configured = Some(session);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::StartFresh
);
}
#[tokio::test]
async fn worktree_switch_mode_forks_materialized_primary_rollout() {
let temp_dir = TempDir::new().expect("temp dir");
let thread_id = ThreadId::new();
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\\n").expect("write rollout");
let session = test_thread_session(thread_id, temp_dir.path().join("repo"), rollout_path);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.primary_session_configured = Some(session);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::Fork(thread_id)
);
}
#[tokio::test]
async fn worktree_switch_mode_uses_active_non_primary_thread_session() {
let temp_dir = TempDir::new().expect("temp dir");
let primary_thread_id = ThreadId::new();
let active_thread_id = ThreadId::new();
let active_rollout_path = temp_dir.path().join("active-rollout.jsonl");
std::fs::write(&active_rollout_path, "{}\\n").expect("write rollout");
let active_session = test_thread_session(
active_thread_id,
temp_dir.path().join("active"),
active_rollout_path,
);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(primary_thread_id);
app.active_thread_id = Some(active_thread_id);
app.primary_session_configured = Some(test_thread_session(
primary_thread_id,
temp_dir.path().join("primary"),
temp_dir.path().join("missing-primary-rollout.jsonl"),
));
app.thread_event_channels.insert(
active_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
active_session,
Vec::new(),
),
);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::Fork(active_thread_id)
);
}
#[test]
fn worktree_session_message_describes_forked_workspace() {
let info = test_worktree_info(
WorktreeSource::Cli,
Some("fcoury/demo".to_string()),
/*dirty*/ false,
);
assert_eq!(
worktree_session_message(&info, WorktreeSessionTransition::Forked),
(
"Forked into cli worktree fcoury/demo · clean · codex".to_string(),
"/repo/codex.fcoury-demo".to_string()
)
);
}
#[test]
fn worktree_session_message_describes_started_dirty_workspace() {
let info = test_worktree_info(
WorktreeSource::App,
/*branch*/ None,
/*dirty*/ true,
);
assert_eq!(
worktree_session_message(&info, WorktreeSessionTransition::Started),
(
"Started session in app worktree app-worktree · dirty · codex".to_string(),
"/repo/codex.fcoury-demo".to_string()
)
);
}
fn test_thread_session(
thread_id: ThreadId,
cwd: PathBuf,
rollout_path: PathBuf,
) -> ThreadSessionState {
ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: AbsolutePathBuf::try_from(cwd).expect("absolute cwd"),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_path),
}
}
fn test_worktree_info(
source: WorktreeSource,
branch: Option<String>,
dirty: bool,
) -> WorktreeInfo {
let path = PathBuf::from("/repo/codex.fcoury-demo");
WorktreeInfo {
id: "repo-id".to_string(),
name: "app-worktree".to_string(),
slug: "fcoury-demo".to_string(),
source,
location: WorktreeLocation::Sibling,
repo_name: "codex".to_string(),
repo_root: path.clone(),
common_git_dir: PathBuf::from("/repo/codex/.git"),
worktree_git_root: path.clone(),
workspace_cwd: path,
original_relative_cwd: PathBuf::new(),
branch,
head: Some("abcdef".to_string()),
owner_thread_id: None,
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
dirty: DirtyState {
has_staged_changes: false,
has_unstaged_changes: dirty,
has_untracked_files: false,
},
}
}
}

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 { .. })
}

View File

@@ -31,12 +31,15 @@ use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use codex_worktree::DirtyPolicy;
use crate::app_command::AppCommand;
use crate::app_server_session::AppServerStartedThread;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::UserMessage;
use crate::legacy_core::config::Config;
use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
@@ -191,6 +194,63 @@ pub(crate) enum AppEvent {
/// Fork the current session into a new thread.
ForkCurrentSession,
/// Open the managed worktree picker.
OpenWorktreePicker,
/// Open the prompt for creating a managed worktree.
OpenWorktreeCreatePrompt,
/// Result of loading worktrees for the managed worktree picker.
WorktreesLoaded {
cwd: PathBuf,
result: Result<Vec<codex_worktree::WorktreeInfo>, String>,
},
/// Create or reuse a managed worktree and switch the TUI into it.
CreateWorktreeAndSwitch {
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
},
/// Result of creating or reusing a managed worktree.
WorktreeCreated {
cwd: PathBuf,
result: Result<codex_worktree::WorktreeResolution, String>,
},
/// Switch the TUI into an existing worktree.
SwitchToWorktree {
target: String,
},
/// Continue switching into an existing worktree after the loading view has rendered.
SwitchToWorktreeAfterLoading {
target: String,
},
/// Result of starting or forking a session in a worktree.
WorktreeSessionReady {
info: codex_worktree::WorktreeInfo,
config: Config,
forked: bool,
warnings: Vec<String>,
result: Result<AppServerStartedThread, String>,
},
/// Show the filesystem path for an existing worktree.
ShowWorktreePath {
target: String,
},
/// Remove a Codex-managed worktree.
RemoveWorktree {
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
},
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the

View File

@@ -117,7 +117,9 @@ 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;
use uuid::Uuid;
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
@@ -165,6 +167,7 @@ impl ThreadParamsMode {
}
}
#[derive(Debug)]
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
@@ -968,6 +971,101 @@ impl AppServerSession {
}
}
pub(crate) async fn start_thread_with_request_handle(
request_handle: AppServerRequestHandle,
config: Config,
remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
let response: ThreadStartResponse = request_handle
.request_typed(ClientRequest::ThreadStart {
request_id: worktree_request_id("worktree-thread-start"),
params: thread_start_params_from_config(
&config,
thread_params_mode,
remote_cwd_override.as_deref(),
/*session_start_source*/ None,
),
})
.await
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
started_thread_from_start_response(response, &config, thread_params_mode).await
}
pub(crate) async fn fork_thread_with_request_handle(
request_handle: AppServerRequestHandle,
config: Config,
thread_id: ThreadId,
remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
let response: ThreadForkResponse = request_handle
.request_typed(ClientRequest::ThreadFork {
request_id: worktree_request_id("worktree-thread-fork"),
params: thread_fork_params_from_config(
config.clone(),
thread_id,
thread_params_mode,
remote_cwd_override.as_deref(),
),
})
.await
.map_err(|err| bootstrap_request_error("thread/fork failed during TUI bootstrap", err))?;
let fork_parent_title = fork_parent_title_from_request_handle(
&request_handle,
response.thread.forked_from_id.as_deref(),
)
.await;
let mut started =
started_thread_from_fork_response(response, &config, thread_params_mode).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
fn worktree_request_id(prefix: &str) -> RequestId {
RequestId::String(format!("{prefix}-{}", Uuid::new_v4()))
}
fn thread_params_mode_from_request_handle(
request_handle: &AppServerRequestHandle,
) -> ThreadParamsMode {
match request_handle {
AppServerRequestHandle::InProcess(_) => ThreadParamsMode::Embedded,
AppServerRequestHandle::Remote(_) => ThreadParamsMode::Remote,
}
}
async fn fork_parent_title_from_request_handle(
request_handle: &AppServerRequestHandle,
forked_from_id: Option<&str>,
) -> Option<String> {
let forked_from_id = forked_from_id?;
let forked_from_id = match ThreadId::from_string(forked_from_id) {
Ok(thread_id) => thread_id,
Err(err) => {
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
return None;
}
};
match request_handle
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
request_id: worktree_request_id("worktree-thread-read"),
params: ThreadReadParams {
thread_id: forked_from_id.to_string(),
include_turns: false,
},
})
.await
{
Ok(thread) => thread.thread.name,
Err(err) => {
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
None
}
}
}
fn thread_realtime_start_params(
thread_id: ThreadId,
transport: Option<ThreadRealtimeStartTransport>,
@@ -1328,6 +1426,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 +1459,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 +1492,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 +1520,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>,

View File

@@ -5377,6 +5377,30 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn show_bottom_pane_view(
&mut self,
view: Box<dyn crate::bottom_pane::BottomPaneView>,
) {
self.bottom_pane.show_view(view);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn replace_selection_view_if_active(
&mut self,
view_id: &'static str,
params: SelectionViewParams,
) -> bool {
let replaced = self
.bottom_pane
.replace_selection_view_if_active(view_id, params);
if replaced {
self.refresh_plan_mode_nudge();
self.request_redraw();
}
replaced
}
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.bottom_pane.no_modal_or_popup_active()
}

View File

@@ -154,6 +154,9 @@ impl ChatWidget {
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Worktree => {
self.app_event_tx.send(AppEvent::OpenWorktreePicker);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
if init_target.exists() {
@@ -772,6 +775,13 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::Worktree if !trimmed.is_empty() => {
if let Err(message) =
crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx)
{
self.add_error_message(message);
}
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
@@ -918,6 +928,7 @@ impl ChatWidget {
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Worktree
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review

View File

@@ -6,19 +6,14 @@
use super::*;
use crate::bottom_pane::status_line_from_segments;
use crate::branch_summary;
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
use crate::motion::activity_spinner_frame_at;
use crate::status::format_tokens_compact;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: activity indicator + project name.
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
/// Braille-pattern dot-spinner frames for the terminal title animation.
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["", "", "", "", "", "", "", "", "", ""];
/// Time between spinner frame advances in the terminal title.
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
/// Time between action-required blink phases in the terminal title.
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
@@ -362,7 +357,7 @@ impl ChatWidget {
}
self.should_animate_terminal_title_spinner_with_selections(selections)
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
.then_some(ACTIVITY_SPINNER_INTERVAL)
}
pub(super) fn request_status_line_branch_refresh(&mut self) {
@@ -816,14 +811,7 @@ impl ChatWidget {
return None;
}
Some(self.terminal_title_spinner_frame_at(now).to_string())
}
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
let frame_index =
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string())
}
fn terminal_title_uses_activity(&self) -> bool {

View File

@@ -179,6 +179,8 @@ mod tui;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::UpdateAction;
mod worktree;
mod worktree_labels;
#[cfg(not(debug_assertions))]
pub use update_action::get_update_action;
mod update_prompt;

View File

@@ -3,6 +3,7 @@
//! Callers choose an explicit reduced-motion fallback here instead of reaching
//! directly for time-varying spinner or shimmer helpers.
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Stylize;
@@ -10,6 +11,10 @@ use ratatui::text::Span;
use crate::shimmer::shimmer_spans;
const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MotionMode {
Animated,
@@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'sta
}
}
pub(crate) fn activity_spinner_frame_at(origin: Instant, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(origin);
let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize;
ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()]
}
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
@@ -117,6 +128,24 @@ mod tests {
);
}
#[test]
fn activity_spinner_frame_advances_and_wraps() {
let origin = Instant::now();
assert_eq!(activity_spinner_frame_at(origin, origin), "");
assert_eq!(
activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL),
""
);
assert_eq!(
activity_spinner_frame_at(
origin,
origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32,
),
""
);
}
#[test]
fn animation_primitives_are_only_used_by_motion_module() {
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();

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));

View File

@@ -33,6 +33,7 @@ pub enum SlashCommand {
New,
Resume,
Fork,
Worktree,
Init,
Compact,
Plan,
@@ -87,6 +88,7 @@ impl SlashCommand {
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::Worktree => "manage worktrees",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
@@ -158,6 +160,7 @@ impl SlashCommand {
| SlashCommand::Raw
| SlashCommand::Side
| SlashCommand::Resume
| SlashCommand::Worktree
| SlashCommand::SandboxReadRoot
)
}
@@ -181,6 +184,7 @@ impl SlashCommand {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Worktree
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Model

View File

@@ -0,0 +1,13 @@
---
source: tui/src/worktree.rs
expression: "render_selection(creating_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
---
Worktrees
• Creating fcoury/demo...
Codex is creating the worktree before starting the chat in that workspace.
Preparing worktree... Codex is creating the worktree before starting the chat in
that workspace.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,14 @@
---
source: tui/src/worktree.rs
expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_string(), None),\n82)"
---
Source checkout has uncommitted changes
Choose what to carry into the new worktree.
1. Fail Cancel creation and leave the source checkout unchanged.
2. Ignore Create from the requested base without copying local changes.
3. Copy tracked Copy staged and unstaged tracked changes.
4. Copy all Copy tracked changes and untracked files.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,10 @@
---
source: tui/src/worktree.rs
expression: "render_selection(empty_params(), 84)"
---
Worktrees
1. New worktree... Type the branch name for the new worktree.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,13 @@
---
source: tui/src/worktree.rs
expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)"
---
Worktrees
• Loading worktrees...
This can take a moment when Codex is checking app, CLI, and Git worktrees.
Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git
worktrees.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,15 @@
---
source: tui/src/worktree.rs
expression: "render_selection(params, 86)"
---
Worktrees
Create a worktree or fork this chat into an existing workspace.
Search worktrees
New worktree... Type the branch name for the new worktree.
fcoury/demo (current) clean · cli · /repo/codex.fcoury-demo
codex clean · app · /repo/codex.codex
main dirty · git · /repo/codex.main
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,12 @@
---
source: tui/src/worktree.rs
expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)"
---
Remove worktree fcoury/demo?
Only Codex-managed worktrees can be removed.
1. Remove Remove the selected worktree.
2. Cancel Keep the worktree.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,13 @@
---
source: tui/src/worktree.rs
expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
---
Worktrees
• Switching to fcoury/demo...
Codex is rebuilding configuration and starting the chat in that workspace.
Preparing worktree session... Codex is rebuilding configuration and starting the
chat in that workspace.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,778 @@
use std::path::Path;
use std::time::Instant;
use codex_worktree::DirtyPolicy;
use codex_worktree::WorktreeInfo;
use codex_worktree::WorktreeSource;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionRowDisplay;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
use crate::motion::activity_spinner_frame_at;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
const WORKTREE_USAGE: &str =
"Usage: /worktree [list|new <branch>|switch <branch>|path <branch>|remove <branch>]";
pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection";
struct WorktreeLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
status: String,
note: String,
}
impl WorktreeLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
status: String,
note: String,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
status,
note,
}
}
}
impl Renderable for WorktreeLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
if self.animations_enabled {
self.frame_requester
.schedule_frame_in(ACTIVITY_SPINNER_INTERVAL);
}
let mut loading_spans = Vec::new();
if self.animations_enabled {
loading_spans.push(activity_spinner_frame_at(self.started_at, Instant::now()).into());
loading_spans.push(" ".into());
} else {
loading_spans.push("".dim());
loading_spans.push(" ".into());
}
loading_spans.push(self.status.clone().dim());
Paragraph::new(vec![
Line::from("Worktrees".bold()),
Line::from(loading_spans),
Line::from(self.note.clone().dim()),
])
.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
3
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorktreeSlashAction {
OpenPicker,
Create {
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
},
Switch {
target: String,
},
ShowPath {
target: String,
},
Remove {
target: String,
force: bool,
delete_branch: bool,
},
}
impl WorktreeSlashAction {
pub(crate) fn dispatch(self, tx: &AppEventSender) {
match self {
WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker),
WorktreeSlashAction::Create {
branch,
base_ref,
dirty_policy,
} => tx.send(AppEvent::CreateWorktreeAndSwitch {
branch,
base_ref,
dirty_policy,
}),
WorktreeSlashAction::Switch { target } => {
tx.send(AppEvent::SwitchToWorktree { target });
}
WorktreeSlashAction::ShowPath { target } => {
tx.send(AppEvent::ShowWorktreePath { target });
}
WorktreeSlashAction::Remove {
target,
force,
delete_branch,
} => tx.send(AppEvent::RemoveWorktree {
target,
force,
delete_branch,
confirmed: force,
}),
}
}
}
pub(crate) fn parse_worktree_slash_args(args: &str) -> Result<WorktreeSlashAction, String> {
let mut parts = args.split_whitespace();
let Some(command) = parts.next() else {
return Ok(WorktreeSlashAction::OpenPicker);
};
match command {
"list" => Ok(WorktreeSlashAction::OpenPicker),
"new" => parse_new(parts),
"switch" | "move" => {
let target = required_target(parts, command)?;
Ok(WorktreeSlashAction::Switch { target })
}
"path" => {
let target = required_target(parts, command)?;
Ok(WorktreeSlashAction::ShowPath { target })
}
"remove" => parse_remove(parts),
_ => Err(WORKTREE_USAGE.to_string()),
}
}
fn parse_new<'a>(mut parts: impl Iterator<Item = &'a str>) -> Result<WorktreeSlashAction, String> {
let Some(branch) = parts.next() else {
return Err("Usage: /worktree new <branch> [--base <ref>] [--dirty <mode>]".to_string());
};
let mut base_ref = None;
let mut dirty_policy = None;
while let Some(flag) = parts.next() {
match flag {
"--base" => {
let Some(value) = parts.next() else {
return Err("Usage: /worktree new <branch> --base <ref>".to_string());
};
base_ref = Some(value.to_string());
}
"--dirty" => {
let Some(value) = parts.next() else {
return Err("Usage: /worktree new <branch> --dirty <mode>".to_string());
};
dirty_policy = Some(parse_dirty_policy(value)?);
}
_ => return Err(format!("Unknown /worktree new option '{flag}'.")),
}
}
Ok(WorktreeSlashAction::Create {
branch: branch.to_string(),
base_ref,
dirty_policy,
})
}
fn parse_remove<'a>(
mut parts: impl Iterator<Item = &'a str>,
) -> Result<WorktreeSlashAction, String> {
let Some(target) = parts.next() else {
return Err(
"Usage: /worktree remove <branch-or-name> [--force] [--delete-branch]".to_string(),
);
};
let mut force = false;
let mut delete_branch = false;
for flag in parts {
match flag {
"--force" => force = true,
"--delete-branch" => delete_branch = true,
_ => return Err(format!("Unknown /worktree remove option '{flag}'.")),
}
}
Ok(WorktreeSlashAction::Remove {
target: target.to_string(),
force,
delete_branch,
})
}
fn required_target<'a>(
mut parts: impl Iterator<Item = &'a str>,
command: &str,
) -> Result<String, String> {
let Some(target) = parts.next() else {
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
};
if parts.next().is_some() {
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
}
Ok(target.to_string())
}
fn parse_dirty_policy(value: &str) -> Result<DirtyPolicy, String> {
match value {
"fail" => Ok(DirtyPolicy::Fail),
"ignore" => Ok(DirtyPolicy::Ignore),
"copy-tracked" => Ok(DirtyPolicy::CopyTracked),
"copy-all" => Ok(DirtyPolicy::CopyAll),
_ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()),
}
}
pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> Result<(), String> {
parse_worktree_slash_args(args)?.dispatch(tx);
Ok(())
}
pub(crate) fn loading_params(
frame_requester: FrameRequester,
animations_enabled: bool,
) -> SelectionViewParams {
let status = "Loading worktrees...".to_string();
let note =
"This can take a moment when Codex is checking app, CLI, and Git worktrees.".to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
frame_requester,
animations_enabled,
status.clone(),
note.clone(),
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: status,
description: Some(note),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn switching_params(
target: String,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> SelectionViewParams {
let status = format!("Switching to {target}...");
let note =
"Codex is rebuilding configuration and starting the chat in that workspace.".to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
frame_requester,
animations_enabled,
status,
note.clone(),
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: "Preparing worktree session...".to_string(),
description: Some(note),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn creating_params(
branch: String,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> SelectionViewParams {
let status = format!("Creating {branch}...");
let note =
"Codex is creating the worktree before starting the chat in that workspace.".to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
frame_requester,
animations_enabled,
status,
note.clone(),
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: "Preparing worktree...".to_string(),
description: Some(note),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn empty_params() -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![new_worktree_item()],
..Default::default()
}
}
pub(crate) fn error_params(error: String) -> SelectionViewParams {
error_with_summary_params("Failed to list worktrees.".to_string(), error)
}
pub(crate) fn error_with_summary_params(summary: String, error: String) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: summary,
description: Some(error),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> SelectionViewParams {
let mut items = vec![new_worktree_item()];
items.extend(entries.into_iter().map(|entry| {
let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone());
let source = source_label(entry.source);
let status = if entry.dirty.is_dirty() {
"dirty"
} else {
"clean"
};
let description = format!("{status} · {source} · {}", entry.workspace_cwd.display());
let search_value = Some(format!(
"{} {} {} {}",
target,
entry.name,
source,
entry.workspace_cwd.display()
));
SelectionItem {
name: target.clone(),
description: Some(description),
selected_description: Some(format!(
"Fork this chat into {}",
entry.workspace_cwd.display()
)),
is_current: paths_match(current_cwd, &entry.workspace_cwd),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SwitchToWorktree {
target: target.clone(),
});
})],
dismiss_on_select: true,
search_value,
..Default::default()
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
header.push(Line::from(
"Create a worktree or fork this chat into an existing workspace.".dim(),
));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Search worktrees".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
row_display: SelectionRowDisplay::SingleLine,
..Default::default()
}
}
fn new_worktree_item() -> SelectionItem {
SelectionItem {
name: "New worktree...".to_string(),
description: Some("Create a sibling worktree and start this chat there.".to_string()),
selected_description: Some("Type the branch name for the new worktree.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenWorktreeCreatePrompt);
})],
dismiss_on_select: false,
search_value: Some("new worktree create branch".to_string()),
..Default::default()
}
}
pub(crate) fn dirty_policy_prompt_params(
branch: String,
base_ref: Option<String>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Source checkout has uncommitted changes".bold()));
header.push(Line::from(
"Choose what to carry into the new worktree.".dim(),
));
let item = |name: &str, description: &str, dirty_policy: DirtyPolicy| SelectionItem {
name: name.to_string(),
description: Some(description.to_string()),
actions: vec![Box::new({
let branch = branch.clone();
let base_ref = base_ref.clone();
move |tx| {
tx.send(AppEvent::CreateWorktreeAndSwitch {
branch: branch.clone(),
base_ref: base_ref.clone(),
dirty_policy: Some(dirty_policy),
});
}
})],
dismiss_on_select: true,
..Default::default()
};
SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
item(
"Fail",
"Cancel creation and leave the source checkout unchanged.",
DirtyPolicy::Fail,
),
item(
"Ignore",
"Create from the requested base without copying local changes.",
DirtyPolicy::Ignore,
),
item(
"Copy tracked",
"Copy staged and unstaged tracked changes.",
DirtyPolicy::CopyTracked,
),
item(
"Copy all",
"Copy tracked changes and untracked files.",
DirtyPolicy::CopyAll,
),
],
..Default::default()
}
}
pub(crate) fn remove_confirmation_params(
target: String,
force: bool,
delete_branch: bool,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Remove worktree {target}?").bold()));
header.push(Line::from(
"Only Codex-managed worktrees can be removed.".dim(),
));
SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: "Remove".to_string(),
description: Some("Remove the selected worktree.".to_string()),
actions: vec![Box::new({
move |tx| {
tx.send(AppEvent::RemoveWorktree {
target: target.clone(),
force,
delete_branch,
confirmed: true,
});
}
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Keep the worktree.".to_string()),
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
}
}
pub(crate) fn find_worktree<'a>(
entries: &'a [WorktreeInfo],
target: &str,
) -> Result<&'a WorktreeInfo, String> {
let matches = entries
.iter()
.filter(|entry| {
entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target
})
.collect::<Vec<_>>();
match matches.as_slice() {
[entry] => Ok(entry),
[] => Err(format!("No worktree found matching '{target}'.")),
_ => Err(format!(
"Multiple worktrees match '{target}'; use a more specific name."
)),
}
}
pub(crate) fn source_label(source: WorktreeSource) -> &'static str {
match source {
WorktreeSource::Cli => "cli",
WorktreeSource::App => "app",
WorktreeSource::Legacy => "legacy",
WorktreeSource::Git => "git",
}
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ListSelectionView;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
use codex_worktree::DirtyState;
use codex_worktree::WorktreeLocation;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn parse_new_with_flags() {
assert_eq!(
parse_worktree_slash_args("new fcoury/demo --base origin/main --dirty copy-tracked"),
Ok(WorktreeSlashAction::Create {
branch: "fcoury/demo".to_string(),
base_ref: Some("origin/main".to_string()),
dirty_policy: Some(DirtyPolicy::CopyTracked),
})
);
}
#[test]
fn parse_switch_aliases_move() {
assert_eq!(
parse_worktree_slash_args("move fcoury/demo"),
Ok(WorktreeSlashAction::Switch {
target: "fcoury/demo".to_string(),
})
);
}
#[test]
fn parse_remove_with_flags() {
assert_eq!(
parse_worktree_slash_args("remove fcoury/demo --force --delete-branch"),
Ok(WorktreeSlashAction::Remove {
target: "fcoury/demo".to_string(),
force: true,
delete_branch: true,
})
);
}
#[test]
fn worktree_picker_snapshot() {
let params = picker_params(
vec![
sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false),
sample_info("codex", WorktreeSource::App, /*dirty*/ false),
sample_info("main", WorktreeSource::Git, /*dirty*/ true),
],
Path::new("/repo/codex.fcoury-demo"),
);
insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86));
}
#[test]
fn worktree_loading_snapshot() {
insta::assert_snapshot!(
"worktree_loading",
render_selection(
loading_params(
FrameRequester::test_dummy(),
/*animations_enabled*/ false
),
/*width*/ 92
)
);
}
#[test]
fn worktree_switching_snapshot() {
insta::assert_snapshot!(
"worktree_switching",
render_selection(
switching_params(
"fcoury/demo".to_string(),
FrameRequester::test_dummy(),
/*animations_enabled*/ false
),
/*width*/ 92
)
);
}
#[test]
fn worktree_creating_snapshot() {
insta::assert_snapshot!(
"worktree_creating",
render_selection(
creating_params(
"fcoury/demo".to_string(),
FrameRequester::test_dummy(),
/*animations_enabled*/ false
),
/*width*/ 92
)
);
}
#[test]
fn worktree_empty_snapshot() {
insta::assert_snapshot!(
"worktree_empty",
render_selection(empty_params(), /*width*/ 84)
);
}
#[test]
fn new_worktree_item_dispatches_create_prompt_event() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let item = new_worktree_item();
assert!(
!item.dismiss_on_select,
"picker should stay behind the branch-name prompt"
);
(item.actions[0])(&tx);
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::OpenWorktreeCreatePrompt)
));
}
#[test]
fn worktree_dirty_policy_prompt_snapshot() {
insta::assert_snapshot!(
"worktree_dirty_policy_prompt",
render_selection(
dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None),
/*width*/ 82
)
);
}
#[test]
fn worktree_remove_confirmation_snapshot() {
insta::assert_snapshot!(
"worktree_remove_confirmation",
render_selection(
remove_confirmation_params(
"fcoury/demo".to_string(),
/*force*/ false,
/*delete_branch*/ false
),
/*width*/ 80
)
);
}
fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo {
let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-")));
WorktreeInfo {
id: "repo-id".to_string(),
name: branch.to_string(),
slug: branch.replace('/', "-"),
source,
location: match source {
WorktreeSource::Cli => WorktreeLocation::Sibling,
WorktreeSource::App | WorktreeSource::Legacy => WorktreeLocation::CodexHome,
WorktreeSource::Git => WorktreeLocation::External,
},
repo_name: "codex".to_string(),
repo_root: path.clone(),
common_git_dir: PathBuf::from("/repo/codex/.git"),
worktree_git_root: path.clone(),
workspace_cwd: path,
original_relative_cwd: PathBuf::new(),
branch: Some(branch.to_string()),
head: Some("abcdef".to_string()),
owner_thread_id: None,
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
dirty: DirtyState {
has_staged_changes: false,
has_unstaged_changes: dirty,
has_untracked_files: false,
},
}
}
fn render_selection(params: SelectionViewParams, width: u16) -> String {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list);
let height = view.desired_height(width);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
lines.join("\n")
}
}

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");
}
}

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;

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;
}

View File

@@ -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,
}

View File

@@ -0,0 +1,7 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "worktree",
crate_name = "codex_worktree",
)

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 }

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());
}
}

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)
}

View File

@@ -0,0 +1,106 @@
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 dirty::dirty_state;
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>,
}

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())
}
]
);
}
}

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()
}

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(())
}
}

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())
}