feat: add explicit worktree setup flow (#286)

* feat: add explicit worktree setup flow

- Centralize worktree activation setup flow

- fix: render orca.yaml file contents in settings repo pane instead of tags when using config file

- fix: command preview title for yaml configs

- fix: update UI copy and styling to better reflect yaml setup hooks

- fix: run setup hook in background if CLI worktree is created while GUI is closed

- fix: prevent duplicate terminal tab creation when creating worktrees from the UI

- fix: ensure CLI worktree create returns correct payload type and hooks up UI setup flow

- Squashed commits

- fix minor regression

- fix: close worktree context menu when deleting

- fix: keep delete dialog open while archive hook runs and support inline yaml scripts

- chore: fix lint, typecheck and react state mutations during render for setup script feature

* fix(lint): split worktrees.test.ts to stay under max-lines limit

Extract Windows path-handling tests into worktrees-windows.test.ts
to bring the original file under the 300-line oxlint max-lines rule.

* fix(test): set up path mocks for setup launch payload test

The computeWorktreePathMock and ensurePathWithinWorkspaceMock were
cleared by afterEach but not re-established in the test, causing
areWorktreePathsEqual to receive undefined and crash.
This commit is contained in:
Jinjing 2026-04-03 23:44:23 -07:00 committed by GitHub
parent ee7ac0cbc0
commit 90d0fae8f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2851 additions and 615 deletions

View File

@ -0,0 +1,122 @@
╔══════════════════════════════════════════════════════════════════╗
║ DESIGN REVIEW ║
╠══════════════════════════════════════════════════════════════════╣
║ Document: docs/worktree-setup-script-design.md ║
║ Reviewer: Gemini CLI (First Principles Design Review) ║
╠══════════════════════════════════════════════════════════════════╣
║ VERDICT: 🟢 PROCEED WITH CAUTION (Design Updated) ║
╚══════════════════════════════════════════════════════════════════╝
═══════════════════════════════════════════════════════════
PHASE A: DESIGN CHALLENGE
═══════════════════════════════════════════════════════════
## Premise & Problem Assessment
The problem diagnosis is highly accurate. Implicit execution of setup scripts creates a hostile UX when things fail (e.g., missing auth, uninstalled dependencies). Exposing this as an explicit, terminal-first user choice directly addresses Issue #238.
## Alternative Approaches Considered
### Alternative 1: Dedicated Background Log Panel
- **Approach**: Run the setup script as a background process (using `node-pty` but not interactive) and stream output to a read-only UI panel in Orca.
- **How it works**: Uses a similar `exec` execution context as today, but surfaces logs.
- **Why it might be better**: Guarantees execution semantics (`set -e` equivalent) and prevents the user from accidentally typing into the terminal mid-setup and messing up the command.
- **Tradeoff**: Cannot handle interactive prompts (e.g., SSH keys, 2FA, package manager choices), which is the primary reason the design rejected background execution.
- **Effort**: 1.5x (Requires building a log viewer UI).
- **Risk**: Hanging setups due to hidden auth prompts.
### Alternative 2: Generated Runner Script (Recommended)
- **Approach**: Main process generates a temporary executable script (e.g., `.orca-setup.sh` or `.orca-setup.cmd`) containing the setup commands wrapped in strict error handling (`set -e`). It then spawns the PTY and injects `source .orca-setup.sh`.
- **How it works**: The terminal remains interactive, but the execution of multiline commands is handled safely by the script runner.
- **Why it might be better**: Fixes the catastrophic failure containment issue of pasting multiline commands into an interactive terminal (see Phase B).
- **Tradeoff**: Requires writing temporary files and platform-specific wrappers.
- **Effort**: 1.2x.
- **Risk**: Edge cases in path resolution or permissions for the temporary script.
### Recommendation
The proposed **Terminal-First** design is fundamentally the right approach for visibility and interactivity. However, the execution model is **💡 BETTER ALTERNATIVE EXISTS**: You must adopt Alternative 2 (Generated Runner Script) to safely execute multiline commands in an interactive shell.
## UX & User Journey Issues
### Interaction State Coverage
| Flow | Loading | Empty | Error | Success | Partial | Notes |
| --------------- | ------- | ----- | ----- | ------- | ------- | ------------------------------------------------------------------------------------------------- |
| Create Worktree | ❌ | ✅ | ✅ | ✅ | ✅ | What does the UI show between clicking "Create" and the terminal opening? Git cloning takes time. |
### UX Findings
| Issue | Severity | User Impact | Suggested Fix |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| Context Switching | P2 | If user creates Worktree A, but clicks Worktree B while Git clone is running, the terminal might steal focus or open in the wrong context. | Define focus-stealing rules: Does the newly created worktree auto-focus when creation completes? |
| Terminal Identification | P3 | User has multiple terminals open. They don't know which one is running the setup script. | Give the setup PTY a specific title or header (e.g., `[Orca Setup]`). |
## Architectural Fit
```text
[UI Dialog] -> IPC: worktrees:create(repoId, ..., setupDecision)
|
[Main Process] -> Git Clone
|
Returns: { worktree, shouldRunSetup, envVars }
|
[Renderer updates UI] <------+
|
[Renderer opens PTY] <------+ (Needs to pass envVars to PTY but cannot!)
|
[PTY runs setup script]
```
### Data Flow (4 paths)
```text
Happy path: [User clicks create] → [Main creates worktree] → [Renderer opens PTY] → [Setup runs]
Nil/missing: [User selects skip] → [Main creates worktree] → [Returns shouldRunSetup=false] → [No terminal]
Empty: [No setup config] → [Dialog hides setup section] → [Normal create]
Upstream error: [Git clone fails] → [Main returns error] → 💥 [UI shows error toast, setup never runs]
```
| Issue | Severity | Example | Why | Evidence |
| ------- | -------- | ------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------- |
| API Gap | P0 | Renderer needs to pass `ORCA_WORKTREE_PATH` to the PTY. | The `pty:spawn` IPC handler does not accept custom environment variables. | `src/main/ipc/pty.ts` lines 65-72 |
═══════════════════════════════════════════════════════════
PHASE B: DESIGN AUDIT
═══════════════════════════════════════════════════════════
## Critical Blockers (P0/P1 - Must Fix Before Implementation)
| Blocker | Severity | Example | Why | Evidence |
| --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| **PTY Env Injection API** | P0 | The design states: "These existing variables should be passed to the setup command PTY". The renderer opens the PTY. | `pty:spawn` hardcodes `process.env`. The requested feature cannot be built without modifying this IPC handler to accept `env?: Record<string, string>`. | `src/main/ipc/pty.ts` |
| **Multiline Failure Containment** | P0 | `orca.yaml` contains: `pnpm install \n pnpm build`. | Pasting multiline text into an interactive bash/zsh prompt executes line by line. If `pnpm install` fails, the shell will STILL execute `pnpm build`. This violates idempotency and can corrupt the environment. | Standard bash/zsh interactive behavior vs script behavior (lack of `set -e`). |
| **Hanging Script Cancellation** | P1 | Setup command hangs. User presses `Ctrl+C`. | If the command was injected as multiline text, `Ctrl+C` only cancels the _currently executing line_, not the rest of the buffered text. The script will plow forward. | Standard interactive shell buffer behavior. |
| **PTY Race Conditions** | P1 | Injecting commands via `pty.write()` on startup. | Depending on the shell (e.g., heavy `.zshrc`), writing to the PTY immediately after spawn can result in dropped characters or execution before the prompt is ready. | Known `node-pty` limitation. |
## Unverified Assumptions
| Assumption | Evidence Required | Severity | Example | Why |
| ---------------------- | ----------------------------------------------------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| PTY Execution Strategy | A proven mechanism to execute arbitrary strings in `node-pty` robustly. | P0 | "Orca starts the setup command in that terminal" | Doing this reliably across Windows (cmd/pwsh) and Mac (bash/zsh) is notoriously difficult without wrapper scripts. |
## Hidden Complexity
| Hidden Issue | Why It Will Surface | Severity | Example | Evidence |
| ------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| UI State Sync | The `setupDecision` logic | P2 | The dialog needs to resolve `getEffectiveHooks()` to know if the `Setup` section should appear, meaning the Renderer needs real-time access to the resolved repo policy. | `worktree-setup-script-design.md` -> Edge Cases -> Repo Changes While Dialog Is Open |
═══════════════════════════════════════════════════════════
QUESTIONS FOR THE AUTHOR
═══════════════════════════════════════════════════════════
1. **How exactly will the Renderer execute the string in the PTY?**
→ What we need: A concrete execution strategy (e.g., wrapper script, `\r` injection) that mitigates the P0 multiline failure containment issue and race conditions.
2. **How will `ORCA_WORKTREE_PATH` reach the PTY?**
→ What we need: Explicit mention of updating `pty:spawn` in `src/main/ipc/pty.ts` to accept an `env` override payload.
3. **What happens in the UI during the "creating..." phase?**
→ What we need: A definition of the UI state between clicking the dialog and the terminal opening. If the user clicks away to another worktree, does the terminal open in the background, or does it force-switch them back?

View File

@ -0,0 +1,650 @@
# Worktree Setup Command Design
## Problem
Issue [#238](https://github.com/stablyai/orca/issues/238) asks for two behaviors:
1. let a repo point to a setup script
2. let the user decide whether that setup should run during worktree creation
Orca already has a partial implementation:
- repo-level `setup` hooks exist today
- hooks can come from `orca.yaml` or repo settings UI
- the setup hook already runs in the new worktree after creation
What is missing is the product model:
- the create-worktree flow does not surface setup at all
- users cannot make a per-create decision
- the current UX frames setup as a generic lifecycle hook instead of a first-class workspace setup step
- setup failures are not visible enough to be actionable
## Research Summary
Broader developer tooling suggests a stronger design than a simple "run hook or not" toggle.
Conductor models setup as a repo-level command that runs in the new workspace and exposes workspace env vars. It also supports repo-committed configuration. That is the closest direct precedent.
Remote-dev tools such as Codespaces, dev containers, and Gitpod separate environment setup from app start:
- one-time or infrequent setup commands install dependencies and prepare the workspace
- interactive commands start services later
- expensive setup work should be explicit, repeatable, and ideally idempotent
Engineering blog guidance around `bin/setup` and setup scripts is consistent:
- provide one obvious command
- make it safe to rerun
- keep it readable
- use it to prepare the environment, not to hide unrelated workflow logic
## Goals
- Make worktree setup explicit in Orca.
- Preserve the existing hook plumbing instead of inventing a second execution system.
- Let the user decide whether setup runs for each new worktree.
- Keep repo-committed configuration possible.
- Improve compatibility with Conductor-style setup commands where it is low-cost and safe.
- Set a clearer product boundary between "prepare this worktree" and "run my app."
## Non-Goals
- Adding a full task runner or process manager UI
- Supporting multiple named setup phases in v1
- Parsing multiple repo config formats with complicated precedence rules
- Replacing the existing archive hook model
- Automatically starting long-running dev servers as part of worktree creation
## Design Principles
- Reuse the existing hook pipeline.
- Treat setup as a repo-level setup command, not as a path-only script field.
- Put the user decision in the create-worktree flow.
- Make the default policy configurable per repo.
- Require setup commands to be safe to rerun.
- Prefer cross-platform command guidance over shell-specific examples.
- Make failure visible enough that the user can recover without guessing.
## Terminology
This feature should be framed in the product as a `Setup Command` or `Setup Command`, not just a generic hook.
Why:
- "hook" is implementation language
- "setup" better communicates that the command prepares a fresh worktree
- it aligns better with established patterns like `bin/setup`, devcontainer lifecycle commands, and Gitpod init tasks
Internally, the existing `setup` hook plumbing can remain.
## Current State In Orca
Relevant code:
- [src/main/hooks.ts](../src/main/hooks.ts)
- [src/main/ipc/worktrees.ts](../src/main/ipc/worktrees.ts)
- [src/renderer/src/components/sidebar/AddWorktreeDialog.tsx](../src/renderer/src/components/sidebar/AddWorktreeDialog.tsx)
- [src/renderer/src/components/settings/RepositoryPane.tsx](../src/renderer/src/components/settings/RepositoryPane.tsx)
- [src/renderer/src/components/settings/HookEditor.tsx](../src/renderer/src/components/settings/HookEditor.tsx)
- [src/shared/types.ts](../src/shared/types.ts)
Behavior today:
- `getEffectiveHooks(repo)` resolves `setup` from `orca.yaml` or UI settings
- `runHook('setup', worktreePath, repo)` executes the command in the new worktree
- `worktrees:create` always runs setup when an effective setup hook exists
- the create-worktree dialog has no setup visibility or opt-out
- setup result visibility is limited to internal logging
Important nuance:
The backend already supports command strings, which means the configured setup may invoke:
- a package manager script like `pnpm worktree:setup`
- a Node entrypoint like `node scripts/setup-worktree.mjs`
- a repo-local shell script like `bash scripts/setup-worktree.sh`
- inline shell commands
So the main missing feature is not command execution support. It is product framing, policy, and UX.
## What The Ecosystem Suggests
## 1. Keep Setup As A Command String
Conductor, dev containers, and common setup patterns all center on a command to run, not a special "script path" object.
This is the right model because it supports:
- package scripts
- Node entrypoints
- shell commands
- wrappers that dispatch differently per platform
Adding a `setupScriptPath` field would be less flexible and would bias the feature toward shell-only implementations.
## 2. Separate Setup From App Start
The strongest cross-product pattern is lifecycle separation:
- setup prepares the environment
- start commands run interactive services later
For Orca v1, the setup/setup command should be explicitly scoped to worktree preparation tasks such as:
- install dependencies
- copy ignored env files
- initialize submodules
- run one-time codegen
- seed local config for that worktree
It should not be positioned as the place to:
- launch long-running servers
- start watch processes
- open ports
- manage ongoing background services
That guidance should appear in the design and product copy even if Orca does not enforce it technically.
## 3. Defaults Should Be Policy, Not Just Boolean State
The initial design proposed a boolean repo default. The broader research suggests a better model:
```ts
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
```
Why:
- some repos should always prompt because setup is expensive or conditional
- some repos almost always want setup
- some repos rarely need setup, but the action should remain visible
This matches the issue better than a hidden boolean because it preserves user intent at the moment of creation.
## 4. Output Visibility Matters
A toast-only model is too weak.
Setup commands fail for predictable reasons:
- missing env files
- package manager auth issues
- version mismatches
- broken scripts
The user needs a place to inspect the output. V1 does not need a full job dashboard, but it should preserve enough output to debug failures.
## Proposed Product UX
## 1. Repository Settings
Keep the current hook system, but surface setup as a first-class setup concept.
Recommended changes:
- relabel the `setup` section to `Setup Command`
- keep `archive` under lifecycle hooks
- add a repo-level `When creating a worktree` policy control with:
- `Ask every time`
- `Run by default`
- `Skip by default`
- add guidance that setup commands should be safe to rerun
Recommended helper text:
- `Runs in the new worktree after creation to prepare the environment. Prefer an idempotent command such as "pnpm worktree:setup" or "node scripts/setup-worktree.mjs". Avoid starting long-running dev servers here.`
Why:
- this makes the feature discoverable
- it nudges teams toward reliable command shapes
- it aligns with cross-platform support
## 2. Create Worktree Dialog
When an effective setup command exists for the selected repo, show a setup section:
- label: `Setup`
- checkbox: `Run setup command after creation`
- supporting text: show whether the command comes from `orca.yaml` or UI settings
- preview: show a truncated first line or command summary
Initial checkbox value and dialog behavior comes from the repo policy:
- `ask` means the dialog must require an explicit choice:
- radio/select choice: `Run setup now` or `Skip for now`
- no implicit default submit path
- the create action stays disabled until the user chooses one
- `run-by-default` means the checkbox is checked. (If triggered via a "Quick Create" flow like a command palette, it may bypass the dialog entirely).
- `skip-by-default` means the checkbox is unchecked.
Why:
- the issue explicitly wants user choice
- expensive setup work should be visible at creation time
- `ask` should mean a real decision, not an unchecked box that silently turns into skip
## 3. Setup Result Visibility (Open A Terminal And Run It)
V1 should run setup in a normal integrated terminal for the new worktree, not as a hidden background task. Because the configured setup command might be a multiline script from `orca.yaml`, it cannot simply be pasted into an interactive terminal (doing so breaks idempotency if an early line fails, as the shell will continue executing subsequent lines).
**Execution Strategy (Generated Runner Script):**
1. The Main Process generates a temporary executable script file inside the worktree (`.git/orca/setup-runner.sh` for macOS/Linux, or `.git/orca/setup-runner.cmd` for Windows).
2. The Main Process writes the resolved multiline setup command into this script. For bash/zsh, prepend `set -e` so failures halt execution immediately.
3. After the worktree is created, if setup is enabled, Orca opens and focuses a terminal for that worktree.
4. Orca starts the generated script in that terminal (e.g., `bash .git/orca/setup-runner.sh` or `cmd.exe /c .git\\orca\\setup-runner.cmd`).
5. Success, failure, and interactive prompts (like SSH keys) are handled directly in the terminal safely.
**Post-Execution UX:**
- the terminal remains a normal, fully interactive shell
- when the command finishes, the user is back at the shell prompt in that same terminal
- the user can inspect output, press `Up Arrow` to retry, or run whatever they want next
Non-goal:
- a background jobs system
- durable setup-session recovery across reloads
- custom log persistence or special transcript storage
## Proposed Data Model
Do not add `setupScriptPath`.
Keep the setup command as a string and add a run policy:
```ts
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
type RepoHookSettings = {
mode: 'auto' | 'override'
setupRunPolicy?: SetupRunPolicy // Defaults to 'run-by-default' if undefined
scripts: {
setup: string
archive: string
}
}
```
Rationale:
- the command model already solves "point to a script" and more
- policy is the missing product state
- `setupRunPolicy` being optional ensures backward compatibility with existing configs (migrating gracefully by defaulting to `run-by-default`)
## Proposed IPC/API Changes
Extend the create-worktree request:
```ts
type CreateWorktreeArgs = {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}
```
And update the return type to provide the generated runner script and environment payload:
```ts
type CreateWorktreeResult = {
// ... existing fields
setup?: {
runnerScriptPath: string
envVars: Record<string, string>
}
}
```
Update `pty:spawn` in `src/main/ipc/pty.ts` to accept custom environment variables so the setup terminal can receive its context:
```ts
// Existing: ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string })
// New:
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string, env?: Record<string, string> })
```
Behavior:
- `run` always runs setup when an effective setup command exists
- `skip` always skips setup
- `inherit` delegates resolution to the backend using repo policy
- if the resolved policy is `ask` and the caller sends `inherit` (or omits the field), the backend rejects the request with an explicit error such as `Setup decision required for this repository`
Why:
- the backend must own policy enforcement so non-dialog callers cannot accidentally bypass `ask` or `skip-by-default`
- a tri-state decision keeps compatibility for existing callers while still allowing the backend to reject ambiguous creates when the repo requires a choice
## Proposed Execution Rules
## 1. Setup Resolution
Continue using `getEffectiveHooks(repo)` for command resolution.
That means setup may still come from:
- `orca.yaml`
- UI override
- UI fallback in auto mode
## 2. Create Flow
`worktrees:create` should:
1. create the git worktree exactly as it does today
2. persist worktree metadata
3. resolve whether this create operation should run setup by combining `setupDecision` with the repo's `setupRunPolicy`
4. if setup should run, generate a temporary runner script (e.g., `.git/orca/setup-runner.sh`) containing the resolved command.
5. return the created worktree plus the path to the generated setup script and the env vars to inject into the PTY.
Crucially, **the backend owns the policy decision and script generation, but the renderer owns opening the terminal and starting the visible terminal command**.
Requirements for execution:
- **Terminal-First:** Setup must run in a visible terminal, not through `runHook()` and not as a hidden background exec.
- **Safe Execution:** The Renderer must pass the generated runner script and environment variables to the new PTY, not raw multiline text.
- **Simple Ownership:** Orca should use the existing terminal flow. Open a terminal for the worktree, then start the setup command there.
- **Best-Effort:** If the renderer reloads before or during setup, Orca does not need to recover or resume that setup run. The user can rerun it manually.
- **Interactivity:** The user is NOT blocked from interacting with the workspace. They can browse code while the terminal runs the setup in plain view.
- **No Rollback:** Setup failure must not roll back worktree creation (the git operation already succeeded).
Why:
- environment preparation is best-effort workspace setupping, not git correctness
- a terminal-first approach avoids the complexity of background process management
- using the existing terminal ownership model is much simpler than inventing setup-session infrastructure
### Terminal Behavior
Required behavior:
- after a successful create with setup enabled, Orca automatically switches focus to the new worktree and opens its terminal panel
- Orca starts the generated runner script in that terminal with the appropriate environment variables injected
- when the command exits, the terminal remains available as a normal shell
- Orca does not guarantee that an in-flight setup survives reloads or terminal closure
Why:
- this solves the actual user problem, "show me the setup and let me interact with it"
- it avoids building a second PTY lifecycle just for setup
- if setup is interrupted, retrying in a terminal is straightforward
## 3. Idempotency Requirement
The product should document a strong expectation that setup commands are idempotent.
That means rerunning the command should be safe and should not corrupt the worktree.
Examples of acceptable behavior:
- reinstall or verify dependencies
- overwrite generated files deterministically
- copy missing env templates without deleting user-edited files
Examples of risky behavior Orca should discourage in docs and copy:
- unconditional destructive deletes
- long-running foreground servers
- one-off mutations that fail or duplicate state on rerun
Why:
- users may create multiple worktrees
- users may retry after failure
- policy defaults may cause setup to run frequently
## 4. Environment Variables
Orca already provides:
- `ORCA_ROOT_PATH`
- `ORCA_WORKTREE_PATH`
- `CONDUCTOR_ROOT_PATH`
- `GHOSTX_ROOT_PATH`
These existing variables should be passed to the setup command PTY to ensure scripts have the context they need.
## 5. Execution Environment (PTY)
Shell scripts can hang indefinitely if they accidentally prompt for user input (e.g., auth prompts, `read -p`).
By executing the setup command in an integrated terminal instead of a hidden background process:
- the user can see and respond to interactive prompts natively.
- the user has full control to cancel hanging scripts via standard terminal controls (`Ctrl+C`).
- familiar, colorized output is preserved.
## Repo-Committed Config Format
For v1, keep `orca.yaml` as the repo-committed config surface.
Example:
```yaml
scripts:
setup: |
pnpm install
node scripts/setup-worktree.mjs
```
or:
```yaml
scripts:
setup: |
node scripts/setup-worktree.mjs
```
Do not add `conductor.json` parsing in this issue.
Why:
- Orca already has `orca.yaml`
- loading both config files introduces precedence ambiguity
- env compatibility gives most of the practical value
## Cross-Platform Guidance
This feature must remain compatible with macOS, Linux, and Windows.
Recommended command examples:
- `pnpm worktree:setup`
- `npm run worktree:setup`
- `node scripts/setup-worktree.mjs`
Avoid recommending only:
- `./scripts/setup-worktree.sh`
Why:
- shell-script-only examples are weaker on Windows
- package scripts and Node entrypoints are easier to keep portable
## Edge Cases
## 1. No Setup Command Configured
- create-worktree dialog shows no setup section
- create flow behaves as it does today without setup execution
## 2. Repo Changes While Dialog Is Open
- the selected repos effective setup state controls setup section visibility
- if the repo selection changes and the new repo has no setup command, hide the section
- if the source changes between YAML and UI fallback, update the source label accordingly
## 3. Expensive Or Conditional Setup
- `ask` policy keeps the choice explicit
- `skip-by-default` covers repos where setup is uncommon but still available
## 4. Setup Failure
- worktree stays created
- failure is surfaced to the user
- the user can inspect output
- no automatic deletion or rollback
## 5. Re-Run After Creation / Recovery
Because setups can fail due to transient issues (e.g., missing `.env`, VPN drops), recovery is straightforward because the user is left in a normal terminal.
- The user can simply press `Up Arrow` and `Enter` in the terminal to retry the setup command.
- We can include a "Rerun Setup" action in the Worktree context menu later as a convenient shortcut that opens a terminal for that worktree and runs the same command again.
- This leverages the idempotency requirement to give developers an easy escape hatch when setup fails.
## 6. UI State During Creation
- Git cloning takes time. During creation, the "Create" button in the dialog should show a loading spinner.
- Once creation is successful, if setup is enabled, Orca should automatically switch focus to the new worktree and immediately open its terminal to surface the setup run.
## 8. Security & Unverified Repositories
- Automatically running setup scripts is a vector for arbitrary code execution if a user clones an untrusted repository.
- Because Orca relies on standard git cloning, if the user explicitly clicks `Run setup now`, they are opting in. However, the `run-by-default` policy must be carefully considered if Orca ever adds features to auto-clone arbitrary public repos. For v1 (managing existing trusted work repositories), defaulting to `run-by-default` is acceptable, but the UI must always display the preview of the command being run.
## Alternatives Considered
## 1. Background Execution with Log Tailing
Rejected.
Reasons:
- running shell scripts in the background is fragile (hidden SSH prompts cause hanging).
- building robust cross-platform process cancellation is difficult.
- tailing text logs in Electron requires additional IPC streaming overhead.
- "preventing interaction" while a 5-minute setup runs creates a hostile UX. A terminal-first approach solves all of these cleanly.
## 2. Add A Dedicated `setupScriptPath` Field
Rejected.
Reasons:
- current command-string model already supports script paths
- a path-only field biases the feature toward shell-specific usage
- command strings cover package scripts, Node entrypoints, wrappers, and inline commands with one model
## 2. Use A Boolean Default
Rejected in favor of a policy enum.
Reasons:
- a boolean cannot express "always prompt"
- issue #238 is fundamentally about user choice at creation time
- policy better matches real repo variation
## 3. Always Auto-Run Setup Like Conductor
Rejected.
Reasons:
- the issue explicitly asks for user choice
- setup commands can be slow, conditional, or side-effectful
## 4. Parse `conductor.json`
Rejected for this issue.
Reasons:
- increases config precedence complexity
- not required to solve the feature request
- environment compatibility provides most of the reuse value
## Implementation Plan
## Main Process
Files:
- [src/shared/types.ts](../src/shared/types.ts)
- [src/main/hooks.ts](../src/main/hooks.ts)
- [src/main/ipc/worktrees.ts](../src/main/ipc/worktrees.ts)
- [src/main/ipc/pty.ts](../src/main/ipc/pty.ts)
Changes:
- add `SetupRunPolicy`
- add `setupRunPolicy?: SetupRunPolicy` to `RepoHookSettings`
- extend `worktrees:create` args with `setupDecision?: 'inherit' | 'run' | 'skip'`
- resolve effective setup behavior in `worktrees:create`, including rejecting ambiguous creates when policy is `ask`
- if setup should run, generate a temporary runner script file (e.g. `.git/orca/setup-runner.sh`) containing the resolved command with `set -e`
- return the created worktree, the generated script path, and the injected environment variables in the result payload
- update `pty:spawn` to accept custom `env` overrides
- keep hidden `runHook()` execution for archive, but do not use it for visible setup execution
## Renderer
Files:
- [src/preload/index.d.ts](../src/preload/index.d.ts)
- [src/preload/index.ts](../src/preload/index.ts)
- [src/renderer/src/store/slices/worktrees.ts](../src/renderer/src/store/slices/worktrees.ts)
- [src/renderer/src/components/sidebar/AddWorktreeDialog.tsx](../src/renderer/src/components/sidebar/AddWorktreeDialog.tsx)
- [src/renderer/src/components/settings/RepositoryPane.tsx](../src/renderer/src/components/settings/RepositoryPane.tsx)
- [src/renderer/src/components/settings/HookEditor.tsx](../src/renderer/src/components/settings/HookEditor.tsx)
Changes:
- thread `setupDecision` through preload and store
- show policy-driven setup controls in `AddWorktreeDialog` with a loading state during creation
- for `ask`, require an explicit `Run setup now` vs `Skip for now` choice before enabling create
- for `run-by-default` and `skip-by-default`, initialize the checkbox from repo policy
- expose setup policy in repository settings
- update settings copy to emphasize setup scope and idempotency
- on successful worktree creation, automatically switch focus to the new worktree
- if setup is enabled, open a terminal for the new worktree, pass the returned custom environment variables to the PTY, and start the generated runner script
## Tests
Add or extend tests for:
- `worktrees:create` skips setup when `setupDecision` is `skip`
- `worktrees:create` generates a runner script and returns path when `setupDecision` is `run`
- `worktrees:create` resolves `inherit` via repo policy
- `worktrees:create` rejects ambiguous `inherit` calls when repo policy is `ask`
- create-worktree dialog shows setup controls only when effective setup exists
- dialog requires an explicit choice when repo policy is `ask`
- dialog uses repo policy for initial state when policy is `run-by-default` or `skip-by-default`
- renderer opens a terminal and starts setup when create returns with setup enabled
- output is visible in the terminal after setup failure
## Recommendation
Implement this as an extension of the current hook system, but tighten the product model:
- frame setup as a worktree setup command
- keep the command as a string
- use a repo-level run policy enum instead of a boolean default
- keep explicit per-create user choice in the dialog, and enforce `ask` in the backend instead of trusting the renderer
- **execute the setup command via a generated runner script in a normal integrated terminal** so multiline commands execute safely, and prompts, cancellation, and output are visible
- explicitly pass standard Orca context variables (`ORCA_WORKTREE_PATH`, etc.) directly into the PTY environment
- keep v1 best-effort, if the terminal is closed or the renderer reloads, the user can rerun setup manually
- document setup as idempotent environment preparation, not app startup
## Sources
- [Conductor environment variables](https://docs.conductor.build/tips/conductor-env)
- [Conductor workspaces and branches](https://docs.conductor.build/tips/workspaces-and-branches)
- [Conductor using monorepos](https://docs.conductor.build/tips/using-monorepos)
- [GitHub Codespaces: Introduction to dev containers](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers)
- [GitHub Codespaces: Configuring prebuilds](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/configuring-prebuilds)
- [containers.dev supporting tools and prebuild patterns](https://containers.dev/supporting.html)
- [containers.dev prebuild guide](https://containers.dev/guide/prebuild)
- [Gitpod tasks](https://ona.com/docs/classic/user/configure/workspaces/tasks)
- [thoughtbot: Use `bin/setup` to simplify development environment setup](https://thoughtbot.com/blog/bin-setup)
- [thoughtbot: Laptop setup for an awesome development environment](https://thoughtbot.com/blog/laptop-setup-for-an-awesome-development-environment)
- [Chris Blunt: Simplifying local environment setup with `bin/setup`](https://www.chrisblunt.com/rails-simplifying-local-environment-setup/)
- [Mesi Rendon: Working environment setupper](https://mesirendon.com/articles/working-environment-setuper/)
- [Nathan Onn: Git worktrees and setup friction in multi-agent workflows](https://www.nathanonn.com/how-i-vibe-code-with-3-ai-agents-using-git-worktrees-without-breaking-anything/)

View File

@ -0,0 +1,77 @@
import type { Repo } from '../shared/types'
import { describe, expect, it, vi } from 'vitest'
const { execFileSyncMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn()
}))
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
chmodSync: vi.fn()
}))
vi.mock('child_process', () => ({
exec: vi.fn(),
execFileSync: execFileSyncMock
}))
describe('createSetupRunnerScript', () => {
const makeRepo = () =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now()
}) as unknown as Repo
it('writes a fail-fast Windows runner that returns after batch commands', async () => {
const fs = await import('fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
makeRepo(),
'C:\\repo\\feature',
'pnpm install\npnpm build'
)
expect(result).toEqual({
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: 'C:\\repo\\feature'
})
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
[
'@echo off',
'setlocal EnableExtensions',
'call pnpm install',
'if errorlevel 1 exit /b %errorlevel%',
'call pnpm build',
'if errorlevel 1 exit /b %errorlevel%',
''
].join('\r\n'),
'utf-8'
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
})

View File

@ -1,3 +1,5 @@
import type { Repo } from '../shared/types'
import { describe, expect, it, vi } from 'vitest'
import { parseOrcaYaml } from './hooks'
@ -12,7 +14,8 @@ const { execMock } = vi.hoisted(() => ({
}))
vi.mock('child_process', () => ({
exec: execMock
exec: execMock,
execFileSync: vi.fn()
}))
describe('parseOrcaYaml', () => {
@ -60,6 +63,17 @@ describe('parseOrcaYaml', () => {
expect(parseOrcaYaml(yaml)).toBeNull()
})
it('parses YAML with inline scalar scripts', () => {
const yaml = `scripts:\n setup: npm install\n archive: sleep 5\n`
const result = parseOrcaYaml(yaml)
expect(result).toEqual({
scripts: {
setup: 'npm install',
archive: 'sleep 5'
}
})
})
it('returns null when scripts block has no setup or archive', () => {
const yaml = `scripts:\n unknown: |\n echo "nope"\n`
expect(parseOrcaYaml(yaml)).toBeNull()
@ -93,25 +107,27 @@ describe('parseOrcaYaml', () => {
describe('getEffectiveHooks', () => {
// We need to dynamically import after mocking
const makeRepo = (hookSettings?: {
mode: 'auto' | 'override'
scripts: { setup: string; archive: string }
}) => ({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings
})
mode?: 'auto' | 'override'
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default'
scripts?: { setup: string; archive: string }
}) =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings
}) as unknown as Repo
it('auto mode with yaml hooks uses yaml hooks', async () => {
it('uses hooks from orca.yaml when present', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
// Re-import to pick up mocks
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({ mode: 'auto', scripts: { setup: '', archive: '' } })
const repo = makeRepo()
const result = getEffectiveHooks(repo)
expect(result).toEqual({
@ -121,25 +137,26 @@ describe('getEffectiveHooks', () => {
})
})
it('auto mode with no yaml hooks but UI scripts uses UI scripts', async () => {
it('falls back to legacy UI hooks when yaml is missing', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(false)
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({
mode: 'auto',
scripts: { setup: 'echo "ui setup"', archive: '' }
mode: 'override',
scripts: { setup: 'echo "legacy ui setup"', archive: 'echo "legacy archive"' }
})
const result = getEffectiveHooks(repo)
expect(result).toEqual({
scripts: {
setup: 'echo "ui setup"'
setup: 'echo "legacy ui setup"',
archive: 'echo "legacy archive"'
}
})
})
it('override mode always uses UI scripts, ignores yaml', async () => {
it('ignores legacy UI override settings when yaml exists', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
@ -153,7 +170,27 @@ describe('getEffectiveHooks', () => {
expect(result).toEqual({
scripts: {
setup: 'echo "ui override"'
setup: 'echo "yaml setup"'
}
})
})
it('falls back per hook when orca.yaml defines only one command', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' }
})
const result = getEffectiveHooks(repo)
expect(result).toEqual({
scripts: {
setup: 'echo "legacy setup"',
archive: 'echo "yaml archive"'
}
})
})
@ -172,16 +209,18 @@ describe('getEffectiveHooks', () => {
describe('runHook', () => {
const makeRepo = (hookSettings?: {
mode: 'auto' | 'override'
scripts: { setup: string; archive: string }
}) => ({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings
})
mode?: 'auto' | 'override'
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default'
scripts?: { setup: string; archive: string }
}) =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings
}) as unknown as Repo
it('uses the Windows command shell when running hooks', async () => {
execMock.mockImplementation((_script, _options, callback) => {
@ -189,6 +228,10 @@ describe('runHook', () => {
return {} as never
})
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
const originalPlatform = process.platform
const originalComSpec = process.env.ComSpec
@ -200,14 +243,7 @@ describe('runHook', () => {
try {
const { runHook } = await import('./hooks')
const result = await runHook(
'setup',
'C:\\repo\\worktree',
makeRepo({
mode: 'override',
scripts: { setup: 'echo hello', archive: '' }
})
)
const result = await runHook('setup', 'C:\\repo\\worktree', makeRepo())
expect(result).toEqual({ success: true, output: '' })
expect(execMock).toHaveBeenCalledWith(
@ -237,6 +273,10 @@ describe('runHook', () => {
return {} as never
})
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@ -248,14 +288,7 @@ describe('runHook', () => {
try {
const { runHook } = await import('./hooks')
const result = await runHook(
'setup',
'/repo/worktree',
makeRepo({
mode: 'override',
scripts: { setup: 'echo hello', archive: '' }
})
)
const result = await runHook('setup', '/repo/worktree', makeRepo())
expect(result).toEqual({ success: true, output: '' })
expect(execMock).toHaveBeenCalledWith(
@ -279,3 +312,41 @@ describe('runHook', () => {
}
})
})
describe('shouldRunSetupForCreate', () => {
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings: {
mode: 'auto',
setupRunPolicy,
scripts: { setup: '', archive: '' }
}
}) as unknown as Repo
it('requires an explicit decision when the repo policy is ask', async () => {
const { shouldRunSetupForCreate } = await import('./hooks')
expect(() => shouldRunSetupForCreate(makeRepo('ask'))).toThrow(
'Setup decision required for this repository'
)
})
it('uses the repo default when the caller inherits', async () => {
const { shouldRunSetupForCreate } = await import('./hooks')
expect(shouldRunSetupForCreate(makeRepo('run-by-default'))).toBe(true)
expect(shouldRunSetupForCreate(makeRepo('skip-by-default'))).toBe(false)
})
it('lets the caller override the repo default per create', async () => {
const { shouldRunSetupForCreate } = await import('./hooks')
expect(shouldRunSetupForCreate(makeRepo('skip-by-default'), 'run')).toBe(true)
expect(shouldRunSetupForCreate(makeRepo('run-by-default'), 'skip')).toBe(false)
})
})

View File

@ -1,11 +1,16 @@
import { readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { exec } from 'child_process'
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs'
import { dirname, join } from 'path'
import { exec, execFileSync } from 'child_process'
import { getDefaultRepoHookSettings } from '../shared/constants'
import type { OrcaHooks, Repo } from '../shared/types'
import type {
OrcaHooks,
Repo,
SetupDecision,
SetupRunPolicy,
WorktreeSetupLaunch
} from '../shared/types'
const HOOK_TIMEOUT = 120_000 // 2 minutes
type HookName = keyof OrcaHooks['scripts']
function getHookShell(): string | undefined {
if (process.platform === 'win32') {
@ -42,15 +47,15 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
break
}
// Indented key like " setup: |" or " archive: |"
const keyMatch = line.match(/^ (setup|archive):\s*\|?\s*$/)
// Indented key like " setup: |" or " archive: |" or " setup: echo hello"
const keyMatch = line.match(/^ (setup|archive):\s*(\|)?\s*(.*)$/)
if (keyMatch) {
// Save previous key
if (currentKey) {
hooks.scripts[currentKey] = currentValue.trimEnd()
}
currentKey = keyMatch[1] as 'setup' | 'archive'
currentValue = ''
currentValue = keyMatch[3] ? `${keyMatch[3]}\n` : ''
continue
}
@ -96,35 +101,127 @@ export function hasHooksFile(repoPath: string): boolean {
}
export function getEffectiveHooks(repo: Repo): OrcaHooks | null {
const defaults = getDefaultRepoHookSettings()
const yamlHooks = loadHooks(repo.path)
const repoSettings = {
...defaults,
...repo.hookSettings,
scripts: {
...defaults.scripts,
...repo.hookSettings?.scripts
}
}
const legacySetup = repo.hookSettings?.scripts.setup?.trim()
const legacyArchive = repo.hookSettings?.scripts.archive?.trim()
const setup = yamlHooks?.scripts.setup?.trim() || legacySetup
const archive = yamlHooks?.scripts.archive?.trim() || legacyArchive
const hooks: OrcaHooks = { scripts: {} }
for (const hookName of ['setup', 'archive'] as HookName[]) {
const yamlScript = yamlHooks?.scripts[hookName]?.trim()
const uiScript = repoSettings.scripts[hookName].trim()
const autoScript = yamlScript || uiScript || undefined
const effectiveScript = repoSettings.mode === 'auto' ? autoScript : uiScript || undefined
if (effectiveScript) {
hooks.scripts[hookName] = effectiveScript
}
}
if (!hooks.scripts.setup && !hooks.scripts.archive) {
if (!setup && !archive) {
return null
}
return hooks
// Why: `orca.yaml` is the preferred source going forward, but existing users may
// still have setup/archive commands persisted only in repo settings. Resolve each
// hook independently so a repo that has only migrated one command into `orca.yaml`
// does not silently lose the other legacy hook until the migration is complete.
return {
scripts: {
...(setup ? { setup } : {}),
...(archive ? { archive } : {})
}
}
}
export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy {
return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy!
}
export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'inherit'): boolean {
if (decision === 'run') {
return true
}
if (decision === 'skip') {
return false
}
const policy = getEffectiveSetupRunPolicy(repo)
if (policy === 'ask') {
throw new Error('Setup decision required for this repository')
}
return policy === 'run-by-default'
}
export function getSetupCommandSource(repo: Repo): { source: 'yaml'; command: string } | null {
const yamlSetup = loadHooks(repo.path)?.scripts.setup?.trim()
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
return null
}
function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: worktreePath,
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
}
}
function getGitPath(cwd: string, relativePath: string): string {
return execFileSync('git', ['rev-parse', '--git-path', relativePath], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
}
function buildWindowsRunnerScript(script: string): string {
const lines = script.replace(/\r?\n/g, '\n').split('\n')
const runnerLines = ['@echo off', 'setlocal EnableExtensions']
for (const rawLine of lines) {
const command = rawLine.trim()
if (!command) {
runnerLines.push('')
continue
}
// Why: setup commands often invoke `npm`/`pnpm`, which are batch files on
// Windows. Calling one batch file from another without `call` never returns
// to later lines, and plain newline-separated commands also keep running
// after failures. Wrap each line in `call` and bail on non-zero exit codes
// so the generated runner matches the fail-fast behavior of `set -e`.
runnerLines.push(`call ${command}`)
runnerLines.push('if errorlevel 1 exit /b %errorlevel%')
}
return `${runnerLines.join('\r\n')}\r\n`
}
export function createSetupRunnerScript(
repo: Repo,
worktreePath: string,
script: string
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
const isWindows = process.platform === 'win32'
const normalizedScript = isWindows
? script.replace(/\r?\n/g, '\r\n')
: script.replace(/\r\n/g, '\n')
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
const runnerScriptPath = getGitPath(
worktreePath,
isWindows ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh'
)
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (isWindows) {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(normalizedScript), 'utf-8')
} else {
writeFileSync(runnerScriptPath, `#!/usr/bin/env bash\nset -e\n${normalizedScript}\n`, 'utf-8')
chmodSync(runnerScriptPath, 0o755)
}
return { runnerScriptPath, envVars }
}
/**
@ -151,11 +248,7 @@ export function runHook(
shell: getHookShell(),
env: {
...process.env,
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: cwd,
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
...getSetupEnvVars(repo, cwd)
}
},
(error, stdout, stderr) => {

View File

@ -67,60 +67,64 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun
}
})
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => {
const id = String(++ptyCounter)
ipcMain.handle(
'pty:spawn',
(_event, args: { cols: number; rows: number; cwd?: string; env?: Record<string, string> }) => {
const id = String(++ptyCounter)
let shellPath: string
let shellArgs: string[]
if (process.platform === 'win32') {
shellPath = process.env.COMSPEC || 'powershell.exe'
shellArgs = []
} else {
shellPath = process.env.SHELL || '/bin/zsh'
shellArgs = ['-l']
let shellPath: string
let shellArgs: string[]
if (process.platform === 'win32') {
shellPath = process.env.COMSPEC || 'powershell.exe'
shellArgs = []
} else {
shellPath = process.env.SHELL || '/bin/zsh'
shellArgs = ['-l']
}
const defaultCwd =
process.platform === 'win32'
? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\'
: process.env.HOME || '/'
const ptyProcess = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd: args.cwd || defaultCwd,
env: {
...process.env,
...args.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
FORCE_HYPERLINK: '1'
} as Record<string, string>
})
ptyProcesses.set(id, ptyProcess)
ptyLoadGeneration.set(id, loadGeneration)
runtime?.onPtySpawned(id)
ptyProcess.onData((data) => {
runtime?.onPtyData(id, data, Date.now())
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', { id, data })
}
})
ptyProcess.onExit(({ exitCode }) => {
ptyProcesses.delete(id)
ptyLoadGeneration.delete(id)
runtime?.onPtyExit(id, exitCode)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
}
})
return { id }
}
const defaultCwd =
process.platform === 'win32'
? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\'
: process.env.HOME || '/'
const ptyProcess = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd: args.cwd || defaultCwd,
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
FORCE_HYPERLINK: '1'
} as Record<string, string>
})
ptyProcesses.set(id, ptyProcess)
ptyLoadGeneration.set(id, loadGeneration)
runtime?.onPtySpawned(id)
ptyProcess.onData((data) => {
runtime?.onPtyData(id, data, Date.now())
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', { id, data })
}
})
ptyProcess.onExit(({ exitCode }) => {
ptyProcesses.delete(id)
ptyLoadGeneration.delete(id)
runtime?.onPtyExit(id, exitCode)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
}
})
return { id }
})
)
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
const proc = ptyProcesses.get(args.id)

View File

@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handleMock,
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
getBranchConflictKindMock,
getPRForBranchMock,
getEffectiveHooksMock,
createSetupRunnerScriptMock,
shouldRunSetupForCreateMock,
runHookMock,
hasHooksFileMock,
loadHooksMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
listWorktreesMock: vi.fn(),
addWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
getGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getEffectiveHooksMock: vi.fn(),
createSetupRunnerScriptMock: vi.fn(),
shouldRunSetupForCreateMock: vi.fn(),
runHookMock: vi.fn(),
hasHooksFileMock: vi.fn(),
loadHooksMock: vi.fn(),
computeWorktreePathMock: vi.fn(),
ensurePathWithinWorkspaceMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
addWorktree: addWorktreeMock,
removeWorktree: removeWorktreeMock
}))
vi.mock('../git/repo', () => ({
getGitUsername: getGitUsernameMock,
getDefaultBaseRef: getDefaultBaseRefMock,
getBranchConflictKind: getBranchConflictKindMock
}))
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock
}))
vi.mock('../hooks', () => ({
createSetupRunnerScript: createSetupRunnerScriptMock,
getEffectiveHooks: getEffectiveHooksMock,
loadHooks: loadHooksMock,
runHook: runHookMock,
hasHooksFile: hasHooksFileMock,
shouldRunSetupForCreate: shouldRunSetupForCreateMock
}))
vi.mock('./worktree-logic', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
computeWorktreePath: computeWorktreePathMock,
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
}
})
import { registerWorktreeHandlers } from './worktrees'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('registerWorktreeHandlers Windows path handling', () => {
const handlers: HandlerMap = {}
const mainWindow = {
isDestroyed: () => false,
webContents: {
send: vi.fn()
}
}
const store = {
getRepos: vi.fn(),
getRepo: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn()
}
beforeEach(() => {
handleMock.mockReset()
removeHandlerMock.mockReset()
listWorktreesMock.mockReset()
addWorktreeMock.mockReset()
removeWorktreeMock.mockReset()
getGitUsernameMock.mockReset()
getDefaultBaseRefMock.mockReset()
getBranchConflictKindMock.mockReset()
getPRForBranchMock.mockReset()
getEffectiveHooksMock.mockReset()
createSetupRunnerScriptMock.mockReset()
shouldRunSetupForCreateMock.mockReset()
runHookMock.mockReset()
hasHooksFileMock.mockReset()
loadHooksMock.mockReset()
computeWorktreePathMock.mockReset()
ensurePathWithinWorkspaceMock.mockReset()
mainWindow.webContents.send.mockReset()
store.getRepos.mockReset()
store.getRepo.mockReset()
store.getSettings.mockReset()
store.getWorktreeMeta.mockReset()
store.setWorktreeMeta.mockReset()
store.removeWorktreeMeta.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
store.getRepo.mockReturnValue({
id: 'repo-1',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
store.getWorktreeMeta.mockReturnValue(undefined)
store.setWorktreeMeta.mockReturnValue({})
getGitUsernameMock.mockReturnValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
getEffectiveHooksMock.mockReturnValue(null)
shouldRunSetupForCreateMock.mockReturnValue(false)
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
listWorktreesMock.mockResolvedValue([])
registerWorktreeHandlers(mainWindow as never, store as never)
})
it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => {
listWorktreesMock.mockResolvedValue([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'C:\\repo',
'C:\\workspaces\\improve-dashboard',
'improve-dashboard',
'origin/main'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::C:/workspaces/improve-dashboard',
expect.objectContaining({
lastActivityAt: expect.any(Number)
})
)
expect(result).toMatchObject({
worktree: expect.objectContaining({
id: 'repo-1::C:/workspaces/improve-dashboard',
path: 'C:/workspaces/improve-dashboard',
branch: 'refs/heads/improve-dashboard'
})
})
})
it('preserves create-time metadata on the next list when Windows path formatting differs', async () => {
listWorktreesMock
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
store.setWorktreeMeta.mockReturnValue({
lastActivityAt: 123,
displayName: 'Improve Dashboard'
})
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
worktreeId === 'repo-1::C:/workspaces/improve-dashboard'
? {
lastActivityAt: 123,
displayName: 'Improve Dashboard'
}
: undefined
)
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'Improve Dashboard'
})
const listed = await handlers['worktrees:list'](null, {
repoId: 'repo-1'
})
expect(listed).toMatchObject([
{
id: 'repo-1::C:/workspaces/improve-dashboard',
displayName: 'Improve Dashboard',
lastActivityAt: 123
}
])
})
})

View File

@ -11,6 +11,8 @@ const {
getBranchConflictKindMock,
getPRForBranchMock,
getEffectiveHooksMock,
createSetupRunnerScriptMock,
shouldRunSetupForCreateMock,
runHookMock,
hasHooksFileMock,
loadHooksMock,
@ -27,6 +29,8 @@ const {
getBranchConflictKindMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getEffectiveHooksMock: vi.fn(),
createSetupRunnerScriptMock: vi.fn(),
shouldRunSetupForCreateMock: vi.fn(),
runHookMock: vi.fn(),
hasHooksFileMock: vi.fn(),
loadHooksMock: vi.fn(),
@ -58,10 +62,12 @@ vi.mock('../github/client', () => ({
}))
vi.mock('../hooks', () => ({
createSetupRunnerScript: createSetupRunnerScriptMock,
getEffectiveHooks: getEffectiveHooksMock,
loadHooks: loadHooksMock,
runHook: runHookMock,
hasHooksFile: hasHooksFileMock
hasHooksFile: hasHooksFileMock,
shouldRunSetupForCreate: shouldRunSetupForCreateMock
}))
vi.mock('./worktree-logic', async (importOriginal) => {
@ -105,6 +111,8 @@ describe('registerWorktreeHandlers', () => {
getBranchConflictKindMock.mockReset()
getPRForBranchMock.mockReset()
getEffectiveHooksMock.mockReset()
createSetupRunnerScriptMock.mockReset()
shouldRunSetupForCreateMock.mockReset()
runHookMock.mockReset()
hasHooksFileMock.mockReset()
loadHooksMock.mockReset()
@ -146,6 +154,14 @@ describe('registerWorktreeHandlers', () => {
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
getEffectiveHooksMock.mockReturnValue(null)
shouldRunSetupForCreateMock.mockReturnValue(false)
createSetupRunnerScriptMock.mockReturnValue({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
})
computeWorktreePathMock.mockImplementation(
(
sanitizedName: string,
@ -153,7 +169,11 @@ describe('registerWorktreeHandlers', () => {
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => {
if (settings.nestWorkspaces) {
const repoName = repoPath.split(/[\\/]/).at(-1)?.replace(/\.git$/, '') ?? 'repo'
const repoName =
repoPath
.split(/[\\/]/)
.at(-1)
?.replace(/\.git$/, '') ?? 'repo'
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
}
return `${settings.workspaceDir}/${sanitizedName}`
@ -204,118 +224,107 @@ describe('registerWorktreeHandlers', () => {
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => {
store.getRepo.mockReturnValue({
id: 'repo-1',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
it('returns a setup launch payload when setup should run', async () => {
listWorktreesMock.mockResolvedValue([
{
path: 'C:/workspaces/improve-dashboard',
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
getEffectiveHooksMock.mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockReturnValue(true)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'C:\\repo',
'C:\\workspaces\\improve-dashboard',
'improve-dashboard',
'origin/main'
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
'/workspace/improve-dashboard',
'pnpm worktree:setup'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::C:/workspaces/improve-dashboard',
expect.objectContaining({
lastActivityAt: expect.any(Number)
})
)
expect(result).toMatchObject({
id: 'repo-1::C:/workspaces/improve-dashboard',
path: 'C:/workspaces/improve-dashboard',
branch: 'refs/heads/improve-dashboard'
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/workspace/improve-dashboard',
branch: 'improve-dashboard'
}),
setup: {
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
}
})
})
it('preserves create-time metadata on the next list when Windows path formatting differs', async () => {
store.getRepo.mockReturnValue({
id: 'repo-1',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
listWorktreesMock
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
store.setWorktreeMeta.mockReturnValue({
lastActivityAt: 123,
displayName: 'Improve Dashboard'
})
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
worktreeId === 'repo-1::C:/workspaces/improve-dashboard'
? {
lastActivityAt: 123,
displayName: 'Improve Dashboard'
}
: undefined
)
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'Improve Dashboard'
})
const listed = await handlers['worktrees:list'](null, {
repoId: 'repo-1'
})
expect(listed).toMatchObject([
it('still returns the created worktree when setup runner generation fails', async () => {
listWorktreesMock.mockResolvedValue([
{
id: 'repo-1::C:/workspaces/improve-dashboard',
displayName: 'Improve Dashboard',
lastActivityAt: 123
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
getEffectiveHooksMock.mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockReturnValue(true)
createSetupRunnerScriptMock.mockImplementation(() => {
throw new Error('disk full')
})
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/workspace/improve-dashboard',
branch: 'improve-dashboard'
})
})
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => {
getEffectiveHooksMock.mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockImplementation(() => {
throw new Error('Setup decision required for this repository')
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow('Setup decision required for this repository')
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
})
})

View File

@ -3,11 +3,23 @@ import { ipcMain } from 'electron'
import { execFileSync } from 'child_process'
import { rm } from 'fs/promises'
import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
Worktree,
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { getEffectiveHooks, loadHooks, runHook, hasHooksFile } from '../hooks'
import {
createSetupRunnerScript,
getEffectiveHooks,
loadHooks,
runHook,
hasHooksFile,
shouldRunSetupForCreate
} from '../hooks'
import {
sanitizeWorktreeName,
computeBranchName,
@ -63,7 +75,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
ipcMain.handle(
'worktrees:create',
async (_event, args: { repoId: string; name: string; baseBranch?: string }) => {
async (_event, args: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
@ -107,6 +119,13 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
// Determine base branch
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
const setupScript = getEffectiveHooks(repo)?.scripts.setup
// Why: `ask` is a pre-create choice gate, not a post-create side effect.
// Resolve it before mutating git state so missing UI input cannot strand
// a real worktree on disk while the renderer reports "create failed".
const shouldLaunchSetup = setupScript
? shouldRunSetupForCreate(repo, args.setupDecision)
: false
// Fetch latest from remote so the worktree starts with up-to-date content
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
@ -142,18 +161,29 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
const worktree = mergeWorktree(repo.id, created, meta)
// Run setup hook asynchronously (don't block the UI)
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.setup) {
runHook('setup', worktreePath, repo).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
}
})
let setup: CreateWorktreeResult['setup']
if (setupScript && shouldLaunchSetup) {
try {
// Why: setup now runs in a visible terminal owned by the renderer so users
// can inspect failures, answer prompts, and rerun it. The main process only
// resolves policy and writes the runner script; it must not execute setup
// itself anymore or we would reintroduce the hidden background-hook behavior.
//
// Why: the git worktree already exists at this point. If runner generation
// fails, surfacing the error as a hard create failure would lie to the UI
// about the underlying git state and strand a real worktree on disk.
// Degrade to "created without setup launch" instead.
setup = createSetupRunnerScript(repo, worktreePath, setupScript)
} catch (error) {
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
}
}
notifyWorktreesChanged(mainWindow, repo.id)
return worktree
return {
worktree,
...(setup ? { setup } : {})
}
}
)

View File

@ -271,6 +271,22 @@ describe('Store', () => {
expect(persisted.repos[0].id).toBe('r1')
})
it('flush remains safe when a debounced save is also pending', async () => {
vi.useFakeTimers()
try {
const store = await createStore()
store.addRepo(makeRepo())
store.flush()
vi.advanceTimersByTime(300)
const persisted = readDataFile() as { repos: Repo[] }
expect(persisted.repos).toHaveLength(1)
expect(persisted.repos[0].id).toBe('r1')
} finally {
vi.useRealTimers()
}
})
// ── 11. Debounced save ─────────────────────────────────────────────
it('debounced save writes data after the delay', async () => {

View File

@ -64,19 +64,28 @@ export class Store {
this.writeTimer = setTimeout(() => {
this.writeTimer = null
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${DATA_FILE}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, DATA_FILE)
this.writeToDisk()
} catch (err) {
console.error('[persistence] Failed to write state:', err)
}
}, 300)
}
private writeToDisk(): void {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Why: synchronous flushes can race the debounced writer during shutdown or
// beforeunload persistence. A shared `.tmp` path lets one rename steal the
// temp file from the other, which surfaces as ENOENT even though the final
// state may already be on disk. Use a unique temp file per write so atomic
// replaces remain race-safe across platforms.
const tmpFile = `${DATA_FILE}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, DATA_FILE)
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
@ -230,13 +239,7 @@ export class Store {
this.writeTimer = null
}
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${DATA_FILE}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, DATA_FILE)
this.writeToDisk()
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}

View File

@ -2,10 +2,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeMeta } from '../../shared/types'
import { addWorktree, listWorktrees } from '../git/worktree'
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
import { OrcaRuntimeService } from './orca-runtime'
const { MOCK_GIT_WORKTREES, addWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock } =
vi.hoisted(() => ({
const {
MOCK_GIT_WORKTREES,
addWorktreeMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock
} = vi.hoisted(() => ({
MOCK_GIT_WORKTREES: [
{
path: '/tmp/worktree-a',
@ -25,6 +30,12 @@ vi.mock('../git/worktree', () => ({
addWorktree: addWorktreeMock
}))
vi.mock('../hooks', () => ({
createSetupRunnerScript: vi.fn(),
getEffectiveHooks: vi.fn().mockReturnValue(null),
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
}))
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
@ -37,16 +48,30 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
afterEach(() => {
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
vi.mocked(addWorktree).mockReset()
vi.mocked(createSetupRunnerScript).mockReset()
vi.mocked(getEffectiveHooks).mockReset()
vi.mocked(runHook).mockReset()
vi.mocked(getEffectiveHooks).mockReturnValue(null)
computeWorktreePathMock.mockReset()
ensurePathWithinWorkspaceMock.mockReset()
})
const TEST_WINDOW_ID = 1
const TEST_REPO_ID = 'repo-1'
const TEST_REPO_PATH = '/tmp/repo'
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
function createRuntime(): OrcaRuntimeService {
return new OrcaRuntimeService(store)
}
const store = {
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
getRepos: () => [
{
id: 'repo-1',
path: '/tmp/repo',
id: TEST_REPO_ID,
path: TEST_REPO_PATH,
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
@ -59,7 +84,7 @@ const store = {
...updates
}) as never,
getAllWorktreeMeta: () => ({
'repo-1::/tmp/worktree-a': {
[TEST_WORKTREE_ID]: {
displayName: 'foo',
comment: '',
linkedIssue: 123,
@ -73,7 +98,7 @@ const store = {
getWorktreeMeta: (worktreeId: string) => store.getAllWorktreeMeta()[worktreeId],
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
({
...store.getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
...store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
...meta
}) as never,
removeWorktreeMeta: () => {},
@ -92,7 +117,11 @@ computeWorktreePathMock.mockImplementation(
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => {
if (settings.nestWorkspaces) {
const repoName = repoPath.split(/[\\/]/).at(-1)?.replace(/\.git$/, '') ?? 'repo'
const repoName =
repoPath
.split(/[\\/]/)
.at(-1)
?.replace(/\.git$/, '') ?? 'repo'
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
}
return `${settings.workspaceDir}/${sanitizedName}`
@ -102,7 +131,7 @@ ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetP
describe('OrcaRuntimeService', () => {
it('starts unavailable with no authoritative window', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
@ -113,20 +142,20 @@ describe('OrcaRuntimeService', () => {
})
it('claims the first window as authoritative and ignores later windows', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
runtime.attachWindow(1)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.attachWindow(2)
expect(runtime.getStatus().authoritativeWindowId).toBe(1)
expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID)
})
it('bumps the epoch and enters reloading when the authoritative window reloads', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
runtime.markRendererReloading(TEST_WINDOW_ID)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'reloading',
@ -135,23 +164,23 @@ describe('OrcaRuntimeService', () => {
})
it('can mark the graph ready for the authoritative window', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
runtime.markGraphReady(1)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
runtime.markRendererReloading(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('drops back to unavailable and clears authority when the window disappears', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.markRendererReloading(1)
runtime.markGraphUnavailable(1)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
runtime.markRendererReloading(TEST_WINDOW_ID)
runtime.markGraphUnavailable(TEST_WINDOW_ID)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
@ -161,10 +190,10 @@ describe('OrcaRuntimeService', () => {
})
it('stays unavailable during initial loads before a graph is published', () => {
const runtime = new OrcaRuntimeService(store)
const runtime = createRuntime()
runtime.attachWindow(1)
runtime.markRendererReloading(1)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markRendererReloading(TEST_WINDOW_ID)
expect(runtime.getStatus()).toMatchObject({
graphStatus: 'unavailable',
@ -542,6 +571,68 @@ describe('OrcaRuntimeService', () => {
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
})
it('returns a setup launch payload for CLI-created worktrees when orca.yaml defines setup', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-test')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-test')
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-test'
}
})
vi.mocked(listWorktrees).mockResolvedValueOnce([
{
path: '/tmp/workspaces/runtime-hook-test',
head: 'def',
branch: 'runtime-hook-test',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-hook-test'
})
expect(createSetupRunnerScript).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }),
'/tmp/workspaces/runtime-hook-test',
'pnpm worktree:setup'
)
expect(runHook).not.toHaveBeenCalled()
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/tmp/workspaces/runtime-hook-test',
branch: 'runtime-hook-test'
}),
setup: {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-test'
}
}
})
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
})
it('preserves create-time metadata on later runtime listings when Windows path formatting differs', async () => {
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {

View File

@ -4,7 +4,7 @@
import { execFileSync } from 'child_process'
import { randomUUID } from 'crypto'
import { rm } from 'fs/promises'
import type { Repo } from '../../shared/types'
import type { CreateWorktreeResult, Repo } from '../../shared/types'
import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
@ -26,7 +26,7 @@ import { listWorktrees } from '../git/worktree'
import { getPRForBranch } from '../github/client'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { addWorktree, removeWorktree } from '../git/worktree'
import { getEffectiveHooks, runHook } from '../hooks'
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
import { REPO_COLORS } from '../../shared/constants'
import { isGitRepo, getRepoName, searchBaseRefs } from '../git/repo'
import type { Store } from '../persistence'
@ -79,7 +79,7 @@ type RuntimePtyController = {
type RuntimeNotifier = {
worktreesChanged(repoId: string): void
reposChanged(): void
activateWorktree(repoId: string, worktreeId: string): void
activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void
}
type TerminalHandleRecord = {
@ -543,7 +543,7 @@ export class OrcaRuntimeService {
baseBranch?: string
linkedIssue?: number | null
comment?: string
}) {
}): Promise<CreateWorktreeResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
@ -607,13 +607,29 @@ export class OrcaRuntimeService {
})
const worktree = mergeWorktree(repo.id, created, meta)
let setup: CreateWorktreeResult['setup']
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.setup) {
void runHook('setup', worktreePath, repo).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
if (this.authoritativeWindowId !== null) {
try {
// Why: CLI-created worktrees must use the same runner-script path as the
// renderer create flow so repo-committed `orca.yaml` setup hooks run in
// the visible first terminal instead of a hidden background shell with
// different failure and prompt behavior.
setup = createSetupRunnerScript(repo, worktreePath, hooks.scripts.setup)
} catch (error) {
// Why: the git worktree is already real at this point. If runner
// generation fails, keep creation successful and surface the problem in
// logs rather than pretending the worktree was never created.
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
}
})
} else {
void runHook('setup', worktreePath, repo).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
}
})
}
}
this.notifier?.worktreesChanged(repo.id)
@ -621,9 +637,12 @@ export class OrcaRuntimeService {
// renderer-side consequence of activating a worktree. CLI-created
// worktrees must trigger that same activation path or they will exist on
// disk without becoming the active workspace in the UI.
this.notifier?.activateWorktree(repo.id, worktree.id)
this.notifier?.activateWorktree(repo.id, worktree.id, setup)
this.invalidateResolvedWorktreeCache()
return worktree
return {
worktree,
...(setup ? { setup } : {})
}
}
async updateManagedWorktreeMeta(

View File

@ -580,7 +580,7 @@ export class OrcaRuntimeRpcServer {
return {
id: request.id,
ok: true,
result: { worktree: result },
result,
_meta: {
runtimeId: this.runtime.getRuntimeId()
}

View File

@ -122,17 +122,40 @@ describe('attachMainWindowServices', () => {
const notifier = runtime.setNotifier.mock.calls[0][0] as {
worktreesChanged: (repoId: string) => void
reposChanged: () => void
activateWorktree: (repoId: string, worktreeId: string) => void
activateWorktree: (
repoId: string,
worktreeId: string,
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
) => void
}
notifier.worktreesChanged('repo-1')
notifier.reposChanged()
notifier.activateWorktree('repo-1', 'wt-1')
notifier.activateWorktree('repo-1', 'wt-1', {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
}
})
expect(sendMock.mock.calls).toEqual([
['worktrees:changed', { repoId: 'repo-1' }],
['repos:changed'],
['ui:activateWorktree', { repoId: 'repo-1', worktreeId: 'wt-1' }]
[
'ui:activateWorktree',
{
repoId: 'repo-1',
worktreeId: 'wt-1',
setup: {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
}
}
}
]
])
})
})

View File

@ -1,6 +1,7 @@
import { app, clipboard, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult } from '../../shared/types'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerPtyHandlers } from '../ipc/pty'
@ -55,9 +56,9 @@ function registerRuntimeWindowLifecycle(
mainWindow.webContents.send('repos:changed')
}
},
activateWorktree: (repoId, worktreeId) => {
activateWorktree: (repoId, worktreeId, setup?: CreateWorktreeResult['setup']) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId })
mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId, setup })
}
}
})

View File

@ -4,6 +4,8 @@ import type {
Repo,
Worktree,
WorktreeMeta,
CreateWorktreeArgs,
CreateWorktreeResult,
PRInfo,
PRCheckDetail,
IssueInfo,
@ -11,6 +13,7 @@ import type {
OrcaHooks,
PersistedUIState,
WorkspaceSessionState,
WorktreeSetupLaunch,
UpdateStatus,
DirEntry,
GitBranchCompareResult,
@ -40,14 +43,19 @@ type ReposApi = {
type WorktreesApi = {
list: (args: { repoId: string }) => Promise<Worktree[]>
listAll: () => Promise<Worktree[]>
create: (args: { repoId: string; name: string; baseBranch?: string }) => Promise<Worktree>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}
type PtyApi = {
spawn: (opts: { cols: number; rows: number; cwd?: string }) => Promise<{ id: string }>
spawn: (opts: {
cols: number
rows: number
cwd?: string
env?: Record<string, string>
}) => Promise<{ id: string }>
write: (id: string, data: string) => void
resize: (id: string, cols: number, rows: number) => void
kill: (id: string) => Promise<void>
@ -130,7 +138,7 @@ type UIApi = {
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string }) => void
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: the preload bridge is the audited contract between
renderer and Electron. Keeping the IPC surface co-located in one file makes security
review and type drift checks easier than scattering these bindings across modules. */
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { CliInstallStatus } from '../shared/cli-install-types'
@ -86,8 +89,12 @@ const api = {
listAll: (): Promise<unknown[]> => ipcRenderer.invoke('worktrees:listAll'),
create: (args: { repoId: string; name: string; baseBranch?: string }): Promise<unknown> =>
ipcRenderer.invoke('worktrees:create', args),
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}): Promise<unknown> => ipcRenderer.invoke('worktrees:create', args),
remove: (args: { worktreeId: string; force?: boolean }): Promise<void> =>
ipcRenderer.invoke('worktrees:remove', args),
@ -106,8 +113,12 @@ const api = {
},
pty: {
spawn: (opts: { cols: number; rows: number; cwd?: string }): Promise<{ id: string }> =>
ipcRenderer.invoke('pty:spawn', opts),
spawn: (opts: {
cols: number
rows: number
cwd?: string
env?: Record<string, string>
}): Promise<{ id: string }> => ipcRenderer.invoke('pty:spawn', opts),
write: (id: string, data: string): void => {
ipcRenderer.send('pty:write', { id, data })
@ -310,11 +321,19 @@ const api = {
return () => ipcRenderer.removeListener('ui:openSettings', listener)
},
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string }) => void
callback: (data: {
repoId: string
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { repoId: string; worktreeId: string }
data: {
repoId: string
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
}
) => callback(data)
ipcRenderer.on('ui:activateWorktree', listener)
return () => ipcRenderer.removeListener('ui:activateWorktree', listener)

View File

@ -90,7 +90,7 @@ export function BaseRefPicker({
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef
return (
<div className="min-h-[280px] space-y-3">
<div className="space-y-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-foreground">{effectiveBaseRef}</div>

View File

@ -1,89 +0,0 @@
import type { OrcaHooks, Repo } from '../../../../shared/types'
import { Label } from '../ui/label'
import type { HookName } from './SettingsConstants'
export function HookEditor({
hookName,
repo,
yamlHooks,
onScriptChange
}: {
hookName: HookName
repo: Repo
yamlHooks: OrcaHooks | null
onScriptChange: (script: string) => void
}): React.JSX.Element {
const uiScript = repo.hookSettings?.scripts[hookName] ?? ''
const yamlScript = yamlHooks?.scripts[hookName]
const effectiveSource =
repo.hookSettings?.mode === 'auto' && yamlScript ? 'yaml' : uiScript.trim() ? 'ui' : 'none'
return (
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h5 className="text-sm font-semibold capitalize">{hookName}</h5>
<p className="text-xs text-muted-foreground">
{hookName === 'setup'
? 'Runs after a worktree is created.'
: 'Runs before a worktree is archived.'}
</p>
</div>
<span
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${
effectiveSource === 'yaml'
? 'border border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: effectiveSource === 'ui'
? 'border border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300'
: 'border border-border/50 bg-muted text-muted-foreground'
}`}
>
{effectiveSource === 'yaml'
? 'Honoring YAML'
: effectiveSource === 'ui'
? 'Using UI'
: 'Inactive'}
</span>
</div>
{yamlScript && (
<div className="space-y-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
YAML Script
</Label>
<span className="text-[10px] text-muted-foreground">Read-only from `orca.yaml`</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background/70 p-3 font-mono text-[11px] leading-5 text-foreground">
{yamlScript}
</pre>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
UI Script
</Label>
<span className="text-[10px] text-muted-foreground">
{repo.hookSettings?.mode === 'auto' && yamlScript
? 'Stored as fallback until you switch to override.'
: 'Editable script stored with this repo.'}
</span>
</div>
<textarea
value={uiScript}
onChange={(e) => onScriptChange(e.target.value)}
placeholder={
hookName === 'setup'
? 'pnpm install\npnpm generate'
: 'echo "Cleaning up before archive"'
}
spellCheck={false}
className="min-h-[12rem] w-full resize-y rounded-xl border border-border/50 bg-background px-3 py-3 font-mono text-[12px] leading-5 outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,236 @@
import type { OrcaHooks, Repo, SetupRunPolicy } from '../../../../shared/types'
import { Button } from '../ui/button'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
type RepositoryHooksSectionProps = {
repo: Repo
yamlHooks: OrcaHooks | null
hasHooksFile: boolean
copiedTemplate: boolean
onCopyTemplate: () => void
onClearLegacyHooks: () => void
onUpdateSetupRunPolicy: (policy: SetupRunPolicy) => void
}
const SETUP_RUN_POLICY_OPTIONS: {
policy: SetupRunPolicy
label: string
description: string
}[] = [
{
policy: 'ask',
label: 'Ask every time',
description: 'Prompt before running setup.'
},
{
policy: 'run-by-default',
label: 'Run by default',
description: 'Run setup automatically.'
},
{
policy: 'skip-by-default',
label: 'Skip by default',
description: 'Only run setup when chosen.'
}
]
const EXAMPLE_TEMPLATE = `scripts:
setup: |
pnpm worktree:setup
archive: |
echo "Cleaning up before archive"`
export function RepositoryHooksSection({
repo,
yamlHooks,
hasHooksFile,
copiedTemplate,
onCopyTemplate,
onClearLegacyHooks,
onUpdateSetupRunPolicy
}: RepositoryHooksSectionProps): React.JSX.Element {
const yamlState = yamlHooks ? 'loaded' : hasHooksFile ? 'invalid' : 'missing'
const legacyHookEntries = (['setup', 'archive'] as const)
.map((hookName) => [hookName, repo.hookSettings?.scripts[hookName]?.trim() ?? ''] as const)
.filter(([, script]) => Boolean(script))
const selectedSetupRunPolicy =
repo.hookSettings?.setupRunPolicy ?? DEFAULT_REPO_HOOK_SETTINGS.setupRunPolicy
return (
<section className="space-y-6">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Worktree Hooks</h2>
<p className="text-xs text-muted-foreground">
Orca prefers shared hooks from `orca.yaml` and still honors older repo-local hook scripts
until you clear them.
</p>
</div>
<div
className={`space-y-3 rounded-xl border p-4 ${
yamlState === 'loaded'
? 'border-emerald-500/20 bg-emerald-500/5'
: yamlState === 'invalid'
? 'border-amber-500/20 bg-amber-500/5'
: 'border-border/50 bg-muted/20'
}`}
>
<div className="space-y-1">
<p
className={`text-sm font-medium ${
yamlState === 'loaded'
? 'text-emerald-700 dark:text-emerald-300'
: yamlState === 'invalid'
? 'text-amber-700 dark:text-amber-300'
: 'text-foreground'
}`}
>
{yamlState === 'loaded'
? 'Using `orca.yaml`'
: yamlState === 'invalid'
? '`orca.yaml` could not be parsed'
: 'No `orca.yaml` detected'}
</p>
<p className="text-xs text-muted-foreground">
{yamlState === 'loaded'
? 'Hook commands are defined in the repo and shared with everyone who uses it.'
: yamlState === 'invalid'
? 'The file exists, but Orca could not read valid setup or archive commands from it yet.'
: 'Add an `orca.yaml` file to enable setup or archive hooks for this repo. Example template:'}
</p>
</div>
{yamlState === 'loaded' ? (
<div className="space-y-2">
<div className="rounded-lg border border-border/50 bg-background/70">
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-foreground">
{renderYamlScriptPreview(yamlHooks)}
</pre>
</div>
<p className="text-xs text-muted-foreground">
Edit `orca.yaml` in the repository if you need to change these commands.
</p>
</div>
) : yamlState === 'invalid' ? (
<p className="text-[10px] text-muted-foreground">
Fix the file format in `orca.yaml` to restore shared hook behavior.
</p>
) : (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Example `orca.yaml` template
</p>
<div className="rounded-lg border border-border/50 bg-background/70">
<div className="flex items-center justify-end border-b border-border/40 px-2 py-1.5">
<Button
type="button"
variant={copiedTemplate ? 'secondary' : 'ghost'}
size="sm"
className={`h-6 px-2 text-[11px] ${
copiedTemplate
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={onCopyTemplate}
>
{copiedTemplate ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-muted-foreground">
{EXAMPLE_TEMPLATE}
</pre>
</div>
</div>
)}
</div>
{legacyHookEntries.length > 0 ? (
<div className="space-y-4 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h5 className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Legacy Repo-Local Hooks
</h5>
<p className="text-xs text-muted-foreground">
These older commands still run as a fallback when `orca.yaml` does not provide a
hook. Clear them after you migrate the behavior into `orca.yaml`.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onClearLegacyHooks}>
Clear Legacy Hooks
</Button>
</div>
{legacyHookEntries.map(([hookName, script]) => (
<div
key={hookName}
className="space-y-2 rounded-xl border border-amber-500/20 bg-background/70 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium capitalize text-foreground">{hookName}</p>
<span className="text-[10px] text-muted-foreground">Compatibility fallback</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background p-3 font-mono text-[11px] leading-5 text-foreground">
{script}
</pre>
</div>
))}
</div>
) : null}
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="space-y-1">
<h5 className="text-sm font-semibold">When to Run Setup</h5>
<p className="text-xs text-muted-foreground">
Choose the default behavior when a setup command is available.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
{SETUP_RUN_POLICY_OPTIONS.map(({ policy, label, description }) => {
const selected = selectedSetupRunPolicy === policy
return (
<button
key={policy}
onClick={() => onUpdateSetupRunPolicy(policy)}
className={`rounded-xl border px-3 py-2.5 text-center transition-colors ${
selected
? 'border-foreground/15 bg-accent text-accent-foreground'
: 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'
}`}
>
<span className={`block text-sm ${selected ? 'font-semibold' : 'font-medium'}`}>
{label}
</span>
<p
className={`mt-1 text-[11px] leading-4 ${
selected ? 'text-accent-foreground/80' : 'text-muted-foreground'
}`}
>
{description}
</p>
</button>
)
})}
</div>
</div>
</section>
)
}
function renderYamlScriptPreview(yamlHooks: OrcaHooks | null): string {
return `scripts:${
yamlHooks?.scripts.setup
? `
setup: |
${yamlHooks.scripts.setup.replace(/^/gm, ' ')}`
: ''
}${
yamlHooks?.scripts.archive
? `
archive: |
${yamlHooks.scripts.archive.replace(/^/gm, ' ')}`
: ''
}`
}

View File

@ -1,19 +1,19 @@
import { useState } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
import type { OrcaHooks, Repo, RepoHookSettings, SetupRunPolicy } from '../../../../shared/types'
import { REPO_COLORS } from '../../../../shared/constants'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Trash2 } from 'lucide-react'
import { HookEditor } from './HookEditor'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import type { HookName } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
type RepositoryPaneProps = {
repo: Repo
yamlHooks: OrcaHooks | null
hasHooksFile: boolean
updateRepo: (repoId: string, updates: Partial<Repo>) => void
removeRepo: (repoId: string) => void
}
@ -21,10 +21,12 @@ type RepositoryPaneProps = {
export function RepositoryPane({
repo,
yamlHooks,
hasHooksFile,
updateRepo,
removeRepo
}: RepositoryPaneProps): React.JSX.Element {
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [copiedTemplate, setCopiedTemplate] = useState(false)
const handleRemoveRepo = (repoId: string) => {
if (confirmingRemove === repoId) {
@ -37,19 +39,15 @@ export function RepositoryPane({
}
const updateSelectedRepoHookSettings = (
updates: Omit<Partial<RepoHookSettings>, 'scripts'> & {
scripts?: Partial<RepoHookSettings['scripts']>
}
updates: Partial<Pick<RepoHookSettings, 'setupRunPolicy'>>
) => {
// Why: persisted repos may still carry legacy UI hook fields from the old dual-source
// design. We preserve them when saving so existing local state stays loadable, but the
// product now treats `orca.yaml` as the only supported hook definition surface.
const nextSettings: RepoHookSettings = {
...DEFAULT_REPO_HOOK_SETTINGS,
...repo.hookSettings,
...updates,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
...repo.hookSettings?.scripts,
...updates.scripts
}
...updates
}
updateRepo(repo.id, {
@ -57,6 +55,34 @@ export function RepositoryPane({
})
}
const handleCopyTemplate = async () => {
// Why: the missing-`orca.yaml` state is a migration aid, so copying the shared-template
// snippet should be one click rather than forcing users to reconstruct the expected shape.
await window.api.ui.writeClipboardText(`scripts:
setup: |
pnpm worktree:setup
archive: |
echo "Cleaning up before archive"`)
setCopiedTemplate(true)
window.setTimeout(() => setCopiedTemplate(false), 1500)
}
const handleClearLegacyHooks = () => {
// Why: legacy repo-local commands are still honored as a compatibility fallback.
// Keep them visible and removable here so the settings surface matches runtime behavior.
updateRepo(repo.id, {
hookSettings: {
...DEFAULT_REPO_HOOK_SETTINGS,
...repo.hookSettings,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
setup: '',
archive: ''
}
}
})
}
return (
<div className="space-y-8">
<section className="space-y-6">
@ -125,81 +151,17 @@ export function RepositoryPane({
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Hook Source</h2>
<p className="text-xs text-muted-foreground">
Auto prefers `orca.yaml` when present, then falls back to the UI script. Override
ignores YAML and only uses the UI script.
</p>
</div>
<div className="flex w-fit gap-1 rounded-xl border border-border/50 p-1">
{(['auto', 'override'] as const).map((mode) => (
<button
key={mode}
onClick={() => updateSelectedRepoHookSettings({ mode })}
className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
repo.hookSettings?.mode === mode
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === 'auto' ? 'Use YAML First' : 'Override in UI'}
</button>
))}
</div>
<div className="rounded-xl border border-dashed bg-muted/30 p-3 text-xs text-muted-foreground">
{yamlHooks ? (
<div className="space-y-2">
<p className="font-medium text-foreground">YAML hooks detected in `orca.yaml`</p>
<div className="flex flex-wrap gap-2">
{(['setup', 'archive'] as HookName[]).map((hookName) =>
yamlHooks.scripts[hookName] ? (
<span
key={hookName}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300"
>
{hookName}
</span>
) : null
)}
</div>
</div>
) : (
<p>No YAML hooks detected for this repo.</p>
)}
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Lifecycle Hooks</h2>
<p className="text-xs text-muted-foreground">
Write scripts directly in the UI. Each repo stores its own setup and archive hook
script.
</p>
</div>
<div className="space-y-4">
{(['setup', 'archive'] as HookName[]).map((hookName) => (
<HookEditor
key={hookName}
hookName={hookName}
repo={repo}
yamlHooks={yamlHooks}
onScriptChange={(script) =>
updateSelectedRepoHookSettings({
scripts: hookName === 'setup' ? { setup: script } : { archive: script }
})
}
/>
))}
</div>
</section>
<RepositoryHooksSection
repo={repo}
yamlHooks={yamlHooks}
hasHooksFile={hasHooksFile}
copiedTemplate={copiedTemplate}
onCopyTemplate={() => void handleCopyTemplate()}
onClearLegacyHooks={handleClearLegacyHooks}
onUpdateSetupRunPolicy={(policy) =>
updateSelectedRepoHookSettings({ setupRunPolicy: policy as SetupRunPolicy })
}
/>
</div>
)
}

View File

@ -20,6 +20,8 @@ function Settings(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const updateRepo = useAppStore((s) => s.updateRepo)
const removeRepo = useAppStore((s) => s.removeRepo)
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
const [selectedPane, setSelectedPane] = useState<
'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo'
@ -40,6 +42,20 @@ function Settings(): React.JSX.Element {
fetchSettings()
}, [fetchSettings])
useEffect(() => {
if (!settingsNavigationTarget) {
return
}
// Why: the create-worktree dialog links here so setup configuration stays
// out of the dialog until the user explicitly asks to edit it.
setSelectedPane(settingsNavigationTarget.pane)
if (settingsNavigationTarget.repoId) {
setSelectedRepoId(settingsNavigationTarget.repoId)
}
clearSettingsTarget()
}, [clearSettingsTarget, settingsNavigationTarget])
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent): void => {
@ -148,7 +164,8 @@ function Settings(): React.JSX.Element {
}, [])
const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null
const selectedYamlHooks = selectedRepo ? (repoHooksMap[selectedRepo.id]?.hooks ?? null) : null
const selectedRepoHooksState = selectedRepo ? repoHooksMap[selectedRepo.id] : undefined
const selectedYamlHooks = selectedRepoHooksState?.hooks ?? null
const showGeneralPane = selectedPane === 'general'
const showAppearancePane = selectedPane === 'appearance'
const showTerminalPane = selectedPane === 'terminal'
@ -339,6 +356,7 @@ function Settings(): React.JSX.Element {
<RepositoryPane
repo={selectedRepo}
yamlHooks={selectedYamlHooks}
hasHooksFile={selectedRepoHooksState?.hasHooks ?? false}
updateRepo={updateRepo}
removeRepo={removeRepo}
/>

View File

@ -1,6 +1,10 @@
/* eslint-disable max-lines */
import React, { useState, useCallback, useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { ChevronRight } from 'lucide-react'
import { useAppStore } from '@/store'
import type { OrcaHooks, SetupDecision, SetupRunPolicy } from '../../../../shared/types'
import {
Dialog,
DialogContent,
@ -21,6 +25,7 @@ import {
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import { SPACE_NAMES } from '@/constants/space-names'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation'
const DIALOG_CLOSE_RESET_DELAY_MS = 200
@ -36,6 +41,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const setActiveRepo = useAppStore((s) => s.setActiveRepo)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
const searchQuery = useAppStore((s) => s.searchQuery)
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
@ -51,6 +57,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const [name, setName] = useState('')
const [linkedIssue, setLinkedIssue] = useState('')
const [comment, setComment] = useState('')
const [yamlHooks, setYamlHooks] = useState<OrcaHooks | null>(null)
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
@ -67,10 +76,31 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
[activeWorktreeId, worktreesByRepo]
)
const selectedRepo = repos.find((r) => r.id === repoId)
const setupConfig = useMemo(
() => getSetupConfig(selectedRepo, yamlHooks),
[selectedRepo, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
const resolvedSetupDecision =
setupDecision ??
(!setupConfig || setupPolicy === 'ask'
? null
: setupPolicy === 'run-by-default'
? 'run'
: 'skip')
const suggestedName = useMemo(
() => getSuggestedSpaceName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? false),
[repoId, worktreesByRepo, settings?.nestWorkspaces]
)
// Why: setup visibility is part of the create decision no matter which default
// policy the repo uses. If we let create proceed before the async hook lookup
// finishes, a repo with `orca.yaml` setup can silently launch setup (or hide a
// skip/default choice) before the dialog ever surfaces that configuration.
// Track which repo has completed a lookup so the first render after opening or
// switching repos still counts as "checking".
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
// Auto-select repo when dialog opens (adjusting state during render)
if (isOpen && !prevIsOpenRef.current && repos.length > 0) {
@ -109,13 +139,19 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
)
const handleCreate = useCallback(async () => {
if (!repoId || !name.trim()) {
if (!repoId || !name.trim() || shouldWaitForSetupCheck) {
return
}
setCreateError(null)
setCreating(true)
try {
const wt = await createWorktree(repoId, name.trim())
const result = await createWorktree(
repoId,
name.trim(),
undefined,
setupConfig ? ((resolvedSetupDecision ?? 'inherit') as SetupDecision) : 'inherit'
)
const wt = result.worktree
// Meta update is best-effort — the worktree already exists, so don't
// block the success path if only the metadata write fails.
try {
@ -146,6 +182,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
setFilterRepoIds([])
}
setActiveWorktree(wt.id)
ensureWorktreeHasInitialTerminal(useAppStore.getState(), wt.id, result.setup)
revealWorktreeInSidebar(wt.id)
if (settings?.rightSidebarOpenByDefault) {
setRightSidebarTab('explorer')
@ -178,7 +215,10 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
setRightSidebarOpen,
setRightSidebarTab,
settings?.rightSidebarOpenByDefault,
handleOpenChange
handleOpenChange,
resolvedSetupDecision,
setupConfig,
shouldWaitForSetupCheck
])
const handleNameChange = useCallback(
@ -194,6 +234,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const handleRepoChange = useCallback(
(value: string) => {
setRepoId(value)
setYamlHooks(null)
setCheckedHooksRepoId(null)
setSetupDecision(null)
if (createError) {
setCreateError(null)
}
@ -201,6 +244,19 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
[createError]
)
const handleOpenSetupSettings = useCallback(() => {
if (!selectedRepo) {
return
}
// Why: the create dialog intentionally keeps setup details collapsed so the
// branch-creation flow stays lightweight; clicking setup is the escape hatch
// into the full repository hook editor.
openSettingsTarget({ pane: 'repo', repoId: selectedRepo.id })
handleOpenChange(false)
setActiveView('settings')
}, [handleOpenChange, openSettingsTarget, selectedRepo, setActiveView])
// Auto-select repo when opening.
React.useEffect(() => {
if (resetTimeoutRef.current !== null) {
@ -217,6 +273,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
setName('')
setLinkedIssue('')
setComment('')
setYamlHooks(null)
setCheckedHooksRepoId(null)
setSetupDecision(null)
setCreateError(null)
lastSuggestedNameRef.current = ''
resetTimeoutRef.current = null
@ -252,14 +311,71 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
}
}, [isOpen, repos.length, handleOpenChange])
React.useEffect(() => {
if (!isOpen || !repoId) {
return
}
let cancelled = false
void window.api.hooks
.check({ repoId })
.then((result) => {
if (!cancelled) {
setYamlHooks(result.hooks)
setCheckedHooksRepoId(repoId)
}
})
.catch(() => {
if (!cancelled) {
setYamlHooks(null)
setCheckedHooksRepoId(repoId)
}
})
return () => {
cancelled = true
}
}, [isOpen, repoId])
React.useEffect(() => {
if (shouldWaitForSetupCheck) {
setSetupDecision(null)
return
}
if (!setupConfig) {
setSetupDecision(null)
return
}
if (setupPolicy === 'ask') {
setSetupDecision(null)
return
}
setSetupDecision(setupPolicy === 'run-by-default' ? 'run' : 'skip')
}, [setupConfig, setupPolicy, shouldWaitForSetupCheck])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && repoId && name.trim() && !creating) {
if (shouldWaitForSetupCheck || (requiresExplicitSetupChoice && !setupDecision)) {
return
}
e.preventDefault()
handleCreate()
}
},
[repoId, name, creating, handleCreate]
[
repoId,
name,
creating,
handleCreate,
requiresExplicitSetupChoice,
setupDecision,
shouldWaitForSetupCheck
]
)
return (
@ -310,8 +426,102 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
autoFocus
/>
{createError && <p className="text-[10px] text-destructive">{createError}</p>}
{shouldWaitForSetupCheck ? (
<p className="text-[10px] text-muted-foreground">Checking setup configuration...</p>
) : null}
</div>
{setupConfig ? (
<div className="space-y-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={setupConfig.source === 'yaml' ? undefined : handleOpenSetupSettings}
className="group min-w-0 flex-1 rounded-md text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div className="flex items-center gap-1 text-[11px] font-medium text-foreground">
<span>Setup</span>
{setupConfig.source !== 'yaml' && (
<ChevronRight className="size-3 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
)}
</div>
<p className="text-[10px] text-muted-foreground">
{setupConfig.source === 'yaml' ? (
<>
This repository uses{' '}
<code className="rounded bg-muted px-1 py-0.5">orca.yaml</code> to define
its setup command.
</>
) : (
'Review setup status here and migrate this legacy command in repository settings.'
)}
</p>
</button>
<span className="rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground">
{setupPolicy === 'ask'
? 'Ask every time'
: setupPolicy === 'run-by-default'
? 'Run by default'
: 'Skip by default'}
</span>
</div>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/60 p-2">
<p className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{setupConfig.source === 'yaml' ? 'orca.yaml' : 'Command Preview'}
</p>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
{summarizeSetupCommand(setupConfig.command)}
</pre>
</div>
{requiresExplicitSetupChoice ? (
<div className="space-y-2">
<label className="text-[11px] font-medium text-muted-foreground">
Run setup now?
</label>
<div className="grid grid-cols-2 gap-2">
{(
[
['run', 'Run setup now'],
['skip', 'Skip for now']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setSetupDecision(value)}
className={`rounded-md border px-3 py-2 text-left text-xs transition-colors ${
setupDecision === value
? 'border-foreground bg-accent text-accent-foreground'
: 'border-border/60 text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{!setupDecision ? (
<p className="text-[10px] text-muted-foreground">
{shouldWaitForSetupCheck
? 'Checking setup configuration...'
: 'Choose whether to run setup before creating this worktree.'}
</p>
) : null}
</div>
) : (
<label className="flex items-center gap-2 text-[11px] text-foreground">
<input
type="checkbox"
checked={resolvedSetupDecision === 'run'}
onChange={(e) => setSetupDecision(e.target.checked ? 'run' : 'skip')}
/>
Run setup command after creation
</label>
)}
</div>
) : null}
{/* Link GH Issue */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
@ -355,7 +565,13 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
<Button
size="sm"
onClick={handleCreate}
disabled={!repoId || !name.trim() || creating}
disabled={
!repoId ||
!name.trim() ||
creating ||
shouldWaitForSetupCheck ||
(requiresExplicitSetupChoice && !setupDecision)
}
className="text-xs"
>
{creating ? 'Creating...' : 'Create'}
@ -411,7 +627,15 @@ function getSuggestedSpaceName(
}
function lastPathSegment(path: string): string {
return path.replace(/\/+$/, '').split('/').pop() ?? path
// Why: worktree paths come from the OS, so Windows worktrees use backslashes.
// Split on both separators or the suggestion logic treats the whole absolute
// path as the "name" and starts re-suggesting already-used worktree names.
return (
path
.replace(/[\\/]+$/, '')
.split(/[\\/]/)
.pop() ?? path
)
}
function normalizeSpaceName(name: string): string {
@ -434,3 +658,49 @@ function findRepoIdForWorktree(
return null
}
function getSetupConfig(
repo:
| {
hookSettings?: {
setupRunPolicy?: SetupRunPolicy
scripts?: { setup?: string }
}
}
| undefined,
yamlHooks: OrcaHooks | null
): { source: 'yaml' | 'legacy-ui'; command: string } | null {
if (!repo) {
return null
}
const yamlSetup = yamlHooks?.scripts.setup?.trim()
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
const legacySetup = repo.hookSettings?.scripts?.setup?.trim()
if (legacySetup) {
// Why: the backend still honors persisted pre-yaml hook commands for backwards
// compatibility, so the create dialog must surface the same effective setup
// command instead of pretending the repo has no setup configured.
return { source: 'legacy-ui', command: legacySetup }
}
return null
}
function summarizeSetupCommand(command: string): string {
const trimmed = command.trim()
if (!trimmed) {
return '(empty setup command)'
}
const lines = trimmed.split(/\r?\n/)
if (lines.length <= 4) {
return trimmed
}
return `${lines.slice(0, 4).join('\n')}\n...`
}

View File

@ -15,7 +15,6 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const removeWorktree = useAppStore((s) => s.removeWorktree)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const allWorktrees = useAppStore((s) => s.allWorktrees)
@ -58,15 +57,18 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
if (!worktreeId) {
return
}
closeModal()
const result = await removeWorktree(worktreeId, force)
if (!result.ok) {
openModal('delete-worktree', { worktreeId })
// Modal is already open, just let it show the error
return
}
// Why: successful delete already cleaned the worktree out of store state.
// Closing explicitly avoids leaving the Radix dialog open if that removal
// effect and this component render land out of order.
clearWorktreeDeleteState(worktreeId)
closeModal()
},
[clearWorktreeDeleteState, closeModal, openModal, removeWorktree, worktreeId]
[clearWorktreeDeleteState, closeModal, removeWorktree, worktreeId]
)
return (

View File

@ -95,6 +95,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
}, [worktree.id, shutdownWorktreeTerminals, activeWorktreeId, setActiveWorktree])
const handleDelete = useCallback(() => {
setMenuOpen(false)
clearWorktreeDeleteState(worktree.id)
openModal('delete-worktree', { worktreeId: worktree.id })
}, [worktree.id, clearWorktreeDeleteState, openModal])

View File

@ -67,6 +67,15 @@ export default function TerminalPane({
const clearTabPtyId = useAppStore((store) => store.clearTabPtyId)
const markWorktreeUnread = useAppStore((store) => store.markWorktreeUnread)
const settings = useAppStore((store) => store.settings)
const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId])
const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand)
useEffect(() => {
if (startup) {
consumeTabStartupCommand(tabId)
}
}, [startup, tabId, consumeTabStartupCommand])
const settingsRef = useRef(settings)
settingsRef.current = settings
const onPtyExitRef = useRef(onPtyExit)
@ -115,6 +124,7 @@ export default function TerminalPane({
tabId,
worktreeId,
cwd,
startup,
isActive,
systemPrefersDark,
settings,

View File

@ -8,6 +8,7 @@ type PtyConnectionDeps = {
tabId: string
worktreeId: string
cwd?: string
startup?: { command: string; env?: Record<string, string> } | null
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
@ -54,6 +55,7 @@ export function connectPanePty(
const transport = createIpcPtyTransport({
cwd: deps.cwd,
env: deps.startup?.env,
onPtyExit: onExit,
onTitleChange,
onPtySpawn,
@ -86,6 +88,14 @@ export function connectPanePty(
cols,
rows,
callbacks: {
onConnect: () => {
if (deps.startup?.command) {
// Why: setup commands are injected only after the PTY reports a live
// shell connection. Writing earlier is racy with shell startup files
// and can drop characters on slower shells.
transport.sendInput(`${deps.startup.command}\r`)
}
},
onData: (data) => {
if (deps.isActiveRef.current) {
pane.terminal.write(data)

View File

@ -66,6 +66,7 @@ export function extractLastOscTitle(data: string): string | null {
export type IpcPtyTransportOptions = {
cwd?: string
env?: Record<string, string>
onPtyExit?: (ptyId: string) => void
onTitleChange?: (title: string, rawTitle: string) => void
onPtySpawn?: (ptyId: string) => void
@ -74,7 +75,7 @@ export type IpcPtyTransportOptions = {
}
export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport {
const { cwd, onPtyExit, onTitleChange, onPtySpawn, onBell, onAgentBecameIdle } = opts
const { cwd, env, onPtyExit, onTitleChange, onPtySpawn, onBell, onAgentBecameIdle } = opts
let connected = false
let destroyed = false
let ptyId: string | null = null
@ -107,11 +108,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
storedCallbacks = options.callbacks
ensurePtyDispatcher()
if (destroyed) {
return
}
try {
const result = await window.api.pty.spawn({
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd
cwd,
env
})
// If destroyed while spawn was in flight, kill the new pty and bail
@ -155,6 +161,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
const cleared = clearWorkingIndicators(lastEmittedTitle)
lastEmittedTitle = cleared
onTitleChange(cleared, cleared)
agentTracker?.handleTitle(cleared)
}
}, STALE_TITLE_TIMEOUT)
}

View File

@ -23,6 +23,7 @@ type UseTerminalPaneLifecycleDeps = {
tabId: string
worktreeId: string
cwd?: string
startup?: { command: string; env?: Record<string, string> } | null
isActive: boolean
systemPrefersDark: boolean
settings: GlobalSettings | null | undefined
@ -53,6 +54,7 @@ export function useTerminalPaneLifecycle({
tabId,
worktreeId,
cwd,
startup,
isActive,
systemPrefersDark,
settings,
@ -151,6 +153,7 @@ export function useTerminalPaneLifecycle({
tabId,
worktreeId,
cwd,
startup,
paneTransportsRef,
pendingWritesRef,
isActiveRef,
@ -205,6 +208,10 @@ export function useTerminalPaneLifecycle({
}
const transport = paneTransportsRef.current.get(paneId)
if (transport) {
const ptyId = transport.getPtyId()
if (ptyId) {
clearTabPtyId(tabId, ptyId)
}
transport.destroy?.()
paneTransportsRef.current.delete(paneId)
}

View File

@ -2,6 +2,7 @@ import { useEffect, createElement } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '../store'
import { applyUIZoom } from '@/lib/ui-zoom'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation'
import type { UpdateStatus } from '../../../shared/types'
const ZOOM_STEP = 0.5
@ -34,7 +35,7 @@ export function useIpcEvents(): void {
)
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId }) => {
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => {
void (async () => {
const store = useAppStore.getState()
await store.fetchWorktrees(repoId)
@ -45,6 +46,8 @@ export function useIpcEvents(): void {
store.setActiveRepo(repoId)
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
ensureWorktreeHasInitialTerminal(store, worktreeId, setup)
store.revealWorktreeInSidebar(worktreeId)
})().catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)

View File

@ -0,0 +1,19 @@
export function buildSetupRunnerCommand(runnerScriptPath: string): string {
if (navigator.userAgent.includes('Windows')) {
return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`
}
return `bash ${quotePosixArg(runnerScriptPath)}`
}
function quotePosixArg(value: string): string {
if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
return value
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
function quoteWindowsArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
describe('ensureWorktreeHasInitialTerminal', () => {
it('creates a first tab and queues setup for newly created worktrees', () => {
const createTab = vi.fn(() => ({ id: 'tab-1' }))
const setActiveTab = vi.fn()
const queueTabStartupCommand = vi.fn()
ensureWorktreeHasInitialTerminal(
{
tabsByWorktree: {},
createTab,
setActiveTab,
queueTabStartupCommand
},
'wt-1',
{
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
}
}
)
expect(createTab).toHaveBeenCalledWith('wt-1')
expect(setActiveTab).toHaveBeenCalledWith('tab-1')
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
}
})
})
it('does not create or queue anything when the worktree already has tabs', () => {
const createTab = vi.fn()
const setActiveTab = vi.fn()
const queueTabStartupCommand = vi.fn()
ensureWorktreeHasInitialTerminal(
{
tabsByWorktree: {
'wt-1': [{ id: 'tab-existing' }]
},
createTab,
setActiveTab,
queueTabStartupCommand
},
'wt-1',
{
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {}
}
)
expect(createTab).not.toHaveBeenCalled()
expect(setActiveTab).not.toHaveBeenCalled()
expect(queueTabStartupCommand).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,37 @@
import type { WorktreeSetupLaunch } from '../../../shared/types'
import { buildSetupRunnerCommand } from './setup-runner'
type WorktreeActivationStore = {
tabsByWorktree: Record<string, { id: string }[]>
createTab: (worktreeId: string) => { id: string }
setActiveTab: (tabId: string) => void
queueTabStartupCommand: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
}
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
setup?: WorktreeSetupLaunch
): void {
const existingTabs = store.tabsByWorktree[worktreeId] ?? []
if (existingTabs.length > 0) {
return
}
const terminalTab = store.createTab(worktreeId)
store.setActiveTab(terminalTab.id)
// Why: UI-created and CLI-created worktrees must bootstrap their first Orca
// terminal the same way or repo setup commands only run for one entry point.
// Keep the "create first tab and queue setup in that tab" behavior centralized
// so future activation changes cannot silently break one flow again.
if (setup) {
store.queueTabStartupCommand(terminalTab.id, {
command: buildSetupRunnerCommand(setup.runnerScriptPath),
env: setup.envVars
})
}
}

View File

@ -1,8 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, TerminalTab, TerminalLayoutSnapshot } from '../../../../shared/types'
import type { OpenFile } from './editor'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@ -47,75 +43,14 @@ const mockApi = {
// @ts-expect-error -- mock
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createEditorSlice } from './editor'
function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createEditorSlice(...a)
}))
}
// ─── Helpers ──────────────────────────────────────────────────────────
function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: string }): Worktree {
return {
path: '/tmp/wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function makeTab(
overrides: Partial<TerminalTab> & { id: string; worktreeId: string }
): TerminalTab {
return {
ptyId: null,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: Date.now(),
...overrides
}
}
function makeLayout(): TerminalLayoutSnapshot {
return { root: null, activeLeafId: null, expandedLeafId: null }
}
function makeOpenFile(overrides: Partial<OpenFile> & { id: string; worktreeId: string }): OpenFile {
return {
filePath: overrides.id,
relativePath: 'file.ts',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}
import {
createTestStore,
makeLayout,
makeOpenFile,
makeTab,
makeWorktree,
seedStore
} from './store-test-helpers'
// ─── Tests ────────────────────────────────────────────────────────────
@ -129,11 +64,7 @@ describe('removeWorktree cascade', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
// Seed state
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/path/wt1' })]
},
@ -189,10 +120,7 @@ describe('removeWorktree cascade', () => {
mockApi.worktrees.remove.mockRejectedValueOnce(new Error('branch has changes'))
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
@ -224,10 +152,7 @@ describe('removeWorktree cascade', () => {
mockApi.worktrees.remove.mockRejectedValueOnce(new Error('fatal error'))
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
@ -252,10 +177,7 @@ describe('removeWorktree cascade', () => {
const wt1 = 'repo1::/path/wt1'
const wt2 = 'repo1::/path/wt2'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' }),
@ -295,6 +217,39 @@ describe('removeWorktree cascade', () => {
expect(s.ptyIdsByTabId['tab1']).toBeUndefined()
expect(s.terminalLayoutsByTabId['tab1']).toBeUndefined()
})
it('shuts down terminals before asking the backend to remove the worktree', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const callOrder: string[] = []
mockApi.pty.kill.mockImplementationOnce(async () => {
callOrder.push('kill')
})
mockApi.worktrees.remove.mockImplementationOnce(async () => {
callOrder.push('remove')
})
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab1', worktreeId })]
},
ptyIdsByTabId: {
tab1: ['pty1']
},
terminalLayoutsByTabId: {
tab1: makeLayout()
}
})
const result = await store.getState().removeWorktree(worktreeId)
expect(result).toEqual({ ok: true })
expect(callOrder).toEqual(['kill', 'remove'])
})
})
describe('setActiveWorktree', () => {
@ -307,10 +262,7 @@ describe('setActiveWorktree', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', sortOrder: 123, isUnread: false })]
},

View File

@ -258,3 +258,58 @@ describe('hydrateWorkspaceSession', () => {
expect(s.activeRepoId).toBe('repo1')
})
})
describe('terminal slice behaviors', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('preserves tabs omitted from a reorder request instead of dropping them', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
store.setState({
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: 'tab-a', worktreeId, sortOrder: 0, createdAt: 1 }),
makeTab({ id: 'tab-b', worktreeId, sortOrder: 1, createdAt: 2 }),
makeTab({ id: 'tab-c', worktreeId, sortOrder: 2, createdAt: 3 })
]
}
})
store.getState().reorderTabs(worktreeId, ['tab-c', 'tab-a'])
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'tab-c', sortOrder: 0 }),
expect.objectContaining({ id: 'tab-a', sortOrder: 1 }),
expect.objectContaining({ id: 'tab-b', sortOrder: 2 })
])
})
it('falls back to the previous PTY id when clearing the active pane PTY', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: 'pty-2' })]
},
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2']
}
})
store.getState().clearTabPtyId('tab-1', 'pty-2')
const tab = store.getState().tabsByWorktree[worktreeId][0]
expect(tab.ptyId).toBe('pty-1')
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['pty-1'])
})
})

View File

@ -0,0 +1,96 @@
import { create } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, TerminalTab, TerminalLayoutSnapshot } from '../../../../shared/types'
import type { OpenFile } from './editor'
import { createRepoSlice } from './repos'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createEditorSlice } from './editor'
export const TEST_REPO = {
id: 'repo1',
path: '/repo1',
displayName: 'Repo 1',
badgeColor: '#000',
addedAt: 0
}
export function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createEditorSlice(...a)
}))
}
export function seedStore(
store: ReturnType<typeof createTestStore>,
state: Partial<AppState>
): void {
// The cascade tests intentionally centralize the default repo fixture here
// so the test files can stay under the enforced max-lines limit without
// disabling the lint rule and hiding further growth.
store.setState({
repos: [TEST_REPO],
...state
})
}
export function makeWorktree(
overrides: Partial<Worktree> & { id: string; repoId: string }
): Worktree {
return {
path: '/tmp/wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
export function makeTab(
overrides: Partial<TerminalTab> & { id: string; worktreeId: string }
): TerminalTab {
return {
ptyId: null,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: Date.now(),
...overrides
}
}
export function makeLayout(): TerminalLayoutSnapshot {
return { root: null, activeLeafId: null, expandedLeafId: null }
}
export function makeOpenFile(
overrides: Partial<OpenFile> & { id: string; worktreeId: string }
): OpenFile {
return {
filePath: overrides.id,
relativePath: 'file.ts',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}

View File

@ -17,6 +17,7 @@ export type TerminalSlice = {
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
pendingStartupByTabId: Record<string, { command: string; env?: Record<string, string> }>
tabBarOrderByWorktree: Record<string, string[]>
workspaceSessionReady: boolean
createTab: (worktreeId: string) => TerminalTab
@ -34,6 +35,13 @@ export type TerminalSlice = {
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
queueTabStartupCommand: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
consumeTabStartupCommand: (
tabId: string
) => { command: string; env?: Record<string, string> } | null
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
}
@ -45,6 +53,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
expandedPaneByTabId: {},
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
pendingStartupByTabId: {},
tabBarOrderByWorktree: {},
workspaceSessionReady: false,
@ -94,13 +103,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
delete nextLayouts[tabId]
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
delete nextPtyIdsByTabId[tabId]
const nextPendingStartupByTabId = { ...s.pendingStartupByTabId }
delete nextPendingStartupByTabId[tabId]
return {
tabsByWorktree: next,
activeTabId: s.activeTabId === tabId ? null : s.activeTabId,
ptyIdsByTabId: nextPtyIdsByTabId,
expandedPaneByTabId: nextExpanded,
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId
}
})
},
@ -109,12 +121,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
set((s) => {
const tabs = s.tabsByWorktree[worktreeId] ?? []
const tabMap = new Map(tabs.map((t) => [t.id, t]))
const reordered = tabIds
.map((id, i) => {
const tab = tabMap.get(id)
return tab ? { ...tab, sortOrder: i } : undefined
})
.filter((t): t is TerminalTab => t !== undefined)
const orderedSet = new Set(tabIds)
const missingTabs = tabs.filter((t) => !orderedSet.has(t.id))
const reordered = [
...tabIds.map((id) => tabMap.get(id)!).filter(Boolean),
...missingTabs
].map((tab, i) => ({ ...tab, sortOrder: i }))
return {
tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered }
}
@ -134,12 +148,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const tabMap = new Map(tabs.map((t) => [t.id, t]))
// Extract terminal IDs in their new relative order
const terminalIdsInOrder = order.filter((id) => tabMap.has(id))
const updatedTabs = terminalIdsInOrder
.map((id, i) => {
const tab = tabMap.get(id)
return tab ? { ...tab, sortOrder: i } : undefined
})
.filter((t): t is TerminalTab => t !== undefined)
const orderedSet = new Set(terminalIdsInOrder)
const missingTabs = tabs.filter((t) => !orderedSet.has(t.id))
const updatedTabs = [
...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean),
...missingTabs
].map((tab, i) => ({ ...tab, sortOrder: i }))
return {
tabBarOrderByWorktree: newTabBarOrder,
tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs }
@ -334,6 +350,30 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
queueTabStartupCommand: (tabId, startup) => {
set((s) => ({
pendingStartupByTabId: {
...s.pendingStartupByTabId,
[tabId]: startup
}
}))
},
consumeTabStartupCommand: (tabId) => {
const pending = get().pendingStartupByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingStartupByTabId }
delete next[tabId]
return { pendingStartupByTabId: next }
})
return pending
},
hydrateWorkspaceSession: (session) => {
set((s) => {
const validWorktreeIds = new Set(

View File

@ -23,6 +23,12 @@ export type UISlice = {
setSidebarWidth: (width: number) => void
activeView: 'terminal' | 'settings'
setActiveView: (view: UISlice['activeView']) => void
settingsNavigationTarget: {
pane: 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo'
repoId: string | null
} | null
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
clearSettingsTarget: () => void
activeModal: 'none' | 'create-worktree' | 'edit-meta' | 'delete-worktree'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
@ -59,6 +65,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
activeView: 'terminal',
setActiveView: (view) => set({ activeView: view }),
settingsNavigationTarget: null,
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),
activeModal: 'none',
modalData: {},

View File

@ -1,4 +1,9 @@
import type { Worktree, WorktreeMeta } from '../../../../shared/types'
import type {
CreateWorktreeResult,
SetupDecision,
Worktree,
WorktreeMeta
} from '../../../../shared/types'
export type WorktreeDeleteState = {
isDeleting: boolean
@ -19,7 +24,12 @@ export type WorktreeSlice = {
sortEpoch: number
fetchWorktrees: (repoId: string) => Promise<void>
fetchAllWorktrees: () => Promise<void>
createWorktree: (repoId: string, name: string, baseBranch?: string) => Promise<Worktree>
createWorktree: (
repoId: string,
name: string,
baseBranch?: string,
setupDecision?: SetupDecision
) => Promise<CreateWorktreeResult>
removeWorktree: (
worktreeId: string,
force?: boolean

View File

@ -67,17 +67,17 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
},
createWorktree: async (repoId, name, baseBranch) => {
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
try {
const worktree = await window.api.worktrees.create({ repoId, name, baseBranch })
const result = await window.api.worktrees.create({ repoId, name, baseBranch, setupDecision })
set((s) => ({
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), worktree]
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), result.worktree]
},
sortEpoch: s.sortEpoch + 1
}))
return worktree
return result
} catch (err) {
console.error('Failed to create worktree:', err)
throw err
@ -97,8 +97,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}))
try {
await window.api.worktrees.remove({ worktreeId, force })
// Why: setup-enabled worktrees now commonly have a live shell open as soon as
// they are created. We must tear those PTYs down before asking Git to remove
// the working tree or Windows and some shells can keep the directory in use
// and make delete look broken even though the git state itself is fine.
await get().shutdownWorktreeTerminals(worktreeId)
await window.api.worktrees.remove({ worktreeId, force })
const tabs = get().tabsByWorktree[worktreeId] ?? []
const tabIds = new Set(tabs.map((t) => t.id))

View File

@ -59,6 +59,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
export function getDefaultRepoHookSettings(): RepoHookSettings {
return {
mode: 'auto',
setupRunPolicy: 'run-by-default',
scripts: {
setup: '',
archive: ''

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines */
// ─── Repo ────────────────────────────────────────────────────────────
export type Repo = {
id: string
@ -10,6 +12,9 @@ export type Repo = {
hookSettings?: RepoHookSettings
}
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
export type SetupDecision = 'inherit' | 'run' | 'skip'
// ─── Worktree (git-level) ────────────────────────────────────────────
export type GitWorktreeInfo = {
path: string
@ -150,13 +155,34 @@ export type OrcaHooks = {
}
export type RepoHookSettings = {
// Why: legacy persisted data may still include the old UI-hook fields. Orca no longer
// treats them as an active config surface, but we keep them in the stored shape so
// existing local state can still be read without migrations.
mode: 'auto' | 'override'
setupRunPolicy?: SetupRunPolicy
scripts: {
setup: string
archive: string
}
}
export type WorktreeSetupLaunch = {
runnerScriptPath: string
envVars: Record<string, string>
}
export type CreateWorktreeArgs = {
repoId: string
name: string
baseBranch?: string
setupDecision?: SetupDecision
}
export type CreateWorktreeResult = {
worktree: Worktree
setup?: WorktreeSetupLaunch
}
// ─── Updater ─────────────────────────────────────────────────────────
export type UpdateStatus =
| { state: 'idle' }