From ee4bbb3ea241cdeda4f31d6fe12edb7eeb6be794 Mon Sep 17 00:00:00 2001 From: deepak-rawat Date: Sun, 3 May 2026 17:08:51 +0530 Subject: [PATCH 1/4] feat(git): support configurable git remote name Archon hardcoded 'origin' as the git remote name in all fetch/push/reset operations. This breaks for repos that use non-standard remote names (e.g. enterprise monorepos with numbered remotes like '260', '262', '264'). Add `worktree.remote` config option in `.archon/config.yaml` and auto-detection via `getDefaultRemote()` when not configured: 1. 'origin' if it exists (standard convention) 2. The sole remote if only one is configured 3. Actionable error if ambiguous (multiple non-origin remotes) Thread the remote name through all git operations: syncWorkspace, syncRepository, getDefaultBranch, getRemoteUrl, worktree creation (issue/PR/ task/fork), branch tracking, and remote branch deletion. All existing callers default to 'origin' so this is fully backwards-compatible. --- packages/core/src/config/config-loader.ts | 5 + packages/core/src/config/config-types.ts | 21 +++ packages/git/src/branch.ts | 32 ++-- packages/git/src/git.test.ts | 90 +++++++++- packages/git/src/index.ts | 1 + packages/git/src/repo.ts | 84 +++++---- packages/isolation/src/pr-state.ts | 6 +- .../isolation/src/providers/worktree.test.ts | 161 +++++++++++++++++- packages/isolation/src/providers/worktree.ts | 141 ++++++++------- packages/isolation/src/types.ts | 18 ++ 10 files changed, 450 insertions(+), 109 deletions(-) diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 4bf22d9144..21a2a6ad80 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -455,6 +455,11 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { result.baseBranch = repo.worktree.baseBranch.trim(); } + // Propagate git remote name for non-origin remote support + if (repo.worktree?.remote?.trim()) { + result.remote = repo.worktree.remote.trim(); + } + // 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 63dd135907..041b23ae97 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -199,6 +199,22 @@ export interface RepoConfig { * @example '.worktrees' */ path?: string; + + /** + * Git remote name for fetch/push operations. + * + * Most repos use the standard 'origin' remote, but some (e.g. enterprise + * monorepos) use numbered or custom-named remotes. When set, all git + * operations (fetch, push, branch tracking) use this remote instead of + * 'origin'. + * + * When omitted, auto-detected: 'origin' if it exists, otherwise the sole + * remote if only one is configured. Fails with an actionable error if + * multiple non-origin remotes exist and none is named 'origin'. + * + * @example '264' + */ + remote?: string; }; /** @@ -286,6 +302,11 @@ export interface MergedConfig { * When undefined, workflows referencing $BASE_BRANCH will fail with an error. */ baseBranch?: string; + /** + * Git remote name from repo config (worktree.remote). + * When undefined, auto-detected at runtime via getDefaultRemote(). + */ + remote?: string; /** * Docs directory path from repo config (docs.path). * Used for $DOCS_DIR substitution in workflow commands. diff --git a/packages/git/src/branch.ts b/packages/git/src/branch.ts index 7307da895c..6a0af4a46b 100644 --- a/packages/git/src/branch.ts +++ b/packages/git/src/branch.ts @@ -14,23 +14,26 @@ function getLog(): ReturnType { * Get the default branch name for a repository * Uses git symbolic-ref to get the remote HEAD reference * - * Fallback chain: symbolic-ref -> origin/main -> throw - * Note: Throws if neither origin/HEAD nor origin/main can be resolved. + * Fallback chain: symbolic-ref -> /main -> throw + * Note: Throws if neither /HEAD nor /main can be resolved. * Callers can set worktree.baseBranch in .archon/config.yaml as a manual override. * * Only falls back for expected git errors (ref not found, branch not found). * Throws for unexpected errors (permission denied, git corruption, etc.) + * + * @param repoPath - Path to the git repository + * @param remote - Remote name to check (default: 'origin') */ -export async function getDefaultBranch(repoPath: RepoPath): Promise { +export async function getDefaultBranch(repoPath: RepoPath, remote = 'origin'): Promise { // Try to get from remote HEAD try { const { stdout } = await execFileAsync( 'git', - ['-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], + ['-C', repoPath, 'symbolic-ref', `refs/remotes/${remote}/HEAD`, '--short'], { timeout: 10000 } ); // stdout is like "origin/main" - extract just the branch name - return toBranchName(stdout.trim().replace('origin/', '')); + return toBranchName(stdout.trim().replace(`${remote}/`, '')); } catch (error) { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; @@ -40,17 +43,20 @@ export async function getDefaultBranch(repoPath: RepoPath): Promise errorText.includes('not a symbolic ref') || errorText.includes('No such file or directory') ) { - getLog().debug({ repoPath, err }, 'symbolic_ref_fallback'); + getLog().debug({ repoPath, remote, err }, 'symbolic_ref_fallback'); } else { // Unexpected error (permission denied, git corruption, etc.) - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'default_branch_symbolic_ref_failed'); + getLog().error( + { repoPath, remote, err, stderr: err.stderr }, + 'default_branch_symbolic_ref_failed' + ); throw new Error(`Failed to get default branch for ${repoPath}: ${err.message}`); } } - // Fallback: check if origin/main exists, otherwise throw + // Fallback: check if /main exists, otherwise throw try { - await execFileAsync('git', ['-C', repoPath, 'rev-parse', '--verify', 'origin/main'], { + await execFileAsync('git', ['-C', repoPath, 'rev-parse', '--verify', `${remote}/main`], { timeout: 10000, }); return toBranchName('main'); @@ -58,21 +64,21 @@ export async function getDefaultBranch(repoPath: RepoPath): Promise const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; - // Expected: origin/main doesn't exist — no safe default, fail fast + // Expected: /main doesn't exist — no safe default, fail fast if ( errorText.includes('Not a valid object name') || errorText.includes('Needed a single revision') || errorText.includes('unknown revision') ) { - getLog().warn({ repoPath }, 'default_branch_detection_failed'); + getLog().warn({ repoPath, remote }, 'default_branch_detection_failed'); throw new Error( - `Cannot detect default branch for ${repoPath}: neither origin/HEAD nor origin/main exist. ` + + `Cannot detect default branch for ${repoPath}: neither ${remote}/HEAD nor ${remote}/main exist. ` + 'Set worktree.baseBranch in .archon/config.yaml to specify the branch explicitly.' ); } // Unexpected error - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'verify_origin_main_failed'); + getLog().error({ repoPath, remote, err, stderr: err.stderr }, 'verify_origin_main_failed'); throw new Error(`Failed to get default branch for ${repoPath}: ${err.message}`); } } diff --git a/packages/git/src/git.test.ts b/packages/git/src/git.test.ts index 518a01324e..831299d8b8 100644 --- a/packages/git/src/git.test.ts +++ b/packages/git/src/git.test.ts @@ -1432,7 +1432,7 @@ branch refs/heads/feature/auth newHead: '', updated: false, }); - expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo'); + expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo', 'origin'); }); test('throws actionable error when configured branch not found on remote', async () => { @@ -1495,6 +1495,94 @@ branch refs/heads/feature/auth }); expect(resetCalls).toHaveLength(0); }); + + test('uses custom remote when provided in options', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + await git.syncWorkspace('/workspace/repo', 'main', { remote: '264' }); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'fetch', '264', 'main'], + expect.any(Object) + ); + + const resetCalls = execSpy.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[]; + return args.includes('reset'); + }); + expect(resetCalls).toHaveLength(1); + expect(resetCalls[0][1]).toEqual(['-C', '/workspace/repo', 'reset', '--hard', '264/main']); + }); + + test('passes custom remote to getDefaultBranch when baseBranch not provided', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + getDefaultBranchSpy.mockResolvedValue('develop'); + + await git.syncWorkspace('/workspace/repo', undefined, { remote: 'upstream' }); + + expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo', 'upstream'); + }); + + test('includes remote name in error message for custom remote', async () => { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('fetch')) { + throw new Error("fatal: '264' does not appear to be a git repository"); + } + return { stdout: '', stderr: '' }; + }); + + await expect(git.syncWorkspace('/workspace/repo', 'main', { remote: '264' })).rejects.toThrow( + 'Sync fetch from 264/main failed' + ); + }); + }); + + describe('getDefaultRemote', () => { + let execSpy: Mock; + + beforeEach(() => { + execSpy = spyOn(git, 'execFileAsync'); + }); + + afterEach(() => { + execSpy.mockRestore(); + }); + + test('returns origin when it exists among multiple remotes', async () => { + execSpy.mockResolvedValue({ stdout: 'origin\nupstream\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBe('origin'); + }); + + test('returns sole remote when only one is configured', async () => { + execSpy.mockResolvedValue({ stdout: '264\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBe('264'); + }); + + test('returns null when multiple non-origin remotes exist', async () => { + execSpy.mockResolvedValue({ stdout: '260\n262\n264\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBeNull(); + }); + + test('returns null when no remotes are configured', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBeNull(); + }); + + test('returns null on git error', async () => { + execSpy.mockRejectedValue(new Error('not a git repository')); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBeNull(); + }); }); describe('cloneRepository', () => { diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 39252ce4d3..5e572040e6 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -43,6 +43,7 @@ export { // Repository operations export { findRepoRoot, + getDefaultRemote, getRemoteUrl, syncWorkspace, cloneRepository, diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index 21ae8d3571..dfa5e337d7 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -39,12 +39,40 @@ export async function findRepoRoot(startPath: string): Promise } /** - * Get the remote URL for origin (if it exists) - * Returns null if no remote is configured + * Detect the default remote name for a repository. + * + * Resolution order: + * 1. 'origin' — if it exists (standard Git convention) + * 2. The sole remote — if only one is configured + * 3. null — ambiguous (multiple non-origin remotes) + * + * Callers can override via `worktree.remote` in `.archon/config.yaml`. */ -export async function getRemoteUrl(repoPath: RepoPath): Promise { +export async function getDefaultRemote(repoPath: RepoPath): Promise { try { - const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); + const remotes = stdout + .trim() + .split('\n') + .filter(r => r.length > 0); + if (remotes.length === 0) return null; + if (remotes.includes('origin')) return 'origin'; + if (remotes.length === 1) return remotes[0]; + return null; + } catch (error) { + const err = error as Error & { stderr?: string }; + getLog().error({ repoPath, err, stderr: err.stderr }, 'get_default_remote_failed'); + return null; + } +} + +/** + * Get the remote URL for a remote (defaults to 'origin'). + * Returns null if no remote is configured. + */ +export async function getRemoteUrl(repoPath: RepoPath, remote = 'origin'): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', remote], { timeout: 10000, }); return stdout.trim() || null; @@ -52,7 +80,6 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; - // Expected: no remote named origin if ( errorText.includes('No such remote') || errorText.includes('does not have a url configured') @@ -60,9 +87,8 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { return null; } - // Unexpected error - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'get_remote_url_failed'); - throw new Error(`Failed to get remote URL for ${repoPath}: ${err.message}`); + getLog().error({ repoPath, remote, err, stderr: err.stderr }, 'get_remote_url_failed'); + throw new Error(`Failed to get remote URL for ${repoPath} (remote: ${remote}): ${err.message}`); } } @@ -87,47 +113,46 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { * * @param workspacePath - Path to the workspace (canonical repo, not worktree) * @param baseBranch - Optional base branch name (e.g., 'main', 'develop'). If omitted, auto-detects default branch - * @param options - Optional settings. `resetAfterFetch` (default true) controls whether `git reset --hard` runs after fetch. + * @param options - Optional settings: + * - `resetAfterFetch` (default true): controls whether `git reset --hard` runs after fetch + * - `remote` (default 'origin'): git remote name to fetch from * @returns Branch used plus whether sync was performed * @throws Error with actionable message if configured branch doesn't exist */ export async function syncWorkspace( workspacePath: RepoPath, baseBranch?: BranchName, - options?: { resetAfterFetch?: boolean } + options?: { resetAfterFetch?: boolean; remote?: string } ): Promise { const shouldReset = options?.resetAfterFetch ?? true; - const branchToSync = baseBranch ?? (await getDefaultBranch(workspacePath)); + const remote = options?.remote ?? 'origin'; + const branchToSync = baseBranch ?? (await getDefaultBranch(workspacePath, remote)); - // Fetch from origin to ensure origin/ is up-to-date try { - await execFileAsync('git', ['-C', workspacePath, 'fetch', 'origin', branchToSync], { + await execFileAsync('git', ['-C', workspacePath, 'fetch', remote, branchToSync], { timeout: 60000, }); } catch (error) { const err = error as Error; const errorMessage = err.message.toLowerCase(); - // If configured branch doesn't exist on remote, provide actionable error if ( baseBranch && (errorMessage.includes("couldn't find remote ref") || errorMessage.includes('not found')) ) { throw new Error( - `Configured base branch '${baseBranch}' not found on remote. ` + + `Configured base branch '${baseBranch}' not found on remote '${remote}'. ` + 'Either create the branch, update worktree.baseBranch in .archon/config.yaml, ' + 'or remove the setting to use the auto-detected default branch.' ); } - throw new Error(`Sync fetch from origin/${branchToSync} failed: ${err.message}`); + throw new Error(`Sync fetch from ${remote}/${branchToSync} failed: ${err.message}`); } if (!shouldReset) { - // Fetch-only mode: safe for locally-registered repos with uncommitted changes return { branch: branchToSync, synced: true, previousHead: '', newHead: '', updated: false }; } - // Capture HEAD before reset so we can report whether anything changed let previousHead = ''; try { const { stdout } = await execFileAsync( @@ -140,15 +165,15 @@ export async function syncWorkspace( // Non-fatal — fresh clone or detached HEAD edge case } - // Hard-reset local working tree to match origin — only safe for Archon-managed - // clones, never for a user's local working directory. try { - await execFileAsync('git', ['-C', workspacePath, 'reset', '--hard', `origin/${branchToSync}`], { - timeout: 30000, - }); + await execFileAsync( + 'git', + ['-C', workspacePath, 'reset', '--hard', `${remote}/${branchToSync}`], + { timeout: 30000 } + ); } catch (error) { const err = error as Error; - throw new Error(`Reset to origin/${branchToSync} failed: ${err.message}`); + throw new Error(`Reset to ${remote}/${branchToSync} failed: ${err.message}`); } let newHead = ''; @@ -234,14 +259,15 @@ export async function cloneRepository( */ export async function syncRepository( repoPath: RepoPath, - branch: BranchName + branch: BranchName, + remote = 'origin' ): Promise> { try { - await execFileAsync('git', ['fetch', 'origin'], { cwd: repoPath, timeout: 60000 }); + await execFileAsync('git', ['fetch', remote], { cwd: repoPath, timeout: 60000 }); } catch (error) { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`.toLowerCase(); - getLog().error({ err, repoPath, branch }, 'sync_repository_fetch_failed'); + getLog().error({ err, repoPath, branch, remote }, 'sync_repository_fetch_failed'); if (errorText.includes('not a git repository')) { return { ok: false, error: { code: 'not_a_repo', path: repoPath } }; @@ -256,7 +282,7 @@ export async function syncRepository( } try { - await execFileAsync('git', ['reset', '--hard', `origin/${branch}`], { + await execFileAsync('git', ['reset', '--hard', `${remote}/${branch}`], { cwd: repoPath, timeout: 30000, }); @@ -268,7 +294,7 @@ export async function syncRepository( return { ok: false, error: { code: 'branch_not_found', branch } }; } - getLog().error({ err, repoPath, branch }, 'sync_repository_reset_failed'); + getLog().error({ err, repoPath, branch, remote }, 'sync_repository_reset_failed'); return { ok: false, error: { code: 'unknown', message: `Reset failed: ${err.message}` } }; } diff --git a/packages/isolation/src/pr-state.ts b/packages/isolation/src/pr-state.ts index 2f4e3c87c3..075e3fe579 100644 --- a/packages/isolation/src/pr-state.ts +++ b/packages/isolation/src/pr-state.ts @@ -30,17 +30,17 @@ export type PrState = 'MERGED' | 'CLOSED' | 'OPEN' | 'NONE'; export async function getPrState( branch: BranchName, repoPath: RepoPath, - cache?: Map + cache?: Map, + remote = 'origin' ): Promise { const cached = cache?.get(branch); if (cached !== undefined) { return cached; } - // Check whether the remote is on GitHub. Non-GitHub remotes are out of scope. let remoteUrl = ''; try { - const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', remote], { timeout: 10000, }); remoteUrl = stdout.trim(); diff --git a/packages/isolation/src/providers/worktree.test.ts b/packages/isolation/src/providers/worktree.test.ts index 329717d374..345e765494 100644 --- a/packages/isolation/src/providers/worktree.test.ts +++ b/packages/isolation/src/providers/worktree.test.ts @@ -30,6 +30,7 @@ import type { IsolationRequest, PRIsolationRequest, RepoConfigLoader } from '../ // Track sync function calls for testing let getDefaultBranchSpy: Mock; +let getDefaultRemoteSpy: Mock; let syncWorkspaceSpy: Mock; // Mock fs.promises.access for destroy() existence check @@ -64,6 +65,7 @@ describe('WorktreeProvider', () => { findWorktreeByBranchSpy = spyOn(git, 'findWorktreeByBranch'); getCanonicalRepoPathSpy = spyOn(git, 'getCanonicalRepoPath'); getDefaultBranchSpy = spyOn(git, 'getDefaultBranch'); + getDefaultRemoteSpy = spyOn(git, 'getDefaultRemote'); syncWorkspaceSpy = spyOn(git, 'syncWorkspace'); // Default mocks @@ -89,6 +91,7 @@ describe('WorktreeProvider', () => { // Default mocks for workspace sync getDefaultBranchSpy.mockResolvedValue('main'); + getDefaultRemoteSpy.mockResolvedValue('origin'); syncWorkspaceSpy.mockResolvedValue({ branch: 'main', synced: true, @@ -106,6 +109,7 @@ describe('WorktreeProvider', () => { findWorktreeByBranchSpy.mockRestore(); getCanonicalRepoPathSpy.mockRestore(); getDefaultBranchSpy.mockRestore(); + getDefaultRemoteSpy.mockRestore(); syncWorkspaceSpy.mockRestore(); mockAccess.mockClear(); mockReadFile.mockClear(); @@ -2261,6 +2265,7 @@ describe('WorktreeProvider', () => { // resetAfterFetch: false because test path is not a managed clone under ~/.archon/workspaces expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { resetAfterFetch: false, + remote: 'origin', }); }); @@ -2281,6 +2286,7 @@ describe('WorktreeProvider', () => { // fromBranch is the start-point for the branch, not for sync — sync auto-detects expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { resetAfterFetch: false, + remote: 'origin', }); }); @@ -2300,6 +2306,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'main', { resetAfterFetch: false, + remote: 'origin', }); }); @@ -2319,6 +2326,7 @@ describe('WorktreeProvider', () => { // fromBranch is ignored for non-task types, so syncWorkspace gets undefined → auto-detect expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { resetAfterFetch: false, + remote: 'origin', }); }); @@ -2333,6 +2341,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'develop', { resetAfterFetch: false, + remote: 'origin', }); expect(getDefaultBranchSpy).not.toHaveBeenCalled(); }); @@ -2342,7 +2351,7 @@ describe('WorktreeProvider', () => { worktreeExistsSpy.mockResolvedValue(false); await expect(provider.create(baseRequest)).rejects.toThrow( - 'Failed to fetch base branch from origin' + "Failed to fetch base branch from 'origin'" ); }); @@ -2385,7 +2394,7 @@ describe('WorktreeProvider', () => { syncWorkspaceSpy.mockRejectedValue(new Error('Network timeout')); await expect(provider.create(baseRequest)).rejects.toThrow( - 'Failed to fetch base branch from origin' + "Failed to fetch base branch from 'origin'" ); }); }); @@ -2883,4 +2892,152 @@ describe('WorktreeProvider', () => { expect(worktreeExistsSpy).toHaveBeenCalledTimes(1); }); }); + + describe('custom remote support', () => { + const baseRequest: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'issue', + identifier: '42', + }; + + test('uses configured remote from worktree config', async () => { + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: '264', + })); + + await customProvider.create(baseRequest); + + // syncWorkspace should receive the custom remote + expect(syncWorkspaceSpy).toHaveBeenCalledWith( + '/workspace/repo', + 'main', + expect.objectContaining({ remote: '264' }) + ); + + // worktree add should use 264/main as start-point + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining([ + 'worktree', + 'add', + expect.any(String), + '-b', + 'archon/issue-42', + '264/main', + ]), + expect.any(Object) + ); + }); + + test('auto-detects remote when not configured', async () => { + getDefaultRemoteSpy.mockResolvedValue('upstream'); + const autoProvider = new WorktreeProvider(async () => ({ baseBranch: 'main' })); + + await autoProvider.create(baseRequest); + + expect(syncWorkspaceSpy).toHaveBeenCalledWith( + '/workspace/repo', + 'main', + expect.objectContaining({ remote: 'upstream' }) + ); + }); + + test('throws actionable error when remote is ambiguous', async () => { + getDefaultRemoteSpy.mockResolvedValue(null); + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('remote') && !args.includes('get-url')) { + return { stdout: '260\n262\n264\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + const ambiguousProvider = new WorktreeProvider(async () => ({ baseBranch: 'main' })); + + await expect(ambiguousProvider.create(baseRequest)).rejects.toThrow( + /Cannot determine git remote.*Set worktree\.remote/ + ); + }); + + test('uses custom remote for same-repo PR fetch and tracking', async () => { + const prRequest: PRIsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'pr', + identifier: '42', + prBranch: 'feature/auth', + isForkPR: false, + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(prRequest); + + // Fetch should use custom remote + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'upstream', 'feature/auth']), + expect.any(Object) + ); + + // Branch tracking should use custom remote + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['branch', '--set-upstream-to', 'upstream/feature/auth']), + expect.any(Object) + ); + }); + + test('uses custom remote for fork PR fetch', async () => { + const forkPrRequest: PRIsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'pr', + identifier: '42', + prBranch: 'feature/auth', + isForkPR: true, + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(forkPrRequest); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining([ + '-C', + '/workspace/repo', + 'fetch', + 'upstream', + 'pull/42/head:pr-42-review', + ]), + expect.any(Object) + ); + }); + + test('uses custom remote for remote branch deletion', async () => { + worktreeExistsSpy.mockResolvedValue(false); + mockAccess.mockResolvedValue(undefined); + + await provider.destroy('worktree-path', { + branchName: 'archon/issue-42' as git.BranchName, + canonicalRepoPath: '/workspace/repo' as git.RepoPath, + deleteRemoteBranch: true, + remote: 'upstream', + }); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'push', 'upstream', '--delete', 'archon/issue-42'], + expect.any(Object) + ); + }); + }); }); diff --git a/packages/isolation/src/providers/worktree.ts b/packages/isolation/src/providers/worktree.ts index 4d76c721a8..f45f69607b 100644 --- a/packages/isolation/src/providers/worktree.ts +++ b/packages/isolation/src/providers/worktree.ts @@ -13,6 +13,7 @@ import { execFileAsync, findWorktreeByBranch, getCanonicalRepoPath, + getDefaultRemote, getWorktreeBase, listWorktrees, mkdirAsync, @@ -287,12 +288,12 @@ export class WorktreeProvider implements IIsolationProvider { if (options?.branchName) { result.branchDeleted = await this.deleteBranchTracked(repoPath, options.branchName, result); - // Delete remote branch if requested (e.g., after PR merge) if (options.deleteRemoteBranch) { result.remoteBranchDeleted = await this.deleteRemoteBranchTracked( repoPath, options.branchName, - result + result, + options.remote ); } } @@ -381,10 +382,11 @@ export class WorktreeProvider implements IIsolationProvider { private async deleteRemoteBranchTracked( repoPath: string, branchName: string, - result: DestroyResult + result: DestroyResult, + remote = 'origin' ): Promise { try { - await execFileAsync('git', ['-C', repoPath, 'push', 'origin', '--delete', branchName], { + await execFileAsync('git', ['-C', repoPath, 'push', remote, '--delete', branchName], { timeout: GIT_OPERATION_TIMEOUT_MS, }); getLog().debug({ repoPath, branchName }, 'remote_branch_deleted'); @@ -704,24 +706,27 @@ export class WorktreeProvider implements IIsolationProvider { ): Promise<{ warnings: string[] }> { const repoPath = request.canonicalRepoPath; + // Resolve git remote name: explicit config > auto-detect > 'origin' fallback + const remote = await this.resolveRemote(repoPath, worktreeConfig?.remote); + // 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 baseBranch = await this.syncWorkspaceBeforeCreate( + repoPath, + worktreeConfig?.baseBranch, + remote + ); 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) - await this.createFromPR(request, worktreePath); + await this.createFromPR(request, worktreePath, remote); } else { - // For issues, tasks, threads: create new branch - await this.createNewBranch(request, repoPath, worktreePath, branchName, baseBranch); + await this.createNewBranch(request, repoPath, worktreePath, branchName, baseBranch, remote); } // Initialize submodules unless explicitly opted out. The check is free @@ -749,49 +754,74 @@ export class WorktreeProvider implements IIsolationProvider { } /** - * Sync workspace with remote before creating a new worktree - * Ensures new work starts from the latest code on the base branch. + * Resolve the git remote name to use for all fetch/push operations. * - * Branch resolution: - * - If configuredBaseBranch is provided: Uses that branch. Fails with actionable - * error if the branch doesn't exist - no silent fallback to default. - * - If configuredBaseBranch is omitted: Auto-detects the default branch via git. + * Resolution order: + * 1. Explicit config (worktree.remote in .archon/config.yaml) + * 2. Auto-detect via getDefaultRemote() ('origin' if exists, sole remote, or null) + * 3. Fail with actionable error if ambiguous + */ + private async resolveRemote(repoPath: RepoPath, configuredRemote?: string): Promise { + if (configuredRemote) { + getLog().debug({ repoPath, remote: configuredRemote }, 'worktree.remote_from_config'); + return configuredRemote; + } + + const detected = await getDefaultRemote(repoPath); + if (detected) { + if (detected !== 'origin') { + getLog().info({ repoPath, remote: detected }, 'worktree.remote_auto_detected'); + } + return detected; + } + + // List remotes for actionable error message + let remoteList = ''; + try { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); + remoteList = stdout.trim().split('\n').join(', '); + } catch { + // Best-effort for error message + } + + throw new Error( + `Cannot determine git remote for ${repoPath}: no 'origin' remote found and ` + + `multiple remotes exist (${remoteList}). ` + + 'Set worktree.remote in .archon/config.yaml to specify which remote to use.' + ); + } + + /** + * Sync workspace with remote before creating a new worktree. + * Ensures new work starts from the latest code on the base branch. * * All sync failures are fatal — creating a worktree from an unknown * start-point risks branching from the wrong commit. - * - * Error classification (for user-facing messages): - * - Permission denied → file permission hint - * - Not a git repository → workspace integrity hint - * - Configured base branch missing → config fix hint - * - Network errors, timeouts → connectivity hint */ private async syncWorkspaceBeforeCreate( repoPath: RepoPath, - configuredBaseBranch?: string + configuredBaseBranch?: string, + remote = 'origin' ): Promise { try { getLog().debug( - { repoPath, branch: configuredBaseBranch ?? 'auto-detect' }, + { repoPath, branch: configuredBaseBranch ?? 'auto-detect', remote }, 'workspace_sync_starting' ); - // Only hard-reset for Archon-managed clones (under ~/.archon/workspaces/). - // Locally-registered repos get fetch-only to avoid destroying uncommitted work. const isManagedClone = repoPath .replace(/\\/g, '/') .startsWith(getArchonWorkspacesPath().replace(/\\/g, '/')); const { branch } = await syncWorkspace( repoPath, configuredBaseBranch ? toBranchName(configuredBaseBranch) : undefined, - { resetAfterFetch: isManagedClone } + { resetAfterFetch: isManagedClone, remote } ); - getLog().debug({ repoPath, branch }, 'workspace_synced'); + getLog().debug({ repoPath, branch, remote }, 'workspace_synced'); return branch; } catch (error) { const err = error as Error & { code?: string }; const errorMessage = err.message.toLowerCase(); - // Fatal errors - throw to prevent confusing downstream failures if (err.code === 'EACCES' || errorMessage.includes('permission denied')) { throw new Error( `Permission denied accessing repository at ${repoPath}. ` + @@ -803,13 +833,11 @@ export class WorktreeProvider implements IIsolationProvider { 'Ensure the workspace was cloned correctly.' ); } else if (errorMessage.includes('configured base branch')) { - // Configured branch errors are fatal - user needs to fix their config throw err; } else { - // Network errors, timeouts — cannot guarantee correct start-point throw new Error( - `Failed to fetch base branch from origin: ${err.message}. ` + - 'Check your network connection and try again.' + `Failed to fetch base branch from '${remote}': ${err.message}. ` + + 'Check your network connection and remote configuration.' ); } } @@ -890,8 +918,11 @@ export class WorktreeProvider implements IIsolationProvider { * When prSha is provided, the worktree is initially created at the specific * commit (detached HEAD), then a local tracking branch is created. */ - private async createFromPR(request: PRIsolationRequest, worktreePath: string): Promise { - // Clean up any orphan directory before creating worktree + private async createFromPR( + request: PRIsolationRequest, + worktreePath: string, + remote = 'origin' + ): Promise { await this.cleanOrphanDirectoryIfExists(worktreePath); const repoPath = request.canonicalRepoPath; @@ -899,15 +930,11 @@ export class WorktreeProvider implements IIsolationProvider { try { if (!request.isForkPR) { - // Same-repo PR: Use the actual branch so changes push directly to PR - await this.createFromSameRepoPR(repoPath, worktreePath, request.prBranch); + await this.createFromSameRepoPR(repoPath, worktreePath, request.prBranch, remote); } else { - // Fork PR: Use synthetic review branch - await this.createFromForkPR(repoPath, worktreePath, prNumber, request.prSha); + await this.createFromForkPR(repoPath, worktreePath, prNumber, remote, request.prSha); } } catch (error) { - // Clean up orphaned git-registered worktree from partial failure - // (e.g., worktree add succeeded but createBranchWithStaleRetry failed) await this.cleanOrphanWorktreeIfExists(repoPath, worktreePath); const err = error as Error; throw new Error(`Failed to create worktree for PR #${prNumber}: ${err.message}`); @@ -920,24 +947,21 @@ export class WorktreeProvider implements IIsolationProvider { private async createFromSameRepoPR( repoPath: string, worktreePath: string, - prBranch: string + prBranch: string, + remote = 'origin' ): Promise { - // Fetch the PR's actual branch - await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', prBranch], { + await execFileAsync('git', ['-C', repoPath, 'fetch', remote, prBranch], { timeout: GIT_OPERATION_TIMEOUT_MS, }); - // Try to create worktree with the branch try { - // If branch doesn't exist locally, create it tracking remote await execFileAsync( 'git', - ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', prBranch, `origin/${prBranch}`], + ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', prBranch, `${remote}/${prBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ); } catch (error) { const err = error as Error & { stderr?: string }; - // Branch already exists locally - use it directly if (err.stderr?.includes('already exists')) { await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, prBranch], { timeout: GIT_OPERATION_TIMEOUT_MS, @@ -947,16 +971,14 @@ export class WorktreeProvider implements IIsolationProvider { } } - // Set up tracking for push/pull (non-fatal - worktree is usable without it) try { await execFileAsync( 'git', - ['-C', worktreePath, 'branch', '--set-upstream-to', `origin/${prBranch}`], + ['-C', worktreePath, 'branch', '--set-upstream-to', `${remote}/${prBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ); } catch (trackingError) { getLog().warn({ err: trackingError, worktreePath, prBranch }, 'upstream_tracking_failed'); - // Continue - the worktree was created successfully, tracking is just convenience } } @@ -970,13 +992,13 @@ export class WorktreeProvider implements IIsolationProvider { repoPath: string, worktreePath: string, prNumber: string, + remote = 'origin', prSha?: string ): Promise { const reviewBranch = `pr-${prNumber}-review`; if (prSha) { - // SHA provided: create at specific commit for reproducible reviews - await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head`], { + await execFileAsync('git', ['-C', repoPath, 'fetch', remote, `pull/${prNumber}/head`], { timeout: GIT_OPERATION_TIMEOUT_MS, }); @@ -984,7 +1006,6 @@ export class WorktreeProvider implements IIsolationProvider { timeout: GIT_OPERATION_TIMEOUT_MS, }); - // Create a local tracking branch so it's not detached HEAD await this.createBranchWithStaleRetry( repoPath, () => @@ -994,13 +1015,12 @@ export class WorktreeProvider implements IIsolationProvider { reviewBranch ); } else { - // No SHA: fetch and create review branch await this.createBranchWithStaleRetry( repoPath, () => execFileAsync( 'git', - ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head:${reviewBranch}`], + ['-C', repoPath, 'fetch', remote, `pull/${prNumber}/head:${reviewBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ), reviewBranch @@ -1045,16 +1065,15 @@ export class WorktreeProvider implements IIsolationProvider { repoPath: string, worktreePath: string, branchName: string, - baseBranch: string + baseBranch: string, + remote = 'origin' ): Promise { - // Clean up any orphan directory before creating worktree await this.cleanOrphanDirectoryIfExists(worktreePath); - // Determine start-point: explicit fromBranch overrides base branch const startPoint = request.workflowType === 'task' && request.fromBranch ? request.fromBranch - : `origin/${baseBranch}`; + : `${remote}/${baseBranch}`; try { // Try to create with new branch diff --git a/packages/isolation/src/types.ts b/packages/isolation/src/types.ts index b369ffd7ad..39c7877a22 100644 --- a/packages/isolation/src/types.ts +++ b/packages/isolation/src/types.ts @@ -136,6 +136,8 @@ export interface WorktreeDestroyOptions extends DestroyOptions { canonicalRepoPath?: RepoPath; /** Delete the remote branch (best-effort, e.g., after PR merge) */ deleteRemoteBranch?: boolean; + /** Git remote name for remote branch deletion (default: 'origin') */ + remote?: string; } /** @@ -261,6 +263,22 @@ export interface WorktreeCreateConfig { * @example '.worktrees' */ path?: string; + /** + * Git remote name to use for fetch/push operations. + * + * Most repos use the standard 'origin' remote, but some (e.g. Salesforce Core) + * use numbered or named remotes. When set, all git operations (fetch, push, + * branch tracking) use this remote instead of 'origin'. + * + * When omitted, auto-detected via `getDefaultRemote()`: + * 1. 'origin' if it exists + * 2. The sole remote if only one is configured + * 3. Fails with actionable error if ambiguous + * + * Sourced from `.archon/config.yaml > worktree.remote` in the repo. + * @example '264' + */ + remote?: string; } export type RepoConfigLoader = (repoPath: string) => Promise; From 7864d0bf27817dc7e3f360629c27041f29ee7277 Mon Sep 17 00:00:00 2001 From: deepak-rawat Date: Sun, 3 May 2026 17:18:04 +0530 Subject: [PATCH 2/4] chore: replace Salesforce-specific examples with generic ones Use 'jan', 'feb', 'mar' as example remote names instead of numbered remotes that reference a specific organization. --- packages/core/src/config/config-types.ts | 10 +++++----- packages/git/src/git.test.ts | 18 +++++++++--------- .../isolation/src/providers/worktree.test.ts | 10 +++++----- packages/isolation/src/types.ts | 9 +++++---- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 041b23ae97..fb5feb1f8b 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -203,16 +203,16 @@ export interface RepoConfig { /** * Git remote name for fetch/push operations. * - * Most repos use the standard 'origin' remote, but some (e.g. enterprise - * monorepos) use numbered or custom-named remotes. When set, all git - * operations (fetch, push, branch tracking) use this remote instead of - * 'origin'. + * Most repos use the standard 'origin' remote, but some use custom-named + * remotes (e.g. 'jan', 'feb', 'mar' for release-based remotes). When set, + * all git operations (fetch, push, branch tracking) use this remote + * instead of 'origin'. * * When omitted, auto-detected: 'origin' if it exists, otherwise the sole * remote if only one is configured. Fails with an actionable error if * multiple non-origin remotes exist and none is named 'origin'. * - * @example '264' + * @example 'upstream' */ remote?: string; }; diff --git a/packages/git/src/git.test.ts b/packages/git/src/git.test.ts index 831299d8b8..bb111e5965 100644 --- a/packages/git/src/git.test.ts +++ b/packages/git/src/git.test.ts @@ -1499,11 +1499,11 @@ branch refs/heads/feature/auth test('uses custom remote when provided in options', async () => { execSpy.mockResolvedValue({ stdout: '', stderr: '' }); - await git.syncWorkspace('/workspace/repo', 'main', { remote: '264' }); + await git.syncWorkspace('/workspace/repo', 'main', { remote: 'mar' }); expect(execSpy).toHaveBeenCalledWith( 'git', - ['-C', '/workspace/repo', 'fetch', '264', 'main'], + ['-C', '/workspace/repo', 'fetch', 'mar', 'main'], expect.any(Object) ); @@ -1512,7 +1512,7 @@ branch refs/heads/feature/auth return args.includes('reset'); }); expect(resetCalls).toHaveLength(1); - expect(resetCalls[0][1]).toEqual(['-C', '/workspace/repo', 'reset', '--hard', '264/main']); + expect(resetCalls[0][1]).toEqual(['-C', '/workspace/repo', 'reset', '--hard', 'mar/main']); }); test('passes custom remote to getDefaultBranch when baseBranch not provided', async () => { @@ -1527,13 +1527,13 @@ branch refs/heads/feature/auth test('includes remote name in error message for custom remote', async () => { execSpy.mockImplementation(async (_cmd: string, args: string[]) => { if (args.includes('fetch')) { - throw new Error("fatal: '264' does not appear to be a git repository"); + throw new Error("fatal: 'mar' does not appear to be a git repository"); } return { stdout: '', stderr: '' }; }); - await expect(git.syncWorkspace('/workspace/repo', 'main', { remote: '264' })).rejects.toThrow( - 'Sync fetch from 264/main failed' + await expect(git.syncWorkspace('/workspace/repo', 'main', { remote: 'mar' })).rejects.toThrow( + 'Sync fetch from mar/main failed' ); }); }); @@ -1557,14 +1557,14 @@ branch refs/heads/feature/auth }); test('returns sole remote when only one is configured', async () => { - execSpy.mockResolvedValue({ stdout: '264\n', stderr: '' }); + execSpy.mockResolvedValue({ stdout: 'mar\n', stderr: '' }); const result = await git.getDefaultRemote('/workspace/repo'); - expect(result).toBe('264'); + expect(result).toBe('mar'); }); test('returns null when multiple non-origin remotes exist', async () => { - execSpy.mockResolvedValue({ stdout: '260\n262\n264\n', stderr: '' }); + execSpy.mockResolvedValue({ stdout: 'jan\nfeb\nmar\n', stderr: '' }); const result = await git.getDefaultRemote('/workspace/repo'); expect(result).toBeNull(); diff --git a/packages/isolation/src/providers/worktree.test.ts b/packages/isolation/src/providers/worktree.test.ts index 345e765494..2defea787c 100644 --- a/packages/isolation/src/providers/worktree.test.ts +++ b/packages/isolation/src/providers/worktree.test.ts @@ -2904,7 +2904,7 @@ describe('WorktreeProvider', () => { test('uses configured remote from worktree config', async () => { const customProvider = new WorktreeProvider(async () => ({ baseBranch: 'main', - remote: '264', + remote: 'mar', })); await customProvider.create(baseRequest); @@ -2913,10 +2913,10 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith( '/workspace/repo', 'main', - expect.objectContaining({ remote: '264' }) + expect.objectContaining({ remote: 'mar' }) ); - // worktree add should use 264/main as start-point + // worktree add should use mar/main as start-point expect(execSpy).toHaveBeenCalledWith( 'git', expect.arrayContaining([ @@ -2925,7 +2925,7 @@ describe('WorktreeProvider', () => { expect.any(String), '-b', 'archon/issue-42', - '264/main', + 'mar/main', ]), expect.any(Object) ); @@ -2948,7 +2948,7 @@ describe('WorktreeProvider', () => { getDefaultRemoteSpy.mockResolvedValue(null); execSpy.mockImplementation(async (_cmd: string, args: string[]) => { if (args.includes('remote') && !args.includes('get-url')) { - return { stdout: '260\n262\n264\n', stderr: '' }; + return { stdout: 'jan\nfeb\nmar\n', stderr: '' }; } return { stdout: '', stderr: '' }; }); diff --git a/packages/isolation/src/types.ts b/packages/isolation/src/types.ts index 39c7877a22..cffbcc9748 100644 --- a/packages/isolation/src/types.ts +++ b/packages/isolation/src/types.ts @@ -266,9 +266,10 @@ export interface WorktreeCreateConfig { /** * Git remote name to use for fetch/push operations. * - * Most repos use the standard 'origin' remote, but some (e.g. Salesforce Core) - * use numbered or named remotes. When set, all git operations (fetch, push, - * branch tracking) use this remote instead of 'origin'. + * Most repos use the standard 'origin' remote, but some use custom-named + * remotes (e.g. 'jan', 'feb', 'mar' for release-based remotes). When set, + * all git operations (fetch, push, branch tracking) use this remote + * instead of 'origin'. * * When omitted, auto-detected via `getDefaultRemote()`: * 1. 'origin' if it exists @@ -276,7 +277,7 @@ export interface WorktreeCreateConfig { * 3. Fails with actionable error if ambiguous * * Sourced from `.archon/config.yaml > worktree.remote` in the repo. - * @example '264' + * @example 'upstream' */ remote?: string; } From 1415e15f93971486f8397fc85c6952120045fb8d Mon Sep 17 00:00:00 2001 From: deepak-rawat Date: Sun, 3 May 2026 20:57:29 +0530 Subject: [PATCH 3/4] fix(git): handle CRLF in git remote output and propagate errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split on /\r?\n/ instead of '\n' so Windows CRLF doesn't break origin detection (e.g. 'origin\r' fails includes('origin')) - Let git execution errors propagate instead of swallowing them as null — callers now see the real failure instead of a misleading "set worktree.remote" message --- packages/git/src/git.test.ts | 10 ++++++++-- packages/git/src/repo.ts | 24 +++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/git/src/git.test.ts b/packages/git/src/git.test.ts index bb111e5965..8dac157589 100644 --- a/packages/git/src/git.test.ts +++ b/packages/git/src/git.test.ts @@ -1577,11 +1577,17 @@ branch refs/heads/feature/auth expect(result).toBeNull(); }); - test('returns null on git error', async () => { + test('propagates git errors instead of swallowing them', async () => { execSpy.mockRejectedValue(new Error('not a git repository')); + await expect(git.getDefaultRemote('/workspace/repo')).rejects.toThrow('not a git repository'); + }); + + test('handles CRLF line endings from git output', async () => { + execSpy.mockResolvedValue({ stdout: 'origin\r\nupstream\r\n', stderr: '' }); + const result = await git.getDefaultRemote('/workspace/repo'); - expect(result).toBeNull(); + expect(result).toBe('origin'); }); }); diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index dfa5e337d7..96b6be3554 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -49,21 +49,15 @@ export async function findRepoRoot(startPath: string): Promise * Callers can override via `worktree.remote` in `.archon/config.yaml`. */ export async function getDefaultRemote(repoPath: RepoPath): Promise { - try { - const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); - const remotes = stdout - .trim() - .split('\n') - .filter(r => r.length > 0); - if (remotes.length === 0) return null; - if (remotes.includes('origin')) return 'origin'; - if (remotes.length === 1) return remotes[0]; - return null; - } catch (error) { - const err = error as Error & { stderr?: string }; - getLog().error({ repoPath, err, stderr: err.stderr }, 'get_default_remote_failed'); - return null; - } + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); + const remotes = stdout + .split(/\r?\n/) + .map(r => r.trim()) + .filter(r => r.length > 0); + if (remotes.length === 0) return null; + if (remotes.includes('origin')) return 'origin'; + if (remotes.length === 1) return remotes[0]; + return null; } /** From c55ab6d160e350f63da7db3e648ee1ac8ceaf2ee Mon Sep 17 00:00:00 2001 From: deepak-rawat Date: Mon, 4 May 2026 22:16:39 +0530 Subject: [PATCH 4/4] feat(core): thread remote through orchestrator and cleanup service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - Load repo config before syncWorkspace in orchestrator-agent.ts and pass the resolved remote name - Thread remote through cleanupMergedWorktrees → isSafeToRemove → getPrState so PR-state detection works for non-origin remotes - Add getDefaultBranch remote param to getWorktreeStatusBreakdown - Add config-loader tests: propagates remote, trims whitespace, undefined when not configured - Add worktree test: fromBranch + custom remote (task workflow) - Document worktree.remote in configuration.md reference - Add CHANGELOG entry under [Unreleased] - Generalize comment examples (remove org-specific language) - Fix redundant docstring in getRemoteUrl --- CHANGELOG.md | 5 ++ .../core/src/config/config-loader.test.ts | 53 +++++++++++++++++++ packages/core/src/config/config-types.ts | 10 ++-- .../src/orchestrator/orchestrator-agent.ts | 11 ++-- packages/core/src/services/cleanup-service.ts | 18 ++++--- .../content/docs/reference/configuration.md | 11 +++- packages/git/src/repo.ts | 4 +- .../isolation/src/providers/worktree.test.ts | 31 +++++++++++ packages/isolation/src/types.ts | 7 ++- 9 files changed, 125 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b90c77b9..9d466c21de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `worktree.remote` config option in `.archon/config.yaml` for repos using non-standard git remote names +- `getDefaultRemote()` auto-detection: prefers `origin`, falls back to sole remote, errors on ambiguity + ## [0.3.10] - 2026-04-29 Maintainer workflow suite, loop output variables, and broad workflow engine fixes diff --git a/packages/core/src/config/config-loader.test.ts b/packages/core/src/config/config-loader.test.ts index ac242040ac..8e3e01fc05 100644 --- a/packages/core/src/config/config-loader.test.ts +++ b/packages/core/src/config/config-loader.test.ts @@ -382,6 +382,59 @@ worktree: expect(config.baseBranch).toBeUndefined(); }); + test('propagates remote from repo worktree config', async () => { + const pathMatches = (path: string, pattern: string): boolean => { + const normalizedPath = path.replace(/\\/g, '/'); + return normalizedPath.includes(pattern); + }; + + mockReadConfigFile.mockImplementation(async (path: string) => { + if (pathMatches(path, '/repo/.archon/config.yaml')) { + return ` +worktree: + remote: upstream +`; + } + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBe('upstream'); + }); + + test('trims whitespace from remote', async () => { + const pathMatches = (path: string, pattern: string): boolean => { + const normalizedPath = path.replace(/\\/g, '/'); + return normalizedPath.includes(pattern); + }; + + mockReadConfigFile.mockImplementation(async (path: string) => { + if (pathMatches(path, '/repo/.archon/config.yaml')) { + return ` +worktree: + remote: " mar " +`; + } + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBe('mar'); + }); + + test('remote is undefined when not configured', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockReadConfigFile.mockRejectedValue(error); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBeUndefined(); + }); + test('propagates docsPath from repo docs config', async () => { const pathMatches = (path: string, pattern: string): boolean => { const normalizedPath = path.replace(/\\/g, '/'); diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index fb5feb1f8b..d23888e323 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -203,14 +203,12 @@ export interface RepoConfig { /** * Git remote name for fetch/push operations. * - * Most repos use the standard 'origin' remote, but some use custom-named - * remotes (e.g. 'jan', 'feb', 'mar' for release-based remotes). When set, - * all git operations (fetch, push, branch tracking) use this remote - * instead of 'origin'. + * When set, all git operations (fetch, push, branch tracking) use this + * remote instead of 'origin'. Useful for repos with multiple remotes or + * non-standard naming conventions. * * When omitted, auto-detected: 'origin' if it exists, otherwise the sole - * remote if only one is configured. Fails with an actionable error if - * multiple non-origin remotes exist and none is named 'origin'. + * remote if only one is configured. * * @example 'upstream' */ diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 8521e83a82..365330caba 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -27,7 +27,7 @@ import { toError } from '../utils/error'; import { getAgentProvider, getProviderCapabilities } from '@archon/providers'; import { getArchonWorkspacesPath } from '@archon/paths'; import { syncArchonToWorktree } from '../utils/worktree-sync'; -import { syncWorkspace, toRepoPath } from '@archon/git'; +import { getDefaultRemote, syncWorkspace, toRepoPath } from '@archon/git'; import type { WorkspaceSyncResult } from '@archon/git'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { findWorkflow } from '@archon/workflows/router'; @@ -38,7 +38,7 @@ import type { WorkflowLoadError, } from '@archon/workflows/schemas/workflow'; import { createWorkflowDeps } from '../workflows/store-adapter'; -import { loadConfig } from '../config/config-loader'; +import { loadConfig, loadRepoConfig } from '../config/config-loader'; import type { MergedConfig } from '../config/config-types'; import { generateAndSetTitle } from '../services/title-generator'; import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator'; @@ -434,8 +434,13 @@ async function discoverAllWorkflows(conversation: Conversation): Promise { const environments = await isolationEnvDb.listByCodebaseWithAge(codebaseId); @@ -462,7 +463,7 @@ export async function getWorktreeStatusBreakdown( activeEnvs: [], }; - const mainBranch = await getDefaultBranch(repoPath); + const mainBranch = await getDefaultBranch(repoPath, remote); for (const env of environments) { // Skip Telegram (never shown as stale) @@ -560,7 +561,8 @@ async function isSafeToRemove( branchName: BranchName, mainBranch: BranchName, prStateCache: Map, - includeClosed: boolean + includeClosed: boolean, + remote?: string ): Promise<{ safe: boolean; openPr: boolean }> { // (a) Fast path — fast-forward / merge-commit ancestry if (await isBranchMerged(repoPath, branchName, mainBranch)) { @@ -571,7 +573,7 @@ async function isSafeToRemove( return { safe: true, openPr: false }; } // (c) GitHub PR state - const prState = await getPrState(branchName, repoPath, prStateCache); + const prState = await getPrState(branchName, repoPath, prStateCache, remote); if (prState === 'MERGED') return { safe: true, openPr: false }; if (prState === 'CLOSED') return { safe: includeClosed, openPr: false }; if (prState === 'OPEN') return { safe: false, openPr: true }; @@ -585,17 +587,16 @@ async function isSafeToRemove( export async function cleanupMergedWorktrees( codebaseId: string, mainRepoPath: string, - options: { includeClosed?: boolean } = {} + options: { includeClosed?: boolean; remote?: string } = {} ): Promise { const result: CleanupOperationResult = { removed: [], skipped: [] }; const environments = await isolationEnvDb.listByCodebase(codebaseId); const repoPath = toRepoPath(mainRepoPath); - const mainBranch = await getDefaultBranch(repoPath); + const mainBranch = await getDefaultBranch(repoPath, options.remote); const includeClosed = options.includeClosed ?? false; const prStateCache = new Map(); for (const env of environments) { - // Check if safe to remove via union of signals (skip env on unexpected errors) let safe = false; let openPr = false; try { @@ -605,7 +606,8 @@ export async function cleanupMergedWorktrees( branchName, mainBranch, prStateCache, - includeClosed + includeClosed, + options.remote ); safe = decision.safe; openPr = decision.openPr; diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index d312c734a2..a307e404ab 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -133,6 +133,8 @@ worktree: # /.worktrees/ instead of under # ~/.archon/workspaces///worktrees/. # Must be relative; no absolute, no `..` segments. + remote: origin # Optional: git remote name for fetch/push. Auto-detected + # when omitted (origin if exists, sole remote otherwise). # Documentation directory docs: @@ -206,9 +208,14 @@ worktree: **Submodule behavior:** When a repo contains `.gitmodules`, submodules are initialized in new worktrees by default (git's `worktree add` does not do this). The check is a cheap filesystem probe — repos without submodules pay zero cost. Submodule init failure throws a classified error (credentials, network, timeout) rather than silently producing a worktree with empty submodule directories. Set `worktree.initSubmodules: false` to opt out. +**Remote behavior:** By default, all git operations (fetch, push, branch tracking) use the `origin` remote. If your repo uses a different remote name, configure `worktree.remote`. Resolution order: +1. If `worktree.remote` is set: Uses the configured remote name for all operations. +2. If omitted: Auto-detects via `getDefaultRemote()` — returns `origin` if it exists, otherwise the sole remote if only one is configured. +3. If multiple non-origin remotes exist and none is named `origin`: **Fails with an actionable error** listing the available remotes and suggesting the config fix. + **Base branch behavior:** Before creating a worktree, the canonical workspace is synced to the latest code. Resolution order: -1. If `worktree.baseBranch` is set: Uses the configured branch. **Fails with an error** if the branch doesn't exist on remote (no silent fallback). -2. If omitted: Auto-detects the default branch via `git remote show origin`. Works without any config for standard repos. +1. If `worktree.baseBranch` is set: Uses the configured branch. **Fails with an error** if the branch doesn't exist on the resolved remote (no silent fallback). +2. If omitted: Auto-detects the default branch via symbolic-ref on the resolved remote. Works without any config for standard repos. 3. If auto-detection fails and a workflow references `$BASE_BRANCH`: Fails with an error explaining the resolution chain. **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`). diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index 96b6be3554..a437834ca0 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -61,8 +61,8 @@ export async function getDefaultRemote(repoPath: RepoPath): Promise { try { diff --git a/packages/isolation/src/providers/worktree.test.ts b/packages/isolation/src/providers/worktree.test.ts index 2defea787c..388d25a597 100644 --- a/packages/isolation/src/providers/worktree.test.ts +++ b/packages/isolation/src/providers/worktree.test.ts @@ -2944,6 +2944,37 @@ describe('WorktreeProvider', () => { ); }); + test('uses fromBranch as start-point with custom remote (task workflow)', async () => { + const taskRequest: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'task', + identifier: 'my-feature', + fromBranch: 'develop', + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(taskRequest); + + // fromBranch overrides remote/baseBranch as start-point + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining([ + 'worktree', + 'add', + expect.any(String), + '-b', + 'archon/task-my-feature', + 'develop', + ]), + expect.any(Object) + ); + }); + test('throws actionable error when remote is ambiguous', async () => { getDefaultRemoteSpy.mockResolvedValue(null); execSpy.mockImplementation(async (_cmd: string, args: string[]) => { diff --git a/packages/isolation/src/types.ts b/packages/isolation/src/types.ts index cffbcc9748..62774f8a8d 100644 --- a/packages/isolation/src/types.ts +++ b/packages/isolation/src/types.ts @@ -266,10 +266,9 @@ export interface WorktreeCreateConfig { /** * Git remote name to use for fetch/push operations. * - * Most repos use the standard 'origin' remote, but some use custom-named - * remotes (e.g. 'jan', 'feb', 'mar' for release-based remotes). When set, - * all git operations (fetch, push, branch tracking) use this remote - * instead of 'origin'. + * When set, all git operations (fetch, push, branch tracking) use this + * remote instead of 'origin'. Useful for repos with multiple remotes or + * non-standard naming conventions. * * When omitted, auto-detected via `getDefaultRemote()`: * 1. 'origin' if it exists