docs(tui): clarify worktree flow

This commit is contained in:
Felipe Coury
2026-05-12 12:56:43 -03:00
parent 7a113fc7e9
commit d6e9945562
9 changed files with 382 additions and 53 deletions
+12
View File
@@ -1,4 +1,13 @@
//! App-layer handlers for the worktree TUI flow.
//!
//! This module connects user-facing worktree commands to the app-server-backed workspace command
//! runner. It owns the TUI state machine: show a loading or confirmation view, run the hidden
//! worktree helper in the active workspace, rebuild configuration for the selected cwd, then start
//! or fork a session there.
//!
//! Git and metadata semantics stay in the codex-worktree crate and hidden CLI helper. The TUI layer
//! should pass structured choices through that helper instead of duplicating path, dirty-state, or
//! ownership checks locally.
use anyhow::Context;
use codex_app_server_protocol::SandboxPolicy;
@@ -654,6 +663,7 @@ impl App {
fn defer_switch_to_worktree_info(&self, info: WorktreeInfo) {
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
// Give the switching view one render tick before session startup begins doing work.
tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await;
app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { info });
});
@@ -713,6 +723,8 @@ async fn run_worktree_helper_with<T>(
where
T: DeserializeOwned,
{
// The helper must be JSON-clean: skip arg0 PATH update output and run the exact Codex binary
// known to app-server so local, remote, and packaged TUI sessions use the same helper path.
let mut command = WorkspaceCommand::codex_self(["worktree", "__internal"])
.cwd(cwd)
.env(codex_arg0::CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR, "1")
+5
View File
@@ -75,10 +75,15 @@ pub(crate) struct HistoryLookupResponse {
#[derive(Debug)]
pub(crate) struct WorktreeSessionReadyEvent {
/// Worktree the new or forked session should attach to.
pub(crate) info: WorktreeInfo,
/// Runtime configuration rebuilt for the worktree cwd.
pub(crate) config: Config,
/// Whether the session was forked from the current thread instead of started fresh.
pub(crate) forked: bool,
/// User-facing warnings emitted while creating or entering the worktree.
pub(crate) warnings: Vec<String>,
/// Result of starting or forking the app-server thread.
pub(crate) result: Result<AppServerStartedThread, String>,
}
+75 -53
View File
@@ -1,3 +1,13 @@
//! Selection-view construction and slash parsing for the TUI worktree flow.
//!
//! This module is intentionally presentation-focused. It parses slash-command arguments, builds
//! picker rows, and dispatches AppEvent values, while app-level handlers perform helper execution
//! and session switching. Keeping the split narrow makes snapshots meaningful: changes here should
//! mostly affect visible text, selection actions, or parser behavior.
//!
//! Picker-selected existing worktrees carry WorktreeInfo through the event path so the TUI does not
//! need to list worktrees a second time after the user chooses a row.
use std::path::Path;
use std::time::Instant;
@@ -110,6 +120,11 @@ pub(crate) enum WorktreeSlashAction {
}
impl WorktreeSlashAction {
/// Dispatches a parsed slash action into the TUI event loop.
///
/// Parser output stays independent of AppEvent so tests can validate command handling without
/// constructing app state. Adding a new slash action should update this method and the event
/// dispatcher together; otherwise the command will parse successfully but do nothing.
pub(crate) fn dispatch(self, tx: &AppEventSender) {
match self {
WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker),
@@ -142,6 +157,11 @@ impl WorktreeSlashAction {
}
}
/// Parses the argument string following the /worktree slash command.
///
/// An empty argument string opens the picker. Creation keeps explicit base-ref and dirty-policy
/// flags for power users even though the interactive picker uses the default base and a simpler
/// two-choice dirty prompt.
pub(crate) fn parse_worktree_slash_args(args: &str) -> Result<WorktreeSlashAction, String> {
let mut parts = args.split_whitespace();
let Some(command) = parts.next() else {
@@ -250,30 +270,22 @@ pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> R
Ok(())
}
/// Builds the animated loading view shown while worktrees are being listed.
///
/// Callers must start the list request asynchronously before returning to the render loop; otherwise
/// the spinner cannot animate and the UI will jump straight to the final result or error.
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()
}
worktree_progress_params(
frame_requester,
animations_enabled,
status.clone(),
"This can take a moment when Codex is checking app, CLI, and Git worktrees.",
status,
)
}
pub(crate) fn switching_params(
@@ -281,26 +293,13 @@ pub(crate) fn switching_params(
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()
}
worktree_progress_params(
frame_requester,
animations_enabled,
format!("Switching to {target}..."),
"Codex is rebuilding configuration and starting the chat in that workspace.",
"Preparing worktree session...".to_string(),
)
}
pub(crate) fn creating_params(
@@ -308,9 +307,23 @@ pub(crate) fn creating_params(
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();
worktree_progress_params(
frame_requester,
animations_enabled,
format!("Creating {branch}..."),
"Codex is creating the worktree before starting the chat in that workspace.",
"Preparing worktree...".to_string(),
)
}
fn worktree_progress_params(
frame_requester: FrameRequester,
animations_enabled: bool,
status: String,
note: &'static str,
item_name: String,
) -> SelectionViewParams {
let note = note.to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
@@ -321,7 +334,7 @@ pub(crate) fn creating_params(
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: "Preparing worktree...".to_string(),
name: item_name,
description: Some(note),
is_disabled: true,
..Default::default()
@@ -331,12 +344,9 @@ pub(crate) fn creating_params(
}
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),
header: Box::new(worktrees_header()),
footer_hint: Some(standard_popup_hint_line()),
items: vec![new_worktree_item()],
..Default::default()
@@ -348,12 +358,9 @@ pub(crate) fn error_params(error: String) -> SelectionViewParams {
}
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),
header: Box::new(worktrees_header()),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: summary,
@@ -365,6 +372,11 @@ pub(crate) fn error_with_summary_params(summary: String, error: String) -> Selec
}
}
/// Builds the picker for creating or entering managed worktrees.
///
/// The first row is always the creation action, so the preselected current worktree index is offset
/// by one. Existing rows dispatch their full WorktreeInfo to avoid resolving the same row by a
/// potentially ambiguous branch or slug after selection.
pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> SelectionViewParams {
let mut items = vec![new_worktree_item()];
let mut initial_selected_idx = None;
@@ -420,8 +432,7 @@ pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> S
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
let mut header = worktrees_header();
header.push(Line::from(
"Create a worktree or fork this chat into an existing workspace.".dim(),
));
@@ -454,6 +465,17 @@ fn new_worktree_item() -> SelectionItem {
}
}
fn worktrees_header() -> ColumnRenderable<'static> {
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
header
}
/// Builds the pending-changes prompt for creating a worktree from a dirty checkout.
///
/// The interactive flow intentionally exposes only two choices: move all pending changes into the
/// new worktree or leave them in the source checkout. More granular copy/tracked policies remain
/// available through explicit slash and CLI flags.
pub(crate) fn dirty_policy_prompt_params(
branch: String,
base_ref: Option<String>,
+16
View File
@@ -1,5 +1,16 @@
//! Compact status labels for sessions running inside managed worktrees.
//!
//! Labels are derived on demand from codex-worktree metadata so status surfaces can show the
//! worktree name, dirty state, and repository without depending on the full picker inventory. A
//! failure to resolve metadata is logged and treated as no label because the status line should not
//! block normal chat rendering.
use std::path::Path;
/// Minimal worktree identity shown in compact TUI surfaces.
///
/// This type intentionally omits paths and owner metadata. It is for display only; callers that need
/// to switch, bind, or remove a worktree must use WorktreeInfo from the worktree manager instead.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorktreeLabel {
pub(crate) name: String,
@@ -9,6 +20,7 @@ pub(crate) struct WorktreeLabel {
}
impl WorktreeLabel {
/// Formats the label as branch-or-name, dirty state, and repository name.
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());
@@ -17,6 +29,10 @@ impl WorktreeLabel {
}
}
/// Resolves a cwd to a compact managed-worktree label when metadata is available.
///
/// Errors are logged and suppressed so a stale metadata file cannot break status rendering. A caller
/// that needs to distinguish unmanaged from broken metadata should use codex_worktree directly.
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"))
+42
View File
@@ -1,3 +1,13 @@
//! Dirty-checkout transfer support for managed worktree creation.
//!
//! Worktree creation is split into validation, transfer preparation, and final cleanup so the
//! caller can roll back a newly-created worktree if copying changes fails. Tracked changes are
//! transferred with Git binary patches so staged and unstaged state is preserved; untracked files
//! are copied by path after rejecting absolute paths, parent traversal, and .git entries.
//!
//! The move policies are intentionally two-phase. The new worktree receives the changes before the
//! source checkout is cleaned, which avoids data loss if applying or copying into the target fails.
use std::fs;
use std::path::Component;
use std::path::Path;
@@ -10,13 +20,25 @@ use serde::Serialize;
use crate::git;
/// Policy for pending source checkout changes during worktree creation.
///
/// Copy policies leave the source checkout dirty, while move policies clean the transferred paths
/// from the source only after the target worktree has received them. Choosing a tracked-only policy
/// with untracked files present is allowed, but callers should surface the warning returned by
/// validation so users know those files stayed behind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyPolicy {
/// Reject creation when the source checkout has pending changes.
Fail,
/// Create the worktree without transferring pending changes.
Ignore,
/// Copy staged and unstaged tracked changes into the new worktree.
CopyTracked,
/// Copy tracked changes and untracked files into the new worktree.
CopyAll,
/// Move staged and unstaged tracked changes into the new worktree.
MoveTracked,
/// Move tracked changes and untracked files into the new worktree.
MoveAll,
}
@@ -39,20 +61,33 @@ struct MovePlan {
move_untracked: bool,
}
/// Dirty-state summary for a Git checkout.
///
/// The fields are coarse by design: they support UI decisions and policy validation without exposing
/// filenames. Callers that need a transfer must let this module recalculate paths from Git rather
/// than deriving behavior from this summary.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirtyState {
/// Whether the index contains staged changes.
pub has_staged_changes: bool,
/// Whether tracked files have unstaged changes.
pub has_unstaged_changes: bool,
/// Whether Git reports untracked, non-ignored files.
pub has_untracked_files: bool,
}
impl DirtyState {
/// Returns true when any tracked or untracked pending changes are present.
pub fn is_dirty(&self) -> bool {
self.has_staged_changes || self.has_unstaged_changes || self.has_untracked_files
}
}
/// Returns a coarse dirty-state summary for a Git checkout.
///
/// The root must be a Git worktree. Errors mean Git could not inspect the checkout, not that the
/// checkout is clean; treating those errors as clean would allow callers to skip policy prompts.
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"])?;
@@ -64,6 +99,11 @@ pub fn dirty_state(root: &Path) -> Result<DirtyState> {
})
}
/// Validates whether a dirty policy can proceed before creating the target worktree.
///
/// This function does not mutate the checkout. It returns user-facing warnings for policies that
/// intentionally leave changes behind, and it fails only when the policy says dirty changes should
/// block creation.
pub fn validate_dirty_policy_before_create(
source_root: &Path,
policy: DirtyPolicy,
@@ -110,6 +150,8 @@ pub(crate) fn prepare_dirty_policy_after_create(
let plan = TransferPlan::capture(source_root)?;
plan.apply_tracked_diff(worktree_root)?;
// Untracked files are copied before the source checkout is cleaned so move-all can fail without
// losing files that were never represented in Git.
let move_untracked = matches!(policy, DirtyPolicy::MoveAll);
if matches!(policy, DirtyPolicy::CopyAll | DirtyPolicy::MoveAll) {
copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?;
+97
View File
@@ -1,3 +1,16 @@
//! Managed Git worktree operations shared by Codex CLI, TUI, and app-server entry points.
//!
//! This crate owns the filesystem and Git contracts for Codex-managed worktrees: deriving stable
//! locations, creating or reusing a worktree, transferring pending changes when requested, writing
//! ownership metadata, listing known worktrees, and removing only worktrees that can be proven to be
//! Codex-managed. UI and transport layers should treat this crate as the source of truth for those
//! semantics rather than reimplementing Git path or metadata checks.
//!
//! The crate deliberately does not start Codex sessions, update TUI state, or decide whether a
//! conversation should be forked. Callers provide the source checkout, target branch, base ref, and
//! dirty-change policy; the returned metadata describes the resulting worktree so higher layers can
//! attach their own session lifecycle.
mod dirty;
mod git;
mod manager;
@@ -27,85 +40,169 @@ pub use paths::repo_fingerprint;
pub use paths::sibling_worktree_git_root;
pub use paths::slugify_name;
/// Request to create or reuse a managed worktree for a branch.
///
/// The source cwd may point anywhere inside the source repository; creation preserves that relative
/// cwd in the returned workspace cwd. The base ref is only used when creating a new branch, while an
/// existing branch is checked out directly. Passing the wrong Codex home can make listing and
/// metadata discovery disagree with the caller's later session binding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRequest {
/// Codex home directory used for metadata discovery and app-style worktree roots.
pub codex_home: PathBuf,
/// Current directory inside the source repository that the worktree should mirror.
pub source_cwd: PathBuf,
/// Branch name to create, reuse, or resolve.
pub branch: String,
/// Optional starting ref for a new branch; omitted means Git's current HEAD.
pub base_ref: Option<String>,
/// Policy for pending changes in the source checkout.
pub dirty_policy: DirtyPolicy,
}
/// Result of creating or reusing a managed worktree.
///
/// Reused is true when the requested Codex-managed worktree already existed and matched the
/// requested branch. Warnings are non-fatal user-facing messages, most commonly dirty-change policy
/// outcomes that should be surfaced before the caller starts or forks a session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeResolution {
/// Whether an existing managed worktree was returned instead of creating a new one.
pub reused: bool,
/// Full inventory record for the selected worktree.
pub info: WorktreeInfo,
/// Non-fatal warnings that callers should display to the user.
pub warnings: Vec<WorktreeWarning>,
}
/// Inventory record for a Git worktree visible to Codex.
///
/// The path fields intentionally distinguish the source repository root, the Git worktree root, and
/// the workspace cwd that should be used when launching Codex. Using the Git worktree root as the
/// session cwd would drop the user's original subdirectory context.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeInfo {
/// Stable repository fingerprint used to group worktrees from the same repository.
pub id: String,
/// Human-readable worktree name.
pub name: String,
/// Filesystem/search-friendly slug derived from the name or branch.
pub slug: String,
/// System that created or exposed this worktree.
pub source: WorktreeSource,
/// Broad filesystem placement category.
pub location: WorktreeLocation,
/// Display name of the source repository.
pub repo_name: String,
/// Source repository root that owns the Git common directory.
pub repo_root: PathBuf,
/// Git common directory used to match worktrees across paths.
pub common_git_dir: PathBuf,
/// Root directory of the Git worktree itself.
pub worktree_git_root: PathBuf,
/// Directory Codex should use as cwd when entering this worktree.
pub workspace_cwd: PathBuf,
/// Relative cwd from the source repository root that produced workspace_cwd.
pub original_relative_cwd: PathBuf,
/// Checked-out branch, when the worktree has one.
pub branch: Option<String>,
/// Current HEAD revision, when the worktree has one.
pub head: Option<String>,
/// Codex thread currently associated with this worktree, if known.
pub owner_thread_id: Option<String>,
/// Metadata file path used for diagnostics and display.
pub metadata_path: PathBuf,
/// Current dirty state of the worktree.
pub dirty: DirtyState,
}
/// Origin of a worktree entry in the combined Codex inventory.
///
/// Callers should not use this as an ownership proof for destructive operations. Removal relies on
/// metadata checks in remove_worktree, because Git and legacy entries can still appear in lists.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeSource {
/// Created by the Codex CLI/TUI managed-worktree flow.
Cli,
/// Created by the Codex app worktree flow.
App,
/// Older Codex metadata layout that still needs to be listed.
Legacy,
/// Plain Git worktree discovered from git worktree list.
Git,
}
/// Filesystem placement category for a worktree.
///
/// This is a display and filtering hint, not an absolute path contract. Code that needs an actual
/// path must use worktree_git_root or workspace_cwd.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeLocation {
/// Sibling directory next to the source repository.
Sibling,
/// Worktree stored under CODEX_HOME/worktrees.
CodexHome,
/// Worktree outside Codex's managed directory conventions.
External,
}
/// Non-fatal message produced while satisfying a worktree request.
///
/// Warnings are already phrased for users. Treating them as errors would make intentional policies
/// like leaving dirty changes behind look like failed worktree creation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeWarning {
/// User-facing warning text.
pub message: String,
}
/// Query for listing worktrees visible to Codex.
///
/// When include_all_repos is false, source_cwd is required and results are filtered to the matching
/// repository. Passing include_all_repos true is intended for inventory tools, not for picker flows
/// that should stay scoped to the current checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeListQuery {
/// Codex home directory used to discover app-style worktrees.
pub codex_home: PathBuf,
/// Current directory used to identify the repository filter.
pub source_cwd: Option<PathBuf>,
/// Whether to include worktrees from all repositories under Codex-managed roots.
pub include_all_repos: bool,
}
/// Request to remove a Codex-managed worktree.
///
/// The target may be a branch/name/slug or an absolute path. Destructive operations still verify
/// Codex metadata before removing the worktree, so a matching display name alone is not enough to
/// delete arbitrary Git worktrees.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRemoveRequest {
/// Codex home directory used for name lookup.
pub codex_home: PathBuf,
/// Optional current directory used to scope name lookup to one repository.
pub source_cwd: Option<PathBuf>,
/// Worktree branch, name, slug, or absolute path to remove.
pub name_or_path: String,
/// Whether to force removal of a dirty worktree.
pub force: bool,
/// Whether to delete the associated branch after removing the worktree.
pub delete_branch: bool,
}
/// Result of removing a Codex-managed worktree.
///
/// The deleted branch is set only when branch deletion was requested and Git reported a branch for
/// the removed worktree. Callers should display the removed path even when no branch was deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeRemoveResult {
/// Filesystem path removed by Git.
pub removed_path: PathBuf,
/// Branch deleted after removal, if any.
pub deleted_branch: Option<String>,
}
+35
View File
@@ -1,3 +1,14 @@
//! High-level manager for creating, listing, resolving, and removing managed worktrees.
//!
//! This module is the only place that combines repository discovery, Git worktree commands,
//! dirty-change transfer, and Codex metadata writes. It treats metadata as the authority for
//! destructive operations, while list and resolve paths also tolerate plain Git and legacy entries
//! so users can see the worktrees that matter before choosing an action.
//!
//! The core invariant is that a managed worktree has both a Git worktree root and a Codex workspace
//! cwd. The workspace cwd preserves the source checkout's original subdirectory and is the path
//! higher layers should use when launching or switching a Codex session.
use std::fs;
use std::path::Path;
use std::path::PathBuf;
@@ -20,6 +31,13 @@ use crate::metadata;
use crate::metadata::WorktreeMetadata;
use crate::paths;
/// Creates or reuses the managed worktree described by the request.
///
/// Existing Codex-managed worktrees are reused only when their metadata or current branch matches
/// the requested branch. New worktrees are created as siblings of the primary repository, receive
/// pending changes according to the dirty policy, and get Codex metadata before the result is
/// returned. A caller that passes a source cwd outside the intended repository can create a valid
/// worktree for the wrong repo, so UI layers should pass the active session workspace cwd.
pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
let repo = SourceRepo::resolve(&req.source_cwd)?;
let branch = req.branch.clone();
@@ -66,6 +84,8 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
.parent()
.context("managed worktree path has no parent")?,
)?;
// Git worktree add has three materially different shapes: reuse an existing branch, create an
// orphan branch for an unborn repository, or create a new branch from a resolved base ref.
let worktree_arg = worktree_git_root.to_string_lossy();
let mut args = vec!["worktree", "add"];
if branch_exists {
@@ -160,6 +180,10 @@ fn rollback_failed_create(
}
}
/// Resolves the current cwd to managed worktree information when Codex metadata is present.
///
/// A non-Git cwd or an unmanaged Git worktree returns Ok(None). Errors are reserved for malformed or
/// unreadable metadata after the path has otherwise looked like a managed worktree.
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);
@@ -175,6 +199,12 @@ pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result<Option<Worktree
)?))
}
/// Lists worktrees visible to Codex, optionally scoped to the current repository.
///
/// Repository-scoped listings combine Git's worktree inventory for the current repo with
/// Codex-home worktrees that carry matching metadata. Entries that cannot be parsed or inspected are
/// skipped so one stale worktree does not break the picker, but the source repository itself must be
/// resolvable when filtering is requested.
pub fn list_worktrees(query: WorktreeListQuery) -> Result<Vec<WorktreeInfo>> {
let repo_filter = if query.include_all_repos {
None
@@ -291,6 +321,11 @@ fn paths_match(a: &Path, b: &Path) -> bool {
a == b
}
/// Removes a Codex-managed worktree and optionally deletes its branch.
///
/// Removal first resolves the target by path or name and then requires Codex metadata at the target
/// path, which prevents a stale picker row or ambiguous name from deleting an arbitrary Git
/// worktree. Dirty worktrees require force so callers cannot accidentally discard uncommitted work.
pub fn remove_worktree(req: WorktreeRemoveRequest) -> Result<WorktreeRemoveResult> {
let target = target_worktree_path(&req)?;
let metadata = metadata::read_worktree_metadata(&target)?
+66
View File
@@ -1,3 +1,14 @@
//! Codex metadata read/write helpers for managed worktrees.
//!
//! Metadata is stored through git rev-parse --git-path so it lands beside the worktree's Git
//! metadata whether the checkout uses a directory .git or a file that points into a common Git dir.
//! That placement keeps ownership data attached to the worktree without requiring callers to know
//! the repository's worktree internals.
//!
//! Two files are intentionally maintained. codex-worktree.json describes the managed worktree for
//! inventory and deletion safety, while codex-thread.json records the Codex thread currently bound
//! to that checkout.
use std::fs;
use std::path::Path;
use std::path::PathBuf;
@@ -13,41 +24,77 @@ use crate::WorktreeLocation;
use crate::WorktreeSource;
use crate::git;
/// Thread ownership metadata stored beside a worktree's Git metadata.
///
/// A pending worktree is written with no owner before the session starts, then updated after the
/// TUI successfully attaches a thread. Consumers should treat a missing owner as unbound rather
/// than corrupt metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeThreadMetadata {
/// Metadata schema version.
pub version: u32,
/// Thread id currently associated with this worktree.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_thread_id: Option<String>,
}
/// Persistent description of a Codex-managed worktree.
///
/// The metadata duplicates a subset of WorktreeInfo so a later process can list or remove a
/// worktree without reconstructing all creation context. The source and location defaults are for
/// older metadata files that predate those fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeMetadata {
/// Metadata schema version.
pub version: u32,
/// Manager that created this metadata.
pub manager: String,
/// Backing implementation for this worktree.
pub backend: String,
/// Source surface that created or exposed this worktree.
#[serde(default = "default_source")]
pub source: WorktreeSource,
/// Filesystem placement category for this worktree.
#[serde(default = "default_location")]
pub location: WorktreeLocation,
/// Stable repository fingerprint used for grouping.
pub id: String,
/// Human-readable worktree name.
pub name: String,
/// Filesystem/search-friendly worktree slug.
pub slug: String,
/// Checked-out branch, if known.
pub branch: Option<String>,
/// Stable repository fingerprint retained for compatibility with app metadata.
pub repo_id: String,
/// Display name of the source repository.
pub repo_name: String,
/// Root of the repository that created this worktree.
pub source_repo_root: PathBuf,
/// Relative cwd from the source repository root.
pub original_relative_cwd: PathBuf,
/// Root directory of the Git worktree.
pub worktree_git_root: PathBuf,
/// Directory Codex should use when starting a session in this worktree.
pub workspace_cwd: PathBuf,
/// Unix timestamp for metadata creation.
pub created_at: i64,
/// Unix timestamp for the most recent metadata update.
pub updated_at: i64,
/// Codex thread currently associated with this worktree.
pub owner_thread_id: Option<String>,
/// Optional tmux session name retained for compatibility with older metadata.
pub tmux_session: Option<String>,
}
impl WorktreeMetadata {
/// Builds fresh metadata from the inventory record produced during creation.
///
/// This constructor stamps created_at and updated_at together because it is only used for new
/// metadata. Callers that update an existing worktree should mutate that record and preserve its
/// original creation time.
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
let now = unix_seconds();
Self {
@@ -82,16 +129,30 @@ fn default_location() -> WorktreeLocation {
WorktreeLocation::CodexHome
}
/// Reads Codex worktree metadata from the Git metadata area for a worktree.
///
/// A missing metadata file returns Ok(None). A parse error means the path looked managed but the
/// stored ownership data is not usable, so callers should surface that as an error rather than
/// silently treating it as unmanaged.
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)
}
/// Writes Codex worktree metadata to the Git metadata area for a worktree.
///
/// The caller is responsible for ensuring the worktree path is the root of the Git worktree. Writing
/// metadata for a nested cwd would bind the wrong checkout and make later removal checks fail.
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
let path = metadata_path(worktree_path, "codex-worktree.json")?;
write_json(&path, metadata)
}
/// Associates a worktree with a Codex thread id.
///
/// This updates both the lightweight thread metadata and the full worktree metadata when present.
/// It should be called after the session is successfully started or forked; calling it earlier can
/// leave metadata pointing at a thread that was never attached.
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);
@@ -110,6 +171,11 @@ pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
Ok(())
}
/// Writes unbound thread metadata for a newly-created worktree.
///
/// The pending owner file lets other Codex surfaces recognize the worktree before a thread is
/// attached. It is intentionally separate from WorktreeMetadata so session binding can be updated
/// independently of creation metadata.
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
let metadata = WorktreeThreadMetadata {
version: 1,
+34
View File
@@ -1,3 +1,9 @@
//! Path and identifier helpers for managed worktrees.
//!
//! These helpers centralize the naming rules that must stay consistent across CLI, TUI, app-server,
//! and tests. They do not touch the filesystem except when canonicalizing paths for containment
//! checks; creation and removal remain in the manager module.
use std::path::Path;
use std::path::PathBuf;
@@ -5,10 +11,19 @@ use anyhow::Context;
use anyhow::Result;
use sha2::Digest;
/// Returns the Codex-home root used for app-style managed worktrees.
///
/// This path is a discovery root, not proof of ownership. Callers must still read metadata before
/// removing or binding a worktree under it.
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
codex_home.join("worktrees")
}
/// Returns true when a path is under the Codex-home managed worktree root.
///
/// The check canonicalizes both sides when possible so symlinks and macOS private-var aliases do not
/// make an owned worktree look external. A true result should be combined with metadata checks
/// before destructive operations.
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)
@@ -17,6 +32,11 @@ pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
path.starts_with(root)
}
/// Produces a short ASCII slug for display, search, and metadata.
///
/// Slugs are intentionally lossy and must not be used as the only identifier for deletion when
/// multiple worktrees can share a name-like prefix. An empty result is rejected because it would
/// make picker rows and metadata ambiguous.
pub fn slugify_name(name: &str) -> Result<String> {
let slug = name
.chars()
@@ -39,6 +59,11 @@ pub fn slugify_name(name: &str) -> Result<String> {
Ok(slug)
}
/// Converts a branch name into a single safe sibling-directory segment.
///
/// This preserves most characters but replaces path separators so branch names cannot escape the
/// sibling worktree directory. Git branch validity is checked separately by the manager before
/// creation.
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
let sanitized = branch.replace(['/', '\\'], "-");
if sanitized.trim().is_empty() {
@@ -47,6 +72,10 @@ pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
Ok(sanitized)
}
/// Builds a stable repository fingerprint from Git identity inputs.
///
/// The common Git directory keeps linked worktrees for the same repository grouped together, while
/// the optional origin URL reduces collisions when paths are similar across different clones.
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());
@@ -62,6 +91,11 @@ pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> Stri
.collect()
}
/// Returns the sibling Git worktree root for a branch.
///
/// The path follows the Worktrunk-style repo.branch naming convention. Callers should pass the
/// primary repository root, not an existing sibling worktree root, or the new path will be anchored
/// beside the wrong checkout.
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
let repo_name = repo_root
.file_name()