Compare commits

...

12 Commits

Author SHA1 Message Date
Felipe Coury
3e1a5e7275 refactor(worktree): simplify app-server client boundaries 2026-05-10 12:53:01 -03:00
Felipe Coury
1276652068 refactor(worktree): route cli management through app-server 2026-05-10 12:21:03 -03:00
Felipe Coury
c0a6795347 refactor(worktree): route interactive flow through app-server 2026-05-09 21:36:06 -03:00
Felipe Coury
5dc9cf6907 fix(worktree): preserve remote dirty transfers 2026-05-09 16:30:58 -03:00
Felipe Coury
93317c151d feat(worktree): fill remaining worktree gaps 2026-05-09 12:52:13 -03:00
Felipe Coury
6e460f31cd feat(worktree): add move-all dirty policy 2026-05-08 14:13:46 -03:00
Felipe Coury
700f1e4a38 fix(tui): make current worktree selection a no-op 2026-05-08 10:08:26 -03:00
Felipe Coury
1c604c0be6 fix(worktree): name siblings from primary checkout 2026-05-07 20:47:52 -03:00
Felipe Coury
1b31e12444 feat(tui): create worktrees from slash command 2026-05-07 20:41:34 -03:00
Felipe Coury
4f3955ff91 fix(tui): keep worktree switching responsive 2026-05-06 23:58:02 -03:00
Felipe Coury
250390cb76 feat(tui): add worktree slash command 2026-05-06 21:07:37 -03:00
Felipe Coury
5a6efcf183 feat(cli): add managed worktree workflow 2026-05-06 19:44:04 -03:00
84 changed files with 7493 additions and 37 deletions

69
CONTEXT.md Normal file
View File

@@ -0,0 +1,69 @@
# Codex Worktrees
This context describes how Codex names and reasons about Git-backed session destinations. The product exposes the familiar Git term while distinguishing those destinations from the broader notion of a session workspace.
## Language
**Workspace**:
The filesystem location where a Codex session runs.
**Managed worktree**:
A Codex-owned workspace backed by a Git worktree.
_Avoid_: workspace when the Git-backed ownership matters
**External worktree**:
A Git worktree visible to Codex but not owned or mutated by Codex.
**Worktree origin**:
The current creator lineage of a managed worktree, such as CLI or App, retained for compatibility while clients converge on one app-server-backed implementation.
**Worktree**:
The user-facing term for a managed worktree, matching the Codex App and developers' existing Git vocabulary.
_Avoid_: workspace when referring specifically to the product feature
**Worktree management**:
Standalone creation, inspection, and removal of managed worktrees outside a running Codex session.
**Worktree launch**:
Starting a Codex session in a named managed worktree, creating it when needed.
**Worktree switching**:
Moving an active Codex session into a managed worktree from inside the TUI.
## Relationships
- A **managed worktree** is one kind of **workspace**
- An **external worktree** is a visible **workspace** that Codex does not own
- A **worktree** is the user-facing name for a **managed worktree**
- A **worktree origin** describes current provenance, not a permanent product subtype
- A **workspace** may exist without being a **managed worktree**
- **Worktree management** operates on **managed worktrees** whether or not a session is currently running in them
- **Worktree launch** may create or reuse a **managed worktree** before the session begins
- **Worktree switching** may create or reuse a **managed worktree** after the session has begun
- **Worktree management**, **worktree launch**, and **worktree switching** share the app-server-backed managed-worktree model
## Example dialogue
> **Dev:** "When a user chooses a **worktree**, are they choosing any **workspace**?"
> **Domain expert:** "No — they are choosing a Codex-managed Git-backed **workspace**."
>
> **Dev:** "Does a **worktree** only exist once a session starts there?"
> **Domain expert:** "No — **worktree management** lets users create and inspect it before a session enters it."
>
> **Dev:** "Why can `--worktree` create one instead of only selecting an existing one?"
> **Domain expert:** "Because **worktree launch** should be a one-step path into the named worktree."
>
> **Dev:** "Can `--force` remove an **external worktree**?"
> **Domain expert:** "No — force bypasses cleanliness checks, not ownership."
>
> **Dev:** "Are CLI and App worktrees different products?"
> **Domain expert:** "No — their **worktree origins** differ today while the App is still migrating toward the shared app-server implementation."
>
> **Dev:** "Can remote CLI commands manage worktrees outside a session?"
> **Domain expert:** "Yes — remote and local management both go through app-server."
## Flagged ambiguities
- "workspace" and "worktree" were being used interchangeably — resolved: **workspace** is broader; **worktree** names the Git-backed product feature.
- "force" could imply bypassing ownership — resolved: it only bypasses dirty-state protection for **managed worktrees**.
- "CLI worktree" and "App worktree" could sound like permanent subtypes — resolved: they are current **worktree origins**, not the long-term domain model.

18
codex-rs/Cargo.lock generated
View File

@@ -1896,6 +1896,7 @@ dependencies = [
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-pty",
"codex-worktree",
"core_test_support",
"flate2",
"futures",
@@ -2179,6 +2180,7 @@ dependencies = [
"clap",
"clap_complete",
"codex-app-server",
"codex-app-server-client",
"codex-app-server-protocol",
"codex-app-server-test-client",
"codex-arg0",
@@ -2192,6 +2194,7 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-features",
"codex-feedback",
"codex-login",
"codex-mcp",
"codex-mcp-server",
@@ -2211,6 +2214,7 @@ dependencies = [
"codex-utils-cli",
"codex-utils-path",
"codex-windows-sandbox",
"codex-worktree",
"libc",
"owo-colors",
"predicates",
@@ -3713,6 +3717,7 @@ dependencies = [
"codex-utils-sleep-inhibitor",
"codex-utils-string",
"codex-windows-sandbox",
"codex-worktree",
"color-eyre",
"cpal",
"crossterm",
@@ -4021,6 +4026,19 @@ dependencies = [
"winres",
]
[[package]]
name = "codex-worktree"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-utils-absolute-path",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
]
[[package]]
name = "color-eyre"
version = "0.6.5"

View File

@@ -74,6 +74,7 @@ members = [
"otel",
"tui",
"tools",
"worktree",
"v8-poc",
"utils/absolute-path",
"utils/cargo-bin",
@@ -206,6 +207,7 @@ codex-thread-store = { path = "thread-store" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-uds = { path = "uds" }
codex-worktree = { path = "worktree" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
codex-utils-approval-presets = { path = "utils/approval-presets" }
codex-utils-cache = { path = "utils/cache" }

View File

@@ -4564,6 +4564,110 @@
"mode"
],
"type": "object"
},
"WorktreeCreateParams": {
"description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.",
"properties": {
"baseRef": {
"type": [
"string",
"null"
]
},
"branch": {
"type": "string"
},
"cwd": {
"description": "Repository-relative workspace cwd to use as the source checkout.",
"type": [
"string",
"null"
]
},
"dirtyPolicy": {
"$ref": "#/definitions/WorktreeDirtyPolicy"
}
},
"required": [
"branch",
"dirtyPolicy"
],
"type": "object"
},
"WorktreeDirtyPolicy": {
"enum": [
"fail",
"ignore",
"copyTracked",
"copyAll",
"moveTracked",
"moveAll"
],
"type": "string"
},
"WorktreeInspectSourceParams": {
"description": "Inspect dirty state for the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WorktreeListParams": {
"description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.",
"properties": {
"all": {
"description": "Include managed worktrees from every repository known to this app-server.",
"type": "boolean"
},
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"WorktreePruneParams": {
"description": "Remove stale managed worktree metadata from app-server storage.",
"properties": {
"dryRun": {
"type": "boolean"
}
},
"type": "object"
},
"WorktreeRemoveParams": {
"description": "Remove a managed worktree in the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.",
"type": [
"string",
"null"
]
},
"deleteBranch": {
"type": "boolean"
},
"force": {
"type": "boolean"
},
"nameOrPath": {
"type": "string"
}
},
"required": [
"nameOrPath"
],
"type": "object"
}
},
"description": "Request from the client to the server.",
@@ -5578,6 +5682,126 @@
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/list"
],
"title": "Worktree/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/inspectSource"
],
"title": "Worktree/inspectSourceRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeInspectSourceParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/inspectSourceRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/create"
],
"title": "Worktree/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/remove"
],
"title": "Worktree/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/prune"
],
"title": "Worktree/pruneRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreePruneParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/pruneRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1194,6 +1194,126 @@
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"worktree/list"
],
"title": "Worktree/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/WorktreeListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"worktree/inspectSource"
],
"title": "Worktree/inspectSourceRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/WorktreeInspectSourceParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/inspectSourceRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"worktree/create"
],
"title": "Worktree/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/WorktreeCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"worktree/remove"
],
"title": "Worktree/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/WorktreeRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"worktree/prune"
],
"title": "Worktree/pruneRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/WorktreePruneParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/pruneRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -18653,6 +18773,338 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorktreeCreateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.",
"properties": {
"baseRef": {
"type": [
"string",
"null"
]
},
"branch": {
"type": "string"
},
"cwd": {
"description": "Repository-relative workspace cwd to use as the source checkout.",
"type": [
"string",
"null"
]
},
"dirtyPolicy": {
"$ref": "#/definitions/v2/WorktreeDirtyPolicy"
}
},
"required": [
"branch",
"dirtyPolicy"
],
"title": "WorktreeCreateParams",
"type": "object"
},
"WorktreeCreateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by \\`worktree/create\\`.",
"properties": {
"info": {
"$ref": "#/definitions/v2/WorktreeInfo"
},
"reused": {
"type": "boolean"
},
"warnings": {
"items": {
"$ref": "#/definitions/v2/WorktreeWarning"
},
"type": "array"
}
},
"required": [
"info",
"reused",
"warnings"
],
"title": "WorktreeCreateResponse",
"type": "object"
},
"WorktreeDirtyPolicy": {
"enum": [
"fail",
"ignore",
"copyTracked",
"copyAll",
"moveTracked",
"moveAll"
],
"type": "string"
},
"WorktreeDirtyState": {
"properties": {
"hasStagedChanges": {
"type": "boolean"
},
"hasUnstagedChanges": {
"type": "boolean"
},
"hasUntrackedFiles": {
"type": "boolean"
}
},
"required": [
"hasStagedChanges",
"hasUnstagedChanges",
"hasUntrackedFiles"
],
"type": "object"
},
"WorktreeInfo": {
"description": "Server-native representation of a managed worktree.",
"properties": {
"branch": {
"type": [
"string",
"null"
]
},
"commonGitDir": {
"type": "string"
},
"dirty": {
"$ref": "#/definitions/v2/WorktreeDirtyState"
},
"head": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"location": {
"$ref": "#/definitions/v2/WorktreeLocation"
},
"metadataPath": {
"type": "string"
},
"name": {
"type": "string"
},
"originalRelativeCwd": {
"type": "string"
},
"ownerThreadId": {
"type": [
"string",
"null"
]
},
"repoName": {
"type": "string"
},
"repoRoot": {
"type": "string"
},
"slug": {
"type": "string"
},
"source": {
"$ref": "#/definitions/v2/WorktreeSource"
},
"workspaceCwd": {
"type": "string"
},
"worktreeGitRoot": {
"type": "string"
}
},
"required": [
"commonGitDir",
"dirty",
"id",
"location",
"metadataPath",
"name",
"originalRelativeCwd",
"repoName",
"repoRoot",
"slug",
"source",
"workspaceCwd",
"worktreeGitRoot"
],
"type": "object"
},
"WorktreeInspectSourceParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Inspect dirty state for the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeInspectSourceParams",
"type": "object"
},
"WorktreeInspectSourceResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Dirty-state response returned by \\`worktree/inspectSource\\`.",
"properties": {
"dirty": {
"$ref": "#/definitions/v2/WorktreeDirtyState"
}
},
"required": [
"dirty"
],
"title": "WorktreeInspectSourceResponse",
"type": "object"
},
"WorktreeListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.",
"properties": {
"all": {
"description": "Include managed worktrees from every repository known to this app-server.",
"type": "boolean"
},
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeListParams",
"type": "object"
},
"WorktreeListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Managed worktrees returned by \\`worktree/list\\`.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/WorktreeInfo"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "WorktreeListResponse",
"type": "object"
},
"WorktreeLocation": {
"enum": [
"sibling",
"codexHome",
"external"
],
"type": "string"
},
"WorktreePruneParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove stale managed worktree metadata from app-server storage.",
"properties": {
"dryRun": {
"type": "boolean"
}
},
"title": "WorktreePruneParams",
"type": "object"
},
"WorktreePruneResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by `worktree/prune`.",
"properties": {
"paths": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"paths"
],
"title": "WorktreePruneResponse",
"type": "object"
},
"WorktreeRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove a managed worktree in the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.",
"type": [
"string",
"null"
]
},
"deleteBranch": {
"type": "boolean"
},
"force": {
"type": "boolean"
},
"nameOrPath": {
"type": "string"
}
},
"required": [
"nameOrPath"
],
"title": "WorktreeRemoveParams",
"type": "object"
},
"WorktreeRemoveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by \\`worktree/remove\\`.",
"properties": {
"deletedBranch": {
"type": [
"string",
"null"
]
},
"removedPath": {
"type": "string"
}
},
"required": [
"removedPath"
],
"title": "WorktreeRemoveResponse",
"type": "object"
},
"WorktreeSource": {
"enum": [
"cli",
"app",
"legacy",
"git"
],
"type": "string"
},
"WorktreeWarning": {
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
},
"WriteStatus": {
"enum": [
"ok",

View File

@@ -1953,6 +1953,126 @@
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/list"
],
"title": "Worktree/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/inspectSource"
],
"title": "Worktree/inspectSourceRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeInspectSourceParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/inspectSourceRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/create"
],
"title": "Worktree/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/remove"
],
"title": "Worktree/removeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreeRemoveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"worktree/prune"
],
"title": "Worktree/pruneRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/WorktreePruneParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Worktree/pruneRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -16539,6 +16659,338 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorktreeCreateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.",
"properties": {
"baseRef": {
"type": [
"string",
"null"
]
},
"branch": {
"type": "string"
},
"cwd": {
"description": "Repository-relative workspace cwd to use as the source checkout.",
"type": [
"string",
"null"
]
},
"dirtyPolicy": {
"$ref": "#/definitions/WorktreeDirtyPolicy"
}
},
"required": [
"branch",
"dirtyPolicy"
],
"title": "WorktreeCreateParams",
"type": "object"
},
"WorktreeCreateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by \\`worktree/create\\`.",
"properties": {
"info": {
"$ref": "#/definitions/WorktreeInfo"
},
"reused": {
"type": "boolean"
},
"warnings": {
"items": {
"$ref": "#/definitions/WorktreeWarning"
},
"type": "array"
}
},
"required": [
"info",
"reused",
"warnings"
],
"title": "WorktreeCreateResponse",
"type": "object"
},
"WorktreeDirtyPolicy": {
"enum": [
"fail",
"ignore",
"copyTracked",
"copyAll",
"moveTracked",
"moveAll"
],
"type": "string"
},
"WorktreeDirtyState": {
"properties": {
"hasStagedChanges": {
"type": "boolean"
},
"hasUnstagedChanges": {
"type": "boolean"
},
"hasUntrackedFiles": {
"type": "boolean"
}
},
"required": [
"hasStagedChanges",
"hasUnstagedChanges",
"hasUntrackedFiles"
],
"type": "object"
},
"WorktreeInfo": {
"description": "Server-native representation of a managed worktree.",
"properties": {
"branch": {
"type": [
"string",
"null"
]
},
"commonGitDir": {
"type": "string"
},
"dirty": {
"$ref": "#/definitions/WorktreeDirtyState"
},
"head": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"location": {
"$ref": "#/definitions/WorktreeLocation"
},
"metadataPath": {
"type": "string"
},
"name": {
"type": "string"
},
"originalRelativeCwd": {
"type": "string"
},
"ownerThreadId": {
"type": [
"string",
"null"
]
},
"repoName": {
"type": "string"
},
"repoRoot": {
"type": "string"
},
"slug": {
"type": "string"
},
"source": {
"$ref": "#/definitions/WorktreeSource"
},
"workspaceCwd": {
"type": "string"
},
"worktreeGitRoot": {
"type": "string"
}
},
"required": [
"commonGitDir",
"dirty",
"id",
"location",
"metadataPath",
"name",
"originalRelativeCwd",
"repoName",
"repoRoot",
"slug",
"source",
"workspaceCwd",
"worktreeGitRoot"
],
"type": "object"
},
"WorktreeInspectSourceParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Inspect dirty state for the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeInspectSourceParams",
"type": "object"
},
"WorktreeInspectSourceResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Dirty-state response returned by \\`worktree/inspectSource\\`.",
"properties": {
"dirty": {
"$ref": "#/definitions/WorktreeDirtyState"
}
},
"required": [
"dirty"
],
"title": "WorktreeInspectSourceResponse",
"type": "object"
},
"WorktreeListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.",
"properties": {
"all": {
"description": "Include managed worktrees from every repository known to this app-server.",
"type": "boolean"
},
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeListParams",
"type": "object"
},
"WorktreeListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Managed worktrees returned by \\`worktree/list\\`.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/WorktreeInfo"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "WorktreeListResponse",
"type": "object"
},
"WorktreeLocation": {
"enum": [
"sibling",
"codexHome",
"external"
],
"type": "string"
},
"WorktreePruneParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove stale managed worktree metadata from app-server storage.",
"properties": {
"dryRun": {
"type": "boolean"
}
},
"title": "WorktreePruneParams",
"type": "object"
},
"WorktreePruneResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by `worktree/prune`.",
"properties": {
"paths": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"paths"
],
"title": "WorktreePruneResponse",
"type": "object"
},
"WorktreeRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove a managed worktree in the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.",
"type": [
"string",
"null"
]
},
"deleteBranch": {
"type": "boolean"
},
"force": {
"type": "boolean"
},
"nameOrPath": {
"type": "string"
}
},
"required": [
"nameOrPath"
],
"title": "WorktreeRemoveParams",
"type": "object"
},
"WorktreeRemoveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by \\`worktree/remove\\`.",
"properties": {
"deletedBranch": {
"type": [
"string",
"null"
]
},
"removedPath": {
"type": "string"
}
},
"required": [
"removedPath"
],
"title": "WorktreeRemoveResponse",
"type": "object"
},
"WorktreeSource": {
"enum": [
"cli",
"app",
"legacy",
"git"
],
"type": "string"
},
"WorktreeWarning": {
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
},
"WriteStatus": {
"enum": [
"ok",

View File

@@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorktreeDirtyPolicy": {
"enum": [
"fail",
"ignore",
"copyTracked",
"copyAll",
"moveTracked",
"moveAll"
],
"type": "string"
}
},
"description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.",
"properties": {
"baseRef": {
"type": [
"string",
"null"
]
},
"branch": {
"type": "string"
},
"cwd": {
"description": "Repository-relative workspace cwd to use as the source checkout.",
"type": [
"string",
"null"
]
},
"dirtyPolicy": {
"$ref": "#/definitions/WorktreeDirtyPolicy"
}
},
"required": [
"branch",
"dirtyPolicy"
],
"title": "WorktreeCreateParams",
"type": "object"
}

