Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/');
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/config/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ export interface RepoConfig {
* @example '.worktrees'
*/
path?: string;

/**
* Git remote name for fetch/push operations.
*
* 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.
*
* @example 'upstream'
*/
remote?: string;
};

/**
Expand Down Expand Up @@ -286,6 +300,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.
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -434,8 +434,13 @@ async function discoverAllWorkflows(conversation: Conversation): Promise<Discove
const isManagedClone = codebase.default_cwd
.replace(/\\/g, '/')
.startsWith(getArchonWorkspacesPath().replace(/\\/g, '/'));
syncResult = await syncWorkspace(toRepoPath(codebase.default_cwd), undefined, {
const repoPath = toRepoPath(codebase.default_cwd);
const repoConf = await loadRepoConfig(codebase.default_cwd);
const remote =
repoConf.worktree?.remote?.trim() || (await getDefaultRemote(repoPath)) || undefined;
syncResult = await syncWorkspace(repoPath, undefined, {
resetAfterFetch: isManagedClone,
remote,
});
getLog().debug(
{
Expand Down
18 changes: 10 additions & 8 deletions packages/core/src/services/cleanup-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ export interface CleanupOperationResult {
*/
export async function getWorktreeStatusBreakdown(
codebaseId: string,
mainRepoPath: string
mainRepoPath: string,
remote?: string
): Promise<WorktreeStatusBreakdown> {
Comment on lines 448 to 452
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all callers of getWorktreeStatusBreakdown and whether they pass remote
rg -n --type ts -A 3 "getWorktreeStatusBreakdown\s*("

Repository: coleam00/Archon

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Fix regex: escape parenthesis properly
rg -n --type ts "getWorktreeStatusBreakdown" -A 3

Repository: coleam00/Archon

Length of output: 4480


🏁 Script executed:

#!/bin/bash
# Also verify the function signature in cleanup-service.ts around lines 448-452
rg -n -B 2 -A 8 "export async function getWorktreeStatusBreakdown" packages/core/src/services/cleanup-service.ts

Repository: coleam00/Archon

Length of output: 484


🏁 Script executed:

#!/bin/bash
# Check the command-handler.ts line 1004 context
sed -n '998,1015p' packages/core/src/handlers/command-handler.ts

Repository: coleam00/Archon

Length of output: 774


🏁 Script executed:

#!/bin/bash
# Read the full getWorktreeStatusBreakdown implementation
sed -n '448,520p' packages/core/src/services/cleanup-service.ts

Repository: coleam00/Archon

Length of output: 2163


🏁 Script executed:

#!/bin/bash
# Check orchestrator.ts context around line 91
sed -n '80,110p' packages/core/src/orchestrator/orchestrator.ts

Repository: coleam00/Archon

Length of output: 1180


🏁 Script executed:

#!/bin/bash
# Check if there's repo config loading at the command-handler call site
sed -n '990,1020p' packages/core/src/handlers/command-handler.ts

Repository: coleam00/Archon

Length of output: 1229


🏁 Script executed:

#!/bin/bash
# Check the getDefaultBranch signature and default behavior
rg -n -B 2 -A 5 "export.*function getDefaultBranch" packages/core/src

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for where getBreakdown is called in IsolationResolver
rg -n "getBreakdown" packages -t ts -A 3

Repository: coleam00/Archon

Length of output: 661


🏁 Script executed:

#!/bin/bash
# Check if loadConfig or loadRepoConfig is used in the command-handler.ts handler
grep -n "loadRepoConfig\|loadConfig" packages/core/src/handlers/command-handler.ts

Repository: coleam00/Archon

Length of output: 364


🏁 Script executed:

#!/bin/bash
# Find getDefaultBranch definition
rg -n "getDefaultBranch" packages/core/src/services/cleanup-service.ts -B 2 -A 3

Repository: coleam00/Archon

Length of output: 1006


🏁 Script executed:

#!/bin/bash
# See how getBreakdown is actually called in IsolationResolver
rg -n "getBreakdown" packages/isolation/src/resolver.ts -B 5 -A 5

Repository: coleam00/Archon

Length of output: 470


🏁 Script executed:

#!/bin/bash
# Check the full cleanup function that uses getBreakdown
sed -n '40,200p' packages/isolation/src/resolver.ts

Repository: coleam00/Archon

Length of output: 5127


🏁 Script executed:

#!/bin/bash
# Find where getBreakdown is actually invoked in IsolationResolver
rg -n "this\.cleanup\?.getBreakdown\|\.getBreakdown\(" packages/isolation/src/resolver.ts -B 2 -A 2

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check the getDefaultBranch function signature
rg -n "^export.*function getDefaultBranch" packages/core/src/services/cleanup-service.ts -A 3

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/all/bin/bash
# Check if loadRepoConfig is available in command-handler context
rg -n "loadRepoConfig" packages/core/src/handlers/command-handler.ts

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for getDefaultBranch more broadly
rg -n "getDefaultBranch" packages/core/src/services/cleanup-service.ts | head -20

Repository: coleam00/Archon

Length of output: 293


🏁 Script executed:

#!/bin/bash
# Find the actual definition of getDefaultBranch
rg -n "function getDefaultBranch" packages/core/src

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search in all .ts files for getDefaultBranch import and definition
rg -n "getDefaultBranch" packages/core/src -A 2 | head -40

Repository: coleam00/Archon

Length of output: 2960


🏁 Script executed:

#!/bin/bash
# Look at the imports in cleanup-service.ts to find where getDefaultBranch comes from
head -50 packages/core/src/services/cleanup-service.ts

Repository: coleam00/Archon

Length of output: 1973


🏁 Script executed:

#!/bin/bash
# Find getDefaultBranch definition in `@archon/git`
rg -n "export.*getDefaultBranch" packages/git/src -A 5

Repository: coleam00/Archon

Length of output: 520


🏁 Script executed:

#!/bin/bash
# Also search for it in git package more broadly
find packages/git -name "*.ts" -exec grep -l "getDefaultBranch" {} \;

Repository: coleam00/Archon

Length of output: 166


🏁 Script executed:

#!/bin/bash
# Check if repo config has a remote field and how it's accessed
rg -n "worktree\.remote" packages/core/src -B 2 -A 2

Repository: coleam00/Archon

Length of output: 833


🏁 Script executed:

#!/bin/bash
# Look at the Codebase type to see if it has remote info
rg -n "type Codebase\|interface Codebase" packages/core/src -A 10

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check the config-loader to see what repo config contains
head -100 packages/core/src/config/config-loader.ts

Repository: coleam00/Archon

Length of output: 3379


🏁 Script executed:

#!/bin/bash
# Check the Codebase type definition
rg -n "export.*interface Codebase\|export.*type Codebase" packages/core/src -A 15

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if codebase has remote field or if it needs to be loaded from config
rg -n "codebase\." packages/core/src/handlers/command-handler.ts | grep -i remote

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# See what fields the Codebase has in the database
rg -n "type CodebaseRow\|interface CodebaseRow" packages/core/src -A 15

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for Codebase import in command-handler to understand where it comes from
grep -n "Codebase" packages/core/src/handlers/command-handler.ts | head -10

Repository: coleam00/Archon

Length of output: 762


🏁 Script executed:

#!/bin/bash
# Find Codebase type in types or db
find packages/core/src -name "*.ts" -exec grep -l "type Codebase\|interface Codebase" {} \;

Repository: coleam00/Archon

Length of output: 205


🏁 Script executed:

#!/bin/bash
# Search more broadly for Codebase type definition
rg -n "Codebase" packages/core/src/db -A 5 | grep -E "type|interface" -A 5

Repository: coleam00/Archon

Length of output: 5849


🏁 Script executed:

#!/bin/bash
# Get the Codebase type definition
grep -n "export.*type Codebase\|export.*interface Codebase" packages/core/src/types/index.ts -A 15

Repository: coleam00/Archon

Length of output: 474


🏁 Script executed:

#!/bin/bash
# Check if there's a loadRepoConfig call or similar available in command-handler
sed -n '550,560p' packages/core/src/handlers/command-handler.ts

Repository: coleam00/Archon

Length of output: 378


🏁 Script executed:

#!/bin/bash
# Check how repo config is used elsewhere to understand the pattern
rg -n "loadRepoConfig\|loadConfig" packages/core/src/handlers -B 2 -A 5

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify the IsolationResolver's actual function signature expectation
sed -n '45,55p' packages/isolation/src/resolver.ts

Repository: coleam00/Archon

Length of output: 437


🏁 Script executed:

#!/bin/bash
# Check how cleanup functions are called in IsolationResolver to see if remote can be passed through
rg -n "cleanup.getBreakdown\|\.cleanup\?.getBreakdown" packages/isolation/src/resolver.ts -B 3 -A 3

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify if loadRepoConfig is accessible in command-handler context
rg -n "loadRepoConfig\|loadConfig" packages/core/src/config/config-loader.ts | head -5

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Double-check: search for all places getBreakdown is actually called in IsolationResolver
rg -n "getBreakdown" packages/isolation/src/resolver.ts -B 5 -A 5

Repository: coleam00/Archon

Length of output: 470


🏁 Script executed:

#!/bin/bash
# Verify if makeRoom function also has the same signature issue
sed -n '46,52p' packages/isolation/src/resolver.ts

Repository: coleam00/Archon

Length of output: 352


🏁 Script executed:

#!/bin/bash
# Check if there are any recent changes to getWorktreeStatusBreakdown signature
git log -p --all -S "getWorktreeStatusBreakdown" -- packages/core/src/services/cleanup-service.ts | head -100

Repository: coleam00/Archon

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if TypeScript compilation would catch this type mismatch
# by looking at the actual assignment in orchestrator.ts
sed -n '85,95p' packages/core/src/orchestrator/orchestrator.ts

Repository: coleam00/Archon

Length of output: 463


🏁 Script executed:

#!/bin/bash
# Verify the complete picture: search for all instances of remote configuration in cleanup/isolation contexts
rg -n "remote" packages/isolation/src/resolver.ts

Repository: coleam00/Archon

Length of output: 139


🏁 Script executed:

#!/bin/bash
# Check if there are any type assertions or overloads for getBreakdown
rg -n "getBreakdown" packages/core/src/orchestrator/orchestrator.ts -B 5 -A 5

Repository: coleam00/Archon

Length of output: 475


🏁 Script executed:

#!/bin/bash
# Check if TypeScript compilation errors are expected or if there's a tsconfig that might suppress this
rg -n "getWorktreeStatusBreakdown" packages/core/src/orchestrator/orchestrator.ts -B 2 -A 2

Repository: coleam00/Archon

Length of output: 390


🏁 Script executed:

#!/bin/bash
# Check the types more carefully - does the assignment work despite the signature mismatch?
# Let's look at the complete function in cleanup-service to verify the parameter count
sed -n '448,455p' packages/core/src/services/cleanup-service.ts

Repository: coleam00/Archon

Length of output: 336


🏁 Script executed:

#!/bin/bash
# Verify the interface definition once more and check if there's been a recent update
sed -n '48,52p' packages/isolation/src/resolver.ts

Repository: coleam00/Archon

Length of output: 294


🏁 Script executed:

#!/bin/bash
# Search for any TypeScript error suppressions around this code
rg -n "@ts-ignore\|@ts-expect-error" packages/core/src/orchestrator/orchestrator.ts -B 3 -A 3

Repository: coleam00/Archon

Length of output: 41


Type signature mismatch and missing remote parameter flow in worktree status breakdown.

getWorktreeStatusBreakdown function signature has 3 parameters including optional remote, and its implementation passes remote to getDefaultBranch(repoPath, remote) at line 466. However, the IsolationResolverDeps.cleanup.getBreakdown interface (packages/isolation/src/resolver.ts:50) is defined with only 2 parameters: (codebaseId: string, repoPath: string). This type incompatibility prevents remote from being passed through the isolation resolver path.

Additionally, the command-handler.ts call (line 1004) passes only 2 arguments: getWorktreeStatusBreakdown(codebase.id, codebase.default_cwd), meaning repos with non-origin remotes configured via worktree.remote will always use 'origin' for merge detection, producing incorrect merged/stale counts in /worktree status output.

Fix requires:

  1. Update IsolationResolverDeps.cleanup.getBreakdown interface to accept an optional 3rd parameter for remote
  2. Update the IsolationResolver implementation to pass remote through the cleanup path
  3. Load repo config in command-handler.ts at line 1004 and pass the configured remote to getWorktreeStatusBreakdown
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/services/cleanup-service.ts` around lines 448 - 452, The
worktree remote argument isn't flowing through the isolation cleanup path:
update the IsolationResolverDeps.cleanup.getBreakdown type to accept an optional
third parameter (remote?: string), adjust the IsolationResolver implementation
(the cleanup.getBreakdown caller/implementer) to forward the remote to the
underlying getWorktreeStatusBreakdown logic, and update the command handler call
site that currently does getWorktreeStatusBreakdown(codebase.id,
codebase.default_cwd) to load the repo config (worktree.remote) and pass that
value as the third argument so merge detection uses the correct remote.

const environments = await isolationEnvDb.listByCodebaseWithAge(codebaseId);

Expand All @@ -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)
Expand Down Expand Up @@ -560,7 +561,8 @@ async function isSafeToRemove(
branchName: BranchName,
mainBranch: BranchName,
prStateCache: Map<string, PrState>,
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)) {
Expand All @@ -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 };
Expand All @@ -585,17 +587,16 @@ async function isSafeToRemove(
export async function cleanupMergedWorktrees(
codebaseId: string,
mainRepoPath: string,
options: { includeClosed?: boolean } = {}
options: { includeClosed?: boolean; remote?: string } = {}
): Promise<CleanupOperationResult> {
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<string, PrState>();

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 {
Expand All @@ -605,7 +606,8 @@ export async function cleanupMergedWorktrees(
branchName,
mainBranch,
prStateCache,
includeClosed
includeClosed,
options.remote
);
safe = decision.safe;
openPr = decision.openPr;
Expand Down
11 changes: 9 additions & 2 deletions packages/docs-web/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ worktree:
# <repoRoot>/.worktrees/<branch> instead of under
# ~/.archon/workspaces/<owner>/<repo>/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:
Expand Down Expand Up @@ -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`).
Expand Down
32 changes: 19 additions & 13 deletions packages/git/src/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,26 @@ function getLog(): ReturnType<typeof createLogger> {
* 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 -> <remote>/main -> throw
* Note: Throws if neither <remote>/HEAD nor <remote>/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<BranchName> {
export async function getDefaultBranch(repoPath: RepoPath, remote = 'origin'): Promise<BranchName> {
// 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 ?? ''}`;
Expand All @@ -40,39 +43,42 @@ export async function getDefaultBranch(repoPath: RepoPath): Promise<BranchName>
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 <remote>/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');
} catch (error) {
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: <remote>/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}`);
}
}
Expand Down
Loading