mirror of
https://github.com/openai/codex.git
synced 2026-05-09 05:42:32 +00:00
Compare commits
4 Commits
bot/update
...
fcoury/wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b31e12444 | ||
|
|
4f3955ff91 | ||
|
|
250390cb76 | ||
|
|
5a6efcf183 |
16
codex-rs/Cargo.lock
generated
16
codex-rs/Cargo.lock
generated
@@ -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 }
|
||||
|
||||
@@ -201,6 +201,7 @@ mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
mod thread_routing;
|
||||
mod thread_session_state;
|
||||
mod worktree;
|
||||
|
||||
use self::agent_navigation::AgentNavigationDirection;
|
||||
use self::agent_navigation::AgentNavigationState;
|
||||
@@ -846,6 +847,9 @@ impl App {
|
||||
if let Some(message) = external_agent_config_migration_message {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
if let Some(message) = managed_worktree_startup_message(&config) {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
|
||||
chat_widget
|
||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
||||
|
||||
@@ -187,6 +187,65 @@ impl App {
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenWorktreePicker => {
|
||||
self.open_worktree_picker(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenWorktreeCreatePrompt => {
|
||||
self.open_worktree_create_prompt();
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::WorktreesLoaded { cwd, result } => {
|
||||
self.on_worktrees_loaded(cwd, result);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::CreateWorktreeAndSwitch {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
} => {
|
||||
self.create_worktree_and_switch(tui, branch, base_ref, dirty_policy);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::WorktreeCreated { cwd, result } => {
|
||||
self.on_worktree_created(tui, app_server, cwd, result).await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::SwitchToWorktree { target } => {
|
||||
self.begin_switch_to_worktree_target(tui, target);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::SwitchToWorktreeAfterLoading { target } => {
|
||||
self.switch_to_worktree_target_after_loading(tui, app_server, target)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::WorktreeSessionReady {
|
||||
info,
|
||||
config,
|
||||
forked,
|
||||
warnings,
|
||||
result,
|
||||
} => {
|
||||
self.on_worktree_session_ready(
|
||||
tui, app_server, info, config, forked, warnings, result,
|
||||
)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ShowWorktreePath { target } => {
|
||||
self.show_worktree_path(target);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::RemoveWorktree {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed,
|
||||
} => {
|
||||
self.remove_worktree(target, force, delete_branch, confirmed);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::BeginInitialHistoryReplayBuffer => {
|
||||
self.begin_initial_history_replay_buffer();
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
760
codex-rs/tui/src/app/worktree.rs
Normal file
760
codex-rs/tui/src/app/worktree.rs
Normal file
@@ -0,0 +1,760 @@
|
||||
//! App-layer handlers for the worktree TUI flow.
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeInfo;
|
||||
use codex_worktree::WorktreeListQuery;
|
||||
use codex_worktree::WorktreeRemoveRequest;
|
||||
use codex_worktree::WorktreeRequest;
|
||||
use codex_worktree::WorktreeResolution;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
|
||||
const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorktreeSwitchMode {
|
||||
StartFresh,
|
||||
Fork(ThreadId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorktreeSessionTransition {
|
||||
Forked,
|
||||
Started,
|
||||
}
|
||||
|
||||
impl WorktreeSessionTransition {
|
||||
fn message_prefix(self) -> &'static str {
|
||||
match self {
|
||||
WorktreeSessionTransition::Forked => "Forked into",
|
||||
WorktreeSessionTransition::Started => "Started session in",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::loading_params(
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
));
|
||||
self.fetch_worktrees_for_picker();
|
||||
}
|
||||
|
||||
pub(super) fn open_worktree_create_prompt(&mut self) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"New worktree".to_string(),
|
||||
"Type a branch name and press Enter".to_string(),
|
||||
/*initial_text*/ String::new(),
|
||||
/*context_label*/
|
||||
Some("Creates a sibling worktree and starts this chat there.".to_string()),
|
||||
Box::new(move |branch: String| {
|
||||
tx.send(AppEvent::CreateWorktreeAndSwitch {
|
||||
branch: branch.trim().to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: None,
|
||||
});
|
||||
}),
|
||||
);
|
||||
self.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(super) fn on_worktrees_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
result: Result<Vec<WorktreeInfo>, String>,
|
||||
) {
|
||||
if cwd.as_path() != self.config.cwd.as_path() {
|
||||
return;
|
||||
}
|
||||
let params = match result {
|
||||
Ok(entries) if entries.is_empty() => crate::worktree::empty_params(),
|
||||
Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()),
|
||||
Err(err) => crate::worktree::error_params(err),
|
||||
};
|
||||
self.replace_worktree_view(params);
|
||||
}
|
||||
|
||||
pub(super) fn create_worktree_and_switch(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dirty_policy = match dirty_policy {
|
||||
Some(policy) => policy,
|
||||
None => match codex_worktree::dirty_state(self.config.cwd.as_path()) {
|
||||
Ok(state) if state.is_dirty() => {
|
||||
let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
Ok(_) => DirtyPolicy::Fail,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to inspect source checkout: {err}"));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
self.show_worktree_creating_view(tui, branch.clone());
|
||||
self.spawn_worktree_create_request(WorktreeRequest {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: self.config.cwd.to_path_buf(),
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) async fn on_worktree_created(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
cwd: PathBuf,
|
||||
result: Result<WorktreeResolution, String>,
|
||||
) {
|
||||
if cwd.as_path() != self.config.cwd.as_path() {
|
||||
return;
|
||||
}
|
||||
let resolution = match result {
|
||||
Ok(resolution) => resolution,
|
||||
Err(err) => {
|
||||
self.show_worktree_error("Failed to create worktree.".to_string(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target = resolution
|
||||
.info
|
||||
.branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.info.name.clone());
|
||||
self.show_worktree_switching_view(tui, target);
|
||||
self.switch_to_worktree_info(
|
||||
tui,
|
||||
app_server,
|
||||
resolution.info,
|
||||
resolution
|
||||
.warnings
|
||||
.into_iter()
|
||||
.map(|warning| warning.message)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_worktree_switching_view(tui, target.clone());
|
||||
self.defer_switch_to_worktree_target(target);
|
||||
}
|
||||
|
||||
pub(super) async fn switch_to_worktree_target_after_loading(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
target: String,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let entries = match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let info = match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => info.clone(),
|
||||
Err(err) => {
|
||||
self.show_worktree_error("Failed to switch worktree.".to_string(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.switch_to_worktree_info(tui, app_server, info, Vec::new())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) fn show_worktree_path(&mut self, target: String) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => {
|
||||
self.chat_widget.add_info_message(
|
||||
info.workspace_cwd.display().to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
Err(err) => self.chat_widget.add_error_message(err),
|
||||
},
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to list worktrees: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_worktree(
|
||||
&mut self,
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
confirmed: bool,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let entries = match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to list worktrees: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let info = match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if info.source != WorktreeSource::Cli {
|
||||
let source = crate::worktree::source_label(info.source);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Refusing to remove {source} worktree '{target}'. Only Codex CLI-managed worktrees can be removed."
|
||||
));
|
||||
return;
|
||||
}
|
||||
if !confirmed {
|
||||
let params = crate::worktree::remove_confirmation_params(target, force, delete_branch);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
|
||||
match codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
name_or_path: target.clone(),
|
||||
force,
|
||||
delete_branch,
|
||||
}) {
|
||||
Ok(result) => {
|
||||
let mut message = format!("Removed worktree {}", result.removed_path.display());
|
||||
if let Some(branch) = result.deleted_branch {
|
||||
message.push_str(&format!(" and deleted branch {branch}"));
|
||||
}
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to remove worktree: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_current_repo_worktrees(&self) -> anyhow::Result<Vec<WorktreeInfo>> {
|
||||
codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_worktrees_for_picker(&mut self) {
|
||||
let query = WorktreeListQuery {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
include_all_repos: false,
|
||||
};
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result });
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_worktree_create_request(&self, request: WorktreeRequest) {
|
||||
let cwd = request.source_cwd.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_worktree::ensure_worktree(request).map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::WorktreeCreated { cwd, result });
|
||||
});
|
||||
}
|
||||
|
||||
async fn switch_to_worktree_info(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
warnings: Vec<String>,
|
||||
) {
|
||||
let mut config = match self
|
||||
.rebuild_config_for_cwd(info.workspace_cwd.clone())
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.show_worktree_error(
|
||||
"Failed to rebuild configuration for worktree.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
|
||||
let mode = self.worktree_switch_mode().await;
|
||||
self.spawn_worktree_session_request(app_server, info, config, mode, warnings);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(super) async fn on_worktree_session_ready(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
config: Config,
|
||||
forked: bool,
|
||||
warnings: Vec<String>,
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
) {
|
||||
match result {
|
||||
Ok(started) => {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config);
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, started, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
} else {
|
||||
let transition = if forked {
|
||||
WorktreeSessionTransition::Forked
|
||||
} else {
|
||||
WorktreeSessionTransition::Started
|
||||
};
|
||||
self.add_worktree_session_message(&info, transition);
|
||||
for warning in warnings {
|
||||
self.chat_widget.add_info_message(warning, /*hint*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let summary = if forked {
|
||||
"Failed to fork current session into worktree."
|
||||
} else {
|
||||
"Failed to start session in worktree."
|
||||
};
|
||||
self.show_worktree_error(summary.to_string(), err);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
fn spawn_worktree_session_request(
|
||||
&self,
|
||||
app_server: &AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
config: Config,
|
||||
mode: WorktreeSwitchMode,
|
||||
warnings: Vec<String>,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let forked = matches!(mode, WorktreeSwitchMode::Fork(_));
|
||||
let result = match mode {
|
||||
WorktreeSwitchMode::Fork(thread_id) => {
|
||||
crate::app_server_session::fork_thread_with_request_handle(
|
||||
request_handle,
|
||||
config.clone(),
|
||||
thread_id,
|
||||
remote_cwd_override,
|
||||
)
|
||||
.await
|
||||
}
|
||||
WorktreeSwitchMode::StartFresh => {
|
||||
crate::app_server_session::start_thread_with_request_handle(
|
||||
request_handle,
|
||||
config.clone(),
|
||||
remote_cwd_override,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::WorktreeSessionReady {
|
||||
info,
|
||||
config,
|
||||
forked,
|
||||
warnings,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn add_worktree_session_message(
|
||||
&mut self,
|
||||
info: &WorktreeInfo,
|
||||
transition: WorktreeSessionTransition,
|
||||
) {
|
||||
let (message, hint) = worktree_session_message(info, transition);
|
||||
self.chat_widget.add_info_message(message, Some(hint));
|
||||
}
|
||||
|
||||
async fn worktree_switch_mode(&self) -> WorktreeSwitchMode {
|
||||
let Some(thread_id) = self.current_displayed_thread_id() else {
|
||||
return WorktreeSwitchMode::StartFresh;
|
||||
};
|
||||
|
||||
if self
|
||||
.session_for_thread(thread_id)
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(Self::session_has_materialized_rollout)
|
||||
{
|
||||
WorktreeSwitchMode::Fork(thread_id)
|
||||
} else {
|
||||
WorktreeSwitchMode::StartFresh
|
||||
}
|
||||
}
|
||||
|
||||
async fn session_for_thread(&self, thread_id: ThreadId) -> Option<ThreadSessionState> {
|
||||
if self.primary_thread_id == Some(thread_id)
|
||||
&& let Some(session) = self.primary_session_configured.clone()
|
||||
{
|
||||
return Some(session);
|
||||
}
|
||||
|
||||
let channel = self.thread_event_channels.get(&thread_id)?;
|
||||
let store = channel.store.lock().await;
|
||||
store.session.clone()
|
||||
}
|
||||
|
||||
fn session_has_materialized_rollout(session: &ThreadSessionState) -> bool {
|
||||
session
|
||||
.rollout_path
|
||||
.as_ref()
|
||||
.is_some_and(|rollout_path| rollout_path.exists())
|
||||
}
|
||||
|
||||
fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
let params = crate::worktree::switching_params(
|
||||
target.clone(),
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
);
|
||||
if !self.replace_worktree_view(params) {
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::switching_params(
|
||||
target,
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
));
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
fn show_worktree_creating_view(&mut self, tui: &mut tui::Tui, branch: String) {
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::creating_params(
|
||||
branch,
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
fn defer_switch_to_worktree_target(&self, target: String) {
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await;
|
||||
app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target });
|
||||
});
|
||||
}
|
||||
|
||||
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
|
||||
self.chat_widget
|
||||
.replace_selection_view_if_active(crate::worktree::WORKTREE_SELECTION_VIEW_ID, params)
|
||||
}
|
||||
|
||||
fn show_worktree_error(&mut self, summary: String, error: String) {
|
||||
let params = crate::worktree::error_with_summary_params(summary.clone(), error.clone());
|
||||
if !self.replace_worktree_view(params) {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("{summary} {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn install_worktree_config(&mut self, tui: &mut tui::Tui, config: Config) {
|
||||
self.config = config;
|
||||
tui.set_notification_settings(
|
||||
self.config.tui_notifications.method,
|
||||
self.config.tui_notifications.condition,
|
||||
);
|
||||
self.file_search
|
||||
.update_search_dir(self.config.cwd.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree_session_message(
|
||||
info: &WorktreeInfo,
|
||||
transition: WorktreeSessionTransition,
|
||||
) -> (String, String) {
|
||||
let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str());
|
||||
let state = if info.dirty.is_dirty() {
|
||||
"dirty"
|
||||
} else {
|
||||
"clean"
|
||||
};
|
||||
let source = crate::worktree::source_label(info.source);
|
||||
(
|
||||
format!(
|
||||
"{} {source} worktree {worktree_name} · {state} · {}",
|
||||
transition.message_prefix(),
|
||||
info.repo_name
|
||||
),
|
||||
info.workspace_cwd.display().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_worktree::DirtyState;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_starts_fresh_without_current_thread() {
|
||||
let app = crate::app::test_support::make_test_app().await;
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::StartFresh
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_starts_fresh_for_unmaterialized_primary_rollout() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let thread_id = ThreadId::new();
|
||||
let missing_rollout_path = temp_dir.path().join("missing-rollout.jsonl");
|
||||
let session = test_thread_session(
|
||||
thread_id,
|
||||
temp_dir.path().join("repo"),
|
||||
missing_rollout_path,
|
||||
);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(thread_id);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.primary_session_configured = Some(session);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::StartFresh
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_forks_materialized_primary_rollout() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
std::fs::write(&rollout_path, "{}\\n").expect("write rollout");
|
||||
let session = test_thread_session(thread_id, temp_dir.path().join("repo"), rollout_path);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(thread_id);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.primary_session_configured = Some(session);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::Fork(thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktree_switch_mode_uses_active_non_primary_thread_session() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let primary_thread_id = ThreadId::new();
|
||||
let active_thread_id = ThreadId::new();
|
||||
let active_rollout_path = temp_dir.path().join("active-rollout.jsonl");
|
||||
std::fs::write(&active_rollout_path, "{}\\n").expect("write rollout");
|
||||
let active_session = test_thread_session(
|
||||
active_thread_id,
|
||||
temp_dir.path().join("active"),
|
||||
active_rollout_path,
|
||||
);
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.primary_thread_id = Some(primary_thread_id);
|
||||
app.active_thread_id = Some(active_thread_id);
|
||||
app.primary_session_configured = Some(test_thread_session(
|
||||
primary_thread_id,
|
||||
temp_dir.path().join("primary"),
|
||||
temp_dir.path().join("missing-primary-rollout.jsonl"),
|
||||
));
|
||||
app.thread_event_channels.insert(
|
||||
active_thread_id,
|
||||
ThreadEventChannel::new_with_session(
|
||||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||||
active_session,
|
||||
Vec::new(),
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.worktree_switch_mode().await,
|
||||
WorktreeSwitchMode::Fork(active_thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_session_message_describes_forked_workspace() {
|
||||
let info = test_worktree_info(
|
||||
WorktreeSource::Cli,
|
||||
Some("fcoury/demo".to_string()),
|
||||
/*dirty*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
worktree_session_message(&info, WorktreeSessionTransition::Forked),
|
||||
(
|
||||
"Forked into cli worktree fcoury/demo · clean · codex".to_string(),
|
||||
"/repo/codex.fcoury-demo".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_session_message_describes_started_dirty_workspace() {
|
||||
let info = test_worktree_info(
|
||||
WorktreeSource::App,
|
||||
/*branch*/ None,
|
||||
/*dirty*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
worktree_session_message(&info, WorktreeSessionTransition::Started),
|
||||
(
|
||||
"Started session in app worktree app-worktree · dirty · codex".to_string(),
|
||||
"/repo/codex.fcoury-demo".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn test_thread_session(
|
||||
thread_id: ThreadId,
|
||||
cwd: PathBuf,
|
||||
rollout_path: PathBuf,
|
||||
) -> ThreadSessionState {
|
||||
ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: AbsolutePathBuf::try_from(cwd).expect("absolute cwd"),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
message_history: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_path),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_worktree_info(
|
||||
source: WorktreeSource,
|
||||
branch: Option<String>,
|
||||
dirty: bool,
|
||||
) -> WorktreeInfo {
|
||||
let path = PathBuf::from("/repo/codex.fcoury-demo");
|
||||
WorktreeInfo {
|
||||
id: "repo-id".to_string(),
|
||||
name: "app-worktree".to_string(),
|
||||
slug: "fcoury-demo".to_string(),
|
||||
source,
|
||||
location: WorktreeLocation::Sibling,
|
||||
repo_name: "codex".to_string(),
|
||||
repo_root: path.clone(),
|
||||
common_git_dir: PathBuf::from("/repo/codex/.git"),
|
||||
worktree_git_root: path.clone(),
|
||||
workspace_cwd: path,
|
||||
original_relative_cwd: PathBuf::new(),
|
||||
branch,
|
||||
head: Some("abcdef".to_string()),
|
||||
owner_thread_id: None,
|
||||
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
|
||||
dirty: DirtyState {
|
||||
has_staged_changes: false,
|
||||
has_unstaged_changes: dirty,
|
||||
has_untracked_files: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })
|
||||
}
|
||||
|
||||
@@ -31,12 +31,15 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
use codex_worktree::DirtyPolicy;
|
||||
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_server_session::AppServerStartedThread;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_features::Feature;
|
||||
@@ -191,6 +194,63 @@ pub(crate) enum AppEvent {
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
|
||||
/// Open the managed worktree picker.
|
||||
OpenWorktreePicker,
|
||||
|
||||
/// Open the prompt for creating a managed worktree.
|
||||
OpenWorktreeCreatePrompt,
|
||||
|
||||
/// Result of loading worktrees for the managed worktree picker.
|
||||
WorktreesLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<Vec<codex_worktree::WorktreeInfo>, String>,
|
||||
},
|
||||
|
||||
/// Create or reuse a managed worktree and switch the TUI into it.
|
||||
CreateWorktreeAndSwitch {
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
},
|
||||
|
||||
/// Result of creating or reusing a managed worktree.
|
||||
WorktreeCreated {
|
||||
cwd: PathBuf,
|
||||
result: Result<codex_worktree::WorktreeResolution, String>,
|
||||
},
|
||||
|
||||
/// Switch the TUI into an existing worktree.
|
||||
SwitchToWorktree {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Continue switching into an existing worktree after the loading view has rendered.
|
||||
SwitchToWorktreeAfterLoading {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Result of starting or forking a session in a worktree.
|
||||
WorktreeSessionReady {
|
||||
info: codex_worktree::WorktreeInfo,
|
||||
config: Config,
|
||||
forked: bool,
|
||||
warnings: Vec<String>,
|
||||
result: Result<AppServerStartedThread, String>,
|
||||
},
|
||||
|
||||
/// Show the filesystem path for an existing worktree.
|
||||
ShowWorktreePath {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Remove a Codex-managed worktree.
|
||||
RemoveWorktree {
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
confirmed: bool,
|
||||
},
|
||||
|
||||
/// Request to exit the application.
|
||||
///
|
||||
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
||||
|
||||
@@ -117,7 +117,9 @@ use color_eyre::eyre::ContextCompat;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
||||
color_eyre::eyre::eyre!("{context}: {err}")
|
||||
@@ -165,6 +167,7 @@ impl ThreadParamsMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AppServerStartedThread {
|
||||
pub(crate) session: ThreadSessionState,
|
||||
pub(crate) turns: Vec<Turn>,
|
||||
@@ -968,6 +971,101 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_thread_with_request_handle(
|
||||
request_handle: AppServerRequestHandle,
|
||||
config: Config,
|
||||
remote_cwd_override: Option<PathBuf>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
|
||||
let response: ThreadStartResponse = request_handle
|
||||
.request_typed(ClientRequest::ThreadStart {
|
||||
request_id: worktree_request_id("worktree-thread-start"),
|
||||
params: thread_start_params_from_config(
|
||||
&config,
|
||||
thread_params_mode,
|
||||
remote_cwd_override.as_deref(),
|
||||
/*session_start_source*/ None,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
|
||||
started_thread_from_start_response(response, &config, thread_params_mode).await
|
||||
}
|
||||
|
||||
pub(crate) async fn fork_thread_with_request_handle(
|
||||
request_handle: AppServerRequestHandle,
|
||||
config: Config,
|
||||
thread_id: ThreadId,
|
||||
remote_cwd_override: Option<PathBuf>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
|
||||
let response: ThreadForkResponse = request_handle
|
||||
.request_typed(ClientRequest::ThreadFork {
|
||||
request_id: worktree_request_id("worktree-thread-fork"),
|
||||
params: thread_fork_params_from_config(
|
||||
config.clone(),
|
||||
thread_id,
|
||||
thread_params_mode,
|
||||
remote_cwd_override.as_deref(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| bootstrap_request_error("thread/fork failed during TUI bootstrap", err))?;
|
||||
let fork_parent_title = fork_parent_title_from_request_handle(
|
||||
&request_handle,
|
||||
response.thread.forked_from_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let mut started =
|
||||
started_thread_from_fork_response(response, &config, thread_params_mode).await?;
|
||||
started.session.fork_parent_title = fork_parent_title;
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
fn worktree_request_id(prefix: &str) -> RequestId {
|
||||
RequestId::String(format!("{prefix}-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn thread_params_mode_from_request_handle(
|
||||
request_handle: &AppServerRequestHandle,
|
||||
) -> ThreadParamsMode {
|
||||
match request_handle {
|
||||
AppServerRequestHandle::InProcess(_) => ThreadParamsMode::Embedded,
|
||||
AppServerRequestHandle::Remote(_) => ThreadParamsMode::Remote,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fork_parent_title_from_request_handle(
|
||||
request_handle: &AppServerRequestHandle,
|
||||
forked_from_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let forked_from_id = forked_from_id?;
|
||||
let forked_from_id = match ThreadId::from_string(forked_from_id) {
|
||||
Ok(thread_id) => thread_id,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match request_handle
|
||||
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
|
||||
request_id: worktree_request_id("worktree-thread-read"),
|
||||
params: ThreadReadParams {
|
||||
thread_id: forked_from_id.to_string(),
|
||||
include_turns: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(thread) => thread.thread.name,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_realtime_start_params(
|
||||
thread_id: ThreadId,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
@@ -1328,6 +1426,7 @@ async fn thread_session_state_from_thread_start_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1360,6 +1459,7 @@ async fn thread_session_state_from_thread_resume_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1392,6 +1492,7 @@ async fn thread_session_state_from_thread_fork_response(
|
||||
config: &Config,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
) -> Result<ThreadSessionState, String> {
|
||||
bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id);
|
||||
let permission_profile = permission_profile_from_thread_response(
|
||||
&response.sandbox,
|
||||
response.permission_profile.as_ref(),
|
||||
@@ -1419,6 +1520,20 @@ async fn thread_session_state_from_thread_fork_response(
|
||||
.await
|
||||
}
|
||||
|
||||
fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) {
|
||||
match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) {
|
||||
Ok(Some(_)) => {
|
||||
if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) {
|
||||
tracing::warn!(?err, "failed to bind managed worktree to thread");
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, "failed to resolve managed worktree metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_profile_from_thread_response(
|
||||
sandbox: &codex_app_server_protocol::SandboxPolicy,
|
||||
permission_profile: Option<&codex_app_server_protocol::PermissionProfile>,
|
||||
|
||||
@@ -5377,6 +5377,30 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_bottom_pane_view(
|
||||
&mut self,
|
||||
view: Box<dyn crate::bottom_pane::BottomPaneView>,
|
||||
) {
|
||||
self.bottom_pane.show_view(view);
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn replace_selection_view_if_active(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
params: SelectionViewParams,
|
||||
) -> bool {
|
||||
let replaced = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(view_id, params);
|
||||
if replaced {
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.request_redraw();
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.bottom_pane.no_modal_or_popup_active()
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ impl ChatWidget {
|
||||
SlashCommand::Fork => {
|
||||
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
||||
}
|
||||
SlashCommand::Worktree => {
|
||||
self.app_event_tx.send(AppEvent::OpenWorktreePicker);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
if init_target.exists() {
|
||||
@@ -772,6 +775,13 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ResumeSessionByIdOrName(args));
|
||||
}
|
||||
SlashCommand::Worktree if !trimmed.is_empty() => {
|
||||
if let Err(message) =
|
||||
crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx)
|
||||
{
|
||||
self.add_error_message(message);
|
||||
}
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
||||
@@ -918,6 +928,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Review
|
||||
|
||||
@@ -6,19 +6,14 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::branch_summary;
|
||||
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
|
||||
use crate::motion::activity_spinner_frame_at;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: activity indicator + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
|
||||
|
||||
/// Braille-pattern dot-spinner frames for the terminal title animation.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/// Time between spinner frame advances in the terminal title.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Time between action-required blink phases in the terminal title.
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -362,7 +357,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections)
|
||||
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
|
||||
.then_some(ACTIVITY_SPINNER_INTERVAL)
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
@@ -816,14 +811,7 @@ impl ChatWidget {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let frame_index =
|
||||
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_uses_activity(&self) -> bool {
|
||||
|
||||
@@ -179,6 +179,8 @@ mod tui;
|
||||
mod ui_consts;
|
||||
pub(crate) mod update_action;
|
||||
pub use update_action::UpdateAction;
|
||||
mod worktree;
|
||||
mod worktree_labels;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use update_action::get_update_action;
|
||||
mod update_prompt;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Callers choose an explicit reduced-motion fallback here instead of reaching
|
||||
//! directly for time-varying spinner or shimmer helpers.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Stylize;
|
||||
@@ -10,6 +11,10 @@ use ratatui::text::Span;
|
||||
|
||||
use crate::shimmer::shimmer_spans;
|
||||
|
||||
const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum MotionMode {
|
||||
Animated,
|
||||
@@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'sta
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activity_spinner_frame_at(origin: Instant, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(origin);
|
||||
let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
|
||||
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
|
||||
if supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
@@ -117,6 +128,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_spinner_frame_advances_and_wraps() {
|
||||
let origin = Instant::now();
|
||||
|
||||
assert_eq!(activity_spinner_frame_at(origin, origin), "⠋");
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL),
|
||||
"⠙"
|
||||
);
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(
|
||||
origin,
|
||||
origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32,
|
||||
),
|
||||
"⠋"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_primitives_are_only_used_by_motion_module() {
|
||||
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum SlashCommand {
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
Worktree,
|
||||
Init,
|
||||
Compact,
|
||||
Plan,
|
||||
@@ -87,6 +88,7 @@ impl SlashCommand {
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
SlashCommand::Worktree => "manage worktrees",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||
@@ -158,6 +160,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
@@ -181,6 +184,7 @@ impl SlashCommand {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(creating_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Creating fcoury/demo...
|
||||
Codex is creating the worktree before starting the chat in that workspace.
|
||||
|
||||
› Preparing worktree... Codex is creating the worktree before starting the chat in
|
||||
that workspace.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_string(), None),\n82)"
|
||||
---
|
||||
|
||||
Source checkout has uncommitted changes
|
||||
Choose what to carry into the new worktree.
|
||||
|
||||
› 1. Fail Cancel creation and leave the source checkout unchanged.
|
||||
2. Ignore Create from the requested base without copying local changes.
|
||||
3. Copy tracked Copy staged and unstaged tracked changes.
|
||||
4. Copy all Copy tracked changes and untracked files.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(empty_params(), 84)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
|
||||
› 1. New worktree... Type the branch name for the new worktree.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Loading worktrees...
|
||||
This can take a moment when Codex is checking app, CLI, and Git worktrees.
|
||||
|
||||
› Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git
|
||||
worktrees.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(params, 86)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
Create a worktree or fork this chat into an existing workspace.
|
||||
|
||||
Search worktrees
|
||||
› New worktree... Type the branch name for the new worktree.
|
||||
fcoury/demo (current) clean · cli · /repo/codex.fcoury-demo
|
||||
codex clean · app · /repo/codex.codex
|
||||
main dirty · git · /repo/codex.main
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)"
|
||||
---
|
||||
|
||||
Remove worktree fcoury/demo?
|
||||
Only Codex-managed worktrees can be removed.
|
||||
|
||||
› 1. Remove Remove the selected worktree.
|
||||
2. Cancel Keep the worktree.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Switching to fcoury/demo...
|
||||
Codex is rebuilding configuration and starting the chat in that workspace.
|
||||
|
||||
› Preparing worktree session... Codex is rebuilding configuration and starting the
|
||||
chat in that workspace.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
778
codex-rs/tui/src/worktree.rs
Normal file
778
codex-rs/tui/src/worktree.rs
Normal file
@@ -0,0 +1,778 @@
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeInfo;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionRowDisplay;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
|
||||
use crate::motion::activity_spinner_frame_at;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
const WORKTREE_USAGE: &str =
|
||||
"Usage: /worktree [list|new <branch>|switch <branch>|path <branch>|remove <branch>]";
|
||||
pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection";
|
||||
|
||||
struct WorktreeLoadingHeader {
|
||||
started_at: Instant,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
status: String,
|
||||
note: String,
|
||||
}
|
||||
|
||||
impl WorktreeLoadingHeader {
|
||||
fn new(
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
status: String,
|
||||
note: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status,
|
||||
note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for WorktreeLoadingHeader {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.animations_enabled {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(ACTIVITY_SPINNER_INTERVAL);
|
||||
}
|
||||
|
||||
let mut loading_spans = Vec::new();
|
||||
if self.animations_enabled {
|
||||
loading_spans.push(activity_spinner_frame_at(self.started_at, Instant::now()).into());
|
||||
loading_spans.push(" ".into());
|
||||
} else {
|
||||
loading_spans.push("•".dim());
|
||||
loading_spans.push(" ".into());
|
||||
}
|
||||
loading_spans.push(self.status.clone().dim());
|
||||
|
||||
Paragraph::new(vec![
|
||||
Line::from("Worktrees".bold()),
|
||||
Line::from(loading_spans),
|
||||
Line::from(self.note.clone().dim()),
|
||||
])
|
||||
.render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum WorktreeSlashAction {
|
||||
OpenPicker,
|
||||
Create {
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
},
|
||||
Switch {
|
||||
target: String,
|
||||
},
|
||||
ShowPath {
|
||||
target: String,
|
||||
},
|
||||
Remove {
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorktreeSlashAction {
|
||||
pub(crate) fn dispatch(self, tx: &AppEventSender) {
|
||||
match self {
|
||||
WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker),
|
||||
WorktreeSlashAction::Create {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
} => tx.send(AppEvent::CreateWorktreeAndSwitch {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
}),
|
||||
WorktreeSlashAction::Switch { target } => {
|
||||
tx.send(AppEvent::SwitchToWorktree { target });
|
||||
}
|
||||
WorktreeSlashAction::ShowPath { target } => {
|
||||
tx.send(AppEvent::ShowWorktreePath { target });
|
||||
}
|
||||
WorktreeSlashAction::Remove {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
} => tx.send(AppEvent::RemoveWorktree {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed: force,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_worktree_slash_args(args: &str) -> Result<WorktreeSlashAction, String> {
|
||||
let mut parts = args.split_whitespace();
|
||||
let Some(command) = parts.next() else {
|
||||
return Ok(WorktreeSlashAction::OpenPicker);
|
||||
};
|
||||
match command {
|
||||
"list" => Ok(WorktreeSlashAction::OpenPicker),
|
||||
"new" => parse_new(parts),
|
||||
"switch" | "move" => {
|
||||
let target = required_target(parts, command)?;
|
||||
Ok(WorktreeSlashAction::Switch { target })
|
||||
}
|
||||
"path" => {
|
||||
let target = required_target(parts, command)?;
|
||||
Ok(WorktreeSlashAction::ShowPath { target })
|
||||
}
|
||||
"remove" => parse_remove(parts),
|
||||
_ => Err(WORKTREE_USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_new<'a>(mut parts: impl Iterator<Item = &'a str>) -> Result<WorktreeSlashAction, String> {
|
||||
let Some(branch) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> [--base <ref>] [--dirty <mode>]".to_string());
|
||||
};
|
||||
let mut base_ref = None;
|
||||
let mut dirty_policy = None;
|
||||
while let Some(flag) = parts.next() {
|
||||
match flag {
|
||||
"--base" => {
|
||||
let Some(value) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> --base <ref>".to_string());
|
||||
};
|
||||
base_ref = Some(value.to_string());
|
||||
}
|
||||
"--dirty" => {
|
||||
let Some(value) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> --dirty <mode>".to_string());
|
||||
};
|
||||
dirty_policy = Some(parse_dirty_policy(value)?);
|
||||
}
|
||||
_ => return Err(format!("Unknown /worktree new option '{flag}'.")),
|
||||
}
|
||||
}
|
||||
Ok(WorktreeSlashAction::Create {
|
||||
branch: branch.to_string(),
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_remove<'a>(
|
||||
mut parts: impl Iterator<Item = &'a str>,
|
||||
) -> Result<WorktreeSlashAction, String> {
|
||||
let Some(target) = parts.next() else {
|
||||
return Err(
|
||||
"Usage: /worktree remove <branch-or-name> [--force] [--delete-branch]".to_string(),
|
||||
);
|
||||
};
|
||||
let mut force = false;
|
||||
let mut delete_branch = false;
|
||||
for flag in parts {
|
||||
match flag {
|
||||
"--force" => force = true,
|
||||
"--delete-branch" => delete_branch = true,
|
||||
_ => return Err(format!("Unknown /worktree remove option '{flag}'.")),
|
||||
}
|
||||
}
|
||||
Ok(WorktreeSlashAction::Remove {
|
||||
target: target.to_string(),
|
||||
force,
|
||||
delete_branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_target<'a>(
|
||||
mut parts: impl Iterator<Item = &'a str>,
|
||||
command: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(target) = parts.next() else {
|
||||
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
|
||||
}
|
||||
Ok(target.to_string())
|
||||
}
|
||||
|
||||
fn parse_dirty_policy(value: &str) -> Result<DirtyPolicy, String> {
|
||||
match value {
|
||||
"fail" => Ok(DirtyPolicy::Fail),
|
||||
"ignore" => Ok(DirtyPolicy::Ignore),
|
||||
"copy-tracked" => Ok(DirtyPolicy::CopyTracked),
|
||||
"copy-all" => Ok(DirtyPolicy::CopyAll),
|
||||
_ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> Result<(), String> {
|
||||
parse_worktree_slash_args(args)?.dispatch(tx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn loading_params(
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> SelectionViewParams {
|
||||
let status = "Loading worktrees...".to_string();
|
||||
let note =
|
||||
"This can take a moment when Codex is checking app, CLI, and Git worktrees.".to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(WorktreeLoadingHeader::new(
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status.clone(),
|
||||
note.clone(),
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: status,
|
||||
description: Some(note),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn switching_params(
|
||||
target: String,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> SelectionViewParams {
|
||||
let status = format!("Switching to {target}...");
|
||||
let note =
|
||||
"Codex is rebuilding configuration and starting the chat in that workspace.".to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(WorktreeLoadingHeader::new(
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status,
|
||||
note.clone(),
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Preparing worktree session...".to_string(),
|
||||
description: Some(note),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn creating_params(
|
||||
branch: String,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> SelectionViewParams {
|
||||
let status = format!("Creating {branch}...");
|
||||
let note =
|
||||
"Codex is creating the worktree before starting the chat in that workspace.".to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(WorktreeLoadingHeader::new(
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status,
|
||||
note.clone(),
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Preparing worktree...".to_string(),
|
||||
description: Some(note),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn empty_params() -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![new_worktree_item()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn error_params(error: String) -> SelectionViewParams {
|
||||
error_with_summary_params("Failed to list worktrees.".to_string(), error)
|
||||
}
|
||||
|
||||
pub(crate) fn error_with_summary_params(summary: String, error: String) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: summary,
|
||||
description: Some(error),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> SelectionViewParams {
|
||||
let mut items = vec![new_worktree_item()];
|
||||
items.extend(entries.into_iter().map(|entry| {
|
||||
let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone());
|
||||
let source = source_label(entry.source);
|
||||
let status = if entry.dirty.is_dirty() {
|
||||
"dirty"
|
||||
} else {
|
||||
"clean"
|
||||
};
|
||||
let description = format!("{status} · {source} · {}", entry.workspace_cwd.display());
|
||||
let search_value = Some(format!(
|
||||
"{} {} {} {}",
|
||||
target,
|
||||
entry.name,
|
||||
source,
|
||||
entry.workspace_cwd.display()
|
||||
));
|
||||
SelectionItem {
|
||||
name: target.clone(),
|
||||
description: Some(description),
|
||||
selected_description: Some(format!(
|
||||
"Fork this chat into {}",
|
||||
entry.workspace_cwd.display()
|
||||
)),
|
||||
is_current: paths_match(current_cwd, &entry.workspace_cwd),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SwitchToWorktree {
|
||||
target: target.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value,
|
||||
..Default::default()
|
||||
}
|
||||
}));
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
header.push(Line::from(
|
||||
"Create a worktree or fork this chat into an existing workspace.".dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Search worktrees".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
row_display: SelectionRowDisplay::SingleLine,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn new_worktree_item() -> SelectionItem {
|
||||
SelectionItem {
|
||||
name: "New worktree...".to_string(),
|
||||
description: Some("Create a sibling worktree and start this chat there.".to_string()),
|
||||
selected_description: Some("Type the branch name for the new worktree.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenWorktreeCreatePrompt);
|
||||
})],
|
||||
dismiss_on_select: false,
|
||||
search_value: Some("new worktree create branch".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dirty_policy_prompt_params(
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Source checkout has uncommitted changes".bold()));
|
||||
header.push(Line::from(
|
||||
"Choose what to carry into the new worktree.".dim(),
|
||||
));
|
||||
let item = |name: &str, description: &str, dirty_policy: DirtyPolicy| SelectionItem {
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
actions: vec![Box::new({
|
||||
let branch = branch.clone();
|
||||
let base_ref = base_ref.clone();
|
||||
move |tx| {
|
||||
tx.send(AppEvent::CreateWorktreeAndSwitch {
|
||||
branch: branch.clone(),
|
||||
base_ref: base_ref.clone(),
|
||||
dirty_policy: Some(dirty_policy),
|
||||
});
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
};
|
||||
SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
item(
|
||||
"Fail",
|
||||
"Cancel creation and leave the source checkout unchanged.",
|
||||
DirtyPolicy::Fail,
|
||||
),
|
||||
item(
|
||||
"Ignore",
|
||||
"Create from the requested base without copying local changes.",
|
||||
DirtyPolicy::Ignore,
|
||||
),
|
||||
item(
|
||||
"Copy tracked",
|
||||
"Copy staged and unstaged tracked changes.",
|
||||
DirtyPolicy::CopyTracked,
|
||||
),
|
||||
item(
|
||||
"Copy all",
|
||||
"Copy tracked changes and untracked files.",
|
||||
DirtyPolicy::CopyAll,
|
||||
),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_confirmation_params(
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from(format!("Remove worktree {target}?").bold()));
|
||||
header.push(Line::from(
|
||||
"Only Codex-managed worktrees can be removed.".dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Remove".to_string(),
|
||||
description: Some("Remove the selected worktree.".to_string()),
|
||||
actions: vec![Box::new({
|
||||
move |tx| {
|
||||
tx.send(AppEvent::RemoveWorktree {
|
||||
target: target.clone(),
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Keep the worktree.".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_worktree<'a>(
|
||||
entries: &'a [WorktreeInfo],
|
||||
target: &str,
|
||||
) -> Result<&'a WorktreeInfo, String> {
|
||||
let matches = entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match matches.as_slice() {
|
||||
[entry] => Ok(entry),
|
||||
[] => Err(format!("No worktree found matching '{target}'.")),
|
||||
_ => Err(format!(
|
||||
"Multiple worktrees match '{target}'; use a more specific name."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_label(source: WorktreeSource) -> &'static str {
|
||||
match source {
|
||||
WorktreeSource::Cli => "cli",
|
||||
WorktreeSource::App => "app",
|
||||
WorktreeSource::Legacy => "legacy",
|
||||
WorktreeSource::Git => "git",
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
|
||||
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
|
||||
a == b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ListSelectionView;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_worktree::DirtyState;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn parse_new_with_flags() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("new fcoury/demo --base origin/main --dirty copy-tracked"),
|
||||
Ok(WorktreeSlashAction::Create {
|
||||
branch: "fcoury/demo".to_string(),
|
||||
base_ref: Some("origin/main".to_string()),
|
||||
dirty_policy: Some(DirtyPolicy::CopyTracked),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_switch_aliases_move() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("move fcoury/demo"),
|
||||
Ok(WorktreeSlashAction::Switch {
|
||||
target: "fcoury/demo".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_remove_with_flags() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("remove fcoury/demo --force --delete-branch"),
|
||||
Ok(WorktreeSlashAction::Remove {
|
||||
target: "fcoury/demo".to_string(),
|
||||
force: true,
|
||||
delete_branch: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_picker_snapshot() {
|
||||
let params = picker_params(
|
||||
vec![
|
||||
sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false),
|
||||
sample_info("codex", WorktreeSource::App, /*dirty*/ false),
|
||||
sample_info("main", WorktreeSource::Git, /*dirty*/ true),
|
||||
],
|
||||
Path::new("/repo/codex.fcoury-demo"),
|
||||
);
|
||||
insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_loading_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_loading",
|
||||
render_selection(
|
||||
loading_params(
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false
|
||||
),
|
||||
/*width*/ 92
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_switching_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_switching",
|
||||
render_selection(
|
||||
switching_params(
|
||||
"fcoury/demo".to_string(),
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false
|
||||
),
|
||||
/*width*/ 92
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_creating_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_creating",
|
||||
render_selection(
|
||||
creating_params(
|
||||
"fcoury/demo".to_string(),
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false
|
||||
),
|
||||
/*width*/ 92
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_empty_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_empty",
|
||||
render_selection(empty_params(), /*width*/ 84)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_worktree_item_dispatches_create_prompt_event() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let item = new_worktree_item();
|
||||
|
||||
assert!(
|
||||
!item.dismiss_on_select,
|
||||
"picker should stay behind the branch-name prompt"
|
||||
);
|
||||
(item.actions[0])(&tx);
|
||||
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::OpenWorktreeCreatePrompt)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_dirty_policy_prompt_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_dirty_policy_prompt",
|
||||
render_selection(
|
||||
dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None),
|
||||
/*width*/ 82
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_remove_confirmation_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_remove_confirmation",
|
||||
render_selection(
|
||||
remove_confirmation_params(
|
||||
"fcoury/demo".to_string(),
|
||||
/*force*/ false,
|
||||
/*delete_branch*/ false
|
||||
),
|
||||
/*width*/ 80
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo {
|
||||
let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-")));
|
||||
WorktreeInfo {
|
||||
id: "repo-id".to_string(),
|
||||
name: branch.to_string(),
|
||||
slug: branch.replace('/', "-"),
|
||||
source,
|
||||
location: match source {
|
||||
WorktreeSource::Cli => WorktreeLocation::Sibling,
|
||||
WorktreeSource::App | WorktreeSource::Legacy => WorktreeLocation::CodexHome,
|
||||
WorktreeSource::Git => WorktreeLocation::External,
|
||||
},
|
||||
repo_name: "codex".to_string(),
|
||||
repo_root: path.clone(),
|
||||
common_git_dir: PathBuf::from("/repo/codex/.git"),
|
||||
worktree_git_root: path.clone(),
|
||||
workspace_cwd: path,
|
||||
original_relative_cwd: PathBuf::new(),
|
||||
branch: Some(branch.to_string()),
|
||||
head: Some("abcdef".to_string()),
|
||||
owner_thread_id: None,
|
||||
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
|
||||
dirty: DirtyState {
|
||||
has_staged_changes: false,
|
||||
has_unstaged_changes: dirty,
|
||||
has_untracked_files: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_selection(params: SelectionViewParams, width: u16) -> String {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list);
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line.trim_end().to_string()
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
49
codex-rs/tui/src/worktree_labels.rs
Normal file
49
codex-rs/tui/src/worktree_labels.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WorktreeLabel {
|
||||
pub(crate) name: String,
|
||||
pub(crate) branch: Option<String>,
|
||||
pub(crate) repo_name: String,
|
||||
pub(crate) dirty: bool,
|
||||
}
|
||||
|
||||
impl WorktreeLabel {
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let mut parts = vec![self.branch.clone().unwrap_or_else(|| self.name.clone())];
|
||||
parts.push(if self.dirty { "dirty" } else { "clean" }.to_string());
|
||||
parts.push(self.repo_name.clone());
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn label_for_cwd(codex_home: &Path, cwd: &Path) -> Option<WorktreeLabel> {
|
||||
let info = codex_worktree::resolve_worktree(codex_home, cwd)
|
||||
.inspect_err(|err| tracing::warn!(?err, "failed to resolve managed worktree label"))
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some(WorktreeLabel {
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
repo_name: info.repo_name,
|
||||
dirty: info.dirty.is_dirty(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn summary_includes_name_branch_and_repo() {
|
||||
let label = WorktreeLabel {
|
||||
name: String::from("parser-fix"),
|
||||
branch: Some(String::from("parser-fix")),
|
||||
repo_name: String::from("codex"),
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
assert_eq!(label.summary(), "parser-fix · clean · codex");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
11
codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs
Normal file
11
codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use clap::ValueEnum;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum WorktreeDirtyCliArg {
|
||||
#[default]
|
||||
Fail,
|
||||
Ignore,
|
||||
CopyTracked,
|
||||
CopyAll,
|
||||
}
|
||||
7
codex-rs/worktree/BUILD.bazel
Normal file
7
codex-rs/worktree/BUILD.bazel
Normal file
@@ -0,0 +1,7 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "worktree",
|
||||
crate_name = "codex_worktree",
|
||||
)
|
||||
|
||||
19
codex-rs/worktree/Cargo.toml
Normal file
19
codex-rs/worktree/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "codex-worktree"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
182
codex-rs/worktree/src/dirty.rs
Normal file
182
codex-rs/worktree/src/dirty.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirtyPolicy {
|
||||
Fail,
|
||||
Ignore,
|
||||
CopyTracked,
|
||||
CopyAll,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirtyState {
|
||||
pub has_staged_changes: bool,
|
||||
pub has_unstaged_changes: bool,
|
||||
pub has_untracked_files: bool,
|
||||
}
|
||||
|
||||
impl DirtyState {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.has_staged_changes || self.has_unstaged_changes || self.has_untracked_files
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty_state(root: &Path) -> Result<DirtyState> {
|
||||
let staged = git::bytes(root, &["diff", "--cached", "--name-only", "-z"])?;
|
||||
let unstaged = git::bytes(root, &["diff", "--name-only", "-z"])?;
|
||||
let untracked = git::bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
|
||||
Ok(DirtyState {
|
||||
has_staged_changes: !staged.is_empty(),
|
||||
has_unstaged_changes: !unstaged.is_empty(),
|
||||
has_untracked_files: !untracked.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_dirty_policy_before_create(
|
||||
source_root: &Path,
|
||||
policy: DirtyPolicy,
|
||||
) -> Result<Vec<String>> {
|
||||
let state = dirty_state(source_root)?;
|
||||
if !state.is_dirty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
match policy {
|
||||
DirtyPolicy::Fail => bail_for_dirty_source(),
|
||||
DirtyPolicy::Ignore => Ok(vec![
|
||||
"source checkout has uncommitted changes; the new worktree was created without them"
|
||||
.to_string(),
|
||||
]),
|
||||
DirtyPolicy::CopyTracked => {
|
||||
if state.has_untracked_files {
|
||||
Ok(vec![
|
||||
"untracked files were left in the source checkout; use --worktree-dirty copy-all to copy them"
|
||||
.to_string(),
|
||||
])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
DirtyPolicy::CopyAll => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_dirty_policy_after_create(
|
||||
source_root: &Path,
|
||||
worktree_root: &Path,
|
||||
policy: DirtyPolicy,
|
||||
) -> Result<()> {
|
||||
let state = dirty_state(source_root)?;
|
||||
if !state.is_dirty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match policy {
|
||||
DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()),
|
||||
DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root),
|
||||
DirtyPolicy::CopyAll => {
|
||||
apply_tracked_diff(source_root, worktree_root)?;
|
||||
copy_untracked_files(source_root, worktree_root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bail_for_dirty_source<T>() -> Result<T> {
|
||||
anyhow::bail!(
|
||||
"source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, or copy-all"
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> {
|
||||
let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?;
|
||||
let unstaged = git::bytes(source_root, &["diff", "--binary"])?;
|
||||
|
||||
if !staged.is_empty() {
|
||||
git::status_with_stdin(
|
||||
worktree_root,
|
||||
&["apply", "--index", "--binary", "-"],
|
||||
&staged,
|
||||
)
|
||||
.context("failed to apply staged changes to worktree")?;
|
||||
}
|
||||
if !unstaged.is_empty() {
|
||||
git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged)
|
||||
.context("failed to apply unstaged changes to worktree")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> {
|
||||
let output = git::bytes(
|
||||
source_root,
|
||||
&["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
)?;
|
||||
for raw_path in output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned());
|
||||
ensure_safe_relative_path(&relative_path)?;
|
||||
let source = source_root.join(&relative_path);
|
||||
let destination = worktree_root.join(&relative_path);
|
||||
let metadata = fs::symlink_metadata(&source)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if metadata.file_type().is_symlink() {
|
||||
let target = fs::read_link(&source)?;
|
||||
create_symlink(&target, &destination)?;
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(&source, &destination)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_safe_relative_path(path: &Path) -> Result<()> {
|
||||
if path.is_absolute() {
|
||||
anyhow::bail!(
|
||||
"refusing to copy absolute untracked path {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if path.components().any(|component| {
|
||||
matches!(component, Component::ParentDir)
|
||||
|| matches!(component, Component::Normal(value) if value == ".git")
|
||||
}) {
|
||||
anyhow::bail!("refusing to copy unsafe untracked path {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
|
||||
std::os::unix::fs::symlink(target, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
|
||||
std::os::windows::fs::symlink_file(target, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dirty_state_reports_clean_by_default() {
|
||||
assert!(!DirtyState::default().is_dirty());
|
||||
}
|
||||
}
|
||||
57
codex-rs/worktree/src/git.rs
Normal file
57
codex-rs/worktree/src/git.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn stdout(cwd: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = output(cwd, args)?;
|
||||
Ok(String::from_utf8(output)?.trim_end().to_string())
|
||||
}
|
||||
|
||||
pub fn bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
output(cwd, args)
|
||||
}
|
||||
|
||||
pub fn status(cwd: &Path, args: &[&str]) -> Result<()> {
|
||||
output(cwd, args).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {}", args.join(" ")))?;
|
||||
use std::io::Write as _;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git stdin unavailable")?
|
||||
.write_all(stdin)?;
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
106
codex-rs/worktree/src/lib.rs
Normal file
106
codex-rs/worktree/src/lib.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
mod dirty;
|
||||
mod git;
|
||||
mod manager;
|
||||
mod metadata;
|
||||
mod paths;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use dirty::DirtyPolicy;
|
||||
pub use dirty::DirtyState;
|
||||
pub use dirty::dirty_state;
|
||||
pub use manager::ensure_worktree;
|
||||
pub use manager::list_worktrees;
|
||||
pub use manager::remove_worktree;
|
||||
pub use manager::resolve_worktree;
|
||||
pub use metadata::bind_thread;
|
||||
pub use metadata::read_worktree_metadata;
|
||||
pub use metadata::write_worktree_metadata;
|
||||
pub use paths::codex_worktrees_root;
|
||||
pub use paths::is_managed_worktree_path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: PathBuf,
|
||||
pub branch: String,
|
||||
pub base_ref: Option<String>,
|
||||
pub dirty_policy: DirtyPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeResolution {
|
||||
pub reused: bool,
|
||||
pub info: WorktreeInfo,
|
||||
pub warnings: Vec<WorktreeWarning>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub source: WorktreeSource,
|
||||
pub location: WorktreeLocation,
|
||||
pub repo_name: String,
|
||||
pub repo_root: PathBuf,
|
||||
pub common_git_dir: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub branch: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub metadata_path: PathBuf,
|
||||
pub dirty: DirtyState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeSource {
|
||||
Cli,
|
||||
App,
|
||||
Legacy,
|
||||
Git,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeLocation {
|
||||
Sibling,
|
||||
CodexHome,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeWarning {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeListQuery {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub include_all_repos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRemoveRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub name_or_path: String,
|
||||
pub force: bool,
|
||||
pub delete_branch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeRemoveResult {
|
||||
pub removed_path: PathBuf,
|
||||
pub deleted_branch: Option<String>,
|
||||
}
|
||||
604
codex-rs/worktree/src/manager.rs
Normal file
604
codex-rs/worktree/src/manager.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::WorktreeInfo;
|
||||
use crate::WorktreeListQuery;
|
||||
use crate::WorktreeLocation;
|
||||
use crate::WorktreeRemoveRequest;
|
||||
use crate::WorktreeRemoveResult;
|
||||
use crate::WorktreeRequest;
|
||||
use crate::WorktreeResolution;
|
||||
use crate::WorktreeSource;
|
||||
use crate::WorktreeWarning;
|
||||
use crate::dirty;
|
||||
use crate::git;
|
||||
use crate::metadata;
|
||||
use crate::metadata::WorktreeMetadata;
|
||||
use crate::paths;
|
||||
|
||||
pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
|
||||
let repo = SourceRepo::resolve(&req.source_cwd)?;
|
||||
let branch = req.branch.clone();
|
||||
let slug = paths::slugify_name(&branch)?;
|
||||
ensure_safe_branch_name(&repo.root, &branch)?;
|
||||
let worktree_git_root = paths::sibling_worktree_git_root(&repo.root, &branch)?;
|
||||
let workspace_cwd = worktree_git_root.join(&repo.relative_cwd);
|
||||
|
||||
if worktree_git_root.exists() {
|
||||
let Some(metadata) = metadata::read_worktree_metadata(&worktree_git_root)? else {
|
||||
anyhow::bail!(
|
||||
"managed worktree path {} already exists but is not owned by Codex",
|
||||
worktree_git_root.display()
|
||||
);
|
||||
};
|
||||
ensure_existing_worktree_matches_branch(&worktree_git_root, &metadata, &branch)?;
|
||||
let info = info_from_existing_worktree(
|
||||
&req.codex_home,
|
||||
&worktree_git_root,
|
||||
Some(branch),
|
||||
Some(slug),
|
||||
)?;
|
||||
return Ok(WorktreeResolution {
|
||||
reused: true,
|
||||
info,
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(path) = branch_checkout_path(&repo.root, &branch)?
|
||||
&& path != worktree_git_root
|
||||
{
|
||||
anyhow::bail!(
|
||||
"branch {branch} is already checked out at {}; remove that worktree first",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?;
|
||||
let base_ref = req.base_ref.as_deref().unwrap_or("HEAD");
|
||||
fs::create_dir_all(
|
||||
worktree_git_root
|
||||
.parent()
|
||||
.context("managed worktree path has no parent")?,
|
||||
)?;
|
||||
if branch_exists(&repo.root, &branch) {
|
||||
git::status(
|
||||
&repo.root,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
&worktree_git_root.to_string_lossy(),
|
||||
&branch,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
git::status(
|
||||
&repo.root,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
&branch,
|
||||
&worktree_git_root.to_string_lossy(),
|
||||
base_ref,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
dirty::apply_dirty_policy_after_create(&repo.root, &worktree_git_root, req.dirty_policy)?;
|
||||
let dirty = dirty::dirty_state(&worktree_git_root)?;
|
||||
let head = git::stdout(&worktree_git_root, &["rev-parse", "HEAD"]).ok();
|
||||
let mut info = WorktreeInfo {
|
||||
id: repo.id.clone(),
|
||||
name: branch.clone(),
|
||||
slug,
|
||||
source: WorktreeSource::Cli,
|
||||
location: WorktreeLocation::Sibling,
|
||||
repo_name: repo.repo_name.clone(),
|
||||
repo_root: repo.root.clone(),
|
||||
common_git_dir: repo.common_git_dir.clone(),
|
||||
worktree_git_root: worktree_git_root.clone(),
|
||||
workspace_cwd,
|
||||
original_relative_cwd: repo.relative_cwd.clone(),
|
||||
branch: Some(branch),
|
||||
head,
|
||||
owner_thread_id: None,
|
||||
metadata_path: metadata_path_for_display(&worktree_git_root)?,
|
||||
dirty,
|
||||
};
|
||||
metadata::write_pending_owner_metadata(&worktree_git_root)?;
|
||||
let worktree_metadata = WorktreeMetadata::from_info(&info, repo.root);
|
||||
metadata::write_worktree_metadata(&worktree_git_root, &worktree_metadata)?;
|
||||
info.owner_thread_id = worktree_metadata.owner_thread_id;
|
||||
Ok(WorktreeResolution {
|
||||
reused: false,
|
||||
info,
|
||||
warnings: warnings
|
||||
.into_iter()
|
||||
.map(|message| WorktreeWarning { message })
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result<Option<WorktreeInfo>> {
|
||||
let Ok(root) = git::stdout(cwd, &["rev-parse", "--show-toplevel"]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
if !paths::is_managed_worktree_path(&root, codex_home)
|
||||
&& metadata::read_worktree_metadata(&root)?.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(info_from_existing_worktree(
|
||||
codex_home, &root, /*fallback_name*/ None, /*fallback_slug*/ None,
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn list_worktrees(query: WorktreeListQuery) -> Result<Vec<WorktreeInfo>> {
|
||||
let repo_filter = if query.include_all_repos {
|
||||
None
|
||||
} else {
|
||||
let source_cwd = query
|
||||
.source_cwd
|
||||
.as_ref()
|
||||
.context("source cwd is required unless include_all_repos is true")?;
|
||||
Some(SourceRepo::resolve(source_cwd)?)
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
if let Some(repo_filter) = repo_filter.as_ref() {
|
||||
for worktree in parse_worktree_list(&git::stdout(
|
||||
&repo_filter.root,
|
||||
&["worktree", "list", "--porcelain"],
|
||||
)?) {
|
||||
let Ok(info) = info_from_existing_worktree(
|
||||
&query.codex_home,
|
||||
worktree.path.as_path(),
|
||||
worktree.branch.clone(),
|
||||
worktree
|
||||
.branch
|
||||
.as_deref()
|
||||
.and_then(|branch| paths::slugify_name(branch).ok()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if worktree_matches_repo(&info, repo_filter) {
|
||||
entries.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let root = paths::codex_worktrees_root(&query.codex_home);
|
||||
if root.exists() {
|
||||
for worktree_root in discover_codex_home_worktree_roots(&root)? {
|
||||
let Ok(info) = info_from_existing_worktree(
|
||||
&query.codex_home,
|
||||
worktree_root.as_path(),
|
||||
/*fallback_name*/ None,
|
||||
/*fallback_slug*/ None,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(repo_filter) = repo_filter.as_ref()
|
||||
&& !worktree_matches_repo(&info, repo_filter)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.push(info);
|
||||
}
|
||||
}
|
||||
let mut unique_entries = Vec::new();
|
||||
for entry in entries {
|
||||
if unique_entries.iter().any(|existing: &WorktreeInfo| {
|
||||
paths_match(&existing.worktree_git_root, &entry.worktree_git_root)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
unique_entries.push(entry);
|
||||
}
|
||||
let mut entries = unique_entries;
|
||||
entries.sort_by(|a, b| {
|
||||
display_branch_or_name(a)
|
||||
.cmp(display_branch_or_name(b))
|
||||
.then_with(|| a.worktree_git_root.cmp(&b.worktree_git_root))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn discover_codex_home_worktree_roots(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut roots = Vec::new();
|
||||
for parent in fs::read_dir(root)? {
|
||||
let parent = parent?;
|
||||
if !parent.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let parent_path = parent.path();
|
||||
if is_git_root(&parent_path) {
|
||||
roots.push(parent_path);
|
||||
continue;
|
||||
}
|
||||
for child in fs::read_dir(parent_path)? {
|
||||
let child = child?;
|
||||
if !child.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let child_path = child.path();
|
||||
if is_git_root(&child_path) {
|
||||
roots.push(child_path);
|
||||
continue;
|
||||
}
|
||||
for grandchild in fs::read_dir(child_path)? {
|
||||
let grandchild = grandchild?;
|
||||
if !grandchild.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let grandchild_path = grandchild.path();
|
||||
if is_git_root(&grandchild_path) {
|
||||
roots.push(grandchild_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
fn is_git_root(path: &Path) -> bool {
|
||||
path.join(".git").exists()
|
||||
}
|
||||
|
||||
fn worktree_matches_repo(info: &WorktreeInfo, repo: &SourceRepo) -> bool {
|
||||
info.id == repo.id || paths_match(&info.common_git_dir, &repo.common_git_dir)
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
|
||||
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
|
||||
a == b
|
||||
}
|
||||
|
||||
pub fn remove_worktree(req: WorktreeRemoveRequest) -> Result<WorktreeRemoveResult> {
|
||||
let target = target_worktree_path(&req)?;
|
||||
let metadata = metadata::read_worktree_metadata(&target)?
|
||||
.context("refusing to remove a worktree not managed by Codex")?;
|
||||
let dirty = dirty::dirty_state(&target)?;
|
||||
if dirty.is_dirty() && !req.force {
|
||||
anyhow::bail!(
|
||||
"refusing to remove dirty worktree {}; use --force to override",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
let branch = current_branch(&target)?;
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
if req.force {
|
||||
args.push("--force");
|
||||
}
|
||||
let target_arg = target.to_string_lossy();
|
||||
args.push(&target_arg);
|
||||
let primary_root = primary_worktree_root(&target)?;
|
||||
git::status(&primary_root, &args)?;
|
||||
|
||||
let mut deleted_branch = None;
|
||||
if req.delete_branch
|
||||
&& let Some(branch) = branch
|
||||
{
|
||||
if req.force {
|
||||
git::status(&primary_root, &["branch", "-D", &branch])?;
|
||||
} else {
|
||||
git::status(&primary_root, &["branch", "-d", &branch])?;
|
||||
}
|
||||
deleted_branch = Some(branch);
|
||||
}
|
||||
|
||||
if metadata.location == WorktreeLocation::CodexHome
|
||||
&& let Some(parent) = metadata.worktree_git_root.parent()
|
||||
&& parent.exists()
|
||||
&& parent.read_dir()?.next().is_none()
|
||||
{
|
||||
fs::remove_dir(parent)?;
|
||||
}
|
||||
|
||||
Ok(WorktreeRemoveResult {
|
||||
removed_path: target,
|
||||
deleted_branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_existing_worktree_matches_branch(
|
||||
worktree_git_root: &Path,
|
||||
metadata: &WorktreeMetadata,
|
||||
requested_branch: &str,
|
||||
) -> Result<()> {
|
||||
if metadata.branch.as_deref() == Some(requested_branch) || metadata.name == requested_branch {
|
||||
return Ok(());
|
||||
}
|
||||
if current_branch(worktree_git_root)?.as_deref() == Some(requested_branch) {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"managed worktree path {} is already used by {}; choose a different branch name",
|
||||
worktree_git_root.display(),
|
||||
metadata.branch.as_deref().unwrap_or(metadata.name.as_str())
|
||||
)
|
||||
}
|
||||
|
||||
fn target_worktree_path(req: &WorktreeRemoveRequest) -> Result<PathBuf> {
|
||||
let raw = PathBuf::from(&req.name_or_path);
|
||||
if raw.is_absolute() {
|
||||
return Ok(raw);
|
||||
}
|
||||
let entries = list_worktrees(WorktreeListQuery {
|
||||
codex_home: req.codex_home.clone(),
|
||||
source_cwd: req.source_cwd.clone(),
|
||||
include_all_repos: req.source_cwd.is_none(),
|
||||
})?;
|
||||
let matches = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.branch.as_deref() == Some(req.name_or_path.as_str())
|
||||
|| entry.name == req.name_or_path
|
||||
|| entry.slug == req.name_or_path
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match matches.as_slice() {
|
||||
[entry] => Ok(entry.worktree_git_root.clone()),
|
||||
[] => anyhow::bail!("no managed worktree named {}", req.name_or_path),
|
||||
_ => anyhow::bail!(
|
||||
"multiple managed worktrees named {}; pass a path instead",
|
||||
req.name_or_path
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_from_existing_worktree(
|
||||
codex_home: &Path,
|
||||
worktree_git_root: &Path,
|
||||
fallback_name: Option<String>,
|
||||
fallback_slug: Option<String>,
|
||||
) -> Result<WorktreeInfo> {
|
||||
let metadata = metadata::read_worktree_metadata(worktree_git_root)?;
|
||||
let root = git::stdout(worktree_git_root, &["rev-parse", "--show-toplevel"])
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| worktree_git_root.to_path_buf());
|
||||
let common_git_dir = git::stdout(worktree_git_root, &["rev-parse", "--git-common-dir"])
|
||||
.map(|value| absolutize(worktree_git_root, Path::new(&value)))
|
||||
.unwrap_or_else(|_| PathBuf::new());
|
||||
let branch = current_branch(worktree_git_root)?;
|
||||
let head = git::stdout(worktree_git_root, &["rev-parse", "HEAD"]).ok();
|
||||
let dirty = dirty::dirty_state(worktree_git_root).unwrap_or_default();
|
||||
let (source, location) = classify_worktree(codex_home, worktree_git_root, metadata.as_ref());
|
||||
let repo_name = root
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "repo".to_string());
|
||||
let id = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.repo_id.clone())
|
||||
.unwrap_or_else(|| {
|
||||
root.strip_prefix(paths::codex_worktrees_root(codex_home))
|
||||
.ok()
|
||||
.and_then(|path| path.components().next())
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let name = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.name.clone())
|
||||
.or(fallback_name)
|
||||
.or_else(|| branch.clone())
|
||||
.unwrap_or_else(|| repo_name.clone());
|
||||
let slug = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.slug.clone())
|
||||
.or(fallback_slug)
|
||||
.unwrap_or_else(|| paths::slugify_name(&name).unwrap_or_else(|_| name.clone()));
|
||||
let workspace_cwd = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.workspace_cwd.clone())
|
||||
.unwrap_or_else(|| root.clone());
|
||||
let original_relative_cwd = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.original_relative_cwd.clone())
|
||||
.unwrap_or_default();
|
||||
Ok(WorktreeInfo {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
source,
|
||||
location,
|
||||
repo_name,
|
||||
repo_root: root,
|
||||
common_git_dir,
|
||||
worktree_git_root: worktree_git_root.to_path_buf(),
|
||||
workspace_cwd,
|
||||
original_relative_cwd,
|
||||
branch,
|
||||
head,
|
||||
owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id),
|
||||
metadata_path: metadata_path_for_display(worktree_git_root)?,
|
||||
dirty,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_worktree(
|
||||
codex_home: &Path,
|
||||
worktree_git_root: &Path,
|
||||
metadata: Option<&WorktreeMetadata>,
|
||||
) -> (WorktreeSource, WorktreeLocation) {
|
||||
if let Some(metadata) = metadata {
|
||||
return (metadata.source, metadata.location);
|
||||
}
|
||||
if paths::is_managed_worktree_path(worktree_git_root, codex_home) {
|
||||
return (WorktreeSource::App, WorktreeLocation::CodexHome);
|
||||
}
|
||||
(WorktreeSource::Git, WorktreeLocation::External)
|
||||
}
|
||||
|
||||
fn display_branch_or_name(info: &WorktreeInfo) -> &str {
|
||||
info.branch.as_deref().unwrap_or(&info.name)
|
||||
}
|
||||
|
||||
struct SourceRepo {
|
||||
root: PathBuf,
|
||||
relative_cwd: PathBuf,
|
||||
common_git_dir: PathBuf,
|
||||
repo_name: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl SourceRepo {
|
||||
fn resolve(source_cwd: &Path) -> Result<Self> {
|
||||
let source_cwd = source_cwd
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| source_cwd.to_path_buf());
|
||||
let root = PathBuf::from(git::stdout(&source_cwd, &["rev-parse", "--show-toplevel"])?);
|
||||
let root = root.canonicalize().unwrap_or(root);
|
||||
let common_git_dir_raw = git::stdout(&source_cwd, &["rev-parse", "--git-common-dir"])?;
|
||||
let common_git_dir = absolutize(&source_cwd, Path::new(&common_git_dir_raw))
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| absolutize(&source_cwd, Path::new(&common_git_dir_raw)));
|
||||
let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok();
|
||||
let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref());
|
||||
let repo_name = root
|
||||
.file_name()
|
||||
.context("repository root has no directory name")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let relative_cwd = source_cwd
|
||||
.strip_prefix(&root)
|
||||
.unwrap_or_else(|_| Path::new(""))
|
||||
.to_path_buf();
|
||||
Ok(Self {
|
||||
root,
|
||||
relative_cwd,
|
||||
common_git_dir,
|
||||
repo_name,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_checkout_path(root: &Path, branch: &str) -> Result<Option<PathBuf>> {
|
||||
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
|
||||
Ok(worktrees
|
||||
.into_iter()
|
||||
.find_map(|entry| (entry.branch.as_deref() == Some(branch)).then_some(entry.path)))
|
||||
}
|
||||
|
||||
fn branch_exists(root: &Path, branch: &str) -> bool {
|
||||
git::status(
|
||||
root,
|
||||
&[
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&format!("refs/heads/{branch}"),
|
||||
],
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn ensure_safe_branch_name(root: &Path, branch: &str) -> Result<()> {
|
||||
if branch.trim().is_empty() {
|
||||
anyhow::bail!("branch name must not be empty");
|
||||
}
|
||||
git::status(root, &["check-ref-format", "--branch", branch]).context("invalid branch name")
|
||||
}
|
||||
|
||||
fn current_branch(root: &Path) -> Result<Option<String>> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
.current_dir(root)
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
let branch = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
Ok((!branch.is_empty()).then_some(branch))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_worktree_root(root: &Path) -> Result<PathBuf> {
|
||||
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
|
||||
worktrees
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|entry| entry.path)
|
||||
.context("git did not report a primary worktree")
|
||||
}
|
||||
|
||||
fn metadata_path_for_display(worktree_path: &Path) -> Result<PathBuf> {
|
||||
let path = git::stdout(
|
||||
worktree_path,
|
||||
&["rev-parse", "--git-path", "codex-worktree.json"],
|
||||
)?;
|
||||
Ok(absolutize(worktree_path, Path::new(&path)))
|
||||
}
|
||||
|
||||
fn absolutize(cwd: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct GitWorktreeEntry {
|
||||
path: PathBuf,
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_worktree_list(output: &str) -> Vec<GitWorktreeEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut path = None;
|
||||
let mut branch = None;
|
||||
for line in output.lines().chain(std::iter::once("")) {
|
||||
if line.is_empty() {
|
||||
if let Some(path) = path.take() {
|
||||
entries.push(GitWorktreeEntry {
|
||||
path,
|
||||
branch: branch.take(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(raw_path) = line.strip_prefix("worktree ") {
|
||||
path = Some(PathBuf::from(raw_path));
|
||||
} else if let Some(raw_branch) = line.strip_prefix("branch ") {
|
||||
branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string());
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_worktree_list_preserves_branches() {
|
||||
let entries = parse_worktree_list(
|
||||
"worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo.wt\nHEAD def\nbranch refs/heads/codex/demo\n\n",
|
||||
);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
GitWorktreeEntry {
|
||||
path: PathBuf::from("/repo"),
|
||||
branch: Some("main".to_string())
|
||||
},
|
||||
GitWorktreeEntry {
|
||||
path: PathBuf::from("/repo.wt"),
|
||||
branch: Some("codex/demo".to_string())
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
157
codex-rs/worktree/src/metadata.rs
Normal file
157
codex-rs/worktree/src/metadata.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WorktreeInfo;
|
||||
use crate::WorktreeLocation;
|
||||
use crate::WorktreeSource;
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeThreadMetadata {
|
||||
pub version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeMetadata {
|
||||
pub version: u32,
|
||||
pub manager: String,
|
||||
pub backend: String,
|
||||
#[serde(default = "default_source")]
|
||||
pub source: WorktreeSource,
|
||||
#[serde(default = "default_location")]
|
||||
pub location: WorktreeLocation,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub branch: Option<String>,
|
||||
pub repo_id: String,
|
||||
pub repo_name: String,
|
||||
pub source_repo_root: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub tmux_session: Option<String>,
|
||||
}
|
||||
|
||||
impl WorktreeMetadata {
|
||||
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
|
||||
let now = unix_seconds();
|
||||
Self {
|
||||
version: 1,
|
||||
manager: "codex-cli".to_string(),
|
||||
backend: "git".to_string(),
|
||||
source: info.source,
|
||||
location: info.location,
|
||||
id: info.id.clone(),
|
||||
name: info.name.clone(),
|
||||
slug: info.slug.clone(),
|
||||
branch: info.branch.clone(),
|
||||
repo_id: info.id.clone(),
|
||||
repo_name: info.repo_name.clone(),
|
||||
source_repo_root,
|
||||
original_relative_cwd: info.original_relative_cwd.clone(),
|
||||
worktree_git_root: info.worktree_git_root.clone(),
|
||||
workspace_cwd: info.workspace_cwd.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
owner_thread_id: info.owner_thread_id.clone(),
|
||||
tmux_session: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_source() -> WorktreeSource {
|
||||
WorktreeSource::Legacy
|
||||
}
|
||||
|
||||
fn default_location() -> WorktreeLocation {
|
||||
WorktreeLocation::CodexHome
|
||||
}
|
||||
|
||||
pub fn read_worktree_metadata(worktree_path: &Path) -> Result<Option<WorktreeMetadata>> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
read_json_if_exists(&path)
|
||||
}
|
||||
|
||||
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
write_json(&path, metadata)
|
||||
}
|
||||
|
||||
pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
|
||||
let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let git_root = PathBuf::from(git_root);
|
||||
let owner = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: Some(thread_id.to_string()),
|
||||
};
|
||||
let owner_path = metadata_path(&git_root, "codex-thread.json")?;
|
||||
write_json(&owner_path, &owner)?;
|
||||
|
||||
if let Some(mut metadata) = read_worktree_metadata(&git_root)? {
|
||||
metadata.owner_thread_id = Some(thread_id.to_string());
|
||||
metadata.updated_at = unix_seconds();
|
||||
write_worktree_metadata(&git_root, &metadata)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
|
||||
let metadata = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: None,
|
||||
};
|
||||
let path = metadata_path(worktree_path, "codex-thread.json")?;
|
||||
write_json(&path, &metadata)
|
||||
}
|
||||
|
||||
fn read_json_if_exists<T>(path: &Path) -> Result<Option<T>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
Ok(Some(serde_json::from_str(&contents)?))
|
||||
}
|
||||
|
||||
fn write_json<T>(path: &Path, value: &T) -> Result<()>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let contents = serde_json::to_string_pretty(value)?;
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_path(worktree_path: &Path, name: &str) -> Result<PathBuf> {
|
||||
let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?;
|
||||
let path = PathBuf::from(path);
|
||||
Ok(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
worktree_path.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn unix_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
105
codex-rs/worktree/src/paths.rs
Normal file
105
codex-rs/worktree/src/paths.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use sha2::Digest;
|
||||
|
||||
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("worktrees")
|
||||
}
|
||||
|
||||
pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
|
||||
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let root = codex_worktrees_root(codex_home)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_worktrees_root(codex_home));
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
pub fn slugify_name(name: &str) -> Result<String> {
|
||||
let slug = name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() {
|
||||
anyhow::bail!("worktree name must contain at least one ASCII letter or digit");
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
|
||||
let sanitized = branch.replace(['/', '\\'], "-");
|
||||
if sanitized.trim().is_empty() {
|
||||
anyhow::bail!("branch name must produce a non-empty worktree path segment");
|
||||
}
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(common_git_dir.to_string_lossy().as_bytes());
|
||||
if let Some(origin_url) = origin_url {
|
||||
hasher.update(b"\0");
|
||||
hasher.update(origin_url.as_bytes());
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
|
||||
let repo_name = repo_root
|
||||
.file_name()
|
||||
.context("source repository root has no directory name")?;
|
||||
let parent = repo_root
|
||||
.parent()
|
||||
.context("source repository root has no parent directory")?;
|
||||
let sanitized_branch = sanitize_branch_for_path(branch)?;
|
||||
let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch);
|
||||
Ok(parent.join(dirname))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn slugify_name_keeps_short_ascii_slug() -> Result<()> {
|
||||
assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> {
|
||||
assert_eq!(
|
||||
sanitize_branch_for_path("feature/auth\\windows")?,
|
||||
"feature-auth-windows"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> {
|
||||
assert_eq!(
|
||||
sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?,
|
||||
PathBuf::from("/Users/me/code/codex.feature-auth")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
239
codex-rs/worktree/tests/git_backend.rs
Normal file
239
codex-rs/worktree/tests/git_backend.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeListQuery;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use codex_worktree::WorktreeRemoveRequest;
|
||||
use codex_worktree::WorktreeRequest;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn creates_reuses_lists_and_removes_managed_worktree() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::create_dir(fixture.repo.path().join("codex-rs"))?;
|
||||
fs::write(fixture.repo.path().join("codex-rs/README.md"), "subdir\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "codex-rs/README.md"])?;
|
||||
run_git(fixture.repo.path(), &["commit", "-m", "add subdir"])?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().join("codex-rs"),
|
||||
branch: "parser-fix".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
assert!(!resolution.reused);
|
||||
assert_eq!(resolution.info.name, "parser-fix");
|
||||
assert_eq!(resolution.info.slug, "parser-fix");
|
||||
assert_eq!(resolution.info.branch.as_deref(), Some("parser-fix"));
|
||||
assert_eq!(resolution.info.source, WorktreeSource::Cli);
|
||||
assert_eq!(resolution.info.location, WorktreeLocation::Sibling);
|
||||
let canonical_repo = fixture.repo.path().canonicalize()?;
|
||||
assert_eq!(
|
||||
resolution.info.worktree_git_root,
|
||||
canonical_repo.with_file_name(format!(
|
||||
"{}.parser-fix",
|
||||
canonical_repo.file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.info.workspace_cwd,
|
||||
resolution.info.worktree_git_root.join("codex-rs")
|
||||
);
|
||||
assert!(resolution.info.workspace_cwd.exists());
|
||||
assert!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["rev-parse", "--git-path", "codex-worktree.json"]
|
||||
)
|
||||
.map(|path| resolution.info.worktree_git_root.join(path).exists())
|
||||
.unwrap_or(false)
|
||||
);
|
||||
|
||||
let reused = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().join("codex-rs"),
|
||||
branch: "parser-fix".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
assert!(reused.reused);
|
||||
assert_eq!(
|
||||
reused.info.worktree_git_root,
|
||||
resolution.info.worktree_git_root
|
||||
);
|
||||
|
||||
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})?;
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.source == WorktreeSource::Cli)
|
||||
.map(|entry| entry.branch.as_deref())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some("parser-fix")]
|
||||
);
|
||||
|
||||
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
name_or_path: "parser-fix".to_string(),
|
||||
force: false,
|
||||
delete_branch: false,
|
||||
})?;
|
||||
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
|
||||
assert!(!removed.removed_path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
|
||||
fs::write(
|
||||
fixture.repo.path().join("unstaged.txt"),
|
||||
"unstaged changed\n",
|
||||
)?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "copy-dirty".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::CopyTracked,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["diff", "--cached", "--name-only"]
|
||||
)?,
|
||||
"staged.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?,
|
||||
"unstaged.txt"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature/foo".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
let err = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature-foo".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})
|
||||
.expect_err("sanitized branch path collision should fail");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("already used by feature/foo"),
|
||||
"{err:#}"
|
||||
);
|
||||
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
name_or_path: "feature/foo".to_string(),
|
||||
force: false,
|
||||
delete_branch: false,
|
||||
})?;
|
||||
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_includes_app_style_worktrees_without_cli_metadata() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
let app_worktree = fixture.codex_home.path().join("worktrees/7f6e/repo");
|
||||
fs::create_dir_all(app_worktree.parent().expect("app worktree parent"))?;
|
||||
run_git(
|
||||
fixture.repo.path(),
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
app_worktree.to_str().expect("utf-8 path"),
|
||||
"HEAD",
|
||||
],
|
||||
)?;
|
||||
|
||||
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})?;
|
||||
|
||||
let canonical_app_worktree = app_worktree.canonicalize()?;
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.source == WorktreeSource::App)
|
||||
.map(|entry| (entry.name.as_str(), entry.worktree_git_root.as_path()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("repo", canonical_app_worktree.as_path())]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct GitFixture {
|
||||
repo: TempDir,
|
||||
codex_home: TempDir,
|
||||
}
|
||||
|
||||
impl GitFixture {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
let repo = TempDir::new()?;
|
||||
let codex_home = TempDir::new()?;
|
||||
run_git(repo.path(), &["init", "-b", "main"])?;
|
||||
run_git(repo.path(), &["config", "user.email", "codex@example.com"])?;
|
||||
run_git(repo.path(), &["config", "user.name", "Codex"])?;
|
||||
fs::write(repo.path().join("staged.txt"), "staged original\n")?;
|
||||
fs::write(repo.path().join("unstaged.txt"), "unstaged original\n")?;
|
||||
run_git(repo.path(), &["add", "."])?;
|
||||
run_git(repo.path(), &["commit", "-m", "initial"])?;
|
||||
Ok(Self { repo, codex_home })
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user