View File

@@ -0,0 +1,152 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorktreeDirtyState": {
"properties": {
"hasStagedChanges": {
"type": "boolean"
},
"hasUnstagedChanges": {
"type": "boolean"
},
"hasUntrackedFiles": {
"type": "boolean"
}
},
"required": [
"hasStagedChanges",
"hasUnstagedChanges",
"hasUntrackedFiles"
],
"type": "object"
},
"WorktreeInfo": {
"description": "Server-native representation of a managed worktree.",
"properties": {
"branch": {
"type": [
"string",
"null"
]
},
"commonGitDir": {
"type": "string"
},
"dirty": {
"$ref": "#/definitions/WorktreeDirtyState"
},
"head": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"location": {
"$ref": "#/definitions/WorktreeLocation"
},
"metadataPath": {
"type": "string"
},
"name": {
"type": "string"
},
"originalRelativeCwd": {
"type": "string"
},
"ownerThreadId": {
"type": [
"string",
"null"
]
},
"repoName": {
"type": "string"
},
"repoRoot": {
"type": "string"
},
"slug": {
"type": "string"
},
"source": {
"$ref": "#/definitions/WorktreeSource"
},
"workspaceCwd": {
"type": "string"
},
"worktreeGitRoot": {
"type": "string"
}
},
"required": [
"commonGitDir",
"dirty",
"id",
"location",
"metadataPath",
"name",
"originalRelativeCwd",
"repoName",
"repoRoot",
"slug",
"source",
"workspaceCwd",
"worktreeGitRoot"
],
"type": "object"
},
"WorktreeLocation": {
"enum": [
"sibling",
"codexHome",
"external"
],
"type": "string"
},
"WorktreeSource": {
"enum": [
"cli",
"app",
"legacy",
"git"
],
"type": "string"
},
"WorktreeWarning": {
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
}
},
"description": "Result returned by \\`worktree/create\\`.",
"properties": {
"info": {
"$ref": "#/definitions/WorktreeInfo"
},
"reused": {
"type": "boolean"
},
"warnings": {
"items": {
"$ref": "#/definitions/WorktreeWarning"
},
"type": "array"
}
},
"required": [
"info",
"reused",
"warnings"
],
"title": "WorktreeCreateResponse",
"type": "object"
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Inspect dirty state for the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeInspectSourceParams",
"type": "object"
}

View File

@@ -0,0 +1,35 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorktreeDirtyState": {
"properties": {
"hasStagedChanges": {
"type": "boolean"
},
"hasUnstagedChanges": {
"type": "boolean"
},
"hasUntrackedFiles": {
"type": "boolean"
}
},
"required": [
"hasStagedChanges",
"hasUnstagedChanges",
"hasUntrackedFiles"
],
"type": "object"
}
},
"description": "Dirty-state response returned by \\`worktree/inspectSource\\`.",
"properties": {
"dirty": {
"$ref": "#/definitions/WorktreeDirtyState"
}
},
"required": [
"dirty"
],
"title": "WorktreeInspectSourceResponse",
"type": "object"
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.",
"properties": {
"all": {
"description": "Include managed worktrees from every repository known to this app-server.",
"type": "boolean"
},
"cwd": {
"description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.",
"type": [
"string",
"null"
]
}
},
"title": "WorktreeListParams",
"type": "object"
}

View File

@@ -0,0 +1,133 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorktreeDirtyState": {
"properties": {
"hasStagedChanges": {
"type": "boolean"
},
"hasUnstagedChanges": {
"type": "boolean"
},
"hasUntrackedFiles": {
"type": "boolean"
}
},
"required": [
"hasStagedChanges",
"hasUnstagedChanges",
"hasUntrackedFiles"
],
"type": "object"
},
"WorktreeInfo": {
"description": "Server-native representation of a managed worktree.",
"properties": {
"branch": {
"type": [
"string",
"null"
]
},
"commonGitDir": {
"type": "string"
},
"dirty": {
"$ref": "#/definitions/WorktreeDirtyState"
},
"head": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"location": {
"$ref": "#/definitions/WorktreeLocation"
},
"metadataPath": {
"type": "string"
},
"name": {
"type": "string"
},
"originalRelativeCwd": {
"type": "string"
},
"ownerThreadId": {
"type": [
"string",
"null"
]
},
"repoName": {
"type": "string"
},
"repoRoot": {
"type": "string"
},
"slug": {
"type": "string"
},
"source": {
"$ref": "#/definitions/WorktreeSource"
},
"workspaceCwd": {
"type": "string"
},
"worktreeGitRoot": {
"type": "string"
}
},
"required": [
"commonGitDir",
"dirty",
"id",
"location",
"metadataPath",
"name",
"originalRelativeCwd",
"repoName",
"repoRoot",
"slug",
"source",
"workspaceCwd",
"worktreeGitRoot"
],
"type": "object"
},
"WorktreeLocation": {
"enum": [
"sibling",
"codexHome",
"external"
],
"type": "string"
},
"WorktreeSource": {
"enum": [
"cli",
"app",
"legacy",
"git"
],
"type": "string"
}
},
"description": "Managed worktrees returned by \\`worktree/list\\`.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/WorktreeInfo"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "WorktreeListResponse",
"type": "object"
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove stale managed worktree metadata from app-server storage.",
"properties": {
"dryRun": {
"type": "boolean"
}
},
"title": "WorktreePruneParams",
"type": "object"
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by `worktree/prune`.",
"properties": {
"paths": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"paths"
],
"title": "WorktreePruneResponse",
"type": "object"
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove a managed worktree in the repository containing \\`cwd\\`.",
"properties": {
"cwd": {
"description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.",
"type": [
"string",
"null"
]
},
"deleteBranch": {
"type": "boolean"
},
"force": {
"type": "boolean"
},
"nameOrPath": {
"type": "string"
}
},
"required": [
"nameOrPath"
],
"title": "WorktreeRemoveParams",
"type": "object"
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Result returned by \\`worktree/remove\\`.",
"properties": {
"deletedBranch": {
"type": [
"string",
"null"
]
},
"removedPath": {
"type": "string"
}
},
"required": [
"removedPath"
],
"title": "WorktreeRemoveResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorktreeDirtyPolicy } from "./WorktreeDirtyPolicy";
/**
* Create or reuse a managed worktree from the repository containing \`cwd\`.
*/
export type WorktreeCreateParams = {
/**
* Repository-relative workspace cwd to use as the source checkout.
*/
cwd?: string | null, branch: string, baseRef?: string | null, dirtyPolicy: WorktreeDirtyPolicy, };

View File

@@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorktreeInfo } from "./WorktreeInfo";
import type { WorktreeWarning } from "./WorktreeWarning";
/**
* Result returned by \`worktree/create\`.
*/
export type WorktreeCreateResponse = { reused: boolean, info: WorktreeInfo, warnings: Array<WorktreeWarning>, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorktreeDirtyPolicy = "fail" | "ignore" | "copyTracked" | "copyAll" | "moveTracked" | "moveAll";

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorktreeDirtyState = { hasStagedChanges: boolean, hasUnstagedChanges: boolean, hasUntrackedFiles: boolean, };

View File

@@ -0,0 +1,11 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorktreeDirtyState } from "./WorktreeDirtyState";
import type { WorktreeLocation } from "./WorktreeLocation";
import type { WorktreeSource } from "./WorktreeSource";
/**
* Server-native representation of a managed worktree.
*/
export type WorktreeInfo = { id: string, name: string, slug: string, source: WorktreeSource, location: WorktreeLocation, repoName: string, repoRoot: string, commonGitDir: string, worktreeGitRoot: string, workspaceCwd: string, originalRelativeCwd: string, branch: string | null, head: string | null, ownerThreadId: string | null, metadataPath: string, dirty: WorktreeDirtyState, };

View File

