feat(isolation,workflows): worktree location + per-workflow isolation policy (#1310)
Some checks are pending
E2E Smoke Tests / e2e-codex (push) Waiting to run
E2E Smoke Tests / e2e-claude (push) Waiting to run
E2E Smoke Tests / e2e-deterministic (push) Waiting to run
E2E Smoke Tests / e2e-mixed (push) Blocked by required conditions
Test Suite / test (ubuntu-latest) (push) Waiting to run
Test Suite / test (windows-latest) (push) Waiting to run
Test Suite / docker-build (push) Waiting to run

* feat(isolation): per-project worktree.path + collapse to two layouts

Adds an opt-in `worktree.path` to .archon/config.yaml so a repo can co-locate
worktrees with its own checkout (`<repoRoot>/<path>/<branch>`) instead of the
default `~/.archon/workspaces/<owner>/<repo>/worktrees/<branch>`. Requested in
joelsb's #1117.

Primitive changes (clean up the graveyard rather than add parallel code paths):

- Collapse worktree layouts from three to two. The old "legacy global" layout
  (`~/.archon/worktrees/<owner>/<repo>/<branch>`) is gone — every repo resolves
  to the workspace-scoped layout (`~/.archon/workspaces/<owner>/<repo>/worktrees/<branch>`),
  whether it was archon-cloned or locally registered. `extractOwnerRepo()` on
  the repo path is the stable identity fallback. Ends the divergence where
  workspace-cloned and local repos had visibly different worktree trees.

- `getWorktreeBase()` in @archon/git now returns `{ base, layout }` and accepts
  an optional `{ repoLocal }` override. The layout value replaces the old
  `isProjectScopedWorktreeBase()` classification at the call sites
  (`isProjectScopedWorktreeBase` stays exported as deprecated back-compat).

- `WorktreeCreateConfig.path` carries the validated override from repo config.
  `resolveRepoLocalOverride()` fails loudly on absolute paths, `..` escapes,
  and resolve-escape edge cases (Fail Fast — no silent default fallback when
  the config is syntactically wrong).

- `WorktreeProvider.create()` now loads repo config exactly once and threads it
  through `getWorktreePath()` + `createWorktree()`. Replaces the prior
  swallow-then-retry pattern flagged on #1117. `generateEnvId()` is gone —
  envId is assigned directly from the resolved path (the invariant was already
  documented on `destroy(envId)`).

Tests (packages/git + packages/isolation):
- Update the pre-existing `getWorktreeBase` / `isProjectScopedWorktreeBase`
  suite for the new two-layout return shape and precedence.
- Add 8 tests for `worktree.path`: default fallthrough, empty/whitespace
  ignored, override wins for workspace-scoped repos, rejects absolute, rejects
  `../` escapes (three variants), accepts nested relative paths.

Docs: add `worktree.path` to the repo config reference with explicit precedence
and the `.gitignore` responsibility note.

Co-authored-by: Joel Bastos <joelsb2001@gmail.com>

* feat(workflows): per-workflow worktree.enabled policy

Introduces a declarative top-level `worktree:` block on a workflow so
authors can pin isolation behavior regardless of invocation surface. Solves
the case where read-only workflows (e.g. `repo-triage`) should always run in
the live checkout, without every CLI/web/scheduled-trigger caller having to
remember to set the right flag.

Schema (packages/workflows/src/schemas/workflow.ts + loader.ts):

- New optional `worktree.enabled: boolean` on `workflowBaseSchema`. Loader
  parses with the same warn-and-ignore discipline used for `interactive`
  and `modelReasoningEffort` — invalid shapes log and drop rather than
  killing workflow discovery.

Policy reconciliation (packages/cli/src/commands/workflow.ts):

- Three hard-error cases when YAML policy contradicts invocation flags:
  • `enabled: false` + `--branch`       (worktree required by flag, forbidden by policy)
  • `enabled: false` + `--from`         (start-point only meaningful with worktree)
  • `enabled: true`  + `--no-worktree`  (policy requires worktree, flag forbids it)
- `enabled: false` + `--no-worktree` is redundant, accepted silently.
- `--resume` ignores the pinned policy (it reuses the existing run's worktree
  even when policy would disable — avoids disturbing a paused run).

Orchestrator wiring (packages/core/src/orchestrator/orchestrator-agent.ts):

- `dispatchOrchestratorWorkflow` short-circuits `validateAndResolveIsolation`
  when `workflow.worktree?.enabled === false` and runs directly in
  `codebase.default_cwd`. Web chat/slack/telegram callers have no flag
  equivalent to `--no-worktree`, so the YAML field is their only control.
- Logged as `workflow.worktree_disabled_by_policy` for operator visibility.

First consumer (.archon/workflows/repo-triage.yaml):

- `worktree: { enabled: false }` — triage reads issues/PRs and writes gh
  labels; no code mutations, no reason to spin up a worktree per run.

Tests:

- Loader: parses `worktree.enabled: true|false`, omits block when absent.
- CLI: four new integration tests for the reconciliation matrix (skip when
  policy false, three hard-error cases, redundant `--no-worktree` accepted,
  `--no-worktree` + `enabled: true` rejected).

Docs: authoring-workflows.md gets the new top-level field in the schema
example with a comment explaining the precedence and the `enabled: true|false`
semantics.

* fix(isolation): use path.sep for repo-containment check on Windows

resolveRepoLocalOverride was hardcoding '/' as the separator in the
startsWith check, so on Windows (where `resolve()` returns backslash
paths like `D:\Users\dev\Projects\myapp`) every otherwise-valid
relative `worktree.path` was rejected with "resolves outside the repo
root". Fixed by importing `path.sep` and using it in the sentinel.

Fixes the 3 Windows CI failures in `worktree.path repo-local override`.

---------

Co-authored-by: Joel Bastos <joelsb2001@gmail.com>
This commit is contained in:
Rasmus Widing 2026-04-20 21:54:10 +03:00 committed by GitHub
parent 7be4d0a35e
commit 5ed38dc765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 748 additions and 171 deletions

View file

@ -0,0 +1,34 @@
# E2E smoke test — workflow-level worktree.enabled: false
# Verifies: when a workflow pins worktree.enabled: false, runs happen in the
# live repo checkout (no worktree created, cwd == repo root). Zero AI calls.
name: e2e-worktree-disabled
description: "Pinned-isolation-off smoke. Asserts cwd is the repo root rather than a worktree path, regardless of how the workflow is invoked."
worktree:
enabled: false
nodes:
# Print cwd so the operator can eyeball it, and capture for the assertion node.
- id: print-cwd
bash: "pwd"
# Assertion: cwd must NOT contain '/.archon/workspaces/' — if it does, the
# policy was ignored and a worktree was created anyway. We also assert the
# cwd ends with a git repo (has a .git directory or file visible).
- id: assert-live-checkout
bash: |
cwd="$(pwd)"
echo "assert-live-checkout cwd=$cwd"
case "$cwd" in
*/.archon/workspaces/*/worktrees/*)
echo "FAIL: workflow ran inside a worktree ($cwd) despite worktree.enabled: false"
exit 1
;;
esac
if [ ! -e "$cwd/.git" ]; then
echo "FAIL: cwd $cwd is not a git checkout root (.git missing)"
exit 1
fi
echo "PASS: ran in live checkout (no worktree created by policy)"
depends_on: [print-cwd]
trigger_rule: all_success

View file

@ -8,6 +8,12 @@ description: >-
runs; safe to re-run; idempotent.
interactive: false
# Read-only triage runs directly in the live checkout. Creating a worktree
# every run would be wasted work (nothing is mutated) and would scatter stale
# branches under ~/.archon/workspaces/<owner>/<repo>/worktrees/.
worktree:
enabled: false
nodes:
# ---------------------------------------------------------------------------
# Issue triage — runs concurrently with pr-link (no depends_on between them).

View file

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`'global'` variant on `WorkflowSource`** — workflows at `~/.archon/workflows/` and commands at `~/.archon/commands/` now render with a distinct source label (no longer coerced to `'project'`). Web UI badges updated.
- **`getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`, `getLegacyHomeWorkflowsPath()`** helpers in `@archon/paths`, exported for both internal discovery and external callers that want to target the home scope directly.
- **`discoverScriptsForCwd(cwd)`** in `@archon/workflows/script-discovery` — merges home-scoped + repo-scoped scripts with repo winning on name collisions. Used by the DAG executor and validator; callers no longer need to know about the two-scope shape.
- **Workflow-level worktree policy (`worktree.enabled` in workflow YAML).** A workflow can now pin whether its runs use isolation regardless of how they were invoked: `worktree.enabled: false` always runs in the live checkout (CLI `--branch` / `--from` hard-error; web/chat/orchestrator short-circuits `validateAndResolveIsolation`), `worktree.enabled: true` requires isolation (CLI `--no-worktree` hard-errors). Omit the block to let the caller decide (current default). First consumer: `.archon/workflows/repo-triage.yaml` pinned to `enabled: false` since it's read-only.
- **Per-project worktree path (`worktree.path` in `.archon/config.yaml`).** Opt-in repo-relative directory (e.g. `.worktrees`) where Archon places worktrees for that repo, instead of the default `~/.archon/workspaces/<owner>/<repo>/worktrees/`. Co-locates worktrees with the project so they appear in the IDE file tree. Validated as a safe relative path (no absolute, no `..`); malformed values fail loudly at worktree creation. Users opting in are responsible for `.gitignore`ing the directory themselves — no automatic file mutation. Credits @joelsb for surfacing the need in #1117.
- **Three-path env model with operator-visible log lines.** The CLI and server now load env vars from `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from <path>` line is emitted per source (only when N > 0). `[archon] stripped N keys from <cwd> (...)` now also prints when stripCwdEnv removes target-repo env keys, replacing the misleading `[dotenv@17.3.1] injecting env (0) from .env` preamble that always reported 0. The `quiet: true` flag suppresses dotenv's own output. (#1302)
- **`archon setup --scope home|project` and `--force` flags.** Default is `--scope home` (writes `~/.archon/.env`). `--scope project` targets `<cwd>/.archon/.env` instead. `--force` overwrites the target wholesale rather than merging; a timestamped backup is still written. (#1303)
- **Merge-only setup writes with timestamped backups.** `archon setup` now reads the existing target file, preserves non-empty values, carries user-added custom keys forward, and writes a `<target>.archon-backup-<ISO-ts>` before every rewrite. Fixes silent PostgreSQL→SQLite downgrade and silent token loss on re-run. (#1303)

View file

@ -865,6 +865,146 @@ describe('workflowRunCommand', () => {
expect(createCallsAfter).toBe(createCallsBefore);
});
// -------------------------------------------------------------------------
// Workflow-level `worktree.enabled` policy
// -------------------------------------------------------------------------
it('skips isolation when workflow YAML pins worktree.enabled: false', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const { executeWorkflow } = await import('@archon/workflows/executor');
const conversationDb = await import('@archon/core/db/conversations');
const codebaseDb = await import('@archon/core/db/codebases');
const isolation = await import('@archon/isolation');
const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType<typeof mock>;
const providerBefore = getIsolationProviderMock.mock.results.at(-1)?.value as
| { create: ReturnType<typeof mock> }
| undefined;
const createCallsBefore = providerBefore?.create.mock.calls.length ?? 0;
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [
makeTestWorkflowWithSource({
name: 'triage',
description: 'Read-only triage',
worktree: { enabled: false },
}),
],
errors: [],
});
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'conv-123',
});
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'cb-123',
default_cwd: '/test/path',
});
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
success: true,
workflowRunId: 'run-123',
});
// No flags — policy alone should disable isolation
await workflowRunCommand('/test/path', 'triage', 'go', {});
const providerAfter = getIsolationProviderMock.mock.results.at(-1)?.value as
| { create: ReturnType<typeof mock> }
| undefined;
const createCallsAfter = providerAfter?.create.mock.calls.length ?? 0;
expect(createCallsAfter).toBe(createCallsBefore);
});
it('throws when workflow pins worktree.enabled: false but caller passes --branch', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [
makeTestWorkflowWithSource({
name: 'triage',
description: 'Read-only triage',
worktree: { enabled: false },
}),
],
errors: [],
});
await expect(
workflowRunCommand('/test/path', 'triage', 'go', { branchName: 'feat-x' })
).rejects.toThrow(/worktree\.enabled: false/);
});
it('throws when workflow pins worktree.enabled: false but caller passes --from', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [
makeTestWorkflowWithSource({
name: 'triage',
description: 'Read-only triage',
worktree: { enabled: false },
}),
],
errors: [],
});
await expect(
workflowRunCommand('/test/path', 'triage', 'go', { fromBranch: 'dev' })
).rejects.toThrow(/worktree\.enabled: false/);
});
it('accepts worktree.enabled: false + --no-worktree as redundant (no error)', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const { executeWorkflow } = await import('@archon/workflows/executor');
const conversationDb = await import('@archon/core/db/conversations');
const codebaseDb = await import('@archon/core/db/codebases');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [
makeTestWorkflowWithSource({
name: 'triage',
description: 'Read-only triage',
worktree: { enabled: false },
}),
],
errors: [],
});
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'conv-123',
});
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'cb-123',
default_cwd: '/test/path',
});
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
success: true,
workflowRunId: 'run-123',
});
// Should not throw — redundant, not contradictory
await workflowRunCommand('/test/path', 'triage', 'go', { noWorktree: true });
});
it('throws when workflow pins worktree.enabled: true but caller passes --no-worktree', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [
makeTestWorkflowWithSource({
name: 'build',
description: 'Requires a worktree',
worktree: { enabled: true },
}),
],
errors: [],
});
await expect(
workflowRunCommand('/test/path', 'build', 'go', { noWorktree: true })
).rejects.toThrow(/worktree\.enabled: true/);
});
it('throws when isolation cannot be created due to missing codebase', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const conversationDb = await import('@archon/core/db/conversations');

View file

@ -261,6 +261,37 @@ export async function workflowRunCommand(
);
}
// Reconcile workflow-level worktree policy with invocation flags.
// The workflow YAML's `worktree.enabled` pins isolation regardless of caller —
// a mismatch between policy and flags is a user error we surface loudly
// rather than silently applying one side and ignoring the other.
const pinnedEnabled = workflow.worktree?.enabled;
if (pinnedEnabled === false) {
if (options.branchName !== undefined) {
throw new Error(
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
' --branch requires an isolated worktree.\n' +
" Drop --branch or change the workflow's worktree.enabled."
);
}
if (options.fromBranch !== undefined) {
throw new Error(
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
' --from/--from-branch only applies when a worktree is created.\n' +
" Drop --from or change the workflow's worktree.enabled."
);
}
// --no-worktree is redundant but not contradictory — silently accept.
} else if (pinnedEnabled === true) {
if (options.noWorktree) {
throw new Error(
`Workflow '${workflow.name}' sets worktree.enabled: true (requires a worktree).\n` +
' --no-worktree conflicts with the workflow policy.\n' +
" Drop --no-worktree or change the workflow's worktree.enabled."
);
}
}
console.log(`Running workflow: ${workflowName}`);
console.log(`Working directory: ${cwd}`);
console.log('');
@ -403,8 +434,14 @@ export async function workflowRunCommand(
console.log('');
}
// Default to worktree isolation unless --no-worktree or --resume
const wantsIsolation = !options.resume && !options.noWorktree;
// Default to worktree isolation unless --no-worktree or --resume.
// Workflow YAML `worktree.enabled` pins the decision — mismatches with CLI
// flags are rejected above, so by this point the policy (if set) and flags
// agree. `--resume` reuses an existing worktree and takes precedence over
// the pinned policy to avoid disturbing a paused run.
const flagWantsIsolation = !options.resume && !options.noWorktree;
const wantsIsolation =
!options.resume && pinnedEnabled !== undefined ? pinnedEnabled : flagWantsIsolation;
if (wantsIsolation && codebase) {
// Auto-generate branch identifier from workflow name + timestamp when --branch not provided

View file

@ -176,6 +176,29 @@ export interface RepoConfig {
* @default true
*/
initSubmodules?: boolean;
/**
* Per-project worktree directory (relative to repo root). When set,
* worktrees are created at `<repoRoot>/<path>/<branch>` instead of under
* `~/.archon/worktrees/` or the workspaces layout.
*
* Opt-in co-locates worktrees with the repo so they appear in the IDE
* file tree. The user is responsible for adding the directory to their
* `.gitignore` (no automatic file mutation).
*
* Path resolution precedence (highest to lowest):
* 1. this `worktree.path` (repo-local)
* 2. global `paths.worktrees` (absolute override in `~/.archon/config.yaml`)
* 3. auto-detected project-scoped (`~/.archon/workspaces/owner/repo/...`)
* 4. default global (`~/.archon/worktrees/`)
*
* Must be a safe relative path: no leading `/`, no `..` segments. Absolute
* or escaping values fail loudly at worktree creation (Fail Fast no silent
* fallback).
*
* @example '.worktrees'
*/
path?: string;
};
/**

View file

@ -228,31 +228,43 @@ async function dispatchOrchestratorWorkflow(
codebase_id: codebase.id,
});
// Validate and resolve isolation
// Validate and resolve isolation.
// A workflow with `worktree.enabled: false` short-circuits the resolver entirely
// and runs in the live checkout — no worktree creation, no env row. This is the
// declarative equivalent of CLI `--no-worktree` for workflows that should always
// run live (e.g. read-only triage, docs generation on the main checkout).
let cwd: string;
try {
const result = await validateAndResolveIsolation(
{ ...conversation, codebase_id: codebase.id },
codebase,
platform,
conversationId,
isolationHints
if (workflow.worktree?.enabled === false) {
getLog().info(
{ workflowName: workflow.name, conversationId, codebaseId: codebase.id },
'workflow.worktree_disabled_by_policy'
);
cwd = result.cwd;
} catch (error) {
if (error instanceof IsolationBlockedError) {
getLog().warn(
{
reason: error.reason,
conversationId,
codebaseId: codebase.id,
workflowName: workflow.name,
},
'isolation_blocked'
cwd = codebase.default_cwd;
} else {
try {
const result = await validateAndResolveIsolation(
{ ...conversation, codebase_id: codebase.id },
codebase,
platform,
conversationId,
isolationHints
);
return;
cwd = result.cwd;
} catch (error) {
if (error instanceof IsolationBlockedError) {
getLog().warn(
{
reason: error.reason,
conversationId,
codebaseId: codebase.id,
workflowName: workflow.name,
},
'isolation_blocked'
);
return;
}
throw error;
}
throw error;
}
// Dispatch workflow

View file

@ -120,6 +120,12 @@ model: sonnet
modelReasoningEffort: medium # Codex only
webSearchMode: live # Codex only
interactive: true # Web only: run in foreground instead of background
worktree: # Optional: pin isolation behavior regardless of caller
enabled: false # false = always run in the live checkout (CLI --no-worktree
# and web both honor it). Use for read-only workflows
# like triage/reporting. true = must use a worktree;
# CLI --no-worktree hard-errors. Omit to let the
# caller decide (current default = worktree).
# Required for DAG-based
nodes:

View file

@ -127,6 +127,10 @@ worktree:
- .vscode # Copy entire directory
initSubmodules: true # Optional: default true — auto-detects .gitmodules and runs
# `git submodule update --init --recursive`. Set false to opt out.
path: .worktrees # Optional: co-locate worktrees with the repo at
# <repoRoot>/.worktrees/<branch> instead of under
# ~/.archon/workspaces/<owner>/<repo>/worktrees/.
# Must be relative; no absolute, no `..` segments.
# Documentation directory
docs:
@ -180,6 +184,8 @@ This is useful when you maintain coding style or identity preferences in `~/.cla
**Docs path behavior:** The `docs.path` setting controls where the `$DOCS_DIR` variable points. When not configured, `$DOCS_DIR` defaults to `docs/`. Unlike `$BASE_BRANCH`, this variable always has a safe default and never throws an error. Configure it when your documentation lives outside the standard `docs/` directory (e.g., `packages/docs-web/src/content/docs`).
**Worktree path behavior:** By default, every repo's worktrees live under `~/.archon/workspaces/<owner>/<repo>/worktrees/<branch>` — outside the repo, invisible to the IDE. Set `worktree.path` to opt in to a **repo-local** layout instead: worktrees are created at `<repoRoot>/<worktree.path>/<branch>` so they show up in the file tree and editor workspace. A common choice is `.worktrees`. Because worktrees now live inside the repository tree, you should add the directory to your `.gitignore` (Archon does not modify user-owned files). The configured path must be relative to the repo root; absolute paths and paths containing `..` segments fail loudly at worktree creation rather than silently falling back.
## Environment Variables
Environment variables override all other configuration. They are organized by category below.

View file

@ -194,79 +194,78 @@ describe('git utilities', () => {
}
});
test('returns ~/.archon/worktrees by default for local (non-Docker)', () => {
test('returns workspace-scoped base for a local non-workspace repo (via path fallback)', () => {
// New-model invariant: every repo resolves to workspace-scoped. For a repo
// living outside ~/.archon/workspaces/, owner/repo is derived from the last
// two path segments (extractOwnerRepo) so the worktree base is still stable.
delete process.env.WORKTREE_BASE;
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join(homedir(), '.archon', 'worktrees'));
expect(result).toEqual({
base: join(homedir(), '.archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('returns /.archon/worktrees for Docker environment', () => {
delete process.env.WORKTREE_BASE;
delete process.env.ARCHON_HOME;
process.env.WORKSPACE_PATH = '/workspace';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join('/', '.archon', 'worktrees'));
});
test('detects Docker by HOME=/root + WORKSPACE_PATH', () => {
delete process.env.WORKTREE_BASE;
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
process.env.HOME = '/root';
process.env.WORKSPACE_PATH = '/app/workspace';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join('/', '.archon', 'worktrees'));
});
test('uses ARCHON_HOME for local (non-Docker)', () => {
test('uses ARCHON_HOME for the workspace-scoped base (local non-Docker)', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.WORKTREE_BASE;
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = '/custom/archon';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join('/custom/archon', 'worktrees'));
expect(result).toEqual({
base: join('/custom/archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('uses fixed path in Docker', () => {
test('uses the Docker archon home for the workspace-scoped base', () => {
delete process.env.ARCHON_HOME;
process.env.ARCHON_DOCKER = 'true';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join('/', '.archon', 'worktrees'));
expect(result).toEqual({
base: join('/', '.archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('returns project-scoped worktrees path when repo is under workspaces', () => {
test('returns workspace-scoped path when repo is already under workspaces/', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const workspacesPath = join(homedir(), '.archon', 'workspaces');
const repoPath = join(workspacesPath, 'acme', 'widget', 'source');
const result = git.getWorktreeBase(repoPath);
expect(result).toBe(join(workspacesPath, 'acme', 'widget', 'worktrees'));
expect(result).toEqual({
base: join(workspacesPath, 'acme', 'widget', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('returns project-scoped path with ARCHON_HOME override', () => {
test('workspace-scoped path honors ARCHON_HOME override', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = join('/', 'custom', 'archon');
const repoPath = join('/', 'custom', 'archon', 'workspaces', 'acme', 'widget', 'source');
const result = git.getWorktreeBase(repoPath);
expect(result).toBe(
join('/', 'custom', 'archon', 'workspaces', 'acme', 'widget', 'worktrees')
);
expect(result).toEqual({
base: join('/', 'custom', 'archon', 'workspaces', 'acme', 'widget', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('uses codebaseName to resolve project-scoped path for local repo', () => {
test('uses codebaseName to resolve workspace-scoped path for a local repo', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const localRepoPath = '/Users/rasmus/Projects/sasha-demo';
const result = git.getWorktreeBase(localRepoPath, 'Widinglabs/sasha-demo');
expect(result).toBe(
join(homedir(), '.archon', 'workspaces', 'Widinglabs', 'sasha-demo', 'worktrees')
);
expect(result).toEqual({
base: join(homedir(), '.archon', 'workspaces', 'Widinglabs', 'sasha-demo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('codebaseName takes priority over workspaces path detection', () => {
@ -276,19 +275,52 @@ describe('git utilities', () => {
const workspacesPath = join(homedir(), '.archon', 'workspaces');
const repoPath = join(workspacesPath, 'old-owner', 'old-repo', 'source');
const result = git.getWorktreeBase(repoPath, 'new-owner/new-repo');
expect(result).toBe(join(workspacesPath, 'new-owner', 'new-repo', 'worktrees'));
expect(result).toEqual({
base: join(workspacesPath, 'new-owner', 'new-repo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('ignores invalid codebaseName and falls back to path detection', () => {
test('ignores invalid codebaseName and falls back to path-derived owner/repo', () => {
// "invalid-no-slash" doesn't parse as owner/repo; the layout still resolves
// to workspace-scoped using the last two segments of the repoPath.
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const result = git.getWorktreeBase('/local/repo', 'invalid-no-slash');
expect(result).toBe(join(homedir(), '.archon', 'worktrees'));
expect(result).toEqual({
base: join(homedir(), '.archon', 'workspaces', 'local', 'repo', 'worktrees'),
layout: 'workspace-scoped',
});
});
test('repoLocal override wins over workspace-scoped default', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const repoPath = '/Users/rasmus/Projects/myapp';
const result = git.getWorktreeBase(repoPath, undefined, { repoLocal: '.worktrees' });
expect(result).toEqual({
base: join(repoPath, '.worktrees'),
layout: 'repo-local',
});
});
test('repoLocal override wins even for repos under workspaces/', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const workspacesPath = join(homedir(), '.archon', 'workspaces');
const repoPath = join(workspacesPath, 'acme', 'widget', 'source');
const result = git.getWorktreeBase(repoPath, 'acme/widget', { repoLocal: '.wt' });
expect(result).toEqual({
base: join(repoPath, '.wt'),
layout: 'repo-local',
});
});
});
describe('isProjectScopedWorktreeBase', () => {
describe('isProjectScopedWorktreeBase (deprecated)', () => {
const originalArchonHome = process.env.ARCHON_HOME;
const originalWorkspacePath = process.env.WORKSPACE_PATH;
const originalArchonDocker = process.env.ARCHON_DOCKER;
@ -321,19 +353,14 @@ describe('git utilities', () => {
).toBe(true);
});
test('returns false for path outside workspaces', () => {
test('returns true for a local non-workspace path (new two-layout model)', () => {
// In the pre-refactor three-layout model, this returned false (legacy global).
// Under the two-layout model every repo is workspace-scoped unless a
// `repoLocal` override is supplied, which this helper does not accept.
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
expect(git.isProjectScopedWorktreeBase('/workspace/my-repo')).toBe(false);
});
test('returns false for path under workspaces with only owner (no repo)', () => {
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
const workspacesPath = join(homedir(), '.archon', 'workspaces');
expect(git.isProjectScopedWorktreeBase(join(workspacesPath, 'acme'))).toBe(false);
expect(git.isProjectScopedWorktreeBase('/workspace/my-repo')).toBe(true);
});
test('returns true when codebaseName is provided (local repo)', () => {
@ -345,11 +372,13 @@ describe('git utilities', () => {
);
});
test('returns false when codebaseName is invalid', () => {
test('returns true when codebaseName is invalid (falls back to path-derived)', () => {
// Under the two-layout model the helper always returns true for any resolvable
// owner/repo. Invalid codebaseName + valid repo path → still workspace-scoped.
delete process.env.WORKSPACE_PATH;
delete process.env.ARCHON_DOCKER;
delete process.env.ARCHON_HOME;
expect(git.isProjectScopedWorktreeBase('/local/repo', 'invalid')).toBe(false);
expect(git.isProjectScopedWorktreeBase('/local/repo', 'invalid')).toBe(true);
});
});

View file

@ -26,6 +26,7 @@ export {
getCanonicalRepoPath,
verifyWorktreeOwnership,
} from './worktree';
export type { WorktreeLayout, WorktreeBaseOverride } from './worktree';
// Branch operations
export {

View file

@ -1,11 +1,6 @@
import { readFile, access } from 'fs/promises';
import { join, resolve } from 'path';
import {
createLogger,
getArchonWorktreesPath,
getArchonWorkspacesPath,
getProjectWorktreesPath,
} from '@archon/paths';
import { createLogger, getArchonWorkspacesPath, getProjectWorktreesPath } from '@archon/paths';
import { execFileAsync } from './exec';
import type { RepoPath, BranchName, WorktreePath, WorktreeInfo } from './types';
import { toRepoPath, toBranchName, toWorktreePath } from './types';
@ -18,60 +13,111 @@ function getLog(): ReturnType<typeof createLogger> {
}
/**
* Get the base directory for worktrees.
* Layout of a worktree base relative to the repository.
*
* Resolution order:
* 1. If `codebaseName` is provided in "owner/repo" format, returns the project-scoped
* path directly: ~/.archon/workspaces/owner/repo/worktrees/
* 2. For paths under ~/.archon/workspaces/owner/repo/..., extracts owner/repo from path
* and returns the project-scoped path.
* 3. Otherwise, returns the legacy global path: ~/.archon/worktrees/
* Two layouts only worktrees live either co-located with the repo (opt-in)
* or inside the user's archon workspace area (default for every repo):
*
* - `repo-local` `<repoRoot>/<override.repoLocal>/` (opt-in per repo config)
* - `workspace-scoped` `~/.archon/workspaces/<owner>/<repo>/worktrees/` (default)
*
* In both layouts the base already includes all repo context, so callers append
* only the branch name to compose the final worktree path there is no layout
* where owner/repo gets tacked on as a separate path segment.
*/
export function getWorktreeBase(repoPath: RepoPath, codebaseName?: string): string {
// If codebase name is known, use project-scoped path directly
export type WorktreeLayout = 'repo-local' | 'workspace-scoped';
/**
* Override inputs for `getWorktreeBase()`. All fields are optional.
*/
export interface WorktreeBaseOverride {
/**
* Repo-relative path where worktrees should live (e.g. `.worktrees`).
* Only supported override today. Must be validated as a safe relative path
* by the caller before reaching this layer.
*/
repoLocal?: string;
}
/**
* Resolve the `{ owner, repo }` identity used to scope archon-managed worktrees.
*
* Precedence:
* 1. Explicit `codebaseName` in `owner/repo` format (from the database / web UI)
* 2. Path segments when `repoPath` is already under `~/.archon/workspaces/owner/repo/`
* 3. Last two path segments of `repoPath` (works for any local checkout)
*
* The third fallback is what lets non-cloned / locally-registered repos still
* land in the workspace-scoped layout every repo gets a stable owner/repo
* identity derived from its filesystem path.
*/
function resolveOwnerRepo(
repoPath: RepoPath,
codebaseName?: string
): { owner: string; repo: string } {
if (codebaseName) {
const parts = codebaseName.split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
return getProjectWorktreesPath(parts[0], parts[1]);
return { owner: parts[0], repo: parts[1] };
}
// codebaseName present but not "owner/repo" format — fall through to path detection.
// This is intentional: safe degradation to legacy global path.
getLog().warn({ codebaseName }, 'worktree.invalid_codebase_name_format');
}
// Existing path-prefix detection (cloned repos under workspaces/)
const workspacesPath = getArchonWorkspacesPath();
if (repoPath.startsWith(workspacesPath)) {
const relative = repoPath.substring(workspacesPath.length + 1);
const parts = relative.split(/[/\\]/).filter(p => p.length > 0);
if (parts.length >= 2) {
return getProjectWorktreesPath(parts[0], parts[1]);
return { owner: parts[0], repo: parts[1] };
}
}
// Legacy global fallback (no codebase name, no workspace path match)
return getArchonWorktreesPath();
// Fallback: derive from path basename/parent-basename — covers local-registered
// repos that never lived under workspaces/. Delegates to extractOwnerRepo()
// which throws on pathologically short paths.
return extractOwnerRepo(repoPath);
}
/**
* Check if the worktree base for a given repo path is project-scoped
* (under ~/.archon/workspaces/owner/repo/worktrees/) vs legacy global.
* Get the base directory for worktrees and the resolved layout.
*
* When project-scoped, the worktree base already includes the owner/repo context,
* so callers should NOT append owner/repo again.
* Resolution (highest to lowest priority):
* 1. `override.repoLocal` `<repoRoot>/<repoLocal>/` (layout: `repo-local`)
* 2. Otherwise `~/.archon/workspaces/<owner>/<repo>/worktrees/`
* (layout: `workspace-scoped`)
*
* Resolution order mirrors `getWorktreeBase`: codebaseName path detection legacy.
* The `<owner>/<repo>` identity is resolved via `resolveOwnerRepo()` see its
* docstring for the precedence. Every repo ends up with a stable workspace-scoped
* base; there is no `~/.archon/worktrees/owner/repo/` fallback layout.
*/
export function getWorktreeBase(
repoPath: RepoPath,
codebaseName?: string,
override?: WorktreeBaseOverride
): { base: string; layout: WorktreeLayout } {
if (override?.repoLocal) {
return { base: join(repoPath, override.repoLocal), layout: 'repo-local' };
}
const { owner, repo } = resolveOwnerRepo(repoPath, codebaseName);
return {
base: getProjectWorktreesPath(owner, repo),
layout: 'workspace-scoped',
};
}
/**
* Check if the worktree base for a given repo path is workspace-scoped.
*
* Kept for backward compatibility with callers outside this package; prefer
* reading `layout` from `getWorktreeBase()` in new code. This helper is unaware
* of `override.repoLocal`, so it does not reflect per-repo overrides use
* `getWorktreeBase(...).layout === 'workspace-scoped'` in override-aware code.
*
* @deprecated Use `getWorktreeBase(...).layout === 'workspace-scoped'` instead.
* This helper returned `false` for pre-workspace registered repos in the old
* two-layout model; in the current model every repo resolves to workspace-scoped
* when no override is set, so this always returns `true`.
*/
export function isProjectScopedWorktreeBase(repoPath: RepoPath, codebaseName?: string): boolean {
// If codebase name is known, it's always project-scoped
if (codebaseName) {
const parts = codebaseName.split('/');
if (parts.length === 2 && parts[0] && parts[1]) return true;
// Invalid format — fall through to path detection (same safe degradation as getWorktreeBase).
}
const workspacesPath = getArchonWorkspacesPath();
if (!repoPath.startsWith(workspacesPath)) return false;
const relative = repoPath.substring(workspacesPath.length + 1);
const parts = relative.split(/[/\\]/).filter(p => p.length > 0);
return parts.length >= 2;
return getWorktreeBase(repoPath, codebaseName).layout === 'workspace-scoped';
}
/**

View file

@ -14,7 +14,7 @@ let configuredLoader: RepoConfigLoader = () => Promise.resolve(null);
/**
* Configure the isolation system with a repo config loader.
* Must be called before getIsolationProvider() for full functionality.
* If not called, WorktreeProvider uses a no-op loader (no custom baseBranch or copyFiles).
* If not called, WorktreeProvider uses a no-op loader (no custom baseBranch, copyFiles, or path).
*/
export function configureIsolation(loader: RepoConfigLoader): void {
configuredLoader = loader;

View file

@ -2462,6 +2462,93 @@ describe('WorktreeProvider', () => {
});
});
// ---------------------------------------------------------------------------
// Per-repo `worktree.path` override (co-located worktrees opt-in) — #1117 successor
// ---------------------------------------------------------------------------
describe('worktree.path repo-local override', () => {
const baseRequest: IsolationRequest = {
codebaseId: 'cb-local-1',
codebaseName: 'owner/myapp',
canonicalRepoPath: '/Users/dev/Projects/myapp',
workflowType: 'task',
identifier: 'add-feature',
};
test('uses <repoRoot>/<path>/<branch> when worktree.path is set', () => {
const branch = provider.generateBranchName(baseRequest);
const result = provider.getWorktreePath(baseRequest, branch, { path: '.worktrees' });
expect(result).toBe(join('/Users/dev/Projects/myapp', '.worktrees', branch));
});
test('empty / whitespace-only path is ignored and default layout applies', () => {
const branch = provider.generateBranchName(baseRequest);
const expectedDefault = join(
TEST_ARCHON_HOME,
'workspaces',
'owner',
'myapp',
'worktrees',
branch
);
expect(provider.getWorktreePath(baseRequest, branch, { path: '' })).toBe(expectedDefault);
expect(provider.getWorktreePath(baseRequest, branch, { path: ' ' })).toBe(expectedDefault);
});
test('null / undefined config falls back to workspace-scoped default', () => {
const branch = provider.generateBranchName(baseRequest);
const expected = join(TEST_ARCHON_HOME, 'workspaces', 'owner', 'myapp', 'worktrees', branch);
expect(provider.getWorktreePath(baseRequest, branch, null)).toBe(expected);
expect(provider.getWorktreePath(baseRequest, branch, undefined)).toBe(expected);
expect(provider.getWorktreePath(baseRequest, branch)).toBe(expected);
});
test('override wins even when repo lives under ~/.archon/workspaces/', () => {
// Precedence contract: per-repo `worktree.path` is the highest layer.
// A repo that would normally land in workspaces/owner/repo/worktrees/
// still gets a repo-local worktree when the config opts in.
const request: IsolationRequest = {
codebaseId: 'cb-local-2',
codebaseName: 'owner/repo',
canonicalRepoPath: join(TEST_ARCHON_HOME, 'workspaces', 'owner', 'repo'),
workflowType: 'task',
identifier: 'my-task',
};
const branch = provider.generateBranchName(request);
const result = provider.getWorktreePath(request, branch, { path: 'worktrees-local' });
expect(result).toBe(
join(TEST_ARCHON_HOME, 'workspaces', 'owner', 'repo', 'worktrees-local', branch)
);
});
test('rejects an absolute worktree.path with a clear error', () => {
const branch = provider.generateBranchName(baseRequest);
expect(() =>
provider.getWorktreePath(baseRequest, branch, { path: '/tmp/worktrees' })
).toThrow(/must be relative to the repo root/);
});
test('rejects a worktree.path that escapes the repo root via `..`', () => {
const branch = provider.generateBranchName(baseRequest);
expect(() => provider.getWorktreePath(baseRequest, branch, { path: '../worktrees' })).toThrow(
/must stay within the repo/
);
expect(() => provider.getWorktreePath(baseRequest, branch, { path: '..' })).toThrow(
/must stay within the repo/
);
expect(() =>
provider.getWorktreePath(baseRequest, branch, { path: 'nested/../../escape' })
).toThrow(/must stay within the repo/);
});
test('accepts a nested relative path without `..`', () => {
const branch = provider.generateBranchName(baseRequest);
const result = provider.getWorktreePath(baseRequest, branch, {
path: '.archon/worktrees',
});
expect(result).toBe(join('/Users/dev/Projects/myapp', '.archon/worktrees', branch));
});
});
// ---------------------------------------------------------------------------
// Additional lifecycle method tests
// ---------------------------------------------------------------------------

