mirror of
https://github.com/openai/codex.git
synced 2026-05-22 12:04:19 +00:00
Compare commits
12 Commits
dev/add-tu
...
fcoury/wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1a5e7275 | ||
|
|
1276652068 | ||
|
|
c0a6795347 | ||
|
|
5dc9cf6907 | ||
|
|
93317c151d | ||
|
|
6e460f31cd | ||
|
|
700f1e4a38 | ||
|
|
1c604c0be6 | ||
|
|
1b31e12444 | ||
|
|
4f3955ff91 | ||
|
|
250390cb76 | ||
|
|
5a6efcf183 |
69
CONTEXT.md
Normal file
69
CONTEXT.md
Normal 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
18
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
44
codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json
generated
Normal file
44
codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json
generated
Normal 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"
|
||||
}
|
||||
152
codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json
generated
Normal file
152
codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json
generated
Normal 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"
|
||||
}
|
||||
15
codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json
generated
Normal file
15
codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json
generated
Normal 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"
|
||||
}
|
||||
35
codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json
generated
Normal file
35
codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json
generated
Normal 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"
|
||||
}
|
||||
19
codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json
generated
Normal file
19
codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json
generated
Normal 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"
|
||||
}
|
||||
133
codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json
generated
Normal file
133
codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json
generated
Normal 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"
|
||||
}
|
||||
11
codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json
generated
Normal file
11
codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json
generated
Normal 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"
|
||||
}
|
||||
17
codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json
generated
Normal file
17
codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json
generated
Normal 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"
|
||||
}
|
||||
27
codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json
generated
Normal file
27
codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json
generated
Normal 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"
|
||||
}
|
||||
20
codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json
generated
Normal file
20
codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json
generated
Normal 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
13
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts
generated
Normal file
13
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts
generated
Normal 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, };
|
||||
10
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts
generated
Normal file
10
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts
generated
Normal 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>, };
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts
generated
Normal 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";
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts
generated
Normal 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, };
|
||||
11
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts
generated
Normal file
11
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts
generated
Normal 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, };
|
||||
12
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts
generated
Normal file
12
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts
generated
Normal 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, };
|
||||
9
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts
generated
Normal file
9
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts
generated
Normal 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, };
|
||||
16
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts
generated
Normal file
16
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts
generated
Normal 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, };
|
||||
9
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts
generated
Normal file
9
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts
generated
Normal 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>, };
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts
generated
Normal 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";
|
||||
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts
generated
Normal file
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts
generated
Normal 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, };
|
||||
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts
generated
Normal file
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts
generated
Normal 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>, };
|
||||
12
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts
generated
Normal file
12
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts
generated
Normal 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, };
|
||||
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts
generated
Normal file
8
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts
generated
Normal 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, };
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts
generated
Normal 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";
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts
generated
Normal 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, };
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
178
codex-rs/app-server-protocol/src/protocol/v2/worktree.rs
Normal file
178
codex-rs/app-server-protocol/src/protocol/v2/worktree.rs
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
222
codex-rs/app-server/src/request_processors/worktree_processor.rs
Normal file
222
codex-rs/app-server/src/request_processors/worktree_processor.rs
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ codex-shell-command = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-utils-approval-presets = { workspace = true }
|
||||
codex-worktree = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
codex-utils-elapsed = { workspace = true }
|
||||
|
||||
@@ -201,6 +201,7 @@ mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
mod thread_routing;
|
||||
mod thread_session_state;
|
||||
mod worktree;
|
||||
|
||||
use self::agent_navigation::AgentNavigationDirection;
|
||||
use self::agent_navigation::AgentNavigationState;
|
||||
@@ -846,6 +847,9 @@ impl App {
|
||||
if let Some(message) = external_agent_config_migration_message {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
if let Some(message) = managed_worktree_startup_message(&config) {
|
||||
chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
|
||||
chat_widget
|
||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
||||
|
||||
@@ -187,6 +187,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();
|
||||
}
|
||||
|
||||
@@ -77,6 +77,12 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &
|
||||
)));
|
||||
}
|
||||
|
||||
pub(super) fn managed_worktree_startup_message(config: &Config) -> Option<String> {
|
||||
let label =
|
||||
crate::worktree_labels::label_for_cwd(config.codex_home.as_path(), config.cwd.as_path())?;
|
||||
Some(format!("Workspace: {}", label.summary()))
|
||||
}
|
||||
|
||||
pub(super) fn hooks_needing_review_warning(count: usize) -> Option<String> {
|
||||
match count {
|
||||
0 => None,
|
||||
|
||||
@@ -497,6 +497,10 @@ impl App {
|
||||
op: &AppCommand,
|
||||
) -> Result<bool> {
|
||||
match op {
|
||||
AppCommand::AddToHistory { text } => {
|
||||
self.append_message_history_entry(thread_id, text.to_string());
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::Interrupt => {
|
||||
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
||||
app_server.turn_interrupt(thread_id, turn_id).await?;
|
||||
|
||||
876
codex-rs/tui/src/app/worktree.rs
Normal file
876
codex-rs/tui/src/app/worktree.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5377,6 +5377,30 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_bottom_pane_view(
|
||||
&mut self,
|
||||
view: Box<dyn crate::bottom_pane::BottomPaneView>,
|
||||
) {
|
||||
self.bottom_pane.show_view(view);
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn replace_selection_view_if_active(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
params: SelectionViewParams,
|
||||
) -> bool {
|
||||
let replaced = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(view_id, params);
|
||||
if replaced {
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.request_redraw();
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.bottom_pane.no_modal_or_popup_active()
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ impl ChatWidget {
|
||||
SlashCommand::Fork => {
|
||||
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
||||
}
|
||||
SlashCommand::Worktree => {
|
||||
self.app_event_tx.send(AppEvent::OpenWorktreePicker);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
if init_target.exists() {
|
||||
@@ -772,6 +775,13 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ResumeSessionByIdOrName(args));
|
||||
}
|
||||
SlashCommand::Worktree if !trimmed.is_empty() => {
|
||||
if let Err(message) =
|
||||
crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx)
|
||||
{
|
||||
self.add_error_message(message);
|
||||
}
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
||||
@@ -918,6 +928,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Review
|
||||
|
||||
@@ -6,19 +6,14 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::branch_summary;
|
||||
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
|
||||
use crate::motion::activity_spinner_frame_at;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: activity indicator + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
|
||||
|
||||
/// Braille-pattern dot-spinner frames for the terminal title animation.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/// Time between spinner frame advances in the terminal title.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Time between action-required blink phases in the terminal title.
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -362,7 +357,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections)
|
||||
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
|
||||
.then_some(ACTIVITY_SPINNER_INTERVAL)
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
@@ -816,14 +811,7 @@ impl ChatWidget {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let frame_index =
|
||||
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_uses_activity(&self) -> bool {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Callers choose an explicit reduced-motion fallback here instead of reaching
|
||||
//! directly for time-varying spinner or shimmer helpers.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Stylize;
|
||||
@@ -10,6 +11,10 @@ use ratatui::text::Span;
|
||||
|
||||
use crate::shimmer::shimmer_spans;
|
||||
|
||||
const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum MotionMode {
|
||||
Animated,
|
||||
@@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'sta
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activity_spinner_frame_at(origin: Instant, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(origin);
|
||||
let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
|
||||
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
|
||||
if supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
@@ -117,6 +128,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_spinner_frame_advances_and_wraps() {
|
||||
let origin = Instant::now();
|
||||
|
||||
assert_eq!(activity_spinner_frame_at(origin, origin), "⠋");
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL),
|
||||
"⠙"
|
||||
);
|
||||
assert_eq!(
|
||||
activity_spinner_frame_at(
|
||||
origin,
|
||||
origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32,
|
||||
),
|
||||
"⠋"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_primitives_are_only_used_by_motion_module() {
|
||||
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();
|
||||
|
||||
@@ -23,6 +23,8 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::worktree_labels::WorktreeLabel;
|
||||
use crate::worktree_labels::label_for_cwd;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
use chrono::DateTime;
|
||||
@@ -375,6 +377,7 @@ async fn run_resume_picker_with_launch_context(
|
||||
app_server,
|
||||
include_non_interactive,
|
||||
raw_reasoning_visibility(config),
|
||||
(!is_remote).then(|| config.codex_home.to_path_buf()),
|
||||
bg_tx,
|
||||
),
|
||||
bg_rx,
|
||||
@@ -420,6 +423,7 @@ pub async fn run_fork_picker_with_app_server(
|
||||
app_server,
|
||||
/*include_non_interactive*/ false,
|
||||
raw_reasoning_visibility(config),
|
||||
(!is_remote).then(|| config.codex_home.to_path_buf()),
|
||||
bg_tx,
|
||||
),
|
||||
bg_rx,
|
||||
@@ -540,6 +544,7 @@ fn spawn_app_server_page_loader(
|
||||
app_server: AppServerSession,
|
||||
include_non_interactive: bool,
|
||||
raw_reasoning_visibility: RawReasoningVisibility,
|
||||
codex_home: Option<PathBuf>,
|
||||
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
|
||||
) -> PickerLoader {
|
||||
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PickerLoadRequest>();
|
||||
@@ -557,6 +562,7 @@ fn spawn_app_server_page_loader(
|
||||
request.provider_filter,
|
||||
request.sort_key,
|
||||
include_non_interactive,
|
||||
codex_home.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let _ = bg_tx.send(BackgroundEvent::Page {
|
||||
@@ -725,6 +731,7 @@ async fn load_app_server_page(
|
||||
provider_filter: ProviderFilter,
|
||||
sort_key: ThreadSortKey,
|
||||
include_non_interactive: bool,
|
||||
codex_home: Option<&Path>,
|
||||
) -> std::io::Result<PickerPage> {
|
||||
let response = app_server
|
||||
.thread_list(thread_list_params(
|
||||
@@ -742,7 +749,7 @@ async fn load_app_server_page(
|
||||
rows: response
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(row_from_app_server_thread)
|
||||
.filter_map(|thread| row_from_app_server_thread(thread, codex_home))
|
||||
.collect(),
|
||||
next_cursor: response.next_cursor.map(PageCursor::AppServer),
|
||||
num_scanned_files,
|
||||
@@ -824,6 +831,7 @@ struct Row {
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
worktree_label: Option<WorktreeLabel>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -844,6 +852,24 @@ impl Row {
|
||||
self.thread_name.as_deref().unwrap_or(&self.preview)
|
||||
}
|
||||
|
||||
fn display_branch(&self) -> Option<&str> {
|
||||
self.worktree_label
|
||||
.as_ref()
|
||||
.and_then(|label| label.branch.as_deref())
|
||||
.or(self.git_branch.as_deref())
|
||||
}
|
||||
|
||||
fn display_cwd(&self) -> Option<String> {
|
||||
let cwd = self
|
||||
.cwd
|
||||
.as_ref()
|
||||
.map(|path| format_directory_display(path, /*max_width*/ None))?;
|
||||
Some(match self.worktree_label.as_ref() {
|
||||
Some(label) => format!("{} · {cwd}", label.summary()),
|
||||
None => cwd,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_query(&self, query: &str) -> bool {
|
||||
if self.preview.to_lowercase().contains(query) {
|
||||
return true;
|
||||
@@ -873,6 +899,16 @@ impl Row {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(label) = self.worktree_label.as_ref()
|
||||
&& (label.name.to_lowercase().contains(query)
|
||||
|| label.repo_name.to_lowercase().contains(query)
|
||||
|| label
|
||||
.branch
|
||||
.as_ref()
|
||||
.is_some_and(|branch| branch.to_lowercase().contains(query)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1793,7 +1829,7 @@ impl PickerState {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
|
||||
fn row_from_app_server_thread(thread: Thread, codex_home: Option<&Path>) -> Option<Row> {
|
||||
let thread_id = match ThreadId::from_string(&thread.id) {
|
||||
Ok(thread_id) => thread_id,
|
||||
Err(err) => {
|
||||
@@ -1802,6 +1838,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
|
||||
}
|
||||
};
|
||||
let preview = thread.preview.trim();
|
||||
let cwd = thread.cwd.to_path_buf();
|
||||
let worktree_label = codex_home.and_then(|codex_home| label_for_cwd(codex_home, &cwd));
|
||||
Some(Row {
|
||||
path: thread.path,
|
||||
preview: if preview.is_empty() {
|
||||
@@ -1815,8 +1853,9 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
cwd: Some(thread.cwd.to_path_buf()),
|
||||
cwd: Some(cwd),
|
||||
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
|
||||
worktree_label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2571,11 +2610,8 @@ fn render_comfortable_session_lines(
|
||||
let reference = state.relative_time_reference.unwrap_or_else(Utc::now);
|
||||
let created = format_relative_time(reference, row.created_at);
|
||||
let updated = format_relative_time(reference, row.updated_at.or(row.created_at));
|
||||
let branch = row.git_branch.as_deref();
|
||||
let cwd = row
|
||||
.cwd
|
||||
.as_ref()
|
||||
.map(|path| format_directory_display(path, /*max_width*/ None));
|
||||
let branch = row.display_branch();
|
||||
let cwd = row.display_cwd();
|
||||
let footer_lines = render_footer_lines(
|
||||
state.sort_key,
|
||||
&created,
|
||||
@@ -2973,12 +3009,10 @@ fn render_expanded_session_details(
|
||||
.map(|path| format_directory_display(path, /*max_width*/ None))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let branch = row
|
||||
.git_branch
|
||||
.as_ref()
|
||||
.display_branch()
|
||||
.map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}"))
|
||||
.unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch"));
|
||||
|
||||
vec![
|
||||
let mut details = vec![
|
||||
expanded_detail_line("Session:", &session, width),
|
||||
expanded_time_detail_line("Created:", reference, row.created_at, width),
|
||||
expanded_time_detail_line(
|
||||
@@ -2987,11 +3021,17 @@ fn render_expanded_session_details(
|
||||
row.updated_at.or(row.created_at),
|
||||
width,
|
||||
),
|
||||
];
|
||||
if let Some(worktree) = row.worktree_label.as_ref().map(WorktreeLabel::summary) {
|
||||
details.push(expanded_detail_line("Workspace:", &worktree, width));
|
||||
}
|
||||
details.extend([
|
||||
expanded_detail_line("Directory:", &directory, width),
|
||||
expanded_detail_line("Branch:", &branch, width),
|
||||
vec![" │".dim()].into(),
|
||||
vec![" │ ".dim(), "Conversation:".dim()].into(),
|
||||
]
|
||||
]);
|
||||
details
|
||||
}
|
||||
|
||||
fn render_conversation_preview_lines(
|
||||
@@ -3263,6 +3303,7 @@ mod tests {
|
||||
updated_at: Some(timestamp),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3309,6 +3350,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
};
|
||||
|
||||
assert_eq!(row.display_preview(), "My session");
|
||||
@@ -3349,6 +3391,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: Some(PathBuf::from("/tmp/codex-session-picker")),
|
||||
git_branch: Some(String::from("fcoury/session-picker")),
|
||||
worktree_label: None,
|
||||
};
|
||||
|
||||
assert!(row.matches_query("session-picker"));
|
||||
@@ -3356,6 +3399,37 @@ mod tests {
|
||||
assert!(row.matches_query(&thread_id.to_string()[..8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_worktree_label_overrides_branch_and_prefixes_cwd() {
|
||||
let row = Row {
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("first message"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
thread_name: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
cwd: Some(PathBuf::from(
|
||||
"/Users/felipe.coury/.codex/worktrees/abcd/parser-fix/codex",
|
||||
)),
|
||||
git_branch: Some(String::from("main")),
|
||||
worktree_label: Some(WorktreeLabel {
|
||||
name: String::from("parser-fix"),
|
||||
branch: Some(String::from("parser-fix")),
|
||||
repo_name: String::from("codex"),
|
||||
dirty: false,
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(row.display_branch(), Some("parser-fix"));
|
||||
assert!(
|
||||
row.display_cwd()
|
||||
.expect("cwd")
|
||||
.starts_with("parser-fix · clean · codex · ")
|
||||
);
|
||||
assert!(row.matches_query("parser-fix"));
|
||||
assert!(row.matches_query("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_time_formats_zero_seconds_as_now() {
|
||||
let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
|
||||
@@ -3409,6 +3483,7 @@ mod tests {
|
||||
updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"),
|
||||
cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")),
|
||||
git_branch: Some(String::from("codex/raw-scrollback-mode")),
|
||||
worktree_label: None,
|
||||
};
|
||||
|
||||
let rendered = render_expanded_session_details(&row, &state, /*width*/ 120)
|
||||
@@ -3615,6 +3690,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: Some(PathBuf::from("/srv/real-project")),
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
};
|
||||
|
||||
assert!(state.row_matches_filter(&row));
|
||||
@@ -3640,6 +3716,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: Some(PathBuf::from("/srv/remote-project")),
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
};
|
||||
|
||||
assert!(state.row_matches_filter(&row));
|
||||
@@ -3671,6 +3748,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::seconds(42)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||||
@@ -3681,6 +3759,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
Row {
|
||||
path: Some(PathBuf::from("/tmp/c.jsonl")),
|
||||
@@ -3691,6 +3770,7 @@ mod tests {
|
||||
updated_at: Some(now - Duration::hours(2)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
];
|
||||
state.all_rows = rows.clone();
|
||||
@@ -4119,6 +4199,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
|
||||
state
|
||||
@@ -4157,6 +4238,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
Row {
|
||||
path: None,
|
||||
@@ -4167,6 +4249,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
];
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
@@ -4236,6 +4319,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
Row {
|
||||
path: None,
|
||||
@@ -4246,6 +4330,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
},
|
||||
];
|
||||
state.update_viewport(/*rows*/ 7, /*width*/ 80);
|
||||
@@ -4301,6 +4386,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
|
||||
state
|
||||
@@ -4331,6 +4417,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
|
||||
state
|
||||
@@ -4407,6 +4494,7 @@ mod tests {
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
state.transcript_cells.insert(
|
||||
thread_id,
|
||||
@@ -4606,6 +4694,7 @@ session_picker_view = "dense"
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
|
||||
state
|
||||
@@ -4645,6 +4734,7 @@ session_picker_view = "dense"
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
}];
|
||||
|
||||
state
|
||||
@@ -4723,6 +4813,7 @@ session_picker_view = "dense"
|
||||
"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs",
|
||||
)),
|
||||
git_branch: Some(String::from("fcoury/session-picker")),
|
||||
worktree_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4975,6 +5066,7 @@ session_picker_view = "dense"
|
||||
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
|
||||
cwd: Some(PathBuf::from("/tmp/codex")),
|
||||
git_branch: Some(String::from("fcoury/session-picker")),
|
||||
worktree_label: None,
|
||||
};
|
||||
let mut state = PickerState::new(
|
||||
FrameRequester::test_dummy(),
|
||||
@@ -5044,6 +5136,7 @@ session_picker_view = "dense"
|
||||
updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"),
|
||||
cwd: Some(PathBuf::from("/tmp/codex")),
|
||||
git_branch: Some(String::from("fcoury/session-picker")),
|
||||
worktree_label: None,
|
||||
};
|
||||
let mut state = PickerState::new(
|
||||
FrameRequester::test_dummy(),
|
||||
@@ -5102,6 +5195,7 @@ session_picker_view = "dense"
|
||||
updated_at: Some(now - Duration::minutes(idx * 5)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
})
|
||||
.collect();
|
||||
state.filtered_rows = state.all_rows.clone();
|
||||
@@ -5154,6 +5248,7 @@ session_picker_view = "dense"
|
||||
updated_at: Some(now - Duration::minutes(idx * 5)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
})
|
||||
.collect();
|
||||
state.filtered_rows = state.all_rows.clone();
|
||||
@@ -5622,6 +5717,7 @@ session_picker_view = "dense"
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
};
|
||||
state.all_rows = vec![row.clone()];
|
||||
state.filtered_rows = vec![row];
|
||||
@@ -5661,6 +5757,7 @@ session_picker_view = "dense"
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
worktree_label: None,
|
||||
};
|
||||
state.all_rows = vec![row.clone()];
|
||||
state.filtered_rows = vec![row];
|
||||
@@ -5704,7 +5801,8 @@ session_picker_view = "dense"
|
||||
turns: Vec::new(),
|
||||
};
|
||||
|
||||
let row = row_from_app_server_thread(thread).expect("row should be preserved");
|
||||
let row = row_from_app_server_thread(thread, /*codex_home*/ None)
|
||||
.expect("row should be preserved");
|
||||
|
||||
assert_eq!(row.path, None);
|
||||
assert_eq!(row.thread_id, Some(thread_id));
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum SlashCommand {
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
Worktree,
|
||||
Init,
|
||||
Compact,
|
||||
Plan,
|
||||
@@ -87,6 +88,7 @@ impl SlashCommand {
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
SlashCommand::Worktree => "manage worktrees",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||
@@ -158,6 +160,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
@@ -181,6 +184,7 @@ impl SlashCommand {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(creating_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Creating fcoury/demo...
|
||||
Codex is creating the worktree before starting the chat in that workspace.
|
||||
|
||||
› Preparing worktree... Codex is creating the worktree before starting the chat in
|
||||
that workspace.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(empty_params(), 84)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
|
||||
› 1. New worktree... Type the branch name for the new worktree.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Loading worktrees...
|
||||
This can take a moment when Codex is checking app, CLI, and Git worktrees.
|
||||
|
||||
› Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git
|
||||
worktrees.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(params, 86)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
Create a worktree or fork this chat into an existing workspace.
|
||||
|
||||
Search worktrees
|
||||
New worktree... 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
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)"
|
||||
---
|
||||
|
||||
Remove worktree fcoury/demo?
|
||||
Only Codex-managed worktrees can be removed.
|
||||
|
||||
› 1. Remove Remove the selected worktree.
|
||||
2. Cancel Keep the worktree.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Switching to fcoury/demo...
|
||||
Codex is rebuilding configuration and starting the chat in that workspace.
|
||||
|
||||
› Preparing worktree session... Codex is rebuilding configuration and starting the
|
||||
chat in that workspace.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
911
codex-rs/tui/src/worktree.rs
Normal file
911
codex-rs/tui/src/worktree.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
49
codex-rs/tui/src/worktree_labels.rs
Normal file
49
codex-rs/tui/src/worktree_labels.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WorktreeLabel {
|
||||
pub(crate) name: String,
|
||||
pub(crate) branch: Option<String>,
|
||||
pub(crate) repo_name: String,
|
||||
pub(crate) dirty: bool,
|
||||
}
|
||||
|
||||
impl WorktreeLabel {
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let mut parts = vec![self.branch.clone().unwrap_or_else(|| self.name.clone())];
|
||||
parts.push(if self.dirty { "dirty" } else { "clean" }.to_string());
|
||||
parts.push(self.repo_name.clone());
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn label_for_cwd(codex_home: &Path, cwd: &Path) -> Option<WorktreeLabel> {
|
||||
let info = codex_worktree::resolve_worktree(codex_home, cwd)
|
||||
.inspect_err(|err| tracing::warn!(?err, "failed to resolve managed worktree label"))
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some(WorktreeLabel {
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
repo_name: info.repo_name,
|
||||
dirty: info.dirty.is_dirty(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn summary_includes_name_branch_and_repo() {
|
||||
let label = WorktreeLabel {
|
||||
name: String::from("parser-fix"),
|
||||
branch: Some(String::from("parser-fix")),
|
||||
repo_name: String::from("codex"),
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
assert_eq!(label.summary(), "parser-fix · clean · codex");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ mod config_override;
|
||||
pub(crate) mod format_env_display;
|
||||
mod sandbox_mode_cli_arg;
|
||||
mod shared_options;
|
||||
mod worktree_dirty_cli_arg;
|
||||
|
||||
pub use approval_mode_cli_arg::ApprovalModeCliArg;
|
||||
pub use config_override::CliConfigOverrides;
|
||||
pub use format_env_display::format_env_display;
|
||||
pub use sandbox_mode_cli_arg::SandboxModeCliArg;
|
||||
pub use shared_options::SharedCliOptions;
|
||||
pub use worktree_dirty_cli_arg::WorktreeDirtyCliArg;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared command-line flags used by both interactive and non-interactive Codex entry points.
|
||||
|
||||
use crate::SandboxModeCliArg;
|
||||
use crate::WorktreeDirtyCliArg;
|
||||
use clap::Args;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -51,6 +52,18 @@ pub struct SharedCliOptions {
|
||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Create or reuse a Codex-managed Git worktree for this branch and run from that workspace.
|
||||
#[arg(long = "worktree", value_name = "BRANCH")]
|
||||
pub worktree: Option<String>,
|
||||
|
||||
/// Base ref for a newly created managed worktree.
|
||||
#[arg(long = "worktree-base", value_name = "REF")]
|
||||
pub worktree_base: Option<String>,
|
||||
|
||||
/// How to handle uncommitted source checkout changes when creating a worktree.
|
||||
#[arg(long = "worktree-dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)]
|
||||
pub worktree_dirty: WorktreeDirtyCliArg,
|
||||
|
||||
/// Additional directories that should be writable alongside the primary workspace.
|
||||
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
|
||||
pub add_dir: Vec<PathBuf>,
|
||||
@@ -69,6 +82,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
worktree,
|
||||
worktree_base,
|
||||
worktree_dirty,
|
||||
add_dir,
|
||||
} = self;
|
||||
let Self {
|
||||
@@ -80,6 +96,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode: root_sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
|
||||
cwd: root_cwd,
|
||||
worktree: root_worktree,
|
||||
worktree_base: root_worktree_base,
|
||||
worktree_dirty: root_worktree_dirty,
|
||||
add_dir: root_add_dir,
|
||||
} = root;
|
||||
|
||||
@@ -105,6 +124,15 @@ impl SharedCliOptions {
|
||||
if cwd.is_none() {
|
||||
cwd.clone_from(root_cwd);
|
||||
}
|
||||
if worktree.is_none() {
|
||||
worktree.clone_from(root_worktree);
|
||||
}
|
||||
if worktree_base.is_none() {
|
||||
worktree_base.clone_from(root_worktree_base);
|
||||
}
|
||||
if *worktree_dirty == WorktreeDirtyCliArg::Fail {
|
||||
*worktree_dirty = *root_worktree_dirty;
|
||||
}
|
||||
if !root_images.is_empty() {
|
||||
let mut merged_images = root_images.clone();
|
||||
merged_images.append(images);
|
||||
@@ -129,6 +157,9 @@ impl SharedCliOptions {
|
||||
sandbox_mode,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
worktree,
|
||||
worktree_base,
|
||||
worktree_dirty,
|
||||
add_dir,
|
||||
} = subcommand;
|
||||
|
||||
@@ -152,6 +183,15 @@ impl SharedCliOptions {
|
||||
if let Some(cwd) = cwd {
|
||||
self.cwd = Some(cwd);
|
||||
}
|
||||
if let Some(worktree) = worktree {
|
||||
self.worktree = Some(worktree);
|
||||
}
|
||||
if let Some(worktree_base) = worktree_base {
|
||||
self.worktree_base = Some(worktree_base);
|
||||
}
|
||||
if worktree_dirty != WorktreeDirtyCliArg::Fail {
|
||||
self.worktree_dirty = worktree_dirty;
|
||||
}
|
||||
if !images.is_empty() {
|
||||
self.images = images;
|
||||
}
|
||||
|
||||
13
codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs
Normal file
13
codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs
Normal 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,
|
||||
}
|
||||
7
codex-rs/worktree/BUILD.bazel
Normal file
7
codex-rs/worktree/BUILD.bazel
Normal file
@@ -0,0 +1,7 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "worktree",
|
||||
crate_name = "codex_worktree",
|
||||
)
|
||||
|
||||
19
codex-rs/worktree/Cargo.toml
Normal file
19
codex-rs/worktree/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "codex-worktree"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
318
codex-rs/worktree/src/dirty.rs
Normal file
318
codex-rs/worktree/src/dirty.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
57
codex-rs/worktree/src/git.rs
Normal file
57
codex-rs/worktree/src/git.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn stdout(cwd: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = output(cwd, args)?;
|
||||
Ok(String::from_utf8(output)?.trim_end().to_string())
|
||||
}
|
||||
|
||||
pub fn bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
output(cwd, args)
|
||||
}
|
||||
|
||||
pub fn status(cwd: &Path, args: &[&str]) -> Result<()> {
|
||||
output(cwd, args).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {}", args.join(" ")))?;
|
||||
use std::io::Write as _;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git stdin unavailable")?
|
||||
.write_all(stdin)?;
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
113
codex-rs/worktree/src/lib.rs
Normal file
113
codex-rs/worktree/src/lib.rs
Normal 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>,
|
||||
}
|
||||
753
codex-rs/worktree/src/manager.rs
Normal file
753
codex-rs/worktree/src/manager.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
157
codex-rs/worktree/src/metadata.rs
Normal file
157
codex-rs/worktree/src/metadata.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WorktreeInfo;
|
||||
use crate::WorktreeLocation;
|
||||
use crate::WorktreeSource;
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeThreadMetadata {
|
||||
pub version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeMetadata {
|
||||
pub version: u32,
|
||||
pub manager: String,
|
||||
pub backend: String,
|
||||
#[serde(default = "default_source")]
|
||||
pub source: WorktreeSource,
|
||||
#[serde(default = "default_location")]
|
||||
pub location: WorktreeLocation,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub branch: Option<String>,
|
||||
pub repo_id: String,
|
||||
pub repo_name: String,
|
||||
pub source_repo_root: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub tmux_session: Option<String>,
|
||||
}
|
||||
|
||||
impl WorktreeMetadata {
|
||||
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
|
||||
let now = unix_seconds();
|
||||
Self {
|
||||
version: 1,
|
||||
manager: "codex-cli".to_string(),
|
||||
backend: "git".to_string(),
|
||||
source: info.source,
|
||||
location: info.location,
|
||||
id: info.id.clone(),
|
||||
name: info.name.clone(),
|
||||
slug: info.slug.clone(),
|
||||
branch: info.branch.clone(),
|
||||
repo_id: info.id.clone(),
|
||||
repo_name: info.repo_name.clone(),
|
||||
source_repo_root,
|
||||
original_relative_cwd: info.original_relative_cwd.clone(),
|
||||
worktree_git_root: info.worktree_git_root.clone(),
|
||||
workspace_cwd: info.workspace_cwd.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
owner_thread_id: info.owner_thread_id.clone(),
|
||||
tmux_session: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_source() -> WorktreeSource {
|
||||
WorktreeSource::Legacy
|
||||
}
|
||||
|
||||
fn default_location() -> WorktreeLocation {
|
||||
WorktreeLocation::CodexHome
|
||||
}
|
||||
|
||||
pub fn read_worktree_metadata(worktree_path: &Path) -> Result<Option<WorktreeMetadata>> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
read_json_if_exists(&path)
|
||||
}
|
||||
|
||||
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
write_json(&path, metadata)
|
||||
}
|
||||
|
||||
pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
|
||||
let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let git_root = PathBuf::from(git_root);
|
||||
let owner = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: Some(thread_id.to_string()),
|
||||
};
|
||||
let owner_path = metadata_path(&git_root, "codex-thread.json")?;
|
||||
write_json(&owner_path, &owner)?;
|
||||
|
||||
if let Some(mut metadata) = read_worktree_metadata(&git_root)? {
|
||||
metadata.owner_thread_id = Some(thread_id.to_string());
|
||||
metadata.updated_at = unix_seconds();
|
||||
write_worktree_metadata(&git_root, &metadata)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
|
||||
let metadata = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: None,
|
||||
};
|
||||
let path = metadata_path(worktree_path, "codex-thread.json")?;
|
||||
write_json(&path, &metadata)
|
||||
}
|
||||
|
||||
fn read_json_if_exists<T>(path: &Path) -> Result<Option<T>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
Ok(Some(serde_json::from_str(&contents)?))
|
||||
}
|
||||
|
||||
fn write_json<T>(path: &Path, value: &T) -> Result<()>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let contents = serde_json::to_string_pretty(value)?;
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_path(worktree_path: &Path, name: &str) -> Result<PathBuf> {
|
||||
let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?;
|
||||
let path = PathBuf::from(path);
|
||||
Ok(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
worktree_path.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn unix_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
105
codex-rs/worktree/src/paths.rs
Normal file
105
codex-rs/worktree/src/paths.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use sha2::Digest;
|
||||
|
||||
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("worktrees")
|
||||
}
|
||||
|
||||
pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
|
||||
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let root = codex_worktrees_root(codex_home)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_worktrees_root(codex_home));
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
pub fn slugify_name(name: &str) -> Result<String> {
|
||||
let slug = name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() {
|
||||
anyhow::bail!("worktree name must contain at least one ASCII letter or digit");
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
|
||||
let sanitized = branch.replace(['/', '\\'], "-");
|
||||
if sanitized.trim().is_empty() {
|
||||
anyhow::bail!("branch name must produce a non-empty worktree path segment");
|
||||
}
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(common_git_dir.to_string_lossy().as_bytes());
|
||||
if let Some(origin_url) = origin_url {
|
||||
hasher.update(b"\0");
|
||||
hasher.update(origin_url.as_bytes());
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
|
||||
let repo_name = repo_root
|
||||
.file_name()
|
||||
.context("source repository root has no directory name")?;
|
||||
let parent = repo_root
|
||||
.parent()
|
||||
.context("source repository root has no parent directory")?;
|
||||
let sanitized_branch = sanitize_branch_for_path(branch)?;
|
||||
let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch);
|
||||
Ok(parent.join(dirname))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn slugify_name_keeps_short_ascii_slug() -> Result<()> {
|
||||
assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> {
|
||||
assert_eq!(
|
||||
sanitize_branch_for_path("feature/auth\\windows")?,
|
||||
"feature-auth-windows"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> {
|
||||
assert_eq!(
|
||||
sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?,
|
||||
PathBuf::from("/Users/me/code/codex.feature-auth")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
516
codex-rs/worktree/tests/git_backend.rs
Normal file
516
codex-rs/worktree/tests/git_backend.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user