mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
feat(cli): add managed worktree workflow
This commit is contained in:
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -846,6 +846,9 @@ impl App {
|
||||
if let Some(message) = external_agent_config_migration_message {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
if let Some(message) = managed_worktree_startup_message(&config) {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
|
||||
chat_widget
|
||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 { .. })
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ use color_eyre::eyre::ContextCompat;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
||||
@@ -1328,6 +1329,7 @@ async fn thread_session_state_from_thread_start_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1360,6 +1362,7 @@ async fn thread_session_state_from_thread_resume_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1392,6 +1395,7 @@ async fn thread_session_state_from_thread_fork_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1419,6 +1423,20 @@ async fn thread_session_state_from_thread_fork_response(
|
||||
.await
|
||||
}
|
||||
|
||||
fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) {
|
||||
match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) {
|
||||
Ok(Some(_)) => {
|
||||
if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) {
|
||||
tracing::warn!(?err, "failed to bind managed worktree to thread");
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, "failed to resolve managed worktree metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_profile_from_thread_response(
|
||||
sandbox: &codex_app_server_protocol::SandboxPolicy,
|
||||
permission_profile: Option<&codex_app_server_protocol::PermissionProfile>,
|
||||
|
||||
@@ -179,6 +179,7 @@ mod tui;
|
||||
mod ui_consts;
|
||||
pub(crate) mod update_action;
|
||||
pub use update_action::UpdateAction;
|
||||
mod worktree_labels;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use update_action::get_update_action;
|
||||
mod update_prompt;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared command-line flags used by both interactive and non-interactive Codex entry points.
|
||||
|
||||
use crate::SandboxModeCliArg;
|
||||
use crate::WorktreeDirtyCliArg;
|
||||
use clap::Args;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -51,6 +52,18 @@ pub struct SharedCliOptions {
|
||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Create or reuse a Codex-managed Git worktree for this branch and run from that workspace.
|
||||
#[arg(long = "worktree", value_name = "BRANCH")]
|
||||
pub worktree: Option<String>,
|
||||
|
||||
/// Base ref for a newly created managed worktree.
|
||||
#[arg(long = "worktree-base", value_name = "REF")]
|
||||
pub worktree_base: Option<String>,
|
||||
|
||||
/// How to handle uncommitted source checkout changes when creating a worktree.
|
||||
#[arg(long = "worktree-dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)]
|
||||
pub worktree_dirty: WorktreeDirtyCliArg,
|
||||
|
||||
/// Additional directories that should be writable alongside the primary workspace.
|
||||
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
|
||||
pub add_dir: Vec<PathBuf>,
|
||||
@@ -69,6 +82,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
worktree,
|
||||
worktree_base,
|
||||
worktree_dirty,
|
||||
add_dir,
|
||||
} = self;
|
||||
let Self {
|
||||
@@ -80,6 +96,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode: root_sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
|
||||
cwd: root_cwd,
|
||||
worktree: root_worktree,
|
||||
worktree_base: root_worktree_base,
|
||||
worktree_dirty: root_worktree_dirty,
|
||||
add_dir: root_add_dir,
|
||||
} = root;
|
||||
|
||||
@@ -105,6 +124,15 @@ impl SharedCliOptions {
|
||||
if cwd.is_none() {
|
||||
cwd.clone_from(root_cwd);
|
||||
}
|
||||
if worktree.is_none() {
|
||||
worktree.clone_from(root_worktree);
|
||||
}
|
||||
if worktree_base.is_none() {
|
||||
worktree_base.clone_from(root_worktree_base);
|
||||
}
|
||||
if *worktree_dirty == WorktreeDirtyCliArg::Fail {
|
||||
*worktree_dirty = *root_worktree_dirty;
|
||||
}
|
||||
if !root_images.is_empty() {
|
||||
let mut merged_images = root_images.clone();
|
||||
merged_images.append(images);
|
||||
@@ -129,6 +157,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
worktree,
|
||||
worktree_base,
|
||||
worktree_dirty,
|
||||
add_dir,
|
||||
} = subcommand;
|
||||
|
||||
@@ -152,6 +183,15 @@ impl SharedCliOptions {
|
||||
if let Some(cwd) = cwd {
|
||||
self.cwd = Some(cwd);
|
||||
}
|
||||
if let Some(worktree) = worktree {
|
||||
self.worktree = Some(worktree);
|
||||
}
|
||||
if let Some(worktree_base) = worktree_base {
|
||||
self.worktree_base = Some(worktree_base);
|
||||
}
|
||||
if worktree_dirty != WorktreeDirtyCliArg::Fail {
|
||||
self.worktree_dirty = worktree_dirty;
|
||||
}
|
||||
if !images.is_empty() {
|
||||
self.images = images;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use clap::ValueEnum;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum WorktreeDirtyCliArg {
|
||||
#[default]
|
||||
Fail,
|
||||
Ignore,
|
||||
CopyTracked,
|
||||
CopyAll,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "worktree",
|
||||
crate_name = "codex_worktree",
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
mod dirty;
|
||||
mod git;
|
||||
mod manager;
|
||||
mod metadata;
|
||||
mod paths;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use dirty::DirtyPolicy;
|
||||
pub use dirty::DirtyState;
|
||||
pub use manager::ensure_worktree;
|
||||
pub use manager::list_worktrees;
|
||||
pub use manager::remove_worktree;
|
||||
pub use manager::resolve_worktree;
|
||||
pub use metadata::bind_thread;
|
||||
pub use metadata::read_worktree_metadata;
|
||||
pub use metadata::write_worktree_metadata;
|
||||
pub use paths::codex_worktrees_root;
|
||||
pub use paths::is_managed_worktree_path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: PathBuf,
|
||||
pub branch: String,
|
||||
pub base_ref: Option<String>,
|
||||
pub dirty_policy: DirtyPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeResolution {
|
||||
pub reused: bool,
|
||||
pub info: WorktreeInfo,
|
||||
pub warnings: Vec<WorktreeWarning>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub source: WorktreeSource,
|
||||
pub location: WorktreeLocation,
|
||||
pub repo_name: String,
|
||||
pub repo_root: PathBuf,
|
||||
pub common_git_dir: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub branch: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub metadata_path: PathBuf,
|
||||
pub dirty: DirtyState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeSource {
|
||||
Cli,
|
||||
App,
|
||||
Legacy,
|
||||
Git,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeLocation {
|
||||
Sibling,
|
||||
CodexHome,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeWarning {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeListQuery {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub include_all_repos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRemoveRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub name_or_path: String,
|
||||
pub force: bool,
|
||||
pub delete_branch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeRemoveResult {
|
||||
pub removed_path: PathBuf,
|
||||
pub deleted_branch: Option<String>,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user