@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Inspect dirty state for the repository containing \`cwd\`.
*/
export type WorktreeInspectSourceParams = {
/**
* Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.
*/
cwd?: string | null, };

View File

@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorktreeDirtyState } from "./WorktreeDirtyState";
/**
* Dirty-state response returned by \`worktree/inspectSource\`.
*/
export type WorktreeInspectSourceResponse = { dirty: WorktreeDirtyState, };

View File

@@ -0,0 +1,16 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Request the managed worktrees associated with the repository containing \`cwd\`.
*/
export type WorktreeListParams = {
/**
* Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.
*/
cwd?: string | null,
/**
* Include managed worktrees from every repository known to this app-server.
*/
all?: boolean, };

View File

@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorktreeInfo } from "./WorktreeInfo";
/**
* Managed worktrees returned by \`worktree/list\`.
*/
export type WorktreeListResponse = { data: Array<WorktreeInfo>, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorktreeLocation = "sibling" | "codexHome" | "external";

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Remove stale managed worktree metadata from app-server storage.
*/
export type WorktreePruneParams = { dryRun?: boolean, };

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Result returned by `worktree/prune`.
*/
export type WorktreePruneResponse = { paths: Array<string>, };

View File

@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Remove a managed worktree in the repository containing \`cwd\`.
*/
export type WorktreeRemoveParams = {
/**
* Repository-relative workspace cwd to use when resolving \`name_or_path\`.
*/
cwd?: string | null, nameOrPath: string, force?: boolean, deleteBranch?: boolean, };

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Result returned by \`worktree/remove\`.
*/
export type WorktreeRemoveResponse = { removedPath: string, deletedBranch: string | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorktreeSource = "cli" | "app" | "legacy" | "git";

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorktreeWarning = { message: string, };

View File

@@ -452,4 +452,20 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
export type { WorktreeCreateParams } from "./WorktreeCreateParams";
export type { WorktreeCreateResponse } from "./WorktreeCreateResponse";
export type { WorktreeDirtyPolicy } from "./WorktreeDirtyPolicy";
export type { WorktreeDirtyState } from "./WorktreeDirtyState";
export type { WorktreeInfo } from "./WorktreeInfo";
export type { WorktreeInspectSourceParams } from "./WorktreeInspectSourceParams";
export type { WorktreeInspectSourceResponse } from "./WorktreeInspectSourceResponse";
export type { WorktreeListParams } from "./WorktreeListParams";
export type { WorktreeListResponse } from "./WorktreeListResponse";
export type { WorktreeLocation } from "./WorktreeLocation";
export type { WorktreePruneParams } from "./WorktreePruneParams";
export type { WorktreePruneResponse } from "./WorktreePruneResponse";
export type { WorktreeRemoveParams } from "./WorktreeRemoveParams";
export type { WorktreeRemoveResponse } from "./WorktreeRemoveResponse";
export type { WorktreeSource } from "./WorktreeSource";
export type { WorktreeWarning } from "./WorktreeWarning";
export type { WriteStatus } from "./WriteStatus";

View File

@@ -710,6 +710,31 @@ client_request_definitions! {
serialization: fs_watch_id(params.watch_id),
response: v2::FsUnwatchResponse,
},
WorktreeList => "worktree/list" {
params: v2::WorktreeListParams,
serialization: None,
response: v2::WorktreeListResponse,
},
WorktreeInspectSource => "worktree/inspectSource" {
params: v2::WorktreeInspectSourceParams,
serialization: None,
response: v2::WorktreeInspectSourceResponse,
},
WorktreeCreate => "worktree/create" {
params: v2::WorktreeCreateParams,
serialization: global("worktree"),
response: v2::WorktreeCreateResponse,
},
WorktreeRemove => "worktree/remove" {
params: v2::WorktreeRemoveParams,
serialization: global("worktree"),
response: v2::WorktreeRemoveResponse,
},
WorktreePrune => "worktree/prune" {
params: v2::WorktreePruneParams,
serialization: global("worktree"),
response: v2::WorktreePruneResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
serialization: global("config"),

View File

@@ -23,6 +23,7 @@ mod thread;
mod thread_data;
mod turn;
mod windows_sandbox;
mod worktree;
pub use account::*;
pub use apps::*;
@@ -48,6 +49,7 @@ pub use thread::*;
pub use thread_data::*;
pub use turn::*;
pub use windows_sandbox::*;
pub use worktree::*;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,178 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Request the managed worktrees associated with the repository containing \`cwd\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeListParams {
/// Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Include managed worktrees from every repository known to this app-server.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub all: bool,
}
/// Managed worktrees returned by \`worktree/list\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeListResponse {
pub data: Vec<WorktreeInfo>,
}
/// Inspect dirty state for the repository containing \`cwd\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeInspectSourceParams {
/// Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.
#[ts(optional = nullable)]
pub cwd: Option<String>,
}
/// Dirty-state response returned by \`worktree/inspectSource\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeInspectSourceResponse {
pub dirty: WorktreeDirtyState,
}
/// Create or reuse a managed worktree from the repository containing \`cwd\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeCreateParams {
/// Repository-relative workspace cwd to use as the source checkout.
#[ts(optional = nullable)]
pub cwd: Option<String>,
pub branch: String,
#[ts(optional = nullable)]
pub base_ref: Option<String>,
pub dirty_policy: WorktreeDirtyPolicy,
}
/// Result returned by \`worktree/create\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeCreateResponse {
pub reused: bool,
pub info: WorktreeInfo,
pub warnings: Vec<WorktreeWarning>,
}
/// Remove a managed worktree in the repository containing \`cwd\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeRemoveParams {
/// Repository-relative workspace cwd to use when resolving \`name_or_path\`.
#[ts(optional = nullable)]
pub cwd: Option<String>,
pub name_or_path: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub delete_branch: bool,
}
/// Result returned by \`worktree/remove\`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeRemoveResponse {
pub removed_path: String,
pub deleted_branch: Option<String>,
}
/// Remove stale managed worktree metadata from app-server storage.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreePruneParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dry_run: bool,
}
/// Result returned by `worktree/prune`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreePruneResponse {
pub paths: Vec<String>,
}
/// Server-native representation of a managed worktree.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
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: String,
pub common_git_dir: String,
pub worktree_git_root: String,
pub workspace_cwd: String,
pub original_relative_cwd: String,
pub branch: Option<String>,
pub head: Option<String>,
pub owner_thread_id: Option<String>,
pub metadata_path: String,
pub dirty: WorktreeDirtyState,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum WorktreeSource {
Cli,
App,
Legacy,
Git,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum WorktreeLocation {
Sibling,
CodexHome,
External,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum WorktreeDirtyPolicy {
Fail,
Ignore,
CopyTracked,
CopyAll,
MoveTracked,
MoveAll,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeDirtyState {
pub has_staged_changes: bool,
pub has_unstaged_changes: bool,
pub has_untracked_files: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorktreeWarning {
pub message: String,
}

View File

@@ -67,6 +67,7 @@ codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-worktree = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
futures = { workspace = true }

View File

@@ -1150,6 +1150,14 @@ All filesystem paths in this section must be absolute.
{ "id": 45, "result": {} }
```
### Worktrees
- `worktree/list` - list Codex-managed worktrees for the repository containing `cwd`; omitted `cwd` uses the app server's effective cwd, and `all` returns worktrees from every repository.
- `worktree/inspectSource` - inspect staged, unstaged, and untracked dirty state for the repository containing `cwd`.
- `worktree/create` - create or reuse a managed worktree from `cwd`, returning the resolved worktree plus any transfer warnings.
- `worktree/remove` - remove a managed worktree resolved from `cwd` and `nameOrPath`, with optional `force` and `deleteBranch` behavior.
- `worktree/prune` - remove stale managed-worktree storage entries, or report them without mutation when `dryRun` is set.
## Events
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications.

View File

