Skip to content

Commit 4211ea6

Browse files
committed
fix: validate base branch when reusing worktrees (coleam00#963)
Reused worktrees were never checked against the configured baseBranch, so PRs could silently target the wrong base after a config change. Changes: - Add isAncestorOf() git helper using merge-base --is-ancestor - Add baseBranch field to IsolationHints for opt-in validation - Validate base branch in resolver checkExisting and findReusable paths - Add base branch warning in CLI worktree reuse path - Add tests for isAncestorOf and resolver base branch mismatch Fixes coleam00#963
1 parent e804bfb commit 4211ea6

7 files changed

Lines changed: 292 additions & 8 deletions

File tree

packages/cli/src/commands/workflow.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,25 @@ export async function workflowRunCommand(
429429
`--from ${options.fromBranch} was not applied (worktree already exists).`
430430
);
431431
}
432+
// Validate base branch before reuse
433+
const repoConfig = await loadRepoConfig(codebase.default_cwd);
434+
const configuredBase =
435+
repoConfig?.worktree?.baseBranch ??
436+
(await git.getDefaultBranch(git.toRepoPath(codebase.default_cwd)));
437+
const isValidBase = await git.isAncestorOf(
438+
git.toWorktreePath(existingEnv.working_path),
439+
`origin/${configuredBase}`
440+
);
441+
if (!isValidBase) {
442+
getLog().warn(
443+
{ path: existingEnv.working_path, configuredBase, branch: existingEnv.branch_name },
444+
'worktree.reuse_base_branch_mismatch'
445+
);
446+
console.warn(
447+
`Warning: Worktree '${existingEnv.branch_name}' is not based on '${configuredBase}'. ` +
448+
`Recreate with: bun run cli complete ${existingEnv.branch_name} --force`
449+
);
450+
}
432451
getLog().info({ path: existingEnv.working_path }, 'worktree_reused');
433452
workingCwd = existingEnv.working_path;
434453
isolationEnvId = existingEnv.id;

packages/git/src/branch.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,47 @@ export async function isBranchMerged(
220220
}
221221
}
222222

