Skip to content

Commit 2abb074

Browse files
committed
fix(shared): resolve git base branch from origin head
1 parent 541bc17 commit 2abb074

3 files changed

Lines changed: 172 additions & 7 deletions

File tree

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ The config is editable via the **Global Settings** dialog in the web UI (gear ic
463463
| `skip_push_after_commit` | `false` | Skip push after /aif-commit |
464464
| `strict_base_update` | `false` | Hard-fail if `git pull --ff-only` of base fails |
465465

466+
When `config.yaml` is absent, Handoff treats the base branch as repository
467+
discovery: it first checks the branch pointed to by `refs/remotes/origin/HEAD`,
468+
then falls back to the legacy `master` branch, and only then to the built-in
469+
`main` default. When `base_branch` is explicitly configured and still points to
470+
the default `main`, but the local repository does not have `main`, Handoff uses
471+
the same `origin/HEAD` then `master` fallback. Explicit non-default base
472+
branches are strict: if you set `base_branch: develop`, that branch must exist.
473+
466474
#### `skip_push_after_commit` semantics
467475

468476
Controls whether the approve-done auto-commit flow (and any other `/aif-commit` run originating from the API) performs `git push` after creating the commit:

packages/shared/src/__tests__/gitIsolation.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,83 @@ describe("gitIsolation", () => {
232232
GIT_TEST_TIMEOUT_MS,
233233
);
234234

235+
it(
236+
"falls back to master when the default main base is missing",
237+
() => {
238+
initRepo(projectRoot);
239+
git(projectRoot, ["branch", "-M", "master"]);
240+
writeConfig(projectRoot, "git:\n base_branch: main\n branch_prefix: feature/\n");
241+
242+
const result = ensureFeatureBranch({
243+
projectRoot,
244+
taskId: "task-master",
245+
title: "Master default",
246+
});
247+
248+
expect(result.action).toBe("created");
249+
expect(result.branchName).toBe("feature/master-default-taskma");
250+
expect(getCurrentBranch(projectRoot)).toBe("feature/master-default-taskma");
251+
},
252+
GIT_TEST_TIMEOUT_MS,
253+
);
254+
255+
it(
256+
"falls back to origin HEAD before legacy master when the default main base is missing",
257+
() => {
258+
initRepo(projectRoot);
259+
git(projectRoot, ["checkout", "-b", "2.x"]);
260+
git(projectRoot, ["branch", "master"]);
261+
git(projectRoot, ["branch", "-D", "main"]);
262+
git(projectRoot, ["update-ref", "refs/remotes/origin/2.x", "2.x"]);
263+
git(projectRoot, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/2.x"]);
264+
writeConfig(projectRoot, "git:\n base_branch: main\n branch_prefix: feature/\n");
265+
266+
const result = ensureFeatureBranch({
267+
projectRoot,
268+
taskId: "task-origin",
269+
title: "Origin default",
270+
});
271+
272+
expect(result.action).toBe("created");
273+
expect(result.branchName).toBe("feature/origin-default-taskor");
274+
if (!result.branchName) throw new Error("expected branch name");
275+
expect(getCurrentBranch(projectRoot)).toBe("feature/origin-default-taskor");
276+
const branchHead = git(projectRoot, ["rev-parse", result.branchName]);
277+
const expectedHead = git(projectRoot, ["rev-parse", "2.x"]);
278+
expect(branchHead).toBe(expectedHead);
279+
},
280+
GIT_TEST_TIMEOUT_MS,
281+
);
282+
283+
it(
284+
"uses origin HEAD before local main when project config is absent",
285+
() => {
286+
initRepo(projectRoot);
287+
git(projectRoot, ["checkout", "-b", "2.x"]);
288+
writeFileSync(join(projectRoot, "release.txt"), "2.x\n");
289+
git(projectRoot, ["add", "release.txt"]);
290+
git(projectRoot, ["commit", "-m", "release branch"]);
291+
git(projectRoot, ["checkout", "main"]);
292+
git(projectRoot, ["update-ref", "refs/remotes/origin/2.x", "2.x"]);
293+
git(projectRoot, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/2.x"]);
294+
295+
const result = ensureFeatureBranch({
296+
projectRoot,
297+
taskId: "task-no-config",
298+
title: "No config",
299+
});
300+
301+
expect(result.action).toBe("created");
302+
expect(result.branchName).toBe("feature/no-config-taskno");
303+
if (!result.branchName) throw new Error("expected branch name");
304+
expect(getCurrentBranch(projectRoot)).toBe("feature/no-config-taskno");
305+
const branchHead = git(projectRoot, ["rev-parse", result.branchName]);
306+
const expectedHead = git(projectRoot, ["rev-parse", "2.x"]);
307+
expect(branchHead).toBe(expectedHead);
308+
},
309+
GIT_TEST_TIMEOUT_MS,
310+
);
311+
235312
it(
236313
"returns switched when already on the task branch",
237314
() => {

packages/shared/src/gitIsolation.ts

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ export function branchExists(projectRoot: string, branchName: string): boolean {
129129
return status === 0;
130130
}
131131

132+
function getOriginHeadBranch(projectRoot: string): string | null {
133+
const { stdout, status } = runGit(
134+
projectRoot,
135+
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
136+
{
137+
ignoreExit: true,
138+
},
139+
);
140+
if (status !== 0 || !stdout) return null;
141+
const prefix = "refs/remotes/origin/";
142+
if (!stdout.startsWith(prefix)) return null;
143+
const branchName = stdout.slice(prefix.length).trim();
144+
return branchName || null;
145+
}
146+
132147
export function workingTreeClean(projectRoot: string): boolean {
133148
const { stdout, status } = runGit(projectRoot, ["status", "--porcelain"], { ignoreExit: true });
134149
return status === 0 && stdout.length === 0;
@@ -208,6 +223,66 @@ function resolveGitConfig(projectRoot: string): AifProjectGit {
208223
return getProjectConfig(projectRoot).git;
209224
}
210225

226+
function hasProjectConfigFile(projectRoot: string): boolean {
227+
return existsSync(join(projectRoot, ".ai-factory", "config.yaml"));
228+
}
229+
230+
function resolveGitDefaultBaseBranch(projectRoot: string, fallbackBase: string): string {
231+
const originHeadBranch = getOriginHeadBranch(projectRoot);
232+
if (originHeadBranch && branchExists(projectRoot, originHeadBranch)) {
233+
log.warn(
234+
{
235+
projectRoot,
236+
configuredBase: fallbackBase,
237+
resolvedBase: originHeadBranch,
238+
source: "origin/HEAD",
239+
},
240+
"No project git base branch is configured; using origin default branch",
241+
);
242+
return originHeadBranch;
243+
}
244+
if (branchExists(projectRoot, "master")) {
245+
log.warn(
246+
{ projectRoot, configuredBase: fallbackBase, resolvedBase: "master" },
247+
"No project git base branch is configured; using legacy master branch",
248+
);
249+
return "master";
250+
}
251+
return fallbackBase;
252+
}
253+
254+
function resolveBaseBranch(
255+
projectRoot: string,
256+
configuredBase: string,
257+
configFileExists: boolean,
258+
): string {
259+
if (!configFileExists) {
260+
return resolveGitDefaultBaseBranch(projectRoot, configuredBase);
261+
}
262+
if (branchExists(projectRoot, configuredBase)) {
263+
return configuredBase;
264+
}
265+
if (configuredBase !== "main") {
266+
return configuredBase;
267+
}
268+
const originHeadBranch = getOriginHeadBranch(projectRoot);
269+
if (originHeadBranch && branchExists(projectRoot, originHeadBranch)) {
270+
log.warn(
271+
{ projectRoot, configuredBase, resolvedBase: originHeadBranch, source: "origin/HEAD" },
272+
"Configured base branch is missing; falling back to origin default branch",
273+
);
274+
return originHeadBranch;
275+
}
276+
if (branchExists(projectRoot, "master")) {
277+
log.warn(
278+
{ projectRoot, configuredBase, resolvedBase: "master" },
279+
"Configured base branch is missing; falling back to legacy master branch",
280+
);
281+
return "master";
282+
}
283+
return configuredBase;
284+
}
285+
211286
export function projectUsesSharedBranchIsolation(projectRoot: string): boolean {
212287
const config = resolveGitConfig(projectRoot);
213288
return config.enabled && config.create_branches && isGitRepo(projectRoot);
@@ -271,8 +346,13 @@ export function ensureFeatureBranch(input: EnsureFeatureBranchInput): EnsureFeat
271346
// Step 1: ensure HEAD is on the base branch. We need it both as the
272347
// create-from-target for `git checkout -b` and as the target of the pull
273348
// policy below.
274-
if (current !== config.base_branch) {
275-
if (!branchExists(projectRoot, config.base_branch)) {
349+
const baseBranch = resolveBaseBranch(
350+
projectRoot,
351+
config.base_branch,
352+
hasProjectConfigFile(projectRoot),
353+
);
354+
if (current !== baseBranch) {
355+
if (!branchExists(projectRoot, baseBranch)) {
276356
throw new BranchIsolationError(
277357
"base_branch_unavailable",
278358
`Base branch ${config.base_branch} does not exist in ${projectRoot}. Cannot create ${branchName} from a known base.`,
@@ -282,13 +362,13 @@ export function ensureFeatureBranch(input: EnsureFeatureBranchInput): EnsureFeat
282362
}
283363
const { status: checkoutStatus, stderr: checkoutErr } = runGit(
284364
projectRoot,
285-
["checkout", config.base_branch],
365+
["checkout", baseBranch],
286366
{ ignoreExit: true },
287367
);
288368
if (checkoutStatus !== 0) {
289369
throw new BranchIsolationError(
290370
"base_branch_unavailable",
291-
`Could not checkout base branch ${config.base_branch}: ${checkoutErr || "unknown error"}`,
371+
`Could not checkout base branch ${baseBranch}: ${checkoutErr || "unknown error"}`,
292372
projectRoot,
293373
branchName,
294374
);
@@ -305,14 +385,14 @@ export function ensureFeatureBranch(input: EnsureFeatureBranchInput): EnsureFeat
305385
// opt into strict mode via `git.strict_base_update: true` — pull failure
306386
// becomes a hard BranchIsolationError("base_update_failed") classified as
307387
// blocked_external by the coordinator.
308-
const pullResult = runGit(projectRoot, ["pull", "--ff-only", "origin", config.base_branch], {
388+
const pullResult = runGit(projectRoot, ["pull", "--ff-only", "origin", baseBranch], {
309389
ignoreExit: true,
310390
});
311391
if (pullResult.status !== 0) {
312392
if (config.strict_base_update) {
313393
throw new BranchIsolationError(
314394
"base_update_failed",
315-
`git pull --ff-only origin ${config.base_branch} failed: ${pullResult.stderr || "unknown error"}. ` +
395+
`git pull --ff-only origin ${baseBranch} failed: ${pullResult.stderr || "unknown error"}. ` +
316396
`Project has git.strict_base_update=true; refusing to branch from a stale base.`,
317397
projectRoot,
318398
branchName,
@@ -322,7 +402,7 @@ export function ensureFeatureBranch(input: EnsureFeatureBranchInput): EnsureFeat
322402
{
323403
projectRoot,
324404
branchName,
325-
baseBranch: config.base_branch,
405+
baseBranch,
326406
stderr: pullResult.stderr,
327407
},
328408
"Could not fast-forward base branch before creating feature branch; continuing from local base (git.strict_base_update=false)",

0 commit comments

Comments
 (0)