@@ -32,6 +32,7 @@ use crate::request_processors::ThreadGoalRequestProcessor;
use crate::request_processors::ThreadRequestProcessor;
use crate::request_processors::TurnRequestProcessor;
use crate::request_processors::WindowsSandboxRequestProcessor;
use crate::request_processors::WorktreeRequestProcessor;
use crate::request_serialization::QueuedInitializedRequest;
use crate::request_serialization::RequestSerializationQueueKey;
use crate::request_serialization::RequestSerializationQueues;
@@ -175,6 +176,7 @@ pub(crate) struct MessageProcessor {
thread_processor: ThreadRequestProcessor,
turn_processor: TurnRequestProcessor,
windows_sandbox_processor: WindowsSandboxRequestProcessor,
worktree_processor: WorktreeRequestProcessor,
request_serialization_queues: RequestSerializationQueues,
}
@@ -456,8 +458,9 @@ impl MessageProcessor {
let windows_sandbox_processor = WindowsSandboxRequestProcessor::new(
outgoing.clone(),
Arc::clone(&config),
config_manager,
config_manager.clone(),
);
let worktree_processor = WorktreeRequestProcessor::new(config_manager);
Self {
outgoing,
@@ -481,6 +484,7 @@ impl MessageProcessor {
thread_processor,
turn_processor,
windows_sandbox_processor,
worktree_processor,
request_serialization_queues: RequestSerializationQueues::default(),
}
}
@@ -937,6 +941,31 @@ impl MessageProcessor {
.unwatch(connection_id, params)
.await
.map(|response| Some(response.into())),
ClientRequest::WorktreeList { params, .. } => self
.worktree_processor
.list(params)
.await
.map(|response| Some(response.into())),
ClientRequest::WorktreeInspectSource { params, .. } => self
.worktree_processor
.inspect_source(params)
.await
.map(|response| Some(response.into())),
ClientRequest::WorktreeCreate { params, .. } => self
.worktree_processor
.create(params)
.await
.map(|response| Some(response.into())),
ClientRequest::WorktreeRemove { params, .. } => self
.worktree_processor
.remove(params)
.await
.map(|response| Some(response.into())),
ClientRequest::WorktreePrune { params, .. } => self
.worktree_processor
.prune(params)
.await
.map(|response| Some(response.into())),
ClientRequest::ModelProviderCapabilitiesRead { params: _, .. } => self
.config_processor
.model_provider_capabilities_read()

View File

@@ -241,6 +241,22 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorktreeCreateParams;
use codex_app_server_protocol::WorktreeCreateResponse;
use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy;
use codex_app_server_protocol::WorktreeDirtyState as ApiWorktreeDirtyState;
use codex_app_server_protocol::WorktreeInfo as ApiWorktreeInfo;
use codex_app_server_protocol::WorktreeInspectSourceParams;
use codex_app_server_protocol::WorktreeInspectSourceResponse;
use codex_app_server_protocol::WorktreeListParams;
use codex_app_server_protocol::WorktreeListResponse;
use codex_app_server_protocol::WorktreeLocation as ApiWorktreeLocation;
use codex_app_server_protocol::WorktreePruneParams;
use codex_app_server_protocol::WorktreePruneResponse;
use codex_app_server_protocol::WorktreeRemoveParams;
use codex_app_server_protocol::WorktreeRemoveResponse;
use codex_app_server_protocol::WorktreeSource as ApiWorktreeSource;
use codex_app_server_protocol::WorktreeWarning as ApiWorktreeWarning;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
@@ -446,6 +462,7 @@ mod thread_processor;
mod token_usage_replay;
mod turn_processor;
mod windows_sandbox_processor;
mod worktree_processor;
pub(crate) use account_processor::AccountRequestProcessor;
pub(crate) use apps_processor::AppsRequestProcessor;
@@ -467,6 +484,7 @@ pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
pub(crate) use turn_processor::TurnRequestProcessor;
pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor;
pub(crate) use worktree_processor::WorktreeRequestProcessor;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;

View File

@@ -1044,6 +1044,7 @@ impl ThreadRequestProcessor {
.collect()
};
let core_dynamic_tool_count = core_dynamic_tools.len();
let codex_home = config.codex_home.to_path_buf();
let NewThread {
thread_id,
@@ -1147,6 +1148,11 @@ impl ThreadRequestProcessor {
);
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
bind_worktree_thread_best_effort(
codex_home.as_path(),
config_snapshot.cwd.as_path(),
&thread.id,
);
let response = ThreadStartResponse {
thread: thread.clone(),
@@ -2480,6 +2486,11 @@ impl ThreadRequestProcessor {
let active_permission_profile = thread_response_active_permission_profile(
config_snapshot.active_permission_profile,
);
bind_worktree_thread_best_effort(
self.config.codex_home.as_path(),
session_configured.cwd.as_path(),
&thread.id,
);
let response = ThreadResumeResponse {
thread,
@@ -3127,6 +3138,11 @@ impl ThreadRequestProcessor {
);
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
bind_worktree_thread_best_effort(
self.config.codex_home.as_path(),
session_configured.cwd.as_path(),
&thread.id,
);
let response = ThreadForkResponse {
thread: thread.clone(),
@@ -3325,6 +3341,24 @@ impl ThreadRequestProcessor {
}
}
fn bind_worktree_thread_best_effort(
codex_home: &std::path::Path,
cwd: &std::path::Path,
thread_id: &str,
) {
match codex_worktree::resolve_worktree(codex_home, 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 xcode_26_4_mcp_elicitations_auto_deny(
client_name: Option<&str>,
client_version: Option<&str>,

View File

@@ -0,0 +1,222 @@
use super::*;
use std::path::PathBuf;
use codex_worktree::DirtyPolicy;
use codex_worktree::DirtyState;
use codex_worktree::WorktreeInfo;
use codex_worktree::WorktreeListQuery;
use codex_worktree::WorktreeLocation;
use codex_worktree::WorktreeRemoveRequest;
use codex_worktree::WorktreeRequest;
use codex_worktree::WorktreeSource;
use codex_worktree::WorktreeWarning;
#[derive(Clone)]
pub(crate) struct WorktreeRequestProcessor {
config_manager: ConfigManager,
}
impl WorktreeRequestProcessor {
pub(crate) fn new(config_manager: ConfigManager) -> Self {
Self { config_manager }
}
pub(crate) async fn list(
&self,
params: WorktreeListParams,
) -> Result<WorktreeListResponse, JSONRPCErrorError> {
let source_cwd = if params.all {
None
} else {
Some(self.resolve_cwd(params.cwd).await?)
};
let data = codex_worktree::list_worktrees(WorktreeListQuery {
codex_home: self.config_manager.codex_home().to_path_buf(),
source_cwd,
include_all_repos: params.all,
})
.map_err(map_worktree_error)?
.into_iter()
.map(api_worktree_info)
.collect();
Ok(WorktreeListResponse { data })
}
pub(crate) async fn inspect_source(
&self,
params: WorktreeInspectSourceParams,
) -> Result<WorktreeInspectSourceResponse, JSONRPCErrorError> {
let cwd = self.resolve_cwd(params.cwd).await?;
let dirty = api_dirty_state(codex_worktree::dirty_state(&cwd).map_err(map_worktree_error)?);
Ok(WorktreeInspectSourceResponse { dirty })
}
pub(crate) async fn create(
&self,
params: WorktreeCreateParams,
) -> Result<WorktreeCreateResponse, JSONRPCErrorError> {
let cwd = self.resolve_cwd(params.cwd).await?;
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: self.config_manager.codex_home().to_path_buf(),
source_cwd: cwd,
branch: params.branch,
base_ref: params.base_ref,
dirty_policy: dirty_policy_from_api(params.dirty_policy),
})
.map_err(map_worktree_error)?;
Ok(WorktreeCreateResponse {
reused: resolution.reused,
info: api_worktree_info(resolution.info),
warnings: resolution
.warnings
.into_iter()
.map(api_worktree_warning)
.collect(),
})
}
pub(crate) async fn remove(
&self,
params: WorktreeRemoveParams,
) -> Result<WorktreeRemoveResponse, JSONRPCErrorError> {
let cwd = self.resolve_cwd(params.cwd).await?;
let result = codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home: self.config_manager.codex_home().to_path_buf(),
source_cwd: Some(cwd),
name_or_path: params.name_or_path,
force: params.force,
delete_branch: params.delete_branch,
})
.map_err(map_worktree_error)?;
Ok(WorktreeRemoveResponse {
removed_path: result.removed_path.to_string_lossy().to_string(),
deleted_branch: result.deleted_branch,
})
}
pub(crate) async fn prune(
&self,
params: WorktreePruneParams,
) -> Result<WorktreePruneResponse, JSONRPCErrorError> {
let stale_paths = codex_worktree::prune_stale_managed_worktree_dirs(
self.config_manager.codex_home(),
params.dry_run,
)
.map_err(map_worktree_error)?;
Ok(WorktreePruneResponse {
paths: stale_paths
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect(),
})
}
async fn resolve_cwd(&self, cwd: Option<String>) -> Result<PathBuf, JSONRPCErrorError> {
match cwd {
Some(cwd) => Ok(PathBuf::from(cwd)),
None => self
.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map(|config| config.cwd.to_path_buf())
.map_err(|err| internal_error(format!("failed to load worktree cwd: {err}"))),
}
}
}
fn map_worktree_error(err: anyhow::Error) -> JSONRPCErrorError {
invalid_request(err.to_string())
}
fn dirty_policy_from_api(value: ApiWorktreeDirtyPolicy) -> DirtyPolicy {
match value {
ApiWorktreeDirtyPolicy::Fail => DirtyPolicy::Fail,
ApiWorktreeDirtyPolicy::Ignore => DirtyPolicy::Ignore,
ApiWorktreeDirtyPolicy::CopyTracked => DirtyPolicy::CopyTracked,
ApiWorktreeDirtyPolicy::CopyAll => DirtyPolicy::CopyAll,
ApiWorktreeDirtyPolicy::MoveTracked => DirtyPolicy::MoveTracked,
ApiWorktreeDirtyPolicy::MoveAll => DirtyPolicy::MoveAll,
}
}
fn api_dirty_state(value: DirtyState) -> ApiWorktreeDirtyState {
ApiWorktreeDirtyState {
has_staged_changes: value.has_staged_changes,
has_unstaged_changes: value.has_unstaged_changes,
has_untracked_files: value.has_untracked_files,
}
}
fn api_worktree_info(value: WorktreeInfo) -> ApiWorktreeInfo {
ApiWorktreeInfo {
id: value.id,
name: value.name,
slug: value.slug,
source: api_worktree_source(value.source),
location: api_worktree_location(value.location),
repo_name: value.repo_name,
repo_root: value.repo_root.to_string_lossy().to_string(),
common_git_dir: value.common_git_dir.to_string_lossy().to_string(),
worktree_git_root: value.worktree_git_root.to_string_lossy().to_string(),
workspace_cwd: value.workspace_cwd.to_string_lossy().to_string(),
original_relative_cwd: value.original_relative_cwd.to_string_lossy().to_string(),
branch: value.branch,
head: value.head,
owner_thread_id: value.owner_thread_id,
metadata_path: value.metadata_path.to_string_lossy().to_string(),
dirty: api_dirty_state(value.dirty),
}
}
fn api_worktree_source(value: WorktreeSource) -> ApiWorktreeSource {
match value {
WorktreeSource::Cli => ApiWorktreeSource::Cli,
WorktreeSource::App => ApiWorktreeSource::App,
WorktreeSource::Legacy => ApiWorktreeSource::Legacy,
WorktreeSource::Git => ApiWorktreeSource::Git,
}
}
fn api_worktree_location(value: WorktreeLocation) -> ApiWorktreeLocation {
match value {
WorktreeLocation::Sibling => ApiWorktreeLocation::Sibling,
WorktreeLocation::CodexHome => ApiWorktreeLocation::CodexHome,
WorktreeLocation::External => ApiWorktreeLocation::External,
}
}
fn api_worktree_warning(value: WorktreeWarning) -> ApiWorktreeWarning {
ApiWorktreeWarning {
message: value.message,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn dirty_policy_conversion_preserves_every_variant() {
assert_eq!(
[
ApiWorktreeDirtyPolicy::Fail,
ApiWorktreeDirtyPolicy::Ignore,
ApiWorktreeDirtyPolicy::CopyTracked,
ApiWorktreeDirtyPolicy::CopyAll,
ApiWorktreeDirtyPolicy::MoveTracked,
ApiWorktreeDirtyPolicy::MoveAll,
]
.map(dirty_policy_from_api),
[
DirtyPolicy::Fail,
DirtyPolicy::Ignore,
DirtyPolicy::CopyTracked,
DirtyPolicy::CopyAll,
DirtyPolicy::MoveTracked,
DirtyPolicy::MoveAll,
]
);
}
}

View File

@@ -21,6 +21,7 @@ anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true }
codex-app-server = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-test-client = { workspace = true }
codex-arg0 = { workspace = true }
@@ -34,6 +35,7 @@ codex-core-plugins = { workspace = true }
codex-exec = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }
codex-feedback = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-memories-write = { workspace = true }
@@ -49,6 +51,7 @@ codex-state = { workspace = true }
codex-stdio-to-uds = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-tui = { workspace = true }
codex-worktree = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path = { workspace = true }
libc = { workspace = true }

View File

@@ -3,6 +3,29 @@ use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
use clap_complete::generate;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::EnvironmentManager;
use codex_app_server_client::EnvironmentManagerArgs;
use codex_app_server_client::ExecServerRuntimePaths;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::WorktreeCreateParams;
use codex_app_server_protocol::WorktreeCreateResponse;
use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy;
use codex_app_server_protocol::WorktreeDirtyState;
use codex_app_server_protocol::WorktreeInfo;
use codex_app_server_protocol::WorktreeListParams;
use codex_app_server_protocol::WorktreeListResponse;
use codex_app_server_protocol::WorktreePruneParams;
use codex_app_server_protocol::WorktreePruneResponse;
use codex_app_server_protocol::WorktreeRemoveParams;
use codex_app_server_protocol::WorktreeRemoveResponse;
use codex_app_server_protocol::WorktreeSource;
use codex_arg0::Arg0DispatchPaths;
use codex_arg0::arg0_dispatch_or_else;
use codex_chatgpt::apply_command::ApplyCommand;
@@ -34,9 +57,12 @@ 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 owo_colors::OwoColorize;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use supports_color::Stream;
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -160,6 +186,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 +359,87 @@ 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 {
/// Create or reuse a Codex-managed worktree for the current repository.
Create(WorktreeCreateCommand),
/// 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 WorktreeCreateCommand {
/// Branch name for the managed worktree.
branch: String,
/// Base ref for a newly created managed worktree.
#[arg(long = "base", value_name = "REF")]
base_ref: Option<String>,
/// How to handle uncommitted source checkout changes when creating the worktree.
#[arg(long = "dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)]
dirty: WorktreeDirtyCliArg,
}
#[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 +776,326 @@ async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Res
}
}
async fn resolve_worktree_options_for_shared_cli(
shared: &mut SharedCliOptions,
remote: Option<&str>,
remote_auth_token_env: Option<&str>,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<()> {
let Some(branch) = shared.worktree.take() else {
return Ok(());
};
if remote.is_some() || remote_auth_token_env.is_some() {
anyhow::bail!("--worktree is not supported with remote app-server sessions yet");
}
let source_cwd = shared.cwd.clone().unwrap_or(std::env::current_dir()?);
let client = worktree_app_server_client(
/*remote*/ None, /*remote_auth_token_env*/ None, arg0_paths,
)
.await?;
let response: WorktreeCreateResponse = client
.request_typed(ClientRequest::WorktreeCreate {
request_id: RequestId::Integer(1),
params: WorktreeCreateParams {
cwd: Some(source_cwd.to_string_lossy().to_string()),
branch,
base_ref: shared.worktree_base.take(),
dirty_policy: api_dirty_policy_from_cli(shared.worktree_dirty),
},
})
.await?;
shared.cwd = Some(PathBuf::from(response.info.workspace_cwd));
#[allow(clippy::print_stderr)]
for warning in &response.warnings {
eprintln!("warning: {}", warning.message);
}
Ok(())
}
async fn run_worktree_command(
cli: WorktreeCli,
remote: Option<String>,
remote_auth_token_env: Option<String>,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<()> {
let is_remote = remote.is_some();
let client = worktree_app_server_client(remote, remote_auth_token_env, arg0_paths).await?;
let cwd = (!is_remote)
.then(std::env::current_dir)
.transpose()?
.map(|cwd| cwd.to_string_lossy().to_string());
let mut next_request_id = 1_i64;
match cli.subcommand {
WorktreeSubcommand::Create(command) => {
let resolution: WorktreeCreateResponse = client
.request_typed(ClientRequest::WorktreeCreate {
request_id: next_cli_request_id(&mut next_request_id),
params: WorktreeCreateParams {
cwd: cwd.clone(),
branch: command.branch,
base_ref: command.base_ref,
dirty_policy: api_dirty_policy_from_cli(command.dirty),
},
})
.await?;
for warning in &resolution.warnings {
eprintln!("warning: {}", warning.message);
}
println!("{}", resolution.info.workspace_cwd);
}
WorktreeSubcommand::List(command) => {
let entries =
cli_list_worktrees(&client, cwd.clone(), command.all, &mut next_request_id).await?;
print_worktree_list(entries, command.json)?;
}
WorktreeSubcommand::Path(command) => {
let entries = cli_list_worktrees(
&client,
cwd.clone(),
/*all*/ false,
&mut next_request_id,
)
.await?;
let entry = find_named_worktree(entries, &command.name)?;
println!("{}", entry.workspace_cwd);
}
WorktreeSubcommand::Remove(command) => {
let result: WorktreeRemoveResponse = client
.request_typed(ClientRequest::WorktreeRemove {
request_id: next_cli_request_id(&mut next_request_id),
params: WorktreeRemoveParams {
cwd,
name_or_path: command.name_or_path,
force: command.force,
delete_branch: command.delete_branch,
},
})
.await?;
println!("removed {}", result.removed_path);
if let Some(branch) = result.deleted_branch {
println!("deleted branch {branch}");
}
}
WorktreeSubcommand::Prune(command) => {
let response: WorktreePruneResponse = client
.request_typed(ClientRequest::WorktreePrune {
request_id: next_cli_request_id(&mut next_request_id),
params: WorktreePruneParams {
dry_run: command.dry_run || command.json,
},
})
.await?;
let stale_paths = response.paths;
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}");
} else {
println!("removed {path}");
}
}
}
}
}
Ok(())
}
async fn cli_list_worktrees(
client: &AppServerClient,
cwd: Option<String>,
all: bool,
next_request_id: &mut i64,
) -> anyhow::Result<Vec<WorktreeInfo>> {
let response: WorktreeListResponse = client
.request_typed(ClientRequest::WorktreeList {
request_id: next_cli_request_id(next_request_id),
params: WorktreeListParams { cwd, all },
})
.await?;
Ok(response.data)
}
fn next_cli_request_id(next: &mut i64) -> RequestId {
let request_id = RequestId::Integer(*next);
*next += 1;
request_id
}
fn api_dirty_policy_from_cli(value: WorktreeDirtyCliArg) -> ApiWorktreeDirtyPolicy {
match value {
WorktreeDirtyCliArg::Fail => ApiWorktreeDirtyPolicy::Fail,
WorktreeDirtyCliArg::Ignore => ApiWorktreeDirtyPolicy::Ignore,
WorktreeDirtyCliArg::CopyTracked => ApiWorktreeDirtyPolicy::CopyTracked,
WorktreeDirtyCliArg::CopyAll => ApiWorktreeDirtyPolicy::CopyAll,
WorktreeDirtyCliArg::MoveTracked => ApiWorktreeDirtyPolicy::MoveTracked,
WorktreeDirtyCliArg::MoveAll => ApiWorktreeDirtyPolicy::MoveAll,
}
}
async fn worktree_app_server_client(
remote: Option<String>,
remote_auth_token_env: Option<String>,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<AppServerClient> {
if let Some(remote) = remote {
let websocket_url =
codex_tui::normalize_remote_addr(&remote).map_err(|err| anyhow::anyhow!("{err}"))?;
let auth_token = remote_auth_token_env
.map(|name| std::env::var(&name))
.transpose()?;
return Ok(AppServerClient::Remote(
RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
auth_token,
client_name: "codex-cli-worktree".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?,
));
}
let config = Config::load_with_cli_overrides(Vec::new()).await?;
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
let config_warnings = config
.startup_warnings
.iter()
.map(
|warning| codex_app_server_protocol::ConfigWarningNotification {
summary: warning.clone(),
details: None,
path: None,
range: None,
},
)
.collect();
Ok(AppServerClient::InProcess(
InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths,
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: codex_config::LoaderOverrides::default(),
cloud_requirements: codex_config::CloudRequirementsLoader::default(),
feedback: codex_feedback::CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await,
),
config_warnings,
session_source: codex_protocol::protocol::SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-cli-worktree".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?,
))
}
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 dirty_state_is_dirty(&entry.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.clone(),
]);
}
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 {
WorktreeSource::Cli => "cli",
WorktreeSource::App => "app",
WorktreeSource::Legacy => "legacy",
WorktreeSource::Git => "git",
}
}
fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool {
dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files
}
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"),
}
}
#[derive(Debug, Default, Parser, Clone)]
struct FeatureToggles {
/// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
@@ -792,6 +1222,13 @@ 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,
arg0_paths.clone(),
)
.await?;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
@@ -979,6 +1416,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Worktree(worktree_cli)) => {
run_worktree_command(
worktree_cli,
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
}
Some(Subcommand::Login(mut login_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
@@ -2045,6 +2491,85 @@ 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 top_level_worktree_flags_parse_move_all_dirty_policy() {
let cli = MultitoolCli::try_parse_from([
"codex",
"--worktree",
"parser-fix",
"--worktree-dirty",
"move-all",
])
.expect("worktree flags should parse");
assert_eq!(cli.interactive.worktree_dirty, WorktreeDirtyCliArg::MoveAll);
}
#[test]
fn worktree_create_subcommand_parses() {
let cli = MultitoolCli::try_parse_from([
"codex",
"worktree",
"create",
"parser-fix",
"--base",
"origin/main",
"--dirty",
"move-tracked",
])
.expect("worktree create should parse");
let Some(Subcommand::Worktree(WorktreeCli {
subcommand: WorktreeSubcommand::Create(command),
})) = cli.subcommand
else {
panic!("expected worktree create subcommand");
};
assert_eq!(command.branch, "parser-fix");
assert_eq!(command.base_ref.as_deref(), Some("origin/main"));
assert_eq!(command.dirty, WorktreeDirtyCliArg::MoveTracked);
}
#[test]
fn worktree_subcommand_parses() {
let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"])
.expect("worktree list should parse");
let Some(Subcommand::Worktree(WorktreeCli {
subcommand: WorktreeSubcommand::List(command),
})) = cli.subcommand
else {
panic!("expected worktree list subcommand");
};
assert!(command.all);
assert!(command.json);
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,

View File

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

View File

@@ -55,6 +55,7 @@ codex-shell-command = { workspace = true }
codex-state = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-utils-approval-presets = { workspace = true }
codex-worktree = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-elapsed = { workspace = true }

View File

@@ -201,6 +201,7 @@ mod thread_events;
mod thread_goal_actions;
mod thread_routing;
mod thread_session_state;
mod worktree;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
@@ -846,6 +847,9 @@ impl App {
if let Some(message) = external_agent_config_migration_message {
chat_widget.add_info_message(message, /*hint*/ None);
}
if let Some(message) = managed_worktree_startup_message(&config) {
chat_widget.add_info_message(message, /*hint*/ None);
}
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);

View File

@@ -187,6 +187,84 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreePicker => {
self.open_worktree_picker(tui, app_server).await;
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreeCreatePrompt => {
self.open_worktree_create_prompt();
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreeBaseRefPrompt { branch } => {
self.open_worktree_base_ref_prompt(branch);
tui.frame_requester().schedule_frame();
}
AppEvent::CreateWorktreeAndSwitch {
branch,
base_ref,
dirty_policy,
} => {
self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy)
.await;
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, app_server, target)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktreeInfo { info } => {
self.begin_switch_to_worktree_info(tui, info);
tui.frame_requester().schedule_frame();
}
AppEvent::CurrentWorktreeSelected { target } => {
self.current_worktree_selected(target);
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktreeAfterLoading { info } => {
self.switch_to_worktree_info_after_loading(tui, app_server, info)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreeSessionReady {
info,
config,
forked,
warnings,
result,
} => {
self.on_worktree_session_ready(
tui,
app_server,
super::worktree::WorktreeSessionReadyArgs {
info,
config,
forked,
warnings,
result,
},
)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::ShowWorktreePath { target } => {
self.show_worktree_path(app_server, target).await;
tui.frame_requester().schedule_frame();
}
AppEvent::RemoveWorktree {
target,
force,
delete_branch,
confirmed,
} => {
self.remove_worktree(app_server, target, force, delete_branch, confirmed)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
}

View File

@@ -77,6 +77,12 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &
)));
}
pub(super) fn managed_worktree_startup_message(config: &Config) -> Option<String> {
let label =
crate::worktree_labels::label_for_cwd(config.codex_home.as_path(), config.cwd.as_path())?;
Some(format!("Workspace: {}", label.summary()))
}
pub(super) fn hooks_needing_review_warning(count: usize) -> Option<String> {
match count {
0 => None,

View File

@@ -497,6 +497,10 @@ impl App {
op: &AppCommand,
) -> Result<bool> {
match op {
AppCommand::AddToHistory { text } => {
self.append_message_history_entry(thread_id, text.to_string());
Ok(true)
}
AppCommand::Interrupt => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server.turn_interrupt(thread_id, turn_id).await?;

View File

@@ -0,0 +1,876 @@
//! App-layer handlers for the worktree TUI flow.
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::WorktreeCreateParams;
use codex_app_server_protocol::WorktreeCreateResponse;
use codex_app_server_protocol::WorktreeDirtyPolicy;
use codex_app_server_protocol::WorktreeDirtyState;
use codex_app_server_protocol::WorktreeInfo;
use codex_app_server_protocol::WorktreeInspectSourceParams;
use codex_app_server_protocol::WorktreeInspectSourceResponse;
use codex_app_server_protocol::WorktreeListParams;
use codex_app_server_protocol::WorktreeListResponse;
use codex_app_server_protocol::WorktreeRemoveParams;
use codex_app_server_protocol::WorktreeRemoveResponse;
use codex_app_server_protocol::WorktreeSource;
use codex_protocol::ThreadId;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
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,
}
pub(super) struct WorktreeSessionReadyArgs {
pub(super) info: WorktreeInfo,
pub(super) config: Config,
pub(super) forked: bool,
pub(super) warnings: Vec<String>,
pub(super) result: Result<AppServerStartedThread, String>,
}
impl WorktreeSessionTransition {
fn message_prefix(self) -> &'static str {
match self {
WorktreeSessionTransition::Forked => "Forked into",
WorktreeSessionTransition::Started => "Started session in",
}
}
}
impl App {
pub(super) async fn open_worktree_picker(
&mut self,
tui: &mut tui::Tui,
app_server: &AppServerSession,
) {
self.chat_widget
.show_selection_view(crate::worktree::loading_params(
tui.frame_requester(),
self.config.animations,
));
let result = self
.list_current_repo_worktrees_for_session(app_server)
.await
.map_err(|err| err.to_string());
self.on_worktrees_loaded(self.session_workspace_cwd(app_server).to_path_buf(), result);
}
pub(super) fn open_worktree_create_prompt(&mut self) {
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::OpenWorktreeBaseRefPrompt {
branch: branch.trim().to_string(),
});
}),
);
self.chat_widget.show_bottom_pane_view(Box::new(view));
}
pub(super) fn open_worktree_base_ref_prompt(&mut self, branch: String) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new_allow_empty(
"Base ref".to_string(),
"Optional base ref; leave blank for default".to_string(),
/*initial_text*/ String::new(),
/*context_label*/
Some(format!(
"Create {branch} from this ref, or leave blank for the default."
)),
Box::new(move |base_ref: String| {
let base_ref = base_ref.trim();
tx.send(AppEvent::CreateWorktreeAndSwitch {
branch: branch.clone(),
base_ref: (!base_ref.is_empty()).then(|| base_ref.to_string()),
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 self.remote_app_server_url.is_none() && 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, cwd.as_path()),
Err(err) => crate::worktree::error_params(err),
};
self.replace_worktree_view(params);
}
pub(super) async fn create_worktree_and_switch(
&mut self,
tui: &mut tui::Tui,
app_server: &AppServerSession,
branch: String,
base_ref: Option<String>,
dirty_policy: Option<WorktreeDirtyPolicy>,
) {
let dirty_policy = match dirty_policy {
Some(policy) => policy,
None => match self.source_worktree_dirty_state(app_server).await {
Ok(state) if dirty_state_is_dirty(&state) => {
let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref);
self.chat_widget.show_selection_view(params);
return;
}
Ok(_) => WorktreeDirtyPolicy::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(
app_server,
self.session_workspace_cwd(app_server).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<WorktreeCreateResponse, String>,
) {
if cwd.as_path() != self.session_workspace_cwd(app_server) {
return;
}
let response = match result {
Ok(response) => response,
Err(err) => {
self.show_worktree_error("Failed to create worktree.".to_string(), err);
return;
}
};
let target = response
.info
.branch
.clone()
.unwrap_or_else(|| response.info.name.clone());
self.show_worktree_switching_view(tui, target);
self.switch_to_worktree_info(
tui,
app_server,
response.info,
response
.warnings
.into_iter()
.map(|warning| warning.message)
.collect(),
)
.await;
}
pub(super) async fn begin_switch_to_worktree_target(
&mut self,
tui: &mut tui::Tui,
app_server: &AppServerSession,
target: String,
) {
let entries = match self
.list_current_repo_worktrees_for_session(app_server)
.await
{
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.begin_switch_to_worktree_info(tui, info);
}
pub(super) fn begin_switch_to_worktree_info(&mut self, tui: &mut tui::Tui, info: WorktreeInfo) {
let target = info.branch.clone().unwrap_or_else(|| info.name.clone());
self.show_worktree_switching_view(tui, target);
self.defer_switch_to_worktree_info(info);
}
pub(super) fn current_worktree_selected(&mut self, target: String) {
self.chat_widget
.add_info_message(format!("Already in worktree {target}."), /*hint*/ None);
}
pub(super) async fn switch_to_worktree_info_after_loading(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
info: WorktreeInfo,
) {
self.switch_to_worktree_info(tui, app_server, info, Vec::new())
.await;
}
pub(super) async fn show_worktree_path(
&mut self,
app_server: &AppServerSession,
target: String,
) {
match self
.list_current_repo_worktrees_for_session(app_server)
.await
{
Ok(entries) => match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => {
self.chat_widget
.add_info_message(info.workspace_cwd.clone(), /*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) async fn remove_worktree(
&mut self,
app_server: &AppServerSession,
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
) {
let entries = match self
.list_current_repo_worktrees_for_session(app_server)
.await
{
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;
}
let result = self
.remove_worktree_via_app_server(app_server, target.clone(), force, delete_branch)
.await;
match result {
Ok(result) => {
let mut message = format!("Removed worktree {}", result.removed_path);
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}"));
}
}
}
async fn list_current_repo_worktrees_for_session(
&self,
app_server: &AppServerSession,
) -> anyhow::Result<Vec<WorktreeInfo>> {
let response: WorktreeListResponse = app_server
.request_handle()
.request_typed(ClientRequest::WorktreeList {
request_id: worktree_request_id("worktree-list"),
params: WorktreeListParams {
cwd: Some(self.session_workspace_cwd(app_server).display().to_string()),
all: false,
},
})
.await?;
Ok(response.data)
}
async fn source_worktree_dirty_state(
&self,
app_server: &AppServerSession,
) -> anyhow::Result<WorktreeDirtyState> {
let response: WorktreeInspectSourceResponse = app_server
.request_handle()
.request_typed(ClientRequest::WorktreeInspectSource {
request_id: worktree_request_id("worktree-inspect-source"),
params: WorktreeInspectSourceParams {
cwd: Some(self.session_workspace_cwd(app_server).display().to_string()),
},
})
.await?;
Ok(response.dirty)
}
fn session_workspace_cwd<'a>(&'a self, app_server: &'a AppServerSession) -> &'a Path {
if self.remote_app_server_url.is_some() {
app_server
.remote_cwd_override()
.or_else(|| {
self.primary_session_configured
.as_ref()
.map(|session| session.cwd.as_path())
})
.unwrap_or(self.config.cwd.as_path())
} else {
self.config.cwd.as_path()
}
}
fn spawn_worktree_create_request(
&self,
app_server: &AppServerSession,
source_cwd: PathBuf,
branch: String,
base_ref: Option<String>,
dirty_policy: WorktreeDirtyPolicy,
) {
let cwd = source_cwd.clone();
let app_event_tx = self.app_event_tx.clone();
let request_handle = app_server.request_handle();
tokio::spawn(async move {
let result: Result<WorktreeCreateResponse, _> = request_handle
.request_typed(ClientRequest::WorktreeCreate {
request_id: worktree_request_id("worktree-create"),
params: WorktreeCreateParams {
cwd: Some(source_cwd.display().to_string()),
branch,
base_ref,
dirty_policy,
},
})
.await;
let result = result.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 = if app_server.is_remote() {
self.config.clone()
} else {
match self
.rebuild_config_for_cwd(PathBuf::from(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,
args: WorktreeSessionReadyArgs,
) {
let WorktreeSessionReadyArgs {
info,
config,
forked,
warnings,
result,
} = args;
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 {
if app_server.is_remote() {
app_server.set_remote_cwd_override(Some(PathBuf::from(
info.workspace_cwd.clone(),
)));
}
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 = if app_server.is_remote() {
Some(PathBuf::from(info.workspace_cwd.clone()))
} else {
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_info(&self, info: WorktreeInfo) {
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 { info });
});
}
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());
}
async fn remove_worktree_via_app_server(
&self,
app_server: &AppServerSession,
target: String,
force: bool,
delete_branch: bool,
) -> anyhow::Result<WorktreeRemoveResponse> {
app_server
.request_handle()
.request_typed(ClientRequest::WorktreeRemove {
request_id: worktree_request_id("worktree-remove"),
params: WorktreeRemoveParams {
cwd: Some(self.session_workspace_cwd(app_server).display().to_string()),
name_or_path: target,
force,
delete_branch,
},
})
.await
.map_err(Into::into)
}
}
fn worktree_request_id(prefix: &str) -> RequestId {
RequestId::String(format!("{prefix}-{}", Uuid::new_v4()))
}
fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool {
dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files
}
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 dirty_state_is_dirty(&info.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.clone(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::WorktreeLocation;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
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.to_string_lossy().to_string(),
common_git_dir: "/repo/codex/.git".to_string(),
worktree_git_root: path.to_string_lossy().to_string(),
workspace_cwd: path.to_string_lossy().to_string(),
original_relative_cwd: String::new(),
branch,
head: Some("abcdef".to_string()),
owner_thread_id: None,
metadata_path: "/repo/codex/.git/codex-worktree.json".to_string(),
dirty: WorktreeDirtyState {
has_staged_changes: false,
has_unstaged_changes: dirty,
has_untracked_files: false,
},
}
}
}

View File

@@ -106,6 +106,9 @@ pub(crate) enum AppCommand {
ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent,
},
AddToHistory {
text: String,
},
}
impl AppCommand {
@@ -272,6 +275,10 @@ impl AppCommand {
Self::ApproveGuardianDeniedAction { event }
}
pub(crate) fn add_to_history(text: String) -> Self {
Self::AddToHistory { text }
}
pub(crate) fn is_review(&self) -> bool {
matches!(self, Self::Review { .. })
}

View File

@@ -26,6 +26,9 @@ use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::WorktreeCreateResponse;
use codex_app_server_protocol::WorktreeDirtyPolicy;
use codex_app_server_protocol::WorktreeInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
@@ -33,10 +36,12 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
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 +196,72 @@ 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,
/// Open the optional base-ref prompt for creating a managed worktree.
OpenWorktreeBaseRefPrompt {
branch: String,
},
/// Create or reuse a managed worktree and switch the TUI into it.
CreateWorktreeAndSwitch {
branch: String,
base_ref: Option<String>,
dirty_policy: Option<WorktreeDirtyPolicy>,
},
/// Result of creating or reusing a managed worktree.
WorktreeCreated {
cwd: PathBuf,
result: Result<WorktreeCreateResponse, String>,
},
/// Switch the TUI into an existing worktree.
SwitchToWorktree {
target: String,
},
/// Switch the TUI into the selected existing worktree.
SwitchToWorktreeInfo {
info: WorktreeInfo,
},
/// A picker row for the current worktree was selected.
CurrentWorktreeSelected {
target: String,
},
/// Continue switching into an existing worktree after the loading view has rendered.
SwitchToWorktreeAfterLoading {
info: WorktreeInfo,
},
/// Result of starting or forking a session in a worktree.
WorktreeSessionReady {
info: WorktreeInfo,
config: Config,
forked: bool,
warnings: Vec<String>,
result: Result<AppServerStartedThread, String>,
},
/// Show the filesystem path for an existing worktree.
ShowWorktreePath {
target: String,
},
/// Remove a Codex-managed worktree.
RemoveWorktree {
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
},
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the

View File

@@ -118,6 +118,7 @@ use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
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 +166,7 @@ impl ThreadParamsMode {
}
}
#[derive(Debug)]
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
@@ -188,6 +190,10 @@ impl AppServerSession {
self.remote_cwd_override.as_deref()
}
pub(crate) fn set_remote_cwd_override(&mut self, remote_cwd_override: Option<PathBuf>) {
self.remote_cwd_override = remote_cwd_override;
}
pub(crate) fn is_remote(&self) -> bool {
matches!(self.client, AppServerClient::Remote(_))
}
@@ -968,6 +974,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>,

View File

@@ -31,6 +31,7 @@ pub(crate) struct CustomPromptView {
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
allow_empty: bool,
// UI state
textarea: TextArea,
@@ -57,11 +58,24 @@ impl CustomPromptView {
placeholder,
context_label,
on_submit,
allow_empty: false,
textarea,
textarea_state: RefCell::new(TextAreaState::default()),
completion: None,
}
}
pub(crate) fn new_allow_empty(
title: String,
placeholder: String,
initial_text: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
) -> Self {
let mut view = Self::new(title, placeholder, initial_text, context_label, on_submit);
view.allow_empty = true;
view
}
}
impl BottomPaneView for CustomPromptView {
@@ -78,7 +92,7 @@ impl BottomPaneView for CustomPromptView {
..
} => {
let text = self.textarea.text().trim().to_string();
if !text.is_empty() {
if self.allow_empty || !text.is_empty() {
(self.on_submit)(text);
self.completion = Some(ViewCompletion::Accepted);
}

View File

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

View File

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

View File

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

View File

@@ -28,12 +28,17 @@ use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_protocol::Account as AppServerAccount;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Thread as AppServerThread;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::WorktreeCreateParams;
use codex_app_server_protocol::WorktreeCreateResponse;
use codex_app_server_protocol::WorktreeDirtyPolicy;
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLoadError;
@@ -55,6 +60,7 @@ use codex_state::log_db;
use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
use codex_utils_cli::WorktreeDirtyCliArg;
use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::WrapErr;
@@ -179,6 +185,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;
@@ -695,6 +703,39 @@ fn latest_session_cwd_filter<'a>(
}
}
async fn create_startup_worktree(
app_server: &AppServerSession,
cwd: Option<&Path>,
branch: String,
base_ref: Option<String>,
dirty_policy: WorktreeDirtyCliArg,
) -> color_eyre::Result<WorktreeCreateResponse> {
app_server
.request_handle()
.request_typed(ClientRequest::WorktreeCreate {
request_id: RequestId::String(format!("startup-worktree-{}", Uuid::new_v4())),
params: WorktreeCreateParams {
cwd: cwd.map(|cwd| cwd.display().to_string()),
branch,
base_ref,
dirty_policy: worktree_dirty_policy_from_cli(dirty_policy),
},
})
.await
.map_err(color_eyre::eyre::Report::msg)
}
fn worktree_dirty_policy_from_cli(value: WorktreeDirtyCliArg) -> WorktreeDirtyPolicy {
match value {
WorktreeDirtyCliArg::Fail => WorktreeDirtyPolicy::Fail,
WorktreeDirtyCliArg::Ignore => WorktreeDirtyPolicy::Ignore,
WorktreeDirtyCliArg::CopyTracked => WorktreeDirtyPolicy::CopyTracked,
WorktreeDirtyCliArg::CopyAll => WorktreeDirtyPolicy::CopyAll,
WorktreeDirtyCliArg::MoveTracked => WorktreeDirtyPolicy::MoveTracked,
WorktreeDirtyCliArg::MoveAll => WorktreeDirtyPolicy::MoveAll,
}
}
pub async fn run_main(
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
@@ -1086,11 +1127,11 @@ pub async fn run_main(
#[allow(clippy::too_many_arguments)]
async fn run_ratatui_app(
cli: Cli,
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
app_server_target: AppServerTarget,
remote_cwd_override: Option<PathBuf>,
mut remote_cwd_override: Option<PathBuf>,
initial_config: Config,
overrides: ConfigOverrides,
cli_kv_overrides: Vec<(String, toml::Value)>,
@@ -1172,6 +1213,51 @@ async fn run_ratatui_app(
},
);
let startup_worktree_requested = cli.worktree.is_some();
let mut initial_config = initial_config;
if let Some(branch) = cli.worktree.take() {
let Some(startup_app_server) = app_server.as_mut() else {
unreachable!("app server should exist while resolving startup worktree");
};
let response = match create_startup_worktree(
startup_app_server,
if remote_mode {
remote_cwd_override.as_deref()
} else {
Some(initial_config.cwd.as_path())
},
branch,
cli.worktree_base.take(),
cli.worktree_dirty,
)
.await
{
Ok(response) => response,
Err(err) => {
shutdown_app_server_if_present(app_server.take()).await;
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
};
let worktree_cwd = PathBuf::from(response.info.workspace_cwd.clone());
if remote_mode {
remote_cwd_override = Some(worktree_cwd.clone());
startup_app_server.set_remote_cwd_override(Some(worktree_cwd));
} else {
initial_config = load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
Some(worktree_cwd),
)
.await;
}
initial_config
.startup_warnings
.extend(response.warnings.into_iter().map(|warning| warning.message));
}
let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config);
let mut trust_decision_was_made = false;
let login_status = if initial_config.model_provider.requires_openai_auth {
@@ -1388,7 +1474,7 @@ async fn run_ratatui_app(
};
let current_cwd = config.cwd.clone();
let allow_prompt = !remote_mode && cli.cwd.is_none();
let allow_prompt = !remote_mode && cli.cwd.is_none() && !startup_worktree_requested;
let action_and_target_session_if_resume_or_fork = match &session_selection {
resume_picker::SessionSelection::Resume(target_session) => {
Some((CwdPromptAction::Resume, target_session))
@@ -1400,7 +1486,7 @@ async fn run_ratatui_app(
};
let fallback_cwd = match action_and_target_session_if_resume_or_fork {
Some((action, target_session)) => {
if remote_mode {
if startup_worktree_requested || remote_mode {
Some(current_cwd.to_path_buf())
} else {
match resolve_cwd_for_resume_or_fork(

View File

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

View File

@@ -23,6 +23,8 @@ use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::worktree_labels::WorktreeLabel;
use crate::worktree_labels::label_for_cwd;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use chrono::DateTime;
@@ -375,6 +377,7 @@ async fn run_resume_picker_with_launch_context(
app_server,
include_non_interactive,
raw_reasoning_visibility(config),
(!is_remote).then(|| config.codex_home.to_path_buf()),
bg_tx,
),
bg_rx,
@@ -420,6 +423,7 @@ pub async fn run_fork_picker_with_app_server(
app_server,
/*include_non_interactive*/ false,
raw_reasoning_visibility(config),
(!is_remote).then(|| config.codex_home.to_path_buf()),
bg_tx,
),
bg_rx,
@@ -540,6 +544,7 @@ fn spawn_app_server_page_loader(
app_server: AppServerSession,
include_non_interactive: bool,
raw_reasoning_visibility: RawReasoningVisibility,
codex_home: Option<PathBuf>,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
) -> PickerLoader {
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PickerLoadRequest>();
@@ -557,6 +562,7 @@ fn spawn_app_server_page_loader(
request.provider_filter,
request.sort_key,
include_non_interactive,
codex_home.as_deref(),
)
.await;
let _ = bg_tx.send(BackgroundEvent::Page {
@@ -725,6 +731,7 @@ async fn load_app_server_page(
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
codex_home: Option<&Path>,
) -> std::io::Result<PickerPage> {
let response = app_server
.thread_list(thread_list_params(
@@ -742,7 +749,7 @@ async fn load_app_server_page(
rows: response
.data
.into_iter()
.filter_map(row_from_app_server_thread)
.filter_map(|thread| row_from_app_server_thread(thread, codex_home))
.collect(),
next_cursor: response.next_cursor.map(PageCursor::AppServer),
num_scanned_files,
@@ -824,6 +831,7 @@ struct Row {
updated_at: Option<DateTime<Utc>>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
worktree_label: Option<WorktreeLabel>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -844,6 +852,24 @@ impl Row {
self.thread_name.as_deref().unwrap_or(&self.preview)
}
fn display_branch(&self) -> Option<&str> {
self.worktree_label
.as_ref()
.and_then(|label| label.branch.as_deref())
.or(self.git_branch.as_deref())
}
fn display_cwd(&self) -> Option<String> {
let cwd = self
.cwd
.as_ref()
.map(|path| format_directory_display(path, /*max_width*/ None))?;
Some(match self.worktree_label.as_ref() {
Some(label) => format!("{} · {cwd}", label.summary()),
None => cwd,
})
}
fn matches_query(&self, query: &str) -> bool {
if self.preview.to_lowercase().contains(query) {
return true;
@@ -873,6 +899,16 @@ impl Row {
{
return true;
}
if let Some(label) = self.worktree_label.as_ref()
&& (label.name.to_lowercase().contains(query)
|| label.repo_name.to_lowercase().contains(query)
|| label
.branch
.as_ref()
.is_some_and(|branch| branch.to_lowercase().contains(query)))
{
return true;
}
false
}
}
@@ -1793,7 +1829,7 @@ impl PickerState {
}
}
fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
fn row_from_app_server_thread(thread: Thread, codex_home: Option<&Path>) -> Option<Row> {
let thread_id = match ThreadId::from_string(&thread.id) {
Ok(thread_id) => thread_id,
Err(err) => {
@@ -1802,6 +1838,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
}
};
let preview = thread.preview.trim();
let cwd = thread.cwd.to_path_buf();
let worktree_label = codex_home.and_then(|codex_home| label_for_cwd(codex_home, &cwd));
Some(Row {
path: thread.path,
preview: if preview.is_empty() {
@@ -1815,8 +1853,9 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
.map(|dt| dt.with_timezone(&Utc)),
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
cwd: Some(thread.cwd.to_path_buf()),
cwd: Some(cwd),
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
worktree_label,
})
}
@@ -2571,11 +2610,8 @@ fn render_comfortable_session_lines(
let reference = state.relative_time_reference.unwrap_or_else(Utc::now);
let created = format_relative_time(reference, row.created_at);
let updated = format_relative_time(reference, row.updated_at.or(row.created_at));
let branch = row.git_branch.as_deref();
let cwd = row
.cwd
.as_ref()
.map(|path| format_directory_display(path, /*max_width*/ None));
let branch = row.display_branch();
let cwd = row.display_cwd();
let footer_lines = render_footer_lines(
state.sort_key,
&created,
@@ -2973,12 +3009,10 @@ fn render_expanded_session_details(
.map(|path| format_directory_display(path, /*max_width*/ None))
.unwrap_or_else(|| "-".to_string());
let branch = row
.git_branch
.as_ref()
.display_branch()
.map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}"))
.unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch"));
vec![
let mut details = vec![
expanded_detail_line("Session:", &session, width),
expanded_time_detail_line("Created:", reference, row.created_at, width),
expanded_time_detail_line(
@@ -2987,11 +3021,17 @@ fn render_expanded_session_details(
row.updated_at.or(row.created_at),
width,
),
];
if let Some(worktree) = row.worktree_label.as_ref().map(WorktreeLabel::summary) {
details.push(expanded_detail_line("Workspace:", &worktree, width));
}
details.extend([
expanded_detail_line("Directory:", &directory, width),
expanded_detail_line("Branch:", &branch, width),
vec!["".dim()].into(),
vec!["".dim(), "Conversation:".dim()].into(),
]
]);
details
}
fn render_conversation_preview_lines(
@@ -3263,6 +3303,7 @@ mod tests {
updated_at: Some(timestamp),
cwd: None,
git_branch: None,
worktree_label: None,
}
}
@@ -3309,6 +3350,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
assert_eq!(row.display_preview(), "My session");
@@ -3349,6 +3391,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/tmp/codex-session-picker")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
assert!(row.matches_query("session-picker"));
@@ -3356,6 +3399,37 @@ mod tests {
assert!(row.matches_query(&thread_id.to_string()[..8]));
}
#[test]
fn row_worktree_label_overrides_branch_and_prefixes_cwd() {
let row = Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: None,
cwd: Some(PathBuf::from(
"/Users/felipe.coury/.codex/worktrees/abcd/parser-fix/codex",
)),
git_branch: Some(String::from("main")),
worktree_label: Some(WorktreeLabel {
name: String::from("parser-fix"),
branch: Some(String::from("parser-fix")),
repo_name: String::from("codex"),
dirty: false,
}),
};
assert_eq!(row.display_branch(), Some("parser-fix"));
assert!(
row.display_cwd()
.expect("cwd")
.starts_with("parser-fix · clean · codex · ")
);
assert!(row.matches_query("parser-fix"));
assert!(row.matches_query("codex"));
}
#[test]
fn relative_time_formats_zero_seconds_as_now() {
let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
@@ -3409,6 +3483,7 @@ mod tests {
updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"),
cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")),
git_branch: Some(String::from("codex/raw-scrollback-mode")),
worktree_label: None,
};
let rendered = render_expanded_session_details(&row, &state, /*width*/ 120)
@@ -3615,6 +3690,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/srv/real-project")),
git_branch: None,
worktree_label: None,
};
assert!(state.row_matches_filter(&row));
@@ -3640,6 +3716,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/srv/remote-project")),
git_branch: None,
worktree_label: None,
};
assert!(state.row_matches_filter(&row));
@@ -3671,6 +3748,7 @@ mod tests {
updated_at: Some(now - Duration::seconds(42)),
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
@@ -3681,6 +3759,7 @@ mod tests {
updated_at: Some(now - Duration::minutes(35)),
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: Some(PathBuf::from("/tmp/c.jsonl")),
@@ -3691,6 +3770,7 @@ mod tests {
updated_at: Some(now - Duration::hours(2)),
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.all_rows = rows.clone();
@@ -4119,6 +4199,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4157,6 +4238,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: None,
@@ -4167,6 +4249,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.pending_transcript_open = Some(thread_id);
@@ -4236,6 +4319,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
Row {
path: None,
@@ -4246,6 +4330,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
},
];
state.update_viewport(/*rows*/ 7, /*width*/ 80);
@@ -4301,6 +4386,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4331,6 +4417,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4407,6 +4494,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state.transcript_cells.insert(
thread_id,
@@ -4606,6 +4694,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4645,6 +4734,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
}];
state
@@ -4723,6 +4813,7 @@ session_picker_view = "dense"
"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs",
)),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
}
}
@@ -4975,6 +5066,7 @@ session_picker_view = "dense"
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
cwd: Some(PathBuf::from("/tmp/codex")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
let mut state = PickerState::new(
FrameRequester::test_dummy(),
@@ -5044,6 +5136,7 @@ session_picker_view = "dense"
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
cwd: Some(PathBuf::from("/tmp/codex")),
git_branch: Some(String::from("fcoury/session-picker")),
worktree_label: None,
};
let mut state = PickerState::new(
FrameRequester::test_dummy(),
@@ -5102,6 +5195,7 @@ session_picker_view = "dense"
updated_at: Some(now - Duration::minutes(idx * 5)),
cwd: None,
git_branch: None,
worktree_label: None,
})
.collect();
state.filtered_rows = state.all_rows.clone();
@@ -5154,6 +5248,7 @@ session_picker_view = "dense"
updated_at: Some(now - Duration::minutes(idx * 5)),
cwd: None,
git_branch: None,
worktree_label: None,
})
.collect();
state.filtered_rows = state.all_rows.clone();
@@ -5622,6 +5717,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -5661,6 +5757,7 @@ session_picker_view = "dense"
updated_at: None,
cwd: None,
git_branch: None,
worktree_label: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -5704,7 +5801,8 @@ session_picker_view = "dense"
turns: Vec::new(),
};
let row = row_from_app_server_thread(thread).expect("row should be preserved");
let row = row_from_app_server_thread(thread, /*codex_home*/ None)
.expect("row should be preserved");
assert_eq!(row.path, None);
assert_eq!(row.thread_id, Some(thread_id));

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
---
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. Move all Move tracked changes and untracked files; leave the source
checkout clean.
2. Copy all Copy tracked changes and untracked files.
3. Move tracked Move staged and unstaged tracked changes; leave untracked
files behind.
4. Copy tracked Copy staged and unstaged tracked changes.
5. Ignore Create from the requested base without copying local changes.
6. Fail Cancel creation and leave the source checkout unchanged.
Press enter to confirm or esc to go back

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,911 @@
use std::path::Path;
use std::time::Instant;
use codex_app_server_protocol::WorktreeDirtyPolicy as DirtyPolicy;
use codex_app_server_protocol::WorktreeDirtyState;
use codex_app_server_protocol::WorktreeInfo;
use codex_app_server_protocol::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::SelectionAction;
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),
"move-tracked" => Ok(DirtyPolicy::MoveTracked),
"move-all" => Ok(DirtyPolicy::MoveAll),
_ => Err(
"Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-tracked, move-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()];
let mut initial_selected_idx = None;
items.extend(entries.into_iter().enumerate().map(|(idx, entry)| {
let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone());
let source = source_label(entry.source);
let status = if dirty_state_is_dirty(&entry.dirty) {
"dirty"
} else {
"clean"
};
let is_current = is_current_worktree(current_cwd, &entry);
if is_current {
initial_selected_idx = Some(idx + 1);
}
let description = format!("{status} · {source} · {}", entry.workspace_cwd);
let selected_description = if is_current {
"Already in this worktree".to_string()
} else {
format!("Fork this chat into {}", entry.workspace_cwd)
};
let search_value = Some(format!(
"{} {} {} {}",
target, entry.name, source, entry.workspace_cwd
));
let target_for_action = target.clone();
let actions: Vec<SelectionAction> = if is_current {
vec![Box::new(move |tx| {
tx.send(AppEvent::CurrentWorktreeSelected {
target: target_for_action.clone(),
});
})]
} else {
let info_for_action = entry;
vec![Box::new(move |tx| {
tx.send(AppEvent::SwitchToWorktreeInfo {
info: info_for_action.clone(),
});
})]
};
SelectionItem {
name: target,
description: Some(description),
selected_description: Some(selected_description),
actions,
dismiss_on_select: true,
search_value,
is_current,
..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,
initial_selected_idx,
..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(
"Move all",
"Move tracked changes and untracked files; leave the source checkout clean.",
DirtyPolicy::MoveAll,
),
item(
"Copy all",
"Copy tracked changes and untracked files.",
DirtyPolicy::CopyAll,
),
item(
"Move tracked",
"Move staged and unstaged tracked changes; leave untracked files behind.",
DirtyPolicy::MoveTracked,
),
item(
"Copy tracked",
"Copy staged and unstaged tracked changes.",
DirtyPolicy::CopyTracked,
),
item(
"Ignore",
"Create from the requested base without copying local changes.",
DirtyPolicy::Ignore,
),
item(
"Fail",
"Cancel creation and leave the source checkout unchanged.",
DirtyPolicy::Fail,
),
],
..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
}
fn is_current_worktree(current_cwd: &Path, entry: &WorktreeInfo) -> bool {
let workspace_cwd = Path::new(&entry.workspace_cwd);
if paths_match(current_cwd, workspace_cwd) {
return true;
}
let current_cwd = current_cwd
.canonicalize()
.unwrap_or_else(|_| current_cwd.to_path_buf());
let worktree_git_root = Path::new(&entry.worktree_git_root);
let worktree_root = worktree_git_root
.canonicalize()
.unwrap_or_else(|_| worktree_git_root.to_path_buf());
current_cwd.starts_with(worktree_root)
}
fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool {
dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files
}
#[cfg(test)]
mod tests {
use super::*;
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_app_server_protocol::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_new_with_move_all_dirty_policy() {
assert_eq!(
parse_worktree_slash_args("new fcoury/demo --dirty move-all"),
Ok(WorktreeSlashAction::Create {
branch: "fcoury/demo".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: Some(DirtyPolicy::MoveAll),
})
);
}
#[test]
fn parse_new_with_move_tracked_dirty_policy() {
assert_eq!(
parse_worktree_slash_args("new fcoury/demo --dirty move-tracked"),
Ok(WorktreeSlashAction::Create {
branch: "fcoury/demo".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: Some(DirtyPolicy::MoveTracked),
})
);
}
#[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_picker_preselects_current_worktree_from_subdirectory() {
let params = picker_params(
vec![
sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false),
sample_info(
"fcoury/worktrees",
WorktreeSource::Git,
/*dirty*/ false,
),
],
Path::new("/repo/codex.fcoury-worktrees/codex-rs"),
);
assert_eq!(params.initial_selected_idx, Some(2));
}
#[test]
fn current_worktree_item_dispatches_current_selection_event() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let params = picker_params(
vec![sample_info(
"fcoury/worktrees",
WorktreeSource::Git,
/*dirty*/ false,
)],
Path::new("/repo/codex.fcoury-worktrees/codex-rs"),
);
(params.items[1].actions[0])(&tx);
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::CurrentWorktreeSelected { target }) if target == "fcoury/worktrees"
));
}
#[test]
fn existing_worktree_item_dispatches_selected_worktree_info() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let params = picker_params(
vec![sample_info(
"fcoury/worktrees",
WorktreeSource::Git,
/*dirty*/ false,
)],
Path::new("/repo/codex"),
);
(params.items[1].actions[0])(&tx);
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::SwitchToWorktreeInfo { info }) if info.branch.as_deref() == Some("fcoury/worktrees")
));
}
#[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 = 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: "/repo/codex/.git".to_string(),
worktree_git_root: path.clone(),
workspace_cwd: path,
original_relative_cwd: String::new(),
branch: Some(branch.to_string()),
head: Some("abcdef".to_string()),
owner_thread_id: None,
metadata_path: "/repo/codex/.git/codex-worktree.json".to_string(),
dirty: WorktreeDirtyState {
has_staged_changes: false,
has_unstaged_changes: dirty,
has_untracked_files: false,
},
}
}
fn render_selection(params: SelectionViewParams, width: u16) -> String {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list);
let height = view.desired_height(width);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
lines.join("\n")
}
}

View File

@@ -0,0 +1,49 @@
use std::path::Path;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorktreeLabel {
pub(crate) name: String,
pub(crate) branch: Option<String>,
pub(crate) repo_name: String,
pub(crate) dirty: bool,
}
impl WorktreeLabel {
pub(crate) fn summary(&self) -> String {
let mut parts = vec![self.branch.clone().unwrap_or_else(|| self.name.clone())];
parts.push(if self.dirty { "dirty" } else { "clean" }.to_string());
parts.push(self.repo_name.clone());
parts.join(" · ")
}
}
pub(crate) fn label_for_cwd(codex_home: &Path, cwd: &Path) -> Option<WorktreeLabel> {
let info = codex_worktree::resolve_worktree(codex_home, cwd)
.inspect_err(|err| tracing::warn!(?err, "failed to resolve managed worktree label"))
.ok()
.flatten()?;
Some(WorktreeLabel {
name: info.name,
branch: info.branch,
repo_name: info.repo_name,
dirty: info.dirty.is_dirty(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn summary_includes_name_branch_and_repo() {
let label = WorktreeLabel {
name: String::from("parser-fix"),
branch: Some(String::from("parser-fix")),
repo_name: String::from("codex"),
dirty: false,
};
assert_eq!(label.summary(), "parser-fix · clean · codex");
}
}

View File

@@ -3,9 +3,11 @@ mod config_override;
pub(crate) mod format_env_display;
mod sandbox_mode_cli_arg;
mod shared_options;
mod worktree_dirty_cli_arg;
pub use approval_mode_cli_arg::ApprovalModeCliArg;
pub use config_override::CliConfigOverrides;
pub use format_env_display::format_env_display;
pub use sandbox_mode_cli_arg::SandboxModeCliArg;
pub use shared_options::SharedCliOptions;
pub use worktree_dirty_cli_arg::WorktreeDirtyCliArg;

View File

@@ -1,6 +1,7 @@
//! Shared command-line flags used by both interactive and non-interactive Codex entry points.
use crate::SandboxModeCliArg;
use crate::WorktreeDirtyCliArg;
use clap::Args;
use std::path::PathBuf;
@@ -51,6 +52,18 @@ pub struct SharedCliOptions {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Create or reuse a Codex-managed Git worktree for this branch and run from that workspace.
#[arg(long = "worktree", value_name = "BRANCH")]
pub worktree: Option<String>,
/// Base ref for a newly created managed worktree.
#[arg(long = "worktree-base", value_name = "REF")]
pub worktree_base: Option<String>,
/// How to handle uncommitted source checkout changes when creating a worktree.
#[arg(long = "worktree-dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)]
pub worktree_dirty: WorktreeDirtyCliArg,
/// Additional directories that should be writable alongside the primary workspace.
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
pub add_dir: Vec<PathBuf>,
@@ -69,6 +82,9 @@ impl SharedCliOptions {
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
cwd,
worktree,
worktree_base,
worktree_dirty,
add_dir,
} = self;
let Self {
@@ -80,6 +96,9 @@ impl SharedCliOptions {
sandbox_mode: root_sandbox_mode,
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
cwd: root_cwd,
worktree: root_worktree,
worktree_base: root_worktree_base,
worktree_dirty: root_worktree_dirty,
add_dir: root_add_dir,
} = root;
@@ -105,6 +124,15 @@ impl SharedCliOptions {
if cwd.is_none() {
cwd.clone_from(root_cwd);
}
if worktree.is_none() {
worktree.clone_from(root_worktree);
}
if worktree_base.is_none() {
worktree_base.clone_from(root_worktree_base);
}
if *worktree_dirty == WorktreeDirtyCliArg::Fail {
*worktree_dirty = *root_worktree_dirty;
}
if !root_images.is_empty() {
let mut merged_images = root_images.clone();
merged_images.append(images);
@@ -129,6 +157,9 @@ impl SharedCliOptions {
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
cwd,
worktree,
worktree_base,
worktree_dirty,
add_dir,
} = subcommand;
@@ -152,6 +183,15 @@ impl SharedCliOptions {
if let Some(cwd) = cwd {
self.cwd = Some(cwd);
}
if let Some(worktree) = worktree {
self.worktree = Some(worktree);
}
if let Some(worktree_base) = worktree_base {
self.worktree_base = Some(worktree_base);
}
if worktree_dirty != WorktreeDirtyCliArg::Fail {
self.worktree_dirty = worktree_dirty;
}
if !images.is_empty() {
self.images = images;
}

View File

@@ -0,0 +1,13 @@
use clap::ValueEnum;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum WorktreeDirtyCliArg {
#[default]
Fail,
Ignore,
CopyTracked,
CopyAll,
MoveTracked,
MoveAll,
}

View File

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

View File

@@ -0,0 +1,19 @@
[package]
name = "codex-worktree"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-utils-absolute-path = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -0,0 +1,318 @@
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,
MoveTracked,
MoveAll,
}
#[derive(Debug)]
struct TransferPlan {
staged_diff: Vec<u8>,
unstaged_diff: Vec<u8>,
tracked_paths: Vec<PathBuf>,
untracked_paths: Vec<PathBuf>,
}
#[derive(Debug)]
pub(crate) struct PreparedDirtyTransfer {
move_plan: Option<MovePlan>,
}
#[derive(Debug)]
struct MovePlan {
transfer: TransferPlan,
move_untracked: bool,
}
#[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 | DirtyPolicy::MoveTracked => {
if state.has_untracked_files {
Ok(vec![
"untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them"
.to_string(),
])
} else {
Ok(Vec::new())
}
}
DirtyPolicy::CopyAll | DirtyPolicy::MoveAll => Ok(Vec::new()),
}
}
pub(crate) fn prepare_dirty_policy_after_create(
source_root: &Path,
worktree_root: &Path,
policy: DirtyPolicy,
) -> Result<PreparedDirtyTransfer> {
let state = dirty_state(source_root)?;
if !state.is_dirty() {
return Ok(PreparedDirtyTransfer { move_plan: None });
}
match policy {
DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(PreparedDirtyTransfer { move_plan: None }),
DirtyPolicy::CopyTracked => {
let plan = TransferPlan::capture(source_root)?;
plan.apply_tracked_diff(worktree_root)?;
Ok(PreparedDirtyTransfer { move_plan: None })
}
DirtyPolicy::CopyAll => {
let plan = TransferPlan::capture(source_root)?;
plan.apply_tracked_diff(worktree_root)?;
copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?;
Ok(PreparedDirtyTransfer { move_plan: None })
}
DirtyPolicy::MoveTracked => {
let plan = TransferPlan::capture(source_root)?;
plan.apply_tracked_diff(worktree_root)?;
Ok(PreparedDirtyTransfer {
move_plan: Some(MovePlan {
transfer: plan,
move_untracked: false,
}),
})
}
DirtyPolicy::MoveAll => {
let plan = TransferPlan::capture(source_root)?;
plan.apply_tracked_diff(worktree_root)?;
copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?;
Ok(PreparedDirtyTransfer {
move_plan: Some(MovePlan {
transfer: plan,
move_untracked: true,
}),
})
}
}
}
pub(crate) fn finalize_dirty_policy_after_create(
source_root: &Path,
prepared: PreparedDirtyTransfer,
) -> Result<()> {
let Some(move_plan) = prepared.move_plan else {
return Ok(());
};
move_plan
.transfer
.clean_source_after_move(source_root, move_plan.move_untracked)
.with_context(|| {
"worktree already contains transferred changes, but failed to clean the source checkout after move"
})
}
fn bail_for_dirty_source<T>() -> Result<T> {
anyhow::bail!(
"source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all"
);
}
impl TransferPlan {
fn capture(source_root: &Path) -> Result<Self> {
Ok(Self {
staged_diff: git::bytes(source_root, &["diff", "--cached", "--binary"])?,
unstaged_diff: git::bytes(source_root, &["diff", "--binary"])?,
tracked_paths: tracked_paths(source_root)?,
untracked_paths: untracked_paths(source_root)?,
})
}
fn apply_tracked_diff(&self, worktree_root: &Path) -> Result<()> {
if !self.staged_diff.is_empty() {
git::status_with_stdin(
worktree_root,
&["apply", "--index", "--binary", "-"],
&self.staged_diff,
)
.context("failed to apply staged changes to worktree")?;
}
if !self.unstaged_diff.is_empty() {
git::status_with_stdin(
worktree_root,
&["apply", "--binary", "-"],
&self.unstaged_diff,
)
.context("failed to apply unstaged changes to worktree")?;
}
Ok(())
}
fn clean_source_after_move(&self, source_root: &Path, move_untracked: bool) -> Result<()> {
if has_head(source_root) {
git::status(source_root, &["reset", "--hard", "HEAD"])
.context("failed to clean tracked changes from source checkout after move")?;
} else {
git::status(source_root, &["read-tree", "--empty"])
.context("failed to clear unborn source index after move")?;
for relative_path in &self.tracked_paths {
remove_file_if_present(source_root, relative_path, "tracked")?;
}
}
if move_untracked {
for relative_path in &self.untracked_paths {
remove_file_if_present(source_root, relative_path, "untracked")?;
}
}
Ok(())
}
}
fn tracked_paths(source_root: &Path) -> Result<Vec<PathBuf>> {
let staged = git::bytes(source_root, &["diff", "--cached", "--name-only", "-z"])?;
let unstaged = git::bytes(source_root, &["diff", "--name-only", "-z"])?;
let mut paths = paths_from_nul_separated(&staged)?;
paths.extend(paths_from_nul_separated(&unstaged)?);
paths.sort();
paths.dedup();
Ok(paths)
}
fn paths_from_nul_separated(output: &[u8]) -> Result<Vec<PathBuf>> {
output
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
.map(|raw_path| {
let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned());
ensure_safe_relative_path(&relative_path)?;
Ok(relative_path)
})
.collect()
}
fn has_head(source_root: &Path) -> bool {
git::status(source_root, &["rev-parse", "--verify", "HEAD"]).is_ok()
}
fn remove_file_if_present(source_root: &Path, relative_path: &Path, kind: &str) -> Result<()> {
match fs::remove_file(source_root.join(relative_path)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| {
format!(
"failed to remove moved {kind} path {} from source checkout",
relative_path.display()
)
}),
}
}
fn untracked_paths(source_root: &Path) -> Result<Vec<PathBuf>> {
let output = git::bytes(
source_root,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?;
paths_from_nul_separated(&output)
}
fn copy_untracked_files_at_paths(
source_root: &Path,
worktree_root: &Path,
paths: &[PathBuf],
) -> Result<()> {
for relative_path in paths {
ensure_safe_relative_path(relative_path)?;
let source = source_root.join(relative_path);
let destination = worktree_root.join(relative_path);
let metadata = fs::symlink_metadata(&source)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if metadata.file_type().is_symlink() {
let target = fs::read_link(&source)?;
create_symlink(&target, &destination)?;
} else if metadata.is_file() {
fs::copy(&source, &destination)?;
}
}
Ok(())
}
fn ensure_safe_relative_path(path: &Path) -> Result<()> {
if path.is_absolute() {
anyhow::bail!(
"refusing to copy absolute untracked path {}",
path.display()
);
}
if path.components().any(|component| {
matches!(component, Component::ParentDir)
|| matches!(component, Component::Normal(value) if value == ".git")
}) {
anyhow::bail!("refusing to copy unsafe untracked path {}", path.display());
}
Ok(())
}
#[cfg(unix)]
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, destination).map_err(Into::into)
}
#[cfg(windows)]
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
std::os::windows::fs::symlink_file(target, destination).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dirty_state_reports_clean_by_default() {
assert!(!DirtyState::default().is_dirty());
}
}

View File

@@ -0,0 +1,57 @@
use std::path::Path;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
pub fn stdout(cwd: &Path, args: &[&str]) -> Result<String> {
let output = output(cwd, args)?;
Ok(String::from_utf8(output)?.trim_end().to_string())
}
pub fn bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
output(cwd, args)
}
pub fn status(cwd: &Path, args: &[&str]) -> Result<()> {
output(cwd, args).map(|_| ())
}
pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> {
let mut child = Command::new("git")
.args(args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn git {}", args.join(" ")))?;
use std::io::Write as _;
child
.stdin
.as_mut()
.context("git stdin unavailable")?
.write_all(stdin)?;
let output = child.wait_with_output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
pub fn output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new("git").args(args).current_dir(cwd).output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(output.stdout)
}

View File

@@ -0,0 +1,113 @@
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::prune_stale_managed_worktree_dirs;
pub use manager::remove_worktree;
pub use manager::resolve_worktree;
pub use manager::stale_managed_worktree_dirs;
pub use metadata::WorktreeMetadata;
pub use metadata::WorktreeThreadMetadata;
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;
pub use paths::repo_fingerprint;
pub use paths::sibling_worktree_git_root;
pub use paths::slugify_name;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRequest {
pub codex_home: PathBuf,
pub source_cwd: PathBuf,
pub branch: String,
pub base_ref: Option<String>,
pub dirty_policy: DirtyPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeResolution {
pub reused: bool,
pub info: WorktreeInfo,
pub warnings: Vec<WorktreeWarning>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeInfo {
pub id: String,
pub name: String,
pub slug: String,
pub source: WorktreeSource,
pub location: WorktreeLocation,
pub repo_name: String,
pub repo_root: PathBuf,
pub common_git_dir: PathBuf,
pub worktree_git_root: PathBuf,
pub workspace_cwd: PathBuf,
pub original_relative_cwd: PathBuf,
pub branch: Option<String>,
pub head: Option<String>,
pub owner_thread_id: Option<String>,
pub metadata_path: PathBuf,
pub dirty: DirtyState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeSource {
Cli,
App,
Legacy,
Git,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeLocation {
Sibling,
CodexHome,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeWarning {
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeListQuery {
pub codex_home: PathBuf,
pub source_cwd: Option<PathBuf>,
pub include_all_repos: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeRemoveRequest {
pub codex_home: PathBuf,
pub source_cwd: Option<PathBuf>,
pub name_or_path: String,
pub force: bool,
pub delete_branch: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeRemoveResult {
pub removed_path: PathBuf,
pub deleted_branch: Option<String>,
}

View File

@@ -0,0 +1,753 @@
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.primary_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 branch_exists = branch_exists(&repo.root, &branch);
let has_head = git::status(&repo.root, &["rev-parse", "--verify", "HEAD"]).is_ok();
fs::create_dir_all(
worktree_git_root
.parent()
.context("managed worktree path has no parent")?,
)?;
if branch_exists {
git::status(
&repo.root,
&[
"worktree",
"add",
&worktree_git_root.to_string_lossy(),
&branch,
],
)?;
} else if req.base_ref.is_none() && !has_head {
git::status(
&repo.root,
&[
"worktree",
"add",
"--orphan",
"-b",
&branch,
&worktree_git_root.to_string_lossy(),
],
)?;
} else {
let base_ref = req.base_ref.as_deref().unwrap_or("HEAD");
git::status(
&repo.root,
&[
"worktree",
"add",
"-b",
&branch,
&worktree_git_root.to_string_lossy(),
base_ref,
],
)?;
}
let prepared_transfer = match dirty::prepare_dirty_policy_after_create(
&repo.root,
&worktree_git_root,
req.dirty_policy,
) {
Ok(prepared_transfer) => prepared_transfer,
Err(err) => {
return Err(rollback_failed_create(
&repo.root,
&worktree_git_root,
&branch,
/*delete_branch*/ !branch_exists,
err,
));
}
};
dirty::finalize_dirty_policy_after_create(&repo.root, prepared_transfer)?;
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(),
})
}
fn rollback_failed_create(
repo_root: &Path,
worktree_git_root: &Path,
branch: &str,
delete_branch: bool,
err: anyhow::Error,
) -> anyhow::Error {
let mut rollback_errors = Vec::new();
let worktree_arg = worktree_git_root.to_string_lossy();
if let Err(rollback_err) =
git::status(repo_root, &["worktree", "remove", "--force", &worktree_arg])
{
rollback_errors.push(rollback_err.to_string());
}
if delete_branch && let Err(rollback_err) = git::status(repo_root, &["branch", "-D", branch]) {
rollback_errors.push(rollback_err.to_string());
}
if rollback_errors.is_empty() {
err
} else {
anyhow::anyhow!(
"{err}; additionally failed to roll back newly-created worktree: {}",
rollback_errors.join("; ")
)
}
}
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()
}
pub fn stale_managed_worktree_dirs(codex_home: &Path) -> Result<Vec<PathBuf>> {
let root = paths::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)
}
pub fn prune_stale_managed_worktree_dirs(codex_home: &Path, dry_run: bool) -> Result<Vec<PathBuf>> {
let stale = stale_managed_worktree_dirs(codex_home)?;
if dry_run {
return Ok(stale);
}
for path in &stale {
fs::remove_dir_all(path).with_context(|| {
format!(
"failed to remove stale worktree directory {}",
path.display()
)
})?;
}
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())
}
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,
primary_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 primary_root = primary_worktree_root(&root)
.unwrap_or_else(|_| root.clone())
.canonicalize()
.unwrap_or_else(|_| root.clone());
let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok();
let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref());
let repo_name = primary_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,
primary_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;
use tempfile::TempDir;
#[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())
}
]
);
}
#[test]
fn prune_stale_managed_worktree_dirs_respects_dry_run() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let stale_path = paths::codex_worktrees_root(codex_home.path())
.join("repo-id")
.join("demo")
.join("codex.demo");
fs::create_dir_all(&stale_path)?;
assert_eq!(
prune_stale_managed_worktree_dirs(codex_home.path(), /*dry_run*/ true)?,
vec![stale_path.clone()]
);
assert!(stale_path.exists());
assert_eq!(
prune_stale_managed_worktree_dirs(codex_home.path(), /*dry_run*/ false)?,
vec![stale_path.clone()]
);
assert!(!stale_path.exists());
Ok(())
}
}

