Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 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
21 changes: 21 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,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;
};

/**
Expand Down Expand Up @@ -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.
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
90 changes: 89 additions & 1 deletion packages/git/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof git.execFileAsync>;

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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
// Repository operations
export {
findRepoRoot,
getDefaultRemote,
getRemoteUrl,
syncWorkspace,
cloneRepository,
Expand Down
Loading