223+
/**
224+
* Check if a ref is an ancestor of HEAD in the given working directory.
225+
*
226+
* Returns true if ancestorRef is an ancestor of HEAD (worktree is based on that branch).
227+
* Returns false if it is not (base branch mismatch detected).
228+
* Returns false for expected errors (branch not found, not a git repo).
229+
* Throws for unexpected errors (permission denied, corruption).
230+
*/
231+
export async function isAncestorOf(
232+
workingPath: RepoPath | WorktreePath,
233+
ancestorRef: string
234+
): Promise<boolean> {
235+
try {
236+
await execFileAsync('git', [
237+
'-C',
238+
workingPath,
239+
'merge-base',
240+
'--is-ancestor',
241+
ancestorRef,
242+
'HEAD',
243+
]);
244+
return true;
245+
} catch (error) {
246+
const err = error as Error & { code?: number | string; stderr?: string };
247+
// exit code 1 = not an ancestor — expected case
248+
if (err.code === 1) return false;
249+
const errorText = `${err.message} ${err.stderr ?? ''}`.toLowerCase();
250+
const isExpectedError =
251+
errorText.includes('not a git repository') ||
252+
errorText.includes('unknown revision') ||
253+
errorText.includes('not a valid object name') ||
254+
errorText.includes('no such file') ||
255+
err.code === 'ENOENT';
256+
if (isExpectedError) return false;
257+
getLog().error({ err: error, workingPath, ancestorRef }, 'branch.ancestor_check_failed');
258+
throw new Error(
259+
`Failed to check if ${ancestorRef} is ancestor of HEAD at ${workingPath}: ${(err as Error).message}`
260+
);
261+
}
262+
}
263+
223264
/**
224265
* Get the last commit date for a repository or worktree.
225266
*

packages/git/src/git.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,79 @@ branch refs/heads/feature/auth
11391139
});
11401140
});
11411141

1142+
describe('isAncestorOf', () => {
1143+
let execSpy: Mock<typeof git.execFileAsync>;
1144+
1145+
beforeEach(() => {
1146+
execSpy = spyOn(git, 'execFileAsync');
1147+
});
1148+
1149+
afterEach(() => {
1150+
execSpy.mockRestore();
1151+
});
1152+
1153+
test('returns true when ref is ancestor of HEAD (exit 0)', async () => {
1154+
execSpy.mockResolvedValue({ stdout: '', stderr: '' });
1155+
1156+
const result = await git.isAncestorOf('/worktrees/feature' as git.WorktreePath, 'origin/dev');
1157+
expect(result).toBe(true);
1158+
expect(execSpy).toHaveBeenCalledWith('git', [
1159+
'-C',
1160+
'/worktrees/feature',
1161+
'merge-base',
1162+
'--is-ancestor',
1163+
'origin/dev',
1164+
'HEAD',
1165+
]);
1166+
});
1167+
1168+
test('returns false when ref is not ancestor of HEAD (exit code 1)', async () => {
1169+
const err = Object.assign(new Error(''), { code: 1, stderr: '' });
1170+
execSpy.mockRejectedValue(err);
1171+
1172+
const result = await git.isAncestorOf(
1173+
'/worktrees/feature' as git.WorktreePath,
1174+
'origin/main'
1175+
);
1176+
expect(result).toBe(false);
1177+
});
1178+
1179+
test('returns false on expected errors (not a git repository)', async () => {
1180+
execSpy.mockRejectedValue(new Error('fatal: not a git repository'));
1181+
1182+
const result = await git.isAncestorOf(
1183+
'/worktrees/feature' as git.WorktreePath,
1184+
'origin/main'
1185+
);
1186+
expect(result).toBe(false);
1187+
});
1188+
1189+
test('returns false on expected errors (unknown revision)', async () => {
1190+
execSpy.mockRejectedValue(
1191+
Object.assign(new Error('unknown revision'), { stderr: 'unknown revision' })
1192+
);
1193+
1194+
const result = await git.isAncestorOf(
1195+
'/worktrees/feature' as git.WorktreePath,
1196+
'origin/missing'
1197+
);
1198+
expect(result).toBe(false);
1199+
});
1200+
1201+
test('throws and logs on unexpected errors', async () => {
1202+
mockLogger.error.mockClear();
1203+
execSpy.mockRejectedValue(new Error('fatal: permission denied'));
1204+
1205+
await expect(
1206+
git.isAncestorOf('/worktrees/feature' as git.WorktreePath, 'origin/dev')
1207+
).rejects.toThrow('Failed to check if origin/dev is ancestor of HEAD');
1208+
expect(mockLogger.error).toHaveBeenCalledWith(
1209+
expect.objectContaining({ ancestorRef: 'origin/dev' }),
1210+
'branch.ancestor_check_failed'
1211+
);
1212+
});
1213+
});
1214+
11421215
// ==========================================================================
11431216
// repo.ts
11441217
// ==========================================================================

packages/git/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export {
3333
hasUncommittedChanges,
3434
commitAllChanges,
3535
isBranchMerged,
36+
isAncestorOf,
3637
getLastCommitDate,
3738
} from './branch';
3839

packages/isolation/src/resolver.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,107 @@ describe('IsolationResolver', () => {
687687
).toThrow('staleThresholdDays must be positive, got -1');
688688
});
689689
});
690+
test('existing env — emits warning when base branch mismatches', async () => {
691+
const env = makeEnvRow();
692+
isAncestorOfSpy.mockResolvedValue(false);
693+
const resolver = createResolver({
694+
store: makeMockStore({ getById: async () => env }),
695+
});
696+
697+
const result = await resolver.resolve({
698+
existingEnvId: 'env-1',
699+
codebase: defaultCodebase,
700+
hints: { baseBranch: 'dev' },
701+
platformType: 'web',
702+
});
703+
704+
expect(result.status).toBe('resolved');
705+
if (result.status === 'resolved') {
706+
expect(result.warnings).toBeDefined();
707+
expect(result.warnings?.[0]).toContain("not based on 'dev'");
708+
}
709+
});
710+
711+
test('existing env — no warning when base branch matches', async () => {
712+
const env = makeEnvRow();
713+
isAncestorOfSpy.mockResolvedValue(true);
714+
const resolver = createResolver({
715+
store: makeMockStore({ getById: async () => env }),
716+
});
717+
718+
const result = await resolver.resolve({
719+
existingEnvId: 'env-1',
720+
codebase: defaultCodebase,
721+
hints: { baseBranch: 'dev' },
722+
platformType: 'web',
723+
});
724+
725+
expect(result.status).toBe('resolved');
726+
if (result.status === 'resolved') {
727+
expect(result.warnings).toBeUndefined();
728+
}
729+
});
730+
731+
test('workflow reuse — emits warning when base branch mismatches', async () => {
732+
const env = makeEnvRow();
733+
isAncestorOfSpy.mockResolvedValue(false);
734+
const resolver = createResolver({
735+
store: makeMockStore({
736+
findActiveByWorkflow: async (_cid, wt, wid) =>
737+
wt === 'issue' && wid === '42' ? env : null,
738+
}),
739+
});
740+
741+
const result = await resolver.resolve({
742+
existingEnvId: null,
743+
codebase: defaultCodebase,
744+
hints: { workflowType: 'issue', workflowId: '42', baseBranch: 'dev' },
745+
platformType: 'web',
746+
});
747+
748+
expect(result.status).toBe('resolved');
749+
if (result.status === 'resolved') {
750+
expect(result.warnings).toBeDefined();
751+
expect(result.warnings?.[0]).toContain("not based on 'dev'");
752+
}
753+
});
754+
755+
test('existing env — no base branch check when baseBranch not in hints', async () => {
756+
const env = makeEnvRow();
757+
const resolver = createResolver({
758+
store: makeMockStore({ getById: async () => env }),
759+
});
760+
761+
await resolver.resolve({
762+
existingEnvId: 'env-1',
763+
codebase: defaultCodebase,
764+
platformType: 'web',
765+
});
766+
767+
expect(isAncestorOfSpy).not.toHaveBeenCalled();
768+
});
769+
770+
// --- Constructor validation tests ---
771+
772+
test('throws on zero staleThresholdDays', () => {
773+
expect(
774+
() =>
775+
new IsolationResolver({
776+
store: makeMockStore(),
777+
provider: makeMockProvider(),
778+
staleThresholdDays: 0,
779+
})
780+
).toThrow('staleThresholdDays must be positive, got 0');
781+
});
782+
783+
test('throws on negative staleThresholdDays', () => {
784+
expect(
785+
() =>
786+
new IsolationResolver({
787+
store: makeMockStore(),
788+
provider: makeMockProvider(),
789+
staleThresholdDays: -1,
790+
})
791+
).toThrow('staleThresholdDays must be positive, got -1');
792+
});
793+
});

packages/isolation/src/resolver.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getCanonicalRepoPath,
1414
findWorktreeByBranch,
1515
toBranchName,
16+
isAncestorOf,
1617
} from '@archon/git';
1718
import type { RepoPath } from '@archon/git';
1819

@@ -84,9 +85,11 @@ export class IsolationResolver {
8485
* Resolve isolation for a conversation request.
8586
*/
8687
async resolve(request: ResolveRequest): Promise<IsolationResolution> {
88+
const baseBranch = request.hints?.baseBranch;
89+
8790
// 1. Check existing isolation reference
8891
if (request.existingEnvId) {
89-
const existing = await this.checkExisting(request.existingEnvId);
92+
const existing = await this.checkExisting(request.existingEnvId, baseBranch);
9093
if (existing) return existing;
9194
// Stale — tell caller to clear and retry
9295
return { status: 'stale_cleaned', previousEnvId: request.existingEnvId };
@@ -103,13 +106,14 @@ export class IsolationResolver {
103106
const workflowId = hints?.workflowId ?? '';
104107

105108
// 3. Check for existing environment with same workflow
106-
const reusable = await this.findReusable(codebase.id, workflowType, workflowId);
109+
const reusable = await this.findReusable(codebase.id, workflowType, workflowId, baseBranch);
107110
if (reusable) {
108111
return {
109112
status: 'resolved',
110-
env: reusable,
111-
cwd: reusable.working_path,
113+
env: reusable.env,
114+
cwd: reusable.env.working_path,
112115
method: { type: 'workflow_reuse' },
116+
...(reusable.warnings.length > 0 ? { warnings: reusable.warnings } : {}),
113117
};
114118
}
115119

@@ -146,14 +150,35 @@ export class IsolationResolver {
146150
/**
147151
* Check if an existing environment reference is still valid.
148152
*/
149-
private async checkExisting(envId: string): Promise<IsolationResolution | null> {
153+
private async checkExisting(
154+
envId: string,
155+
baseBranch?: string
156+
): Promise<IsolationResolution | null> {
150157
const env = await this.store.getById(envId);
151158
if (env && (await worktreeExists(toWorktreePath(env.working_path)))) {
159+
const warnings: string[] = [];
160+
if (baseBranch) {
161+
const isValid = await isAncestorOf(
162+
toWorktreePath(env.working_path),
163+
`origin/${baseBranch}`
164+
);
165+
if (!isValid) {
166+
warnings.push(
167+
`Worktree branch '${env.branch_name}' is not based on '${baseBranch}'. ` +
168+
`Recreate with: archon complete ${env.branch_name} --force`
169+
);
170+
getLog().warn(
171+
{ envId, branchName: env.branch_name, baseBranch },
172+
'isolation.reuse_base_branch_mismatch'
173+
);
174+
}
175+
}
152176
return {
153177
status: 'resolved',
154178
env,
155179
cwd: env.working_path,
156180
method: { type: 'existing' },
181+
...(warnings.length > 0 ? { warnings } : {}),
157182
};
158183
}
159184

@@ -170,14 +195,32 @@ export class IsolationResolver {
170195
private async findReusable(
171196
codebaseId: string,
172197
workflowType: IsolationWorkflowType,
173-
workflowId: string
174-
): Promise<IsolationEnvironmentRow | null> {
198+
workflowId: string,
199+
baseBranch?: string
200+
): Promise<{ env: IsolationEnvironmentRow; warnings: string[] } | null> {
175201
const existing = await this.store.findActiveByWorkflow(codebaseId, workflowType, workflowId);
176202
if (!existing) return null;
177203

178204
if (await worktreeExists(toWorktreePath(existing.working_path))) {
179205
getLog().debug({ workflowType, workflowId }, 'isolation_reuse_existing');
180-
return existing;
206+
const warnings: string[] = [];
207+
if (baseBranch) {
208+
const isValid = await isAncestorOf(
209+
toWorktreePath(existing.working_path),
210+
`origin/${baseBranch}`
211+
);
212+
if (!isValid) {
213+
warnings.push(
214+
`Worktree branch '${existing.branch_name}' is not based on '${baseBranch}'. ` +
215+
`Recreate with: archon complete ${existing.branch_name} --force`
216+
);
217+
getLog().warn(
218+
{ workflowType, workflowId, branchName: existing.branch_name, baseBranch },
219+
'isolation.reuse_base_branch_mismatch'
220+
);
221+
}
222+
}
223+
return { env: existing, warnings };
181224
}
182225

183226
await this.markDestroyedBestEffort(existing.id);

packages/isolation/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ export interface IsolationHints {
208208
/** Start-point branch for new task worktree creation. Only consumed when workflowType === 'task'. */
209209
fromBranch?: BranchName;
210210

211+
/** Expected base branch for this workflow. When set, reused worktrees are validated with merge-base. */
212+
baseBranch?: string;
213+
211214
// Cross-reference hints
212215
linkedIssues?: number[];
213216
linkedPRs?: number[];

0 commit comments

Comments
 (0)