View File

@@ -0,0 +1,157 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use crate::WorktreeInfo;
use crate::WorktreeLocation;
use crate::WorktreeSource;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeThreadMetadata {
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_thread_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeMetadata {
pub version: u32,
pub manager: String,
pub backend: String,
#[serde(default = "default_source")]
pub source: WorktreeSource,
#[serde(default = "default_location")]
pub location: WorktreeLocation,
pub id: String,
pub name: String,
pub slug: String,
pub branch: Option<String>,
pub repo_id: String,
pub repo_name: String,
pub source_repo_root: PathBuf,
pub original_relative_cwd: PathBuf,
pub worktree_git_root: PathBuf,
pub workspace_cwd: PathBuf,
pub created_at: i64,
pub updated_at: i64,
pub owner_thread_id: Option<String>,
pub tmux_session: Option<String>,
}
impl WorktreeMetadata {
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
let now = unix_seconds();
Self {
version: 1,
manager: "codex-cli".to_string(),
backend: "git".to_string(),
source: info.source,
location: info.location,
id: info.id.clone(),
name: info.name.clone(),
slug: info.slug.clone(),
branch: info.branch.clone(),
repo_id: info.id.clone(),
repo_name: info.repo_name.clone(),
source_repo_root,
original_relative_cwd: info.original_relative_cwd.clone(),
worktree_git_root: info.worktree_git_root.clone(),
workspace_cwd: info.workspace_cwd.clone(),
created_at: now,
updated_at: now,
owner_thread_id: info.owner_thread_id.clone(),
tmux_session: None,
}
}
}
fn default_source() -> WorktreeSource {
WorktreeSource::Legacy
}
fn default_location() -> WorktreeLocation {
WorktreeLocation::CodexHome
}
pub fn read_worktree_metadata(worktree_path: &Path) -> Result<Option<WorktreeMetadata>> {
let path = metadata_path(worktree_path, "codex-worktree.json")?;
read_json_if_exists(&path)
}
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
let path = metadata_path(worktree_path, "codex-worktree.json")?;
write_json(&path, metadata)
}
pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?;
let git_root = PathBuf::from(git_root);
let owner = WorktreeThreadMetadata {
version: 1,
owner_thread_id: Some(thread_id.to_string()),
};
let owner_path = metadata_path(&git_root, "codex-thread.json")?;
write_json(&owner_path, &owner)?;
if let Some(mut metadata) = read_worktree_metadata(&git_root)? {
metadata.owner_thread_id = Some(thread_id.to_string());
metadata.updated_at = unix_seconds();
write_worktree_metadata(&git_root, &metadata)?;
}
Ok(())
}
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
let metadata = WorktreeThreadMetadata {
version: 1,
owner_thread_id: None,
};
let path = metadata_path(worktree_path, "codex-thread.json")?;
write_json(&path, &metadata)
}
fn read_json_if_exists<T>(path: &Path) -> Result<Option<T>>
where
T: serde::de::DeserializeOwned,
{
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
Ok(Some(serde_json::from_str(&contents)?))
}
fn write_json<T>(path: &Path, value: &T) -> Result<()>
where
T: serde::Serialize,
{
let contents = serde_json::to_string_pretty(value)?;
fs::write(path, contents)?;
Ok(())
}
fn metadata_path(worktree_path: &Path, name: &str) -> Result<PathBuf> {
let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?;
let path = PathBuf::from(path);
Ok(if path.is_absolute() {
path
} else {
worktree_path.join(path)
})
}
fn unix_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
}

