From 659d5b676987f8c6368ac837ed489ec804634556 Mon Sep 17 00:00:00 2001 From: Joel Bastos Date: Sun, 12 Apr 2026 13:46:42 +0200 Subject: [PATCH 1/3] chore: add .worktrees to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a2f33c5d5c..4b4ad0e1da 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ undefined/ coverage worktrees +.worktrees screenshots/ test-screenshots/ .claude/worktree-registry.json From b7bd6e96e1060cf7cdcf6a00579bdfb35990b5bb Mon Sep 17 00:00:00 2001 From: Joel Bastos Date: Sun, 12 Apr 2026 13:54:31 +0200 Subject: [PATCH 2/3] feat: add per-project worktree.path config option Allows repositories to configure a custom worktree directory relative to the repo root (e.g. `.worktrees`) via `.archon/config.yaml`: ```yaml worktree: path: .worktrees ``` When set, worktrees are created at `//` instead of the global `~/.archon/worktrees/` directory. This keeps worktrees co-located with the project and visible in the IDE. The per-project path takes highest priority, overriding both the project-scoped workspaces path and the legacy global path. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/config/config-types.ts | 8 ++ .../isolation/src/providers/worktree.test.ts | 67 +++++++++++++++ packages/isolation/src/providers/worktree.ts | 82 ++++++++++++++----- packages/isolation/src/types.ts | 7 ++ 4 files changed, 143 insertions(+), 21 deletions(-) diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 3baa3dfdca..cf43030e80 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -155,6 +155,14 @@ export interface RepoConfig { * @example [".env", ".archon", "data/fixtures/"] */ copyFiles?: string[]; + + /** + * Per-project worktree directory path (relative to repo root). + * When set, worktrees are created at `//` + * instead of the global `~/.archon/worktrees/` directory. + * @example '.worktrees' + */ + path?: string; }; /** diff --git a/packages/isolation/src/providers/worktree.test.ts b/packages/isolation/src/providers/worktree.test.ts index bb3afffbda..989eaf824a 100644 --- a/packages/isolation/src/providers/worktree.test.ts +++ b/packages/isolation/src/providers/worktree.test.ts @@ -2161,6 +2161,73 @@ describe('WorktreeProvider', () => { }); }); + describe('per-project worktree.path', () => { + test('getWorktreePath uses config.path when set', () => { + const request: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/Users/joel/Projects/myapp', + workflowType: 'task', + identifier: 'add-feature', + }; + const branchName = provider.generateBranchName(request); + const config = { path: '.worktrees' }; + const path = provider.getWorktreePath(request, branchName, config); + expect(path).toBe(join('/Users/joel/Projects/myapp', '.worktrees', branchName)); + }); + + test('getWorktreePath ignores empty or whitespace-only path', () => { + const request: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/Users/joel/Projects/myapp', + workflowType: 'task', + identifier: 'add-feature', + }; + const branchName = provider.generateBranchName(request); + + // Empty string + const path1 = provider.getWorktreePath(request, branchName, { path: '' }); + expect(path1).not.toContain('.worktrees'); + + // Whitespace-only + const path2 = provider.getWorktreePath(request, branchName, { path: ' ' }); + expect(path2).not.toContain('.worktrees'); + }); + + test('getWorktreePath falls back to default when config is null', () => { + const request: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/Users/joel/Projects/myapp', + workflowType: 'task', + identifier: 'add-feature', + }; + const branchName = provider.generateBranchName(request); + const pathWithNull = provider.getWorktreePath(request, branchName, null); + const pathWithUndefined = provider.getWorktreePath(request, branchName, undefined); + const pathDefault = provider.getWorktreePath(request, branchName); + expect(pathWithNull).toBe(pathDefault); + expect(pathWithUndefined).toBe(pathDefault); + }); + + test('getWorktreePath config.path overrides project-scoped and legacy paths', () => { + // Even for repos under workspaces/, config.path should win + const request: IsolationRequest = { + codebaseId: 'cb-123', + codebaseName: 'owner/repo', + canonicalRepoPath: join(TEST_ARCHON_HOME, 'workspaces', 'owner', 'repo'), + workflowType: 'task', + identifier: 'my-task', + }; + const branchName = provider.generateBranchName(request); + const config = { path: 'worktrees-local' }; + const path = provider.getWorktreePath(request, branchName, config); + expect(path).toBe( + join(TEST_ARCHON_HOME, 'workspaces', 'owner', 'repo', 'worktrees-local', branchName) + ); + // Should NOT use the project-scoped worktrees/ path + expect(path).not.toContain('/worktrees/archon/'); + }); + }); + // --------------------------------------------------------------------------- // Additional lifecycle method tests // --------------------------------------------------------------------------- diff --git a/packages/isolation/src/providers/worktree.ts b/packages/isolation/src/providers/worktree.ts index 912b550fc5..3450710bb9 100644 --- a/packages/isolation/src/providers/worktree.ts +++ b/packages/isolation/src/providers/worktree.ts @@ -57,9 +57,22 @@ export class WorktreeProvider implements IIsolationProvider { * Create an isolated environment using git worktrees */ async create(request: IsolationRequest): Promise { + // Load config early so worktree.path can influence path resolution. + // On success, pass to createWorktree to avoid double loading. + // On failure, let createWorktree reload (so config errors propagate correctly). + let earlyConfig: WorktreeCreateConfig | null = null; + let earlyConfigLoaded = false; + try { + earlyConfig = await this.loadConfig(request.canonicalRepoPath); + earlyConfigLoaded = true; + } catch { + // Non-fatal here: fall back to default path resolution. + // createWorktree will re-attempt the load and throw if needed. + } + const branchName = toBranchName(this.generateBranchName(request)); - const worktreePath = this.getWorktreePath(request, branchName); - const envId = this.generateEnvId(request); + const worktreePath = this.getWorktreePath(request, branchName, earlyConfig); + const envId = worktreePath; // Check for existing worktree (adoption) const existing = await this.findExisting(request, branchName, worktreePath); @@ -67,8 +80,14 @@ export class WorktreeProvider implements IIsolationProvider { return existing; } - // Create new worktree - const { warnings } = await this.createWorktree(request, worktreePath, branchName); + // Create new worktree. Pass pre-loaded config only when early load succeeded; + // otherwise pass undefined so createWorktree reloads (and throws on error). + const { warnings } = await this.createWorktree( + request, + worktreePath, + branchName, + earlyConfigLoaded ? earlyConfig : undefined + ); return { id: envId, @@ -454,16 +473,26 @@ export class WorktreeProvider implements IIsolationProvider { /** * Get worktree path for request. * - * Path format depends on the worktree base layout: - * - Project-scoped: `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}` - * - Legacy global: `~/.archon/worktrees/{owner}/{repo}/{branch}` + * Path format depends on configuration: + * - Per-project path: `//` (when repo config sets worktree.path) + * - Project-scoped: `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}` + * - Legacy global: `~/.archon/worktrees/{owner}/{repo}/{branch}` * * 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. */ - getWorktreePath(request: IsolationRequest, branchName: string): string { + getWorktreePath( + request: IsolationRequest, + branchName: string, + config?: WorktreeCreateConfig | null + ): string { + // Per-project worktree path takes highest priority + if (config?.path?.trim()) { + return join(request.canonicalRepoPath, config.path.trim(), branchName); + } + const worktreeBase = getWorktreeBase(request.canonicalRepoPath, request.codebaseName); if (isProjectScopedWorktreeBase(request.canonicalRepoPath, request.codebaseName)) { @@ -530,30 +559,41 @@ export class WorktreeProvider implements IIsolationProvider { private async createWorktree( request: IsolationRequest, worktreePath: string, - branchName: string + branchName: string, + preloadedConfig?: 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}`); + if (preloadedConfig !== undefined) { + // Use pre-loaded config (avoids double loading from create()) + worktreeConfig = preloadedConfig; + } else { + 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 }); + // Ensure parent directory for worktree exists + if (worktreeConfig?.path?.trim()) { + // Per-project path: create the configured directory under the repo root + await mkdirAsync(join(repoPath, worktreeConfig.path.trim()), { recursive: true }); } else { - const { owner, repo } = this.extractOwnerRepo(repoPath); - await mkdirAsync(join(worktreeBase, owner, repo), { recursive: true }); + 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 }); + } } if (isPRIsolationRequest(request)) { diff --git a/packages/isolation/src/types.ts b/packages/isolation/src/types.ts index 9ff01ec640..980cd4b029 100644 --- a/packages/isolation/src/types.ts +++ b/packages/isolation/src/types.ts @@ -242,6 +242,13 @@ export interface IsolationEnvironmentRow { export interface WorktreeCreateConfig { baseBranch?: string; copyFiles?: string[]; + /** + * Per-project worktree directory path (relative to repo root). + * When set, worktrees are created at `//` + * instead of the global `~/.archon/worktrees/` directory. + * @example '.worktrees' + */ + path?: string; } export type RepoConfigLoader = (repoPath: string) => Promise; From 6a630d8347446aea9a10c8231dce40e90333f423 Mon Sep 17 00:00:00 2001 From: Joel Bastos Date: Sun, 12 Apr 2026 13:57:25 +0200 Subject: [PATCH 3/3] feat: propagate worktree.path to MergedConfig Exposes the per-project worktree.path through MergedConfig so tools, the web UI, and API endpoints can read and display both the global paths.worktrees and the per-project worktreePath override. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/config/config-loader.ts | 10 ++++++++++ packages/core/src/config/config-types.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 8ee702c613..2afce2ebc7 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -385,6 +385,16 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { result.baseBranch = repo.worktree.baseBranch.trim(); } + // Propagate per-project worktree path for isolation providers + if (repo.worktree?.path !== undefined) { + const trimmed = repo.worktree.path.trim(); + if (trimmed) { + result.worktreePath = trimmed; + } else { + getLog().warn({ rawValue: repo.worktree.path }, 'config.worktree_path_whitespace_ignored'); + } + } + // Propagate docs path for $DOCS_DIR substitution in workflow commands if (repo.docs?.path !== undefined) { const trimmed = repo.docs.path.trim(); diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index cf43030e80..0cba6023c2 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -259,6 +259,14 @@ export interface MergedConfig { * When undefined, workflows referencing $BASE_BRANCH will fail with an error. */ baseBranch?: string; + /** + * Per-project worktree directory path from repo config (worktree.path). + * When set, worktrees are created at `//` + * instead of the global `paths.worktrees` directory. + * Undefined when not configured (uses global paths.worktrees). + * @example '.worktrees' + */ + worktreePath?: string; /** * Docs directory path from repo config (docs.path). * Used for $DOCS_DIR substitution in workflow commands.