mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
docs(tui): clarify worktree flow
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user