View File

@@ -0,0 +1,105 @@
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use sha2::Digest;
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
codex_home.join("worktrees")
}
pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let root = codex_worktrees_root(codex_home)
.canonicalize()
.unwrap_or_else(|_| codex_worktrees_root(codex_home));
path.starts_with(root)
}
pub fn slugify_name(name: &str) -> Result<String> {
let slug = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.take(12)
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
anyhow::bail!("worktree name must contain at least one ASCII letter or digit");
}
Ok(slug)
}
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
let sanitized = branch.replace(['/', '\\'], "-");
if sanitized.trim().is_empty() {
anyhow::bail!("branch name must produce a non-empty worktree path segment");
}
Ok(sanitized)
}
pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(common_git_dir.to_string_lossy().as_bytes());
if let Some(origin_url) = origin_url {
hasher.update(b"\0");
hasher.update(origin_url.as_bytes());
}
let digest = hasher.finalize();
digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
let repo_name = repo_root
.file_name()
.context("source repository root has no directory name")?;
let parent = repo_root
.parent()
.context("source repository root has no parent directory")?;
let sanitized_branch = sanitize_branch_for_path(branch)?;
let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch);
Ok(parent.join(dirname))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn slugify_name_keeps_short_ascii_slug() -> Result<()> {
assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests");
Ok(())
}
#[test]
fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> {
assert_eq!(
sanitize_branch_for_path("feature/auth\\windows")?,
"feature-auth-windows"
);
Ok(())
}
#[test]
fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> {
assert_eq!(
sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?,
PathBuf::from("/Users/me/code/codex.feature-auth")
);
Ok(())
}
}