View file

@ -6,16 +6,14 @@
import { createHash } from 'crypto';
import { access, rm } from 'fs/promises';
import { join, resolve } from 'path';
import { isAbsolute, join, normalize as normalizePath, resolve, sep } from 'path';
import { createLogger } from '@archon/paths';
import {
execFileAsync,
extractOwnerRepo,
findWorktreeByBranch,
getCanonicalRepoPath,
getWorktreeBase,
isProjectScopedWorktreeBase,
listWorktrees,
mkdirAsync,
removeWorktree,
@ -26,6 +24,7 @@ import {
toWorktreePath,
toBranchName,
} from '@archon/git';
import type { WorktreeBaseOverride } from '@archon/git';
import { getArchonWorkspacesPath } from '@archon/paths';
import type { RepoPath, WorktreeInfo } from '@archon/git';
import { copyWorktreeFiles } from '../worktree-copy';
@ -56,18 +55,94 @@ function getLog(): ReturnType<typeof createLogger> {
*/
const GIT_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Validate a user-supplied `worktree.path` from `.archon/config.yaml` and return
* it as a safe relative path for `getWorktreeBase()`, or `undefined` to fall
* through to default path resolution.
*
* Rules (Fail Fast malformed values throw; empty/whitespace values are ignored):
* - `undefined` / empty-after-trim `undefined` (no override; default resolution applies)
* - Absolute path throw (users must configure globally, not per-repo)
* - Contains `..` segment throw (escapes repo root)
* - Resolved path escapes repoRoot throw (covers symlink / nested `../` edge cases)
*
* The path is returned trimmed. The caller composes it via `join(repoRoot, result)`.
*/
function resolveRepoLocalOverride(
rawPath: string | undefined,
repoRoot: string
): string | undefined {
if (rawPath === undefined) return undefined;
const trimmed = rawPath.trim();
if (!trimmed) return undefined;
if (isAbsolute(trimmed)) {
throw new Error(
`.archon/config.yaml worktree.path must be relative to the repo root (got absolute: ${trimmed}). ` +
'For an absolute location, set ~/.archon/config.yaml paths.worktrees instead.'
);
}
const normalized = normalizePath(trimmed);
// A plain `..` or anything that starts with `../` or contains `/../` escapes the repo.
if (
normalized === '..' ||
normalized.startsWith('../') ||
normalized.startsWith('..\\') ||
normalized.includes('/../') ||
normalized.includes('\\..\\')
) {
throw new Error(
`.archon/config.yaml worktree.path must stay within the repo (got: ${trimmed}). ` +
'Remove any `..` segments.'
);
}
// Double-check via resolved absolute paths — catches edge cases like a path that
// normalizes clean but still escapes when joined (e.g. leading `./../` on some platforms).
// Uses `path.sep` so the "is inside repoRoot" check works on Windows (\\) as well as POSIX (/).
const resolved = resolve(repoRoot, normalized);
const repoRootResolved = resolve(repoRoot);
if (resolved !== repoRootResolved && !resolved.startsWith(repoRootResolved + sep)) {
throw new Error(
`.archon/config.yaml worktree.path resolves outside the repo root (got: ${trimmed}${resolved}).`
);
}
return normalized;
}
export class WorktreeProvider implements IIsolationProvider {
readonly providerType = 'worktree';
constructor(private loadConfig: RepoConfigLoader = () => Promise.resolve(null)) {}
/**
* Create an isolated environment using git worktrees
* Create an isolated environment using git worktrees.
*
* Config is loaded exactly once here and threaded through the rest of the
* `create()` call. A malformed `.archon/config.yaml` fails loudly at this
* boundary rather than being swallowed see CLAUDE.md "Fail Fast + Explicit
* Errors". Downstream helpers assume they receive either a valid config
* object or `null`, never a second chance to reload.
*/
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
let repoConfig: WorktreeCreateConfig | null;
try {
repoConfig = await this.loadConfig(request.canonicalRepoPath);
} catch (error) {
const err = error as Error;
getLog().error({ err, repoPath: request.canonicalRepoPath }, 'repo_config_load_failed');
throw new Error(`Failed to load config: ${err.message}`);
}
const branchName = toBranchName(this.generateBranchName(request));
const worktreePath = this.getWorktreePath(request, branchName);
const envId = this.generateEnvId(request);
const worktreePath = this.getWorktreePath(request, branchName, repoConfig);
// envId is, by contract, the worktree filesystem path (see `destroy()` docstring).
// Assign directly from the resolved path to keep the invariant in sync with
// the actual directory created below — computing it via a separate helper would
// risk divergence if resolution rules change.
const envId = worktreePath;
// Check for existing worktree (adoption)
const existing = await this.findExisting(request, branchName, worktreePath);
@ -75,8 +150,8 @@ export class WorktreeProvider implements IIsolationProvider {
return existing;
}
// Create new worktree
const { warnings } = await this.createWorktree(request, worktreePath, branchName);
// Create new worktree (re-uses the already-loaded repoConfig — no double load).
const { warnings } = await this.createWorktree(request, worktreePath, branchName, repoConfig);
return {
id: envId,
@ -498,34 +573,29 @@ export class WorktreeProvider implements IIsolationProvider {
}
/**
* Generate unique environment ID
*/
generateEnvId(request: IsolationRequest): string {
const branchName = this.generateBranchName(request);
return this.getWorktreePath(request, branchName);
}
/**
* Get worktree path for request.
* Get worktree path for a request, honoring the per-repo override if set.
*
* Path format depends on the worktree base layout:
* - Project-scoped: `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}`
* - Legacy global: `~/.archon/worktrees/{owner}/{repo}/{branch}`
* Layouts (see `getWorktreeBase()` in `@archon/git` for resolution):
* - `repo-local` `<repoRoot>/<config.path>/{branch}` (opt-in)
* - `workspace-scoped` `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}` (default)
*
* When the worktree base is project-scoped (under workspaces/owner/repo/worktrees/),
* only append the branch name since the base already includes owner/repo.
* When using the legacy global worktrees path, append owner/repo/branch to
* avoid collisions between repos.
* In both layouts the resolved base already carries full repo context, so the
* caller simply appends the branch name no owner/repo namespacing here.
*
* The per-repo `config.path` is validated via `resolveRepoLocalOverride()`;
* unsafe values (absolute, `..` segments, escape-from-repoRoot) throw rather
* than silently falling back to the default layout.
*/
getWorktreePath(request: IsolationRequest, branchName: string): string {
const worktreeBase = getWorktreeBase(request.canonicalRepoPath, request.codebaseName);
if (isProjectScopedWorktreeBase(request.canonicalRepoPath, request.codebaseName)) {
return join(worktreeBase, branchName);
}
const { owner, repo } = this.extractOwnerRepo(request.canonicalRepoPath);
return join(worktreeBase, owner, repo, branchName);
getWorktreePath(
request: IsolationRequest,
branchName: string,
config?: WorktreeCreateConfig | null
): string {
const override: WorktreeBaseOverride = {
repoLocal: resolveRepoLocalOverride(config?.path, request.canonicalRepoPath),
};
const { base } = getWorktreeBase(request.canonicalRepoPath, request.codebaseName, override);
return join(base, branchName);
}
/**
@ -621,35 +691,30 @@ export class WorktreeProvider implements IIsolationProvider {
/**
* Create the actual worktree.
* Returns warnings that should be surfaced to the user (non-fatal issues).
*
* `repoConfig` is the already-loaded config from `create()`. Receiving it here
* keeps the work of each public entrypoint tied to exactly one config load
* see the "Fail Fast" comment on `create()`.
*/
private async createWorktree(
request: IsolationRequest,
worktreePath: string,
branchName: string
branchName: string,
worktreeConfig: WorktreeCreateConfig | null
): Promise<{ warnings: string[] }> {
const repoPath = request.canonicalRepoPath;
let worktreeConfig: WorktreeCreateConfig | null;
try {
worktreeConfig = await this.loadConfig(repoPath);
} catch (error) {
const err = error as Error;
getLog().error({ err, repoPath }, 'repo_config_load_failed');
throw new Error(`Failed to load config: ${err.message}`);
}
// Sync uses only the configured base branch (or auto-detects via getDefaultBranch).
// request.fromBranch is the start-point for worktree creation, not a sync target.
const baseBranch = await this.syncWorkspaceBeforeCreate(repoPath, worktreeConfig?.baseBranch);
const worktreeBase = getWorktreeBase(repoPath, request.codebaseName);
if (isProjectScopedWorktreeBase(repoPath, request.codebaseName)) {
await mkdirAsync(worktreeBase, { recursive: true });
} else {
const { owner, repo } = this.extractOwnerRepo(repoPath);
await mkdirAsync(join(worktreeBase, owner, repo), { recursive: true });
}
const override: WorktreeBaseOverride = {
repoLocal: resolveRepoLocalOverride(worktreeConfig?.path, repoPath),
};
const { base: worktreeBase } = getWorktreeBase(repoPath, request.codebaseName, override);
// In both layouts the base already carries repo context — creating it
// recursively is enough.
await mkdirAsync(worktreeBase, { recursive: true });
if (isPRIsolationRequest(request)) {
// For PRs: fetch and checkout the PR branch (actual or synthetic)
@ -1141,14 +1206,6 @@ export class WorktreeProvider implements IIsolationProvider {
}
}
/**
* Extract owner and repo name from a repository path.
* Used for legacy global worktree base layout where owner/repo must be appended.
*/
private extractOwnerRepo(repoPath: string): { owner: string; repo: string } {
return extractOwnerRepo(toRepoPath(repoPath));
}
/**
* Generate short hash for thread identifiers
*/

View file

@ -248,6 +248,19 @@ export interface WorktreeCreateConfig {
* Set to `false` to opt out. No-op when `.gitmodules` is absent.
*/
initSubmodules?: boolean;
/**
* Per-project relative path (from repo root) where worktrees should be created.
* When set, worktrees live at `<repoRoot>/<path>/<branch>` with `repo-local` layout.
* Highest priority in path resolution overrides project-scoped and global defaults.
*
* Must be a safe relative path: no leading `/`, no `..` segments, non-empty after trim.
* Validation is enforced in `WorktreeProvider.getWorktreePath()` (fails fast with a
* clear error rather than silently falling back).
*
* Sourced from `.archon/config.yaml > worktree.path` in the repo.
* @example '.worktrees'
*/
path?: string;
}
export type RepoConfigLoader = (repoPath: string) => Promise<WorktreeCreateConfig | null>;

View file

@ -93,6 +93,33 @@ describe('Workflow Loader', () => {
expect(result.workflows[0].workflow.interactive).toBeUndefined();
});
it('should parse worktree.enabled: false', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: triage\ndescription: read-only\nworktree:\n enabled: false\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'triage.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.worktree).toEqual({ enabled: false });
});
it('should parse worktree.enabled: true', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: build\ndescription: needs worktree\nworktree:\n enabled: true\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'build.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.worktree).toEqual({ enabled: true });
});
it('should omit worktree block when not present (policy is caller-decides)', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });
const yaml = `name: normal\ndescription: no policy\nnodes:\n - id: n\n prompt: p\n`;
await writeFile(join(workflowDir, 'normal.yaml'), yaml);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.workflows[0].workflow.worktree).toBeUndefined();
});
it('should parse valid DAG workflow YAML', async () => {
const workflowDir = join(testDir, '.archon', 'workflows');
await mkdir(workflowDir, { recursive: true });

View file

@ -339,6 +339,28 @@ export function parseWorkflow(content: string, filename: string): ParseResult {
}
}
// Parse workflow-level worktree policy. Same warn-and-ignore pattern used
// for `interactive` / `modelReasoningEffort` — invalid values are dropped
// rather than rejected, so a typo in one workflow doesn't nuke the whole
// discovery pass. Only `worktree.enabled` is recognised today.
let worktreePolicy: { enabled?: boolean } | undefined;
if (raw.worktree !== undefined) {
if (
typeof raw.worktree === 'object' &&
raw.worktree !== null &&
!Array.isArray(raw.worktree)
) {
const rawEnabled = (raw.worktree as Record<string, unknown>).enabled;
if (typeof rawEnabled === 'boolean') {
worktreePolicy = { enabled: rawEnabled };
} else if (rawEnabled !== undefined) {
getLog().warn({ filename, value: rawEnabled }, 'invalid_worktree_enabled_value_ignored');
}
} else {
getLog().warn({ filename, value: raw.worktree }, 'invalid_worktree_block_ignored');
}
}
return {
workflow: {
name: raw.name,
@ -350,6 +372,7 @@ export function parseWorkflow(content: string, filename: string): ParseResult {
additionalDirectories,
interactive,
nodes: dagNodes,
...(worktreePolicy ? { worktree: worktreePolicy } : {}),
},
error: null,
};

View file

@ -22,6 +22,33 @@ export const webSearchModeSchema = z.enum(['disabled', 'cached', 'live']);
export type WebSearchMode = z.infer<typeof webSearchModeSchema>;
// ---------------------------------------------------------------------------
// Workflow-level worktree policy
// ---------------------------------------------------------------------------
/**
* Per-workflow worktree policy. Pins whether a run uses isolation regardless of
* how it was invoked (CLI flags, web UI, chat). When the field is omitted the
* caller's default applies worktree for task/issue/pr, etc.
*
* Currently one field (`enabled`). Other worktree-shaped settings (copyFiles,
* initSubmodules, path, baseBranch) live in repo-level `.archon/config.yaml`
* because they are repo-wide, not per-workflow. This block is deliberately
* narrow to avoid re-expressing the repo-level knobs here.
*/
export const workflowWorktreePolicySchema = z.object({
/**
* Pin worktree isolation on or off for this workflow.
* - `true` always run inside a worktree; CLI `--no-worktree` hard-errors
* - `false` always run in the live checkout; CLI `--branch` / `--from`
* hard-error, orchestrator skips isolation resolution
* - omitted caller decides (current default = worktree for most types)
*/
enabled: z.boolean().optional(),
});
export type WorkflowWorktreePolicy = z.infer<typeof workflowWorktreePolicySchema>;
// ---------------------------------------------------------------------------
// WorkflowBase — common fields shared by all workflow types
// ---------------------------------------------------------------------------
@ -40,6 +67,7 @@ export const workflowBaseSchema = z.object({
fallbackModel: z.string().min(1).optional(),
betas: z.array(z.string().min(1)).nonempty("'betas' must be a non-empty array").optional(),
sandbox: sandboxSettingsSchema.optional(),
worktree: workflowWorktreePolicySchema.optional(),
});
export type WorkflowBase = z.infer<typeof workflowBaseSchema>;