View File

@@ -0,0 +1,516 @@
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 creates_sibling_from_sibling_using_primary_repo_name() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
let first = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().to_path_buf(),
branch: "fcoury/worktrees".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})?;
let second = codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: first.info.workspace_cwd,
branch: "fcoury/test".to_string(),
base_ref: None,
dirty_policy: DirtyPolicy::Fail,
})?;
let canonical_repo = fixture.repo.path().canonicalize()?;
assert_eq!(
second.info.worktree_git_root,
canonical_repo.with_file_name(format!(
"{}.fcoury-test",
canonical_repo.file_name().unwrap().to_string_lossy()
))
);
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 move_all_transfers_dirty_state_and_cleans_source_checkout() -> anyhow::Result<()> {
let fixture = GitFixture::new()?;
fs::write(fixture.repo.path().join(".gitignore"), "ignored.txt\n")?;
run_git(fixture.repo.path(), &["add", ".gitignore"])?;
run_git(fixture.repo.path(), &["commit", "-m", "ignore fixture"])?;
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",
)?;
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?;
fs::write(fixture.repo.path().join("ignored.txt"), "ignored\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: "move-dirty".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: DirtyPolicy::MoveAll,
})?;
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"
);
assert_eq!(
fs::read_to_string(resolution.info.worktree_git_root.join("untracked.txt"))?,
"untracked\n"
);
assert!(
!resolution
.info
.worktree_git_root
.join("ignored.txt")
.exists()
);
assert_eq!(git(fixture.repo.path(), &["status", "--short"])?, "");
assert!(fixture.repo.path().join("ignored.txt").exists());
Ok(())
}
#[test]
fn move_tracked_transfers_tracked_state_and_leaves_untracked_files() -> 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",
)?;
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\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: "move-tracked".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: DirtyPolicy::MoveTracked,
})?;
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"
);
assert!(
!resolution
.info
.worktree_git_root
.join("untracked.txt")
.exists()
);
assert_eq!(
git(fixture.repo.path(), &["status", "--short"])?,
"?? untracked.txt"
);
Ok(())
}
#[test]
fn failed_transfer_rolls_back_new_worktree_and_branch() -> anyhow::Result<()> {
let fixture = GitFixture::new_with_base_before_tracked_file()?;
fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?;
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: "rollback-created".to_string(),
base_ref: Some("HEAD~1".to_string()),
dirty_policy: DirtyPolicy::CopyTracked,
})
.expect_err("tracked patch should not apply against an older base");
assert!(
err.to_string()
.contains("failed to apply unstaged changes to worktree"),
"{err:#}"
);
assert!(
!fixture
.repo
.path()
.with_file_name(format!(
"{}.rollback-created",
fixture.repo.path().file_name().unwrap().to_string_lossy()
))
.exists()
);
assert!(!branch_exists(fixture.repo.path(), "rollback-created")?);
assert_eq!(
git(fixture.repo.path(), &["status", "--short"])?,
" M tracked.txt"
);
Ok(())
}
#[test]
fn failed_transfer_preserves_preexisting_branch() -> anyhow::Result<()> {
let fixture = GitFixture::new_with_base_before_tracked_file()?;
run_git(fixture.repo.path(), &["branch", "keep-me", "HEAD~1"])?;
fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?;
codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: fixture.codex_home.path().to_path_buf(),
source_cwd: fixture.repo.path().to_path_buf(),
branch: "keep-me".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: DirtyPolicy::CopyTracked,
})
.expect_err("tracked patch should not apply against the preexisting branch");
assert!(branch_exists(fixture.repo.path(), "keep-me")?);
assert!(
!fixture
.repo
.path()
.with_file_name(format!(
"{}.keep-me",
fixture.repo.path().file_name().unwrap().to_string_lossy()
))
.exists()
);
Ok(())
}
#[test]
fn creates_orphan_worktree_from_unborn_repo_without_base_ref() -> anyhow::Result<()> {
let fixture = GitFixture::new_unborn()?;
fs::write(fixture.repo.path().join("README.md"), "hello\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: "feature".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: DirtyPolicy::CopyAll,
})?;
assert_eq!(
git(
&resolution.info.worktree_git_root,
&["status", "--short", "--branch"]
)?,
"## No commits yet on feature\n?? README.md"
);
Ok(())
}
#[test]
fn move_all_cleans_unborn_source_checkout() -> anyhow::Result<()> {
let fixture = GitFixture::new_unborn()?;
fs::write(fixture.repo.path().join("staged.txt"), "staged\n")?;
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\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: "feature".to_string(),
base_ref: /*base_ref*/ None,
dirty_policy: DirtyPolicy::MoveAll,
})?;
assert_eq!(
git(
&resolution.info.worktree_git_root,
&["status", "--short", "--branch"]
)?,
"## No commits yet on feature\nA staged.txt\n?? untracked.txt"
);
assert_eq!(
git(fixture.repo.path(), &["status", "--short", "--branch"])?,
"## No commits yet on main"
);
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 new_unborn() -> 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"])?;
Ok(Self { repo, codex_home })
}
fn new_with_base_before_tracked_file() -> 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"])?;
run_git(repo.path(), &["commit", "--allow-empty", "-m", "base"])?;
fs::write(repo.path().join("tracked.txt"), "original\n")?;
run_git(repo.path(), &["add", "tracked.txt"])?;
run_git(repo.path(), &["commit", "-m", "add tracked file"])?;
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())
}
fn branch_exists(cwd: &Path, branch: &str) -> anyhow::Result<bool> {
let output = Command::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
])
.current_dir(cwd)
.output()?;
Ok(output.status.success())
}