From 55a7455706494f0bbae1d09be04e185574c802b8 Mon Sep 17 00:00:00 2001 From: Timoteo Date: Fri, 25 Sep 2026 01:15:05 -0300 Subject: [PATCH] fix(repo): merge-boundary-proof (thematic slice, <=10 files, 2026-09-25-03:40) --- ...fix-merge-boundary-optional-group-proof.md | 7 + .../integration-branch-validate-exists.md | 8 + .../__tests__/executor-graph-boundary.test.ts | 161 +- ...integration-branch-validate-exists.test.ts | 1322 +++++++++++++++++ .../src/__tests__/integration-branch.test.ts | 264 +++- ...integration-branch-master.real-git.test.ts | 21 + ...on-branch-validate-exists.real-git.test.ts | 124 ++ .../evaluate-workflow-merge-boundary.ts | 27 +- .../workflow-merge-boundary-helpers.ts | 15 +- .../engine/src/merge/integration-branch.ts | 411 ++++- 10 files changed, 2324 insertions(+), 36 deletions(-) create mode 100644 .changeset/fix-merge-boundary-optional-group-proof.md create mode 100644 .changeset/integration-branch-validate-exists.md create mode 100644 packages/engine/src/__tests__/integration-branch-validate-exists.test.ts create mode 100644 packages/engine/src/__tests__/real-git/integration-branch-validate-exists.real-git.test.ts diff --git a/.changeset/fix-merge-boundary-optional-group-proof.md b/.changeset/fix-merge-boundary-optional-group-proof.md new file mode 100644 index 0000000000..6e0c78fdca --- /dev/null +++ b/.changeset/fix-merge-boundary-optional-group-proof.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop parking tasks at merge-boundary-unproven when their passed pre-merge reviews came from enabled optional steps. +category: fix +dev: The merge-boundary proof now accepts graph-native pre-merge results from both origins (`source="node"` and `source="optional-group"`); phase (`pre-merge`) and terminality plus foreach instance coverage stay mandatory. Shared predicate `isGraphNativePreMergeResult` also backs `shouldCompleteChecklistAtWorkflowMerge`. diff --git a/.changeset/integration-branch-validate-exists.md b/.changeset/integration-branch-validate-exists.md new file mode 100644 index 0000000000..b546fb856f --- /dev/null +++ b/.changeset/integration-branch-validate-exists.md @@ -0,0 +1,8 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Worktree setup no longer aborts when the configured integration branch is missing locally. +category: fix +dev: resolveIntegrationBranch probes refs/heads via argv-based `git show-ref --verify` (no shell interpolation) and walks the documented ladder `integrationBranch -> baseBranch -> origin/HEAD -> inference -> main`: a set-but-missing integrationBranch no longer hides a valid baseBranch. Configured and inferred branches that exist only under refs/remotes/origin are materialized locally (plain `git branch refs/remotes/origin/`, never --track, matching the readiness path FN-183) before being returned, so consumers like `git worktree add` and the merge-time CAS advance always find refs/heads/. Every non-settings rung is verified against the local ref; the fallback ladder verifies-or-materializes the origin-derived candidate and then `main`, probes the remote-tracking ref before creating (absent -> falls through), rechecks the local ref after a failed creation to absorb a lost race, rethrows the original write error when the ref is still absent, and throws an actionable error when no local integration ref can be established at all — so no-checkout, detached, or ghost clones can no longer hand consumers a nonexistent bare name. Only show-ref's missing-ref exit status (1) is treated as absent; operational git failures propagate to callers. Branch-name candidates are validated with full git-check-ref-format(1) semantics (pure-JS, no subprocess) at every rung — settings, origin/HEAD, inference — and at every inference return path before any probe or materialization: names git would reject (leading dash, reserved HEAD, spaces, .lock/@{/dot-component rules) fall through the ladder instead of being handed to `git branch` as a bare name or aborting fallback resolution, and a plumbing-created or symbolic ref can no longer pass an existence probe while every consumer command would fail. The origin/HEAD default stays authoritative: when only its remote-tracking ref exists it is materialized locally before local inference runs. Parity with real git is pinned by a real-git integration test. +dev: resolveIntegrationBranch probes refs/heads via argv-based `git show-ref --verify` (no shell interpolation) and walks the documented ladder `integrationBranch -> baseBranch -> origin/HEAD -> inference -> main`: a set-but-missing integrationBranch no longer hides a valid baseBranch. Configured and inferred branches that exist only under refs/remotes/origin are materialized locally with `git branch --no-track refs/remotes/origin/` (matching the readiness path FN-183) before being returned, so consumers like `git worktree add` and the merge-time CAS advance always find refs/heads/ without configuring upstream tracking. Every non-settings rung is verified against the local ref; the fallback ladder verifies-or-materializes the origin-derived candidate and then `main`, probes the remote-tracking ref before creating (absent -> falls through), rechecks the local ref after a failed creation to absorb a lost race, rethrows the original write error when the ref is still absent, and throws an actionable error when no local integration ref can be established at all — so no-checkout, detached, or ghost clones can no longer hand consumers a nonexistent bare name. Only show-ref's missing-ref exit status (1) is treated as absent; operational git failures propagate to callers. Branch-name candidates are validated with full git-check-ref-format(1) semantics (pure-JS, no subprocess) at every rung — settings, origin/HEAD, inference — and at every inference return path before any probe or materialization: names git would reject (leading dash, reserved HEAD, spaces, .lock/@{/dot-component rules) fall through the ladder instead of being handed to `git branch` as a bare name or aborting fallback resolution, and a plumbing-created or symbolic ref can no longer pass an existence probe while every consumer command would fail. The origin/HEAD default stays authoritative: when only its remote-tracking ref exists it is materialized locally before local inference runs. Parity with real git is pinned by a real-git integration test. \ No newline at end of file diff --git a/packages/engine/src/__tests__/executor-graph-boundary.test.ts b/packages/engine/src/__tests__/executor-graph-boundary.test.ts index fc7a249de7..8bc81170d2 100644 --- a/packages/engine/src/__tests__/executor-graph-boundary.test.ts +++ b/packages/engine/src/__tests__/executor-graph-boundary.test.ts @@ -12,6 +12,7 @@ assertion does not depend on the full agent-session execute() path. import { describe, expect, it, vi } from "vitest"; import "./executor-test-helpers.js"; import { TaskExecutor } from "../executor.js"; +import { evaluateWorkflowMergeBoundary } from "../executor/evaluate-workflow-merge-boundary.js"; import { createMockStore } from "./executor-test-helpers.js"; import type { WorkflowIr } from "@fusion/core"; @@ -76,8 +77,14 @@ function makeExecutor(opts: { workflowStepResults?: Array<{ workflowStepId: string; workflowStepName: string; - source: "node"; - phase: "pre-merge"; + /* + FNXC:WorkflowMerge 2026-09-19-03:58: + Pre-merge results have two graph-runtime origins: `node` (graph-authored node progress) and + `optional-group` (enabled optional steps such as Plan Review / Code Review). Boundary cases + must be able to construct both, plus a post-merge phase to prove the phase filter still holds. + */ + source: "node" | "optional-group"; + phase: "pre-merge" | "post-merge"; status: "passed" | "pending"; completedAt: string; }>; @@ -255,3 +262,153 @@ describe("U5a — IR-driven merge boundary (scenario 1)", () => { } }); }); + +/* +FNXC:WorkflowMerge 2026-09-19-03:58: +Resultados pre-merge podem vir de passos opcionais habilitados (source="optional-group"); a prova de +fronteira deve enxerga-los, senao tarefas com reviews aprovados ficam presas em merge-boundary-unproven. + +Pre-merge results have TWO graph-runtime origins: `node` (graph-authored node progress) and +`optional-group` (an enabled optional step, e.g. the builtin Plan Review / Code Review groups). +Measured on a live card (project proj_9ef728e7cc084681): its only two workflowStepResults were +`phase="pre-merge"`, `status="passed"`, `source="optional-group"` (plan-review, code-review) with +enabledWorkflowSteps ["plan-review","code-review"], and the boundary parked it with +"workflow graph terminal merge failure at node 'merge' (merge-boundary-unproven) — operator action required". + +The proof must therefore accept BOTH origins without loosening anything else: a non-pre-merge phase +stays out, terminality stays mandatory, and terminal foreach instance coverage stays mandatory. +*/ +describe("merge boundary pre-merge result provenance (node | optional-group)", () => { + const optionalGroup = ( + workflowStepId: string, + workflowStepName: string, + overrides: Partial<{ phase: "pre-merge" | "post-merge"; status: "passed" | "pending" }> = {}, + ) => ({ + workflowStepId, + workflowStepName, + source: "optional-group" as const, + phase: overrides.phase ?? ("pre-merge" as const), + status: overrides.status ?? ("passed" as const), + completedAt: "2026-09-19T03:58:00.000Z", + }); + + function boundaryHarness(workflowStepResults: ReturnType[], steps: Array<{ id: string; title: string; status: "pending" | "done" | "skipped" }>) { + return makeExecutor({ + selection: { workflowId: "custom:foreach", stepIds: [] }, + ir: foreachIr(), + taskColumn: "in-progress", + steps, + workflowStepResults, + }); + } + + it("proves the boundary when the only pre-merge results came from enabled optional groups", async () => { + const { executor, store, liveTask } = boundaryHarness([ + optionalGroup("plan-review", "Plan Review"), + optionalGroup("code-review", "Code Review"), + ], []); + + const result = await executor.ensureWorkflowMergeBoundaryTask( + liveTask, + { reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" }, + ) as { blocked?: { code: string } }; + + expect(result.blocked).toBeUndefined(); + expect(store.logEntry).not.toHaveBeenCalledWith("FN-B1", expect.stringContaining("Workflow merge boundary blocked:"), expect.anything(), expect.anything()); + expect(store.moveTask).toHaveBeenCalledWith("FN-B1", "in-review", expect.anything()); + }); + + it("reports the boundary proof as resolved/complete for an optional-group-only task", async () => { + const proof = await evaluateWorkflowMergeBoundary( + { + store: { + getTaskWorkflowSelection: () => ({ workflowId: "custom:foreach", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: foreachIr() }), + } as never, + loadMergeBoundaryInstances: async () => [], + }, + { + id: "FN-B1", + column: "in-progress", + steps: [], + workflowStepResults: [optionalGroup("plan-review", "Plan Review"), optionalGroup("code-review", "Code Review")], + } as never, + "r1", + ); + + expect(proof.resolved).toBe(true); + expect(proof.hasRelevantNodeResult).toBe(true); + expect(proof.allResultsTerminal).toBe(true); + expect(proof.complete).toBe(true); + }); + + it("still blocks a non-terminal optional-group result (terminality stays mandatory)", async () => { + const { executor, liveTask } = boundaryHarness([ + optionalGroup("plan-review", "Plan Review"), + optionalGroup("code-review", "Code Review", { status: "pending" }), + ], []); + + const result = await executor.ensureWorkflowMergeBoundaryTask( + liveTask, + { reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" }, + ) as { blocked?: { code: string } }; + + expect(result.blocked).toMatchObject({ code: "non-terminal-node-result" }); + }); + + it("still ignores a passed optional-group result from another phase (post-merge stays out)", async () => { + const { executor, store, liveTask } = boundaryHarness([ + optionalGroup("post-merge-verification", "Post-merge verification", { phase: "post-merge" }), + ], []); + + const result = await executor.ensureWorkflowMergeBoundaryTask( + liveTask, + { reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" }, + ) as { blocked?: { code: string } }; + + expect(result.blocked).toMatchObject({ code: "no-node-result" }); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("still requires terminal foreach instance coverage beside an optional-group result", async () => { + const { executor, liveTask } = boundaryHarness([ + optionalGroup("plan-review", "Plan Review"), + ], [{ id: "0", title: "Implement", status: "pending" }]); + + const result = await executor.ensureWorkflowMergeBoundaryTask( + liveTask, + { reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" }, + ) as { blocked?: { code: string; missingInstanceCount: number } }; + + expect(result.blocked).toMatchObject({ code: "missing-foreach-instances", missingInstanceCount: 1 }); + }); + + /* + FNXC:WorkflowMerge 2026-09-19-03:58: + `shouldCompleteChecklistAtWorkflowMerge` answers the same "did graph-native pre-merge work run?" + question as the boundary proof when no proof is supplied, so it shares the predicate. Assert both + directions: optional-group pre-merge work completes it; a post-merge-only result does not. + */ + const unfinishedSteps = [{ id: "0", title: "Implement", status: "pending" as const }]; + + it("completes the checklist fallback from optional-group pre-merge results", () => { + const { executor, liveTask } = boundaryHarness([ + optionalGroup("plan-review", "Plan Review"), + optionalGroup("code-review", "Code Review"), + ], unfinishedSteps); + const shouldComplete = (executor as unknown as { + shouldCompleteChecklistAtWorkflowMerge(task: unknown, proof?: { complete: boolean }): boolean; + }).shouldCompleteChecklistAtWorkflowMerge; + expect(shouldComplete(liveTask)).toBe(true); + }); + + it("does not complete the checklist fallback from a post-merge-only result", () => { + const { executor, liveTask } = boundaryHarness([ + optionalGroup("post-merge-verification", "Post-merge verification", { phase: "post-merge" }), + ], unfinishedSteps); + const shouldComplete = (executor as unknown as { + shouldCompleteChecklistAtWorkflowMerge(task: unknown, proof?: { complete: boolean }): boolean; + }).shouldCompleteChecklistAtWorkflowMerge; + expect(shouldComplete(liveTask)).toBe(false); + }); +}); diff --git a/packages/engine/src/__tests__/integration-branch-validate-exists.test.ts b/packages/engine/src/__tests__/integration-branch-validate-exists.test.ts new file mode 100644 index 0000000000..e4ec8bf80c --- /dev/null +++ b/packages/engine/src/__tests__/integration-branch-validate-exists.test.ts @@ -0,0 +1,1322 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { selectIntegrationBranch } from "@fusion/core"; + +const { execMock, execSyncMock, execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execMock: vi.fn(), + execSyncMock: vi.fn(), + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + exec: execMock, + execSync: execSyncMock, + execFile: execFileMock, + execFileSync: execFileSyncMock, +})); + +import { + __resetIntegrationBranchCacheForTests, + INTEGRATION_BRANCH_FALLBACK, + resolveIntegrationBranch, + resolveIntegrationBranchSync, +} from "../merge/integration-branch.js"; + +/** + * Build an execFile/execFileSync mock that answers `git show-ref --verify` + * existence probes for one set of refs. Both helpers probe, in order: + * `refs/heads/` then `refs/remotes/origin/`. + */ +function missingRefError(): Error { + const error = new Error("ref not found"); + (error as NodeJS.ErrnoException).code = 1; + return error; +} + +function mockShowRef(existing: string[], mode: "async" | "sync"): void { + const impl = mode === "async" + ? (_cmd: string, args: string[], _opts: object, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && existing.includes(args[3])) { + cb(null); + } else { + cb(missingRefError()); + } + return {}; + } + : (_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && existing.includes(args[3])) { + return ""; + } + throw missingRefError(); + }; + if (mode === "async") execFileMock.mockImplementation(impl as any); + else execFileSyncMock.mockImplementation(impl as any); +} + +describe("integration-branch resolver — settings branch existence guard", () => { + beforeEach(() => { + __resetIntegrationBranchCacheForTests(); + execMock.mockReset(); + execSyncMock.mockReset(); + execFileMock.mockReset(); + execFileSyncMock.mockReset(); + }); + + afterEach(() => { + __resetIntegrationBranchCacheForTests(); + vi.restoreAllMocks(); + }); + + it("falls back to baseBranch when a set integrationBranch is missing locally (async)", async () => { + // integrationBranch=ghost (missing), baseBranch=release (exists locally): the settings + // ladder must try baseBranch before falling through to origin/HEAD. + mockShowRef(["refs/heads/release"], "async"); + const resolved = await resolveIntegrationBranch("/settings-ladder", { integrationBranch: "ghost", baseBranch: "release" } as any); + expect(resolved).toBe("release"); + }); + + it("falls back to baseBranch when a set integrationBranch is missing locally (sync)", () => { + mockShowRef(["refs/heads/release"], "sync"); + const resolved = resolveIntegrationBranchSync("/settings-ladder", { integrationBranch: "ghost", baseBranch: "release" } as any); + expect(resolved).toBe("release"); + }); + + it("materializes a settings branch that exists only in origin (async)", async () => { + // Normal clone: refs/remotes/origin/develop present, refs/heads/develop absent. + // The configured branch is authoritative — materialize it, do not fall through. + mockShowRef(["refs/remotes/origin/develop"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + cb(new Error("unset"), { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch" && args[1] === "develop") { + if (args[0] === "branch" && args[2] === "develop") { branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/origin-only-settings", { integrationBranch: "develop" } as any, { logger: { warn } }); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "develop", "refs/remotes/origin/develop"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("materializes a settings branch that exists only in origin (sync)", () => { + mockShowRef(["refs/remotes/origin/develop"], "sync"); + execSyncMock.mockImplementation((_command: string, _opts: object) => { + throw new Error("unset"); + }); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch" && args[1] === "develop") { + if (args[0] === "branch" && args[2] === "develop") { branchCalls.push(args); + return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/origin-only-settings", { integrationBranch: "develop" } as any); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "develop", "refs/remotes/origin/develop"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); + }); + + it("materializes with --no-track so no upstream tracking is configured (async and sync)", async () => { + // `git branch ` auto-configures upstream tracking + // (branch.autoSetupMerge default), violating the FN-183 "never track" contract: + // the creation argv must carry --no-track explicitly. Promisified execFile mocks + // stay callback-style and report a missing ref via error.code = 1 (async) or + // error.status = 1 (execFileSync's real error shape). + const syncMissingRefError = (): Error => { + const error = new Error("ref not found"); + (error as NodeJS.ErrnoException).status = 1; + return error; + }; + execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + cb(new Error("unset"), { stdout: "" }); + return {}; + }); + + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + await resolveIntegrationBranch("/no-track-async", { integrationBranch: "develop" } as any); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); + + const syncBranchCalls: string[][] = []; + execSyncMock.mockImplementation(() => ""); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch") { + syncBranchCalls.push(args); + return ""; + } + throw syncMissingRefError(); + }); + expect(resolveIntegrationBranchSync("/no-track-sync", { integrationBranch: "develop" } as any)).toBe("develop"); + expect(syncBranchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("materializes an inferred remote-only branch when no local ref exists (async)", async () => { + // No-checkout clone: sole remote branch develop, no local branches, origin/HEAD unset. + // Inference selects develop; the resolver must materialize it instead of falling to main. + mockShowRef(["refs/remotes/origin/develop"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("no checkout"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + cb(new Error("unset"), { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch" && args[1] === "develop") { + if (args[0] === "branch" && args[2] === "develop") { branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/inferred-remote-only", undefined); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "develop", "refs/remotes/origin/develop"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("materializes an inferred remote-only branch when no local ref exists (sync)", () => { + mockShowRef(["refs/remotes/origin/develop"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --quiet --short HEAD")) { + throw new Error("no checkout"); + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/heads/")) { + return ""; + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + return "origin/develop\n"; + } + throw new Error("unset"); + }); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch" && args[1] === "develop") { + if (args[0] === "branch" && args[2] === "develop") { branchCalls.push(args); + return ""; + } + throw missingRefError(); + }); + + expect(resolveIntegrationBranchSync("/inferred-remote-only", {} as any)).toBe("develop"); + expect(branchCalls).toEqual([["branch", "develop", "refs/remotes/origin/develop"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("rejects unusable inferred branches before returning them (async)", async () => { + // Devin BUG-0001 on 4ba9dd8: the inference return paths probed only ref existence. + // A sole local branch like `-m` (creatable via `git update-ref`) passes the probe + // and gets returned, later parsed by git as a switch. Both inference return paths + // must apply the usability guard before returning; with nothing usable left the + // resolver throws its terminal no-candidate error. + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref")) { + cb(null, { stdout: "" }); // no origin/HEAD, no checked-out HEAD + return {}; + } + if (command.includes("for-each-ref")) { + cb(null, { stdout: command.includes("refs/heads/") ? "-m\n" : "" }); // sole local branch, unusable name + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/-m") { + cb(null); // the ref exists — the NAME is what must be rejected + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + } + cb(missingRefError()); + return {}; + }) as any); + + await expect(resolveIntegrationBranch("/inference-dash-guard", undefined)).rejects.toThrow(/could not establish/); + expect(branchCalls).toEqual([]); // never hand an unusable name to `git branch` + }); + + it("rejects unusable inferred branches before returning them (sync)", () => { + // Mirror of the async case for resolveIntegrationBranchSync (Devin BUG-0001). + execSyncMock.mockImplementation((command: string, _opts: object) => { + const cmd = command as string; + if (cmd.includes("symbolic-ref")) return ""; // no origin/HEAD, no checked-out HEAD + if (cmd.includes("for-each-ref")) { + return cmd.includes("refs/heads/") ? "-m\n" : ""; // sole local branch, unusable name + } + return ""; + }); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/-m") { + return ""; // exists — name unusable + } + if (args[0] === "branch") { + branchCalls.push(args); + } + throw missingRefError(); + }); + + expect(() => resolveIntegrationBranchSync("/inference-dash-guard-sync", {} as any)).toThrow(/could not establish/); + expect(branchCalls).toEqual([]); + }); + + it("falls through a settings candidate that git rejects as a branch name (async)", async () => { + // Devin BUG-0002 on 4ba9dd8: a configured candidate named `HEAD` passes the + // refs/remotes/origin/HEAD existence probe (that symbolic ref exists in any normal + // clone), but `git branch HEAD ` fails — HEAD is reserved — and the materialize + // error aborted the ladder instead of reaching baseBranch. Git-invalid candidates + // must be rejected before probing; baseBranch must still resolve. + const branchCalls: string[][] = []; + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/trunk\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + // Normal clone: the candidate's origin/HEAD symbolic ref, the origin/trunk + // remote-tracking ref, and the local main all exist. + if (args[0] === "show-ref" && (args[3] === "refs/remotes/origin/HEAD" || args[3] === "refs/remotes/origin/trunk" || args[3] === "refs/heads/main")) { + cb(null); + return {}; + } + if (args[0] === "branch") branchCalls.push(args); + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/reserved-name-fallback", { integrationBranch: "HEAD", baseBranch: "main" } as any); + expect(resolved).toBe("main"); + expect(branchCalls).toEqual([]); // never attempt to materialize a name git rejects + }); + + it("falls through a settings candidate that git rejects as a branch name (sync)", () => { + // Mirror of the async case for resolveIntegrationBranchSync (Devin BUG-0002). + const branchCalls: string[][] = []; + execSyncMock.mockImplementation((command: string, _opts: object) => { + const cmd = command as string; + if (cmd.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/trunk\n"; + } + return ""; + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && (args[3] === "refs/remotes/origin/HEAD" || args[3] === "refs/remotes/origin/trunk" || args[3] === "refs/heads/main")) { + return ""; + } + if (args[0] === "branch") branchCalls.push(args); + throw missingRefError(); + }); + + expect(resolveIntegrationBranchSync("/reserved-name-fallback-sync", { integrationBranch: "HEAD", baseBranch: "main" } as any)).toBe("main"); + expect(branchCalls).toEqual([]); + }); + + it("skips a settings branch that does not exist locally or in origin, falling through to origin/HEAD", async () => { + mockShowRef(["refs/heads/main"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/main\n" }); + return {}; + } + if (command.includes("for-each-ref") || command.includes("symbolic-ref --quiet --short HEAD")) { + cb(null, { stdout: "" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "homolog", baseBranch: "main" } as any); + expect(resolved).toBe("main"); + }); + + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "homolog" } as any); + expect(resolved).toBe("main"); + }); + + it("warns when configured candidates are skipped, deduplicated by rootDir and candidate (async)", async () => { + // A configured candidate that fails the usability check or has neither a local ref + // nor a refs/remotes/origin start point is skipped silently today: the operator only + // sees the ladder land on another branch. Each skip must warn through the resolver's + // logger once per rootDir and candidate, even across repeated resolutions. + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(new Error("unset"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") || command.includes("symbolic-ref --quiet --short HEAD")) { + cb(null, { stdout: "" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + mockShowRef(["refs/heads/main"], "async"); + const warn = vi.fn(); + + const settings = { integrationBranch: "ghost", baseBranch: "phantom" } as any; + const first = await resolveIntegrationBranch("/skip-warn", settings, { logger: { warn } }); + const second = await resolveIntegrationBranch("/skip-warn", settings, { logger: { warn } }); + + expect(first).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(second).toBe(INTEGRATION_BRANCH_FALLBACK); + const skipped = warn.mock.calls.map((call) => String(call[0])).filter((message) => message.includes("skipped")); + expect(skipped.filter((message) => message.includes("'ghost'"))).toHaveLength(1); + expect(skipped.filter((message) => message.includes("'phantom'"))).toHaveLength(1); + }); + + it("warns when a configured candidate is skipped for an unusable branch name (sync)", () => { + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + throw new Error("unset"); + } + return ""; + }); + mockShowRef(["refs/heads/main"], "sync"); + const warn = vi.fn(); + + const resolved = resolveIntegrationBranchSync("/skip-warn-sync", { integrationBranch: "HEAD" } as any, { logger: { warn } }); + + expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK); + const skipped = warn.mock.calls.map((call) => String(call[0])).filter((message) => message.includes("skipped")); + expect(skipped).toHaveLength(1); + expect(skipped[0]).toContain("'HEAD'"); + }); + it("accepts a settings branch that exists locally", async () => { + mockShowRef(["refs/heads/homolog"], "async"); + + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "homolog" } as any); + expect(resolved).toBe("homolog"); + }); + + it("prefers a materializable origin/HEAD default over a well-known local inferred branch", async () => { + // origin/HEAD -> trunk (remote-tracking ref exists, refs/heads/trunk does not): + // the default branch is authoritative and CAN be materialized, so inference + // (which prefers well-known local branches like master) must not win. + mockShowRef(["refs/heads/master", "refs/remotes/origin/trunk"], "async"); + // Single router for every exec() command the resolver issues: both the + // origin/HEAD lookup and the inference listing flow through production paths, + // so removing the origin/HEAD materialization block makes this test fail + // (inference would select well-known local 'master' from the same mock). + const branchCalls: string[][] = []; + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/trunk\n" }); + return {}; + } + if (command.includes("for-each-ref")) { + cb(null, { stdout: "refs/heads/master\nrefs/remotes/origin/trunk\n" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD") || command.includes("git remote")) { + cb(null, { stdout: "" }); + return {}; + } + cb(new Error("unset command"), { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref") { + if (args[3] === "refs/remotes/origin/trunk") { cb(null); return {}; } + cb(missingRefError()); + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + cb(new Error("unset execFile command")); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/repo", {} as any); + expect(resolved).toBe("trunk"); + expect(branchCalls).toEqual([["branch", "trunk", "refs/remotes/origin/trunk"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "trunk", "refs/remotes/origin/trunk"]]); }); + + it("skips an origin/HEAD default that cannot be used as a branch name", async () => { + // origin/HEAD -> -m: the default is unusable (git branch parses the name as a + // switch), so it must fall through to inference and never reach `git branch`. + // Single production router: origin/HEAD lookup and inference listing in one mock, + // so removing the isUsableBranchName guard makes this test fail (materialization + // would be attempted with '-m'). + mockShowRef(["refs/heads/master"], "async"); + const branchCalls: string[][] = []; + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/-m\n" }); + return {}; + } + if (command.includes("for-each-ref")) { + cb(null, { stdout: "refs/heads/master\n" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD") || command.includes("git remote")) { + cb(null, { stdout: "" }); + return {}; + } + cb(new Error("unset command"), { stdout: "" }); + return {}; + }); + // Guards must turn '-m' away BEFORE git runs: any `git branch` invocation here is + // a bug (the name would land in option position). show-ref probes still answer so + // the ladder can verify local 'master' during inference. + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/repo", {} as any); + expect(resolved).toBe("master"); + expect(branchCalls).toEqual([]); + }); + + it("resolveIntegrationBranchSync prefers a materializable origin/HEAD default over inference too", () => { + // Same scenario as the async test: origin/HEAD -> trunk must outrank local + // well-known 'master'. Single execSync router carries origin/HEAD AND the + // inference listing through the production path. + mockShowRef(["refs/heads/master", "refs/remotes/origin/trunk"], "sync"); + const branchCalls: string[][] = []; + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/trunk\n"; + } + if (command.includes("for-each-ref")) { + return "refs/heads/master\nrefs/remotes/origin/trunk\n"; + } + if (command.includes("symbolic-ref --quiet --short HEAD") || command.includes("git remote")) { + return ""; + } + throw new Error("unset command"); + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref") { + if (args[3] === "refs/remotes/origin/trunk") return ""; + throw missingRefError(); + } + if (args[0] === "branch") { + branchCalls.push(args); + return ""; + } + throw new Error("unset execFileSync command"); + }); + + const resolved = resolveIntegrationBranchSync("/repo", {} as any); + expect(resolved).toBe("trunk"); + expect(branchCalls).toEqual([["branch", "trunk", "refs/remotes/origin/trunk"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "trunk", "refs/remotes/origin/trunk"]]); }); + + it("resolveIntegrationBranchSync skips an unusable origin/HEAD default the same way", () => { + // Single execSync router (the previous double mockImplementation silently replaced + // the origin/HEAD answer): '-m' is unusable, so inference must win locally and + // `git branch` must never be invoked. + mockShowRef(["refs/heads/master"], "sync"); + const branchCalls: string[][] = []; + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/-m\n"; + } + if (command.includes("for-each-ref")) { + return "refs/heads/master\n"; + } + if (command.includes("symbolic-ref --quiet --short HEAD") || command.includes("git remote")) { + return ""; + } + throw new Error("unset command"); + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "branch") { + branchCalls.push(args); + return ""; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/repo", {} as any); + expect(resolved).toBe("master"); + expect(branchCalls).toEqual([]); + }); + + it("skips a dash-prefixed settings branch even when it exists in origin remote-tracking", async () => { + // A ref named refs/remotes/origin/-m can exist, but `git branch -m ...` parses the + // name as a switch and even `git branch -- -m ...` rejects it ("not a valid branch + // name"), and consumers (git worktree add) cannot use it either. Skip to next rung. + mockShowRef(["refs/heads/main", "refs/remotes/origin/-m"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/main\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + if (args[0] === "show-ref" && (args[3] === "refs/heads/main" || args[3] === "refs/remotes/origin/-m" || args[3] === "refs/remotes/origin/main")) { + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "-m" } as any); + expect(resolved).toBe("main"); + // '-m' must never reach `git branch`, even as a materialization target. + expect(branchCalls).toEqual([]); + }); + + it("resolveIntegrationBranchSync skips a dash-prefixed settings branch the same way", () => { + mockShowRef(["refs/heads/main", "refs/remotes/origin/-m"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("refs/remotes/origin/HEAD")) { + return "origin/main\n"; + } + return ""; + }); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "branch") { + branchCalls.push(args); + return ""; + } + if (args[0] === "show-ref" && (args[3] === "refs/heads/main" || args[3] === "refs/remotes/origin/-m" || args[3] === "refs/remotes/origin/main")) { + return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/repo", { integrationBranch: "-m", baseBranch: "main" } as any); + expect(resolved).toBe("main"); + // '-m' must never reach `git branch`, even as a materialization target. + expect(branchCalls).toEqual([]); + }); + + it("materializes a settings branch that exists only in origin remote-tracking before consumers use it", async () => { + // refs/remotes/origin/develop exists, refs/heads/develop does not. The configured + // branch is authoritative: materialize the local ref (FN-183) so `git worktree add` + // and the merge-time CAS advance find refs/heads/develop instead of "invalid reference". + mockShowRef(["refs/heads/main", "refs/remotes/origin/develop"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/main\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch" && args[1] === "develop") { + if (args[0] === "branch" && args[2] === "develop") { branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "develop" } as any, { logger: { warn } }); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "develop", "refs/remotes/origin/develop"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("probes only the local refs/heads form", async () => { + const probed: string[] = []; + execFileMock.mockImplementation((_cmd: string, args: string[], _opts: object, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref") probed.push(args[3]); + if (args[0] === "show-ref" && args[3] === "refs/heads/develop") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }); + + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "develop" } as any); + expect(resolved).toBe("develop"); + expect(probed).toEqual(["refs/heads/develop"]); + }); + + it("passes the branch as a single argv element — no shell, no command interpolation", async () => { + // Shell-hostile but git-VALID name (git forbids only space/~/^/:/?/*/[/\ — `;`, + // `|`, `&` are legal refname chars): proves the raw name is passed as ONE argv + // element to execFile and never shell-interpolated. Names git itself rejects + // (spaces, leading dash, HEAD, ...) are rejected earlier by isUsableBranchName; + // parity with git's accept/reject table is pinned by the real-git test. + const hostile = "evil;rm-rf"; + execFileMock.mockImplementation((_cmd: string, args: string[], _opts: object, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === `refs/heads/${hostile}`) { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }); + + // The name must resolve as one argv element (ref simply will not exist in a + // real repo) instead of reaching a shell. + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: hostile } as any); + expect(resolved).toBe(hostile); + }); + + it("falls through to INTEGRATION_BRANCH_FALLBACK when settings branch is missing and no other rung resolves", async () => { + mockShowRef(["refs/heads/main"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(new Error("no symbolic ref"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") || command.includes("symbolic-ref --quiet --short HEAD")) { + cb(null, { stdout: "" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: "homolog" } as any, { logger: { warn } }); + expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("origin/HEAD is unset")); + }); + + it("resolveIntegrationBranchSync applies the same existence guard for the missing case", () => { + mockShowRef(["refs/heads/main"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("refs/remotes/origin/HEAD")) { + return Buffer.from("origin/main\n"); + } + if (typeof command === "string" && (command.includes("for-each-ref") || command.includes("symbolic-ref --quiet --short HEAD"))) { + return Buffer.from(""); + } + if (typeof command === "string" && command === "git remote") { + return Buffer.from("origin\n"); + } + return Buffer.from(""); + }); + + const resolved = resolveIntegrationBranchSync("/repo", { integrationBranch: "homolog", baseBranch: "main" } as any); + expect(resolved).toBe("main"); + }); + + it("resolveIntegrationBranchSync accepts a local branch via the argv-based probe", () => { + mockShowRef(["refs/heads/homolog"], "sync"); + + const resolved = resolveIntegrationBranchSync("/repo", { integrationBranch: "homolog" } as any); + expect(resolved).toBe("homolog"); + }); + + it("resolveIntegrationBranchSync materializes an origin-only settings branch the same way", () => { + mockShowRef(["refs/heads/main", "refs/remotes/origin/homolog"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("refs/remotes/origin/HEAD")) { + return Buffer.from("origin/main\n"); + } + return Buffer.from(""); + }); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/homolog") { + return ""; + } + if (args[0] === "branch" && args[1] === "homolog") { + if (args[0] === "branch" && args[2] === "homolog") { branchCalls.push(args); + return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/repo", { integrationBranch: "homolog", baseBranch: "main" } as any); + expect(resolved).toBe("homolog"); + expect(branchCalls).toEqual([["branch", "homolog", "refs/remotes/origin/homolog"]]); + }); + + it("rejects an inferred remote-only branch and falls back to main (async)", async () => { + // No local ref exists for anything (show-ref always fails). + mockShowRef(["refs/heads/main"], "async"); + expect(branchCalls).toEqual([["branch", "--no-track", "homolog", "refs/remotes/origin/homolog"]]); + }); + + it("materializes an inferred remote-only branch even when unrelated local branches exist (async)", async () => { execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(new Error("unset"), { stdout: "" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("detached"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "alpha\nbeta\n" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + + // Inference's remote-tracking tier selects "develop", which has no local ref. + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/repo", {} as any, { logger: { warn } }); + expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("origin/HEAD is unset")); + }); + + it("rejects an inferred remote-only branch and falls back to main (sync)", () => { + mockShowRef(["refs/heads/main"], "sync"); + // Inference's remote-tracking tier selects "develop"; its listed origin/develop ref + // is probeable (never model a listed remote ref that fails the show-ref probe), so + // the resolver materializes the local ref instead of rejecting the remote-only name. + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/repo", {} as any, { logger: { warn: vi.fn() } }); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); + }); + + it("materializes an inferred remote-only branch even when unrelated local branches exist (sync)", () => { execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + throw new Error("unset"); + } + if (typeof command === "string" && command.includes("symbolic-ref --quiet --short HEAD")) { + throw new Error("detached"); + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/heads/")) { + return "alpha\nbeta\n"; + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + return "origin/develop\n"; + } + if (typeof command === "string" && command === "git remote") { + return "origin\n"; + } + return ""; + }); + + const resolved = resolveIntegrationBranchSync("/repo", {} as any); + expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK); + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch") { + branchCalls.push(args); + return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/repo-sync", {} as any, { logger: { warn: vi.fn() } }); + expect(resolved).toBe("develop"); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); + + it("verifies the plain fallback before returning it after a failed materialization (async)", async () => { + // Inference selects develop (remote-only), its ref is missing, materialization of the + // origin-derived candidate fails, but ANOTHER caller concurrently created refs/heads/main. + mockShowRef(["refs/remotes/origin/develop"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("no checkout"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null, result?: { stdout: string }) => void) => { + // Remote-tracking ref EXISTS (probe passes); the write itself fails operationally + // and the local ref is still absent afterwards -> the ORIGINAL error must surface. + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).code = 128; + cb(error); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + // Remote-tracking ref EXISTS (probe passes); absent refs/heads refs report the + // missing-ref shape (error.code = 1); ONLY the write itself fails operationally + // and the local ref is still absent afterwards -> the ORIGINAL error must surface. + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).code = 128; + cb(error); + return {}; + } + cb(missingRefError()); return {}; + }) as any); + + await expect(resolveIntegrationBranch("/no-checkout", undefined)).rejects.toThrow( + /cannot lock ref/, + ); + + expect(branchCalls).toHaveLength(1); }); + + it("materializes the plain fallback when origin/HEAD was unset and refs/heads/main is missing (async)", async () => { + // origin/HEAD unset; no inference; refs/heads/main missing but refs/remotes/origin/main exists. + mockShowRef(["refs/remotes/origin/main"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(new Error("unset"), { stdout: "" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("detached"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/main\n" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null, result?: { stdout: string }) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/main") { + cb(null, { stdout: "" }); + return {}; + } + if (args[0] === "branch" && args[1] === "main") { + if (args[0] === "branch" && args[2] === "main") { branchCalls.push(args); + cb(null, { stdout: "" }); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/origin-main-only", undefined, { logger: { warn } }); + expect(resolved).toBe("main"); + expect(branchCalls).toEqual([["branch", "main", "refs/remotes/origin/main"]]); + expect(branchCalls).toEqual([["branch", "--no-track", "main", "refs/remotes/origin/main"]]); expect(warn).toHaveBeenCalledWith(expect.stringContaining("created local branch 'main'")); + }); + + it("throws an actionable error when no local ref can be established at all (async)", async () => { + // Ghost clone: no local branches, no origin/HEAD, no remote-tracking refs, nothing to create. + mockShowRef([], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + cb(new Error("unset"), { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null, result?: { stdout: string }) => void) => { + if (args[0] === "branch") { + const error = new Error("fatal: not a valid object name: refs/remotes/origin/main"); + (error as NodeJS.ErrnoException).code = 128; + cb(error); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + await expect(resolveIntegrationBranch("/ghost", undefined)).rejects.toThrow( + /could not establish a local integration branch/, + ); + }); + + it("verifies the plain fallback before returning it after a failed materialization (sync)", () => { + mockShowRef(["refs/remotes/origin/develop"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/develop\n"; + } + if (typeof command === "string" && command.includes("symbolic-ref --quiet --short HEAD")) { + throw new Error("no checkout"); + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/heads/")) { + return ""; + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + return "origin/develop\n"; + } + if (typeof command === "string" && command === "git remote") { + return "origin\n"; + } + return ""; + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + // Remote-tracking ref EXISTS; the write fails operationally and the local ref is + // still absent afterwards -> the ORIGINAL error must surface. + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).code = 128; + throw error; + const branchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + // Remote-tracking ref EXISTS (probe passes); absent refs/heads refs report the + // sync missing-ref shape (error.status = 1); ONLY the write itself fails + // operationally and the local ref is still absent afterwards -> the ORIGINAL + // error must surface. + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch") { + branchCalls.push(args); + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).status = 128; + throw error; + } + const missing = new Error("ref not found"); + (missing as NodeJS.ErrnoException).status = 1; + throw missing; }); + + expect(() => resolveIntegrationBranchSync("/no-checkout", {} as any)).toThrow( + /cannot lock ref/, + ); + + expect(branchCalls).toHaveLength(1); }); + + it("recovers from a lost creation race by rechecking the local ref (async)", async () => { + // origin/HEAD -> origin/develop (remote-only). The write loses to a concurrent creator, + // but refs/heads/develop exists by the time the helper rechecks -> resolve to develop. + mockShowRef(["refs/remotes/origin/develop"], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("no checkout"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/develop\n" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "branch") { + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).code = 128; + cb(error); + return {}; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/develop") { + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const resolved = await resolveIntegrationBranch("/race-async", undefined); + expect(resolved).toBe("develop"); + }); + + it("recovers from a lost creation race by rechecking the local ref (sync)", () => { + mockShowRef(["refs/remotes/origin/develop"], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/develop\n"; + } + if (typeof command === "string" && command.includes("symbolic-ref --quiet --short HEAD")) { + throw new Error("no checkout"); + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/heads/")) { + return ""; + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + return "origin/develop\n"; + } + if (typeof command === "string" && command === "git remote") { + return "origin\n"; + } + return ""; + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "branch") { + const error = new Error("fatal: cannot lock ref"); + (error as NodeJS.ErrnoException).code = 128; + throw error; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/develop") { + return ""; + } + throw missingRefError(); + }); + + expect(resolveIntegrationBranchSync("/race-sync", {} as any)).toBe("develop"); + }); + + it("skips creation entirely when the remote-tracking ref is absent (async)", async () => { + // Ghost clone: no refs/remotes/origin/main, so the fallback ladder must not invoke + // `git branch` at all — it probes first and reaches the actionable error. + mockShowRef([], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + cb(new Error("unset"), { stdout: "" }); + return {}; + }); + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "branch") { + branchCalls.push(args); + } + cb(missingRefError()); + return {}; + }) as any); + + await expect(resolveIntegrationBranch("/ghost-nobranch", undefined)).rejects.toThrow( + /could not establish a local integration branch/, + ); + expect(branchCalls).toEqual([]); + }); + + it("materializes the origin/HEAD default branch when no local ref exists (async)", async () => { + // No-checkout / detached clone: no local branches, origin/HEAD -> origin/master (remote-only). + mockShowRef([], "async"); + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/master\n" }); + return {}; + } + if (command.includes("symbolic-ref --quiet --short HEAD")) { + cb(new Error("no checkout"), { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/heads/")) { + cb(null, { stdout: "" }); + return {}; + } + if (command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + cb(null, { stdout: "origin/master\n" }); + return {}; + } + if (command === "git remote") { + cb(null, { stdout: "origin\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/master") { + cb(null); + return {}; + } + if (args[0] === "branch" && args[1] === "master" && args[2] === "refs/remotes/origin/master") { + if (args[0] === "branch" && args[2] === "master" && args[3] === "refs/remotes/origin/master") { cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + + const warn = vi.fn(); + const resolved = await resolveIntegrationBranch("/no-checkout", undefined, { logger: { warn } }); + expect(resolved).toBe("master"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("created local branch 'master'")); + }); + + it("materializes the origin/HEAD default branch when no local ref exists (sync)", () => { + mockShowRef([], "sync"); + execSyncMock.mockImplementation((command: string, _opts: object) => { + if (typeof command === "string" && command.includes("symbolic-ref --short refs/remotes/origin/HEAD")) { + return "origin/master\n"; + } + if (typeof command === "string" && command.includes("symbolic-ref --quiet --short HEAD")) { + throw new Error("no checkout"); + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/heads/")) { + return ""; + } + if (typeof command === "string" && command.includes("for-each-ref") && command.includes("refs/remotes/origin/")) { + return "origin/master\n"; + } + if (typeof command === "string" && command === "git remote") { + return "origin\n"; + } + return ""; + }); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/master") { + return ""; + } + if (args[0] === "branch" && args[1] === "master" && args[2] === "refs/remotes/origin/master") { + if (args[0] === "branch" && args[2] === "master" && args[3] === "refs/remotes/origin/master") { return ""; + } + throw missingRefError(); + }); + + const resolved = resolveIntegrationBranchSync("/no-checkout", {} as any); + expect(resolved).toBe("master"); + }); + + it("propagates an operational probe failure instead of treating it as a missing ref (async)", async () => { + mockShowRef([], "async"); + // Override with an operational failure (git fatal, exit 128) rather than exit 1 missing. + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + const error = new Error("fatal: not a git repository"); + (error as NodeJS.ErrnoException).code = 128; + cb(error); + return {}; + }) as any); + + await expect( + resolveIntegrationBranch("/broken", { integrationBranch: "release", baseBranch: undefined } as any), + ).rejects.toThrow(/not a git repository/); + }); + + it("propagates an operational probe failure instead of treating it as a missing ref (sync)", () => { + mockShowRef([], "sync"); + execFileSyncMock.mockImplementation(() => { + const error = new Error("fatal: not a git repository"); + (error as NodeJS.ErrnoException).code = 128; + throw error; + }); + + expect(() => + resolveIntegrationBranchSync("/broken", { integrationBranch: "release", baseBranch: undefined } as any), + ).toThrow(/not a git repository/); + }); + + it("verify helper honors the priority contract shared with core selection", () => { + expect(selectIntegrationBranch({ + localBranches: ["master"], + currentBranch: "master", + remoteBranches: ["origin/master"], + })).toBeTruthy(); + }); +}); diff --git a/packages/engine/src/__tests__/integration-branch.test.ts b/packages/engine/src/__tests__/integration-branch.test.ts index 63c9f170c1..7cd8d396d2 100644 --- a/packages/engine/src/__tests__/integration-branch.test.ts +++ b/packages/engine/src/__tests__/integration-branch.test.ts @@ -1,15 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { selectIntegrationBranch } from "@fusion/core"; -const { execMock, execSyncMock } = vi.hoisted(() => ({ +const { execMock, execSyncMock, execFileMock, execFileSyncMock } = vi.hoisted(() => ({ execMock: vi.fn(), execSyncMock: vi.fn(), + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), })); vi.mock("node:child_process", () => ({ exec: execMock, execSync: execSyncMock, - execFile: vi.fn(), + execFile: execFileMock, + execFileSync: execFileSyncMock, })); import { @@ -19,11 +22,48 @@ import { resolveIntegrationBranchSync, } from "../merge/integration-branch.js"; +function missingRefError(): Error { + const error = new Error("ref not found"); + (error as NodeJS.ErrnoException).code = 1; + return error; +} + +function operationalGitError(message = "fatal: unable to write ref"): Error { + const error = new Error(message); + (error as NodeJS.ErrnoException).code = 128; + return error; +} + +/** + * Degraded-but-ordinary environment: refs/heads/main exists, every OTHER probe reports a + * missing ref (exit 1), and branch writes fail operationally. This is the state the + * resolver must survive by naming the verified local `main`. + */ +function mockFailingGitProbes(): void { + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref") { + cb(args[3] === "refs/heads/main" ? null : missingRefError()); + return {}; + } + cb(operationalGitError()); + return {}; + }) as any); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref") { + if (args[3] === "refs/heads/main") return ""; + throw missingRefError(); + } + throw operationalGitError(); + }); +} + describe("integration-branch resolver", () => { beforeEach(() => { __resetIntegrationBranchCacheForTests(); execMock.mockReset(); execSyncMock.mockReset(); + execFileMock.mockReset(); + execFileSyncMock.mockReset(); }); afterEach(() => { @@ -32,17 +72,47 @@ describe("integration-branch resolver", () => { }); it("integrationBranch override wins over baseBranch and origin/HEAD", async () => { + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/master\n" }); + return {}; + } + cb(new Error("unexpected command"), { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/trunk") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " trunk ", baseBranch: "develop" } as any); expect(resolved).toBe("trunk"); - expect(execMock).not.toHaveBeenCalled(); }); it("baseBranch wins over origin/HEAD", async () => { + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("refs/remotes/origin/HEAD")) { + cb(null, { stdout: "origin/master\n" }); + return {}; + } + cb(new Error("unexpected command"), { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/develop") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); const resolved = await resolveIntegrationBranch("/repo", { baseBranch: " develop " } as any); expect(resolved).toBe("develop"); - expect(execMock).not.toHaveBeenCalled(); }); it("strips refs/remotes/origin and origin prefixes", async () => { @@ -54,6 +124,14 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "origin/develop\n" }); return {}; }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && (args[3] === "refs/heads/master" || args[3] === "refs/heads/develop")) { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); const first = await resolveIntegrationBranch("/repo-a", {} as any); const second = await resolveIntegrationBranch("/repo-b", {} as any); @@ -67,6 +145,14 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "origin/master\n" }); return {}; }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " ", baseBranch: "" } as any); @@ -78,6 +164,7 @@ describe("integration-branch resolver", () => { cb(new Error("no symbolic ref"), { stdout: "" }); return {}; }); + mockFailingGitProbes(); const warn = vi.fn(); const first = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } }); @@ -102,6 +189,7 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "gitlab\n" }); return {}; }); + mockFailingGitProbes(); const warn = vi.fn(); const resolved = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } }); @@ -127,6 +215,7 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "origin\ngitlab\norigin\n" }); return {}; }); + mockFailingGitProbes(); const warn = vi.fn(); await expect(resolveIntegrationBranch("/repo", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); @@ -154,6 +243,14 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "" }); return {}; }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); const warn = vi.fn(); await expect(resolveIntegrationBranch("/master-only", undefined, { logger: { warn } })).resolves.toBe("master"); @@ -161,6 +258,54 @@ describe("integration-branch resolver", () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining("well-known-local")); }); + it("falls through on a sync missing ref reported through the execFileSync error shape", async () => { + // Real execFileSync errors carry the exit code on `status` (verified: code is undefined + // for exit != 0). The sync resolver must treat that as "absent" and keep falling through + // instead of rethrowing an ordinary missing ref as an operational failure. The missing + // 'ghost' settings candidate routes the show-ref existence probe through that same + // status-shaped error before the ladder falls through to the verified fallback. + execSyncMock.mockImplementation(() => ""); + execFileSyncMock.mockImplementation(((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") { + return ""; + } + const error = new Error("Command failed: git show-ref") as NodeJS.ErrnoException; + error.status = 1; + throw error; + }) as any); + const warn = vi.fn(); + + expect(resolveIntegrationBranchSync("/sync-status-shape", { integrationBranch: "ghost" } as any, { logger: { warn } })).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("falling back to 'main'")); + }); + + it("never adopts an origin/HEAD name that fails the usability check", async () => { + // origin/HEAD can name something git-check-ref-format rejects (here a name with `..`), + // and a plumbing-created local ref for it can pass an existence probe. Every bare-name + // consumer (worktree, merge) would still fail on that name, so the ladder must fall + // through to the plain fallback instead of returning it. + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("symbolic-ref")) { + cb(null, { stdout: "origin/bad..name\n" }); + return {}; + } + cb(null, { stdout: "" }); + return {}; + }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && (args[3] === "refs/heads/bad..name" || args[3] === "refs/heads/main")) { + cb(null); + } else { + cb(missingRefError()); + } + return {}; + }) as any); + const warn = vi.fn(); + + await expect(resolveIntegrationBranch("/rejected-origin-name", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("bad..name")); + }); + it("prefers a well-known local branch when multiple local branches exist", async () => { execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { if (command.includes("origin/HEAD")) { @@ -178,12 +323,20 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "" }); return {}; }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); await expect(resolveIntegrationBranch("/multiple-local", undefined, { logger: { warn: vi.fn() } })).resolves.toBe("main"); }); - it("adopts an unambiguous remote-only branch", async () => { - execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + it("rejects an unambiguous remote-only branch — worktree add needs a local ref", async () => { + it("materializes an unambiguous remote-only branch so worktree add gets a local ref", async () => { execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { if (command.includes("origin/HEAD") || command.includes("symbolic-ref --quiet --short HEAD")) { cb(new Error("no symbolic ref"), { stdout: "" }); return {}; @@ -199,9 +352,37 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "" }); return {}; }); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref") { + cb(args[3] === "refs/heads/main" ? null : missingRefError()); + return {}; + } + cb(operationalGitError()); + return {}; + }) as any); + + await expect(resolveIntegrationBranch("/remote-only", undefined, { logger: { warn: vi.fn() } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); + // The listed origin/develop ref is probeable, so under the current contract the + // resolver materializes it locally (git branch --no-track) instead of rejecting the + // remote-only name: `git worktree add` and the merge-time CAS need refs/heads/. + // Never model a remote ref that appears in the listing but fails the show-ref probe. + const branchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch") { + branchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); await expect(resolveIntegrationBranch("/remote-only", undefined, { logger: { warn: vi.fn() } })).resolves.toBe("develop"); - }); + expect(branchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); it("falls back when every inferred branch is a Fusion sibling", async () => { execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { @@ -220,6 +401,7 @@ describe("integration-branch resolver", () => { cb(null, { stdout: "" }); return {}; }); + mockFailingGitProbes(); await expect(resolveIntegrationBranch("/fusion-only", undefined, { logger: { warn: vi.fn() } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); }); @@ -255,13 +437,60 @@ describe("integration-branch resolver", () => { remoteBranches: ["develop"], }); + // No local ref exists for "develop" (show-ref probes fail), so both variants must + // discard the remote-tracking candidate and fall back to main. + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref") { + cb(args[3] === "refs/heads/main" ? null : missingRefError()); + return {}; + } + cb(operationalGitError()); + return {}; + }) as any); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref") { + if (args[3] === "refs/heads/main") return ""; + throw missingRefError(); + } + throw operationalGitError(); + // The listed origin/develop ref is probeable and has no local ref yet, so both + // variants must materialize it from refs/remotes/origin/develop and return the same + // local branch (never model a listed remote ref that fails the show-ref probe). + const asyncBranchCalls: string[][] = []; + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + cb(null); + return {}; + } + if (args[0] === "branch") { + asyncBranchCalls.push(args); + cb(null); + return {}; + } + cb(missingRefError()); + return {}; + }) as any); + const syncBranchCalls: string[][] = []; + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/remotes/origin/develop") { + return ""; + } + if (args[0] === "branch") { + syncBranchCalls.push(args); + return ""; + } + throw missingRefError(); }); + const asyncResolved = await resolveIntegrationBranch("/parity", undefined, { logger: { warn: vi.fn() } }); const syncResolved = resolveIntegrationBranchSync("/parity-sync", undefined, { logger: { warn: vi.fn() } }); expect(expected).toEqual({ branch: "develop", source: "remote-tracking" }); - expect(asyncResolved).toBe(expected?.branch); - expect(syncResolved).toBe(expected?.branch); - }); + expect(asyncResolved).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(syncResolved).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(asyncResolved).toBe("develop"); + expect(syncResolved).toBe("develop"); + expect(asyncBranchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); + expect(syncBranchCalls).toEqual([["branch", "--no-track", "develop", "refs/remotes/origin/develop"]]); }); it("sync and async variants match", async () => { execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { @@ -269,6 +498,20 @@ describe("integration-branch resolver", () => { return {}; }); execSyncMock.mockReturnValue("origin/master\n"); + execFileMock.mockImplementation(((_cmd: string, args: string[], _opts: unknown, cb: (error: Error | null) => void) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + cb(null); + } else { + cb(new Error("ref not found")); + } + return {}; + }) as any); + execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/master") { + return ""; + } + throw new Error("ref not found"); + }); const asyncResolved = await resolveIntegrationBranch("/repo", undefined); const syncResolved = resolveIntegrationBranchSync("/repo", undefined); @@ -285,6 +528,7 @@ describe("integration-branch resolver", () => { execSyncMock.mockImplementation(() => { throw new Error("git failed"); }); + mockFailingGitProbes(); await expect(resolveIntegrationBranch("/repo", undefined)).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); expect(() => resolveIntegrationBranchSync("/repo", undefined)).not.toThrow(); diff --git a/packages/engine/src/__tests__/real-git/integration-branch-master.real-git.test.ts b/packages/engine/src/__tests__/real-git/integration-branch-master.real-git.test.ts index f9812d84cd..d9a8ab78ee 100644 --- a/packages/engine/src/__tests__/real-git/integration-branch-master.real-git.test.ts +++ b/packages/engine/src/__tests__/real-git/integration-branch-master.real-git.test.ts @@ -33,10 +33,31 @@ describeIfGit("integration branch resolution (real git, master)", () => { it("resolves origin/HEAD and respects explicit override", async () => { const repo = setupRepo(); + git(repo, "git branch trunk"); await expect(resolveIntegrationBranch(repo, {})).resolves.toBe("master"); await expect(resolveIntegrationBranch(repo, { integrationBranch: "trunk" })).resolves.toBe("trunk"); }); + it("falls back to origin/HEAD when the configured branch does not exist", async () => { + const repo = setupRepo(); + // No `ghost` branch exists locally or in refs/remotes/origin. + await expect(resolveIntegrationBranch(repo, { integrationBranch: "ghost" })).resolves.toBe("master"); + // A local branch that exists keeps priority. + git(repo, "git branch trunk"); + await expect(resolveIntegrationBranch(repo, { integrationBranch: "trunk" })).resolves.toBe("trunk"); + }); + + it("materializes a configured branch that exists only as a remote-tracking ref", async () => { + const repo = setupRepo(); + // refs/remotes/origin/develop exists, refs/heads/develop does not. The configured + // branch is authoritative: the resolver materializes refs/heads/develop from the + // remote-tracking ref (FN-183) so consumers like `git worktree add` find a local ref. + git(repo, "git update-ref refs/remotes/origin/develop HEAD"); + await expect(resolveIntegrationBranch(repo, { integrationBranch: "develop" })).resolves.toBe("develop"); + await expect(resolveIntegrationBranch(repo, { integrationBranch: "develop" })).resolves.toBe("develop"); + git(repo, "git rev-parse --verify refs/heads/develop"); + }); + it("inspects branch conflicts against master without disturbing dirty root worktree", async () => { const repo = setupRepo(); git(repo, "git checkout -b fusion/fn-5349-check"); diff --git a/packages/engine/src/__tests__/real-git/integration-branch-validate-exists.real-git.test.ts b/packages/engine/src/__tests__/real-git/integration-branch-validate-exists.real-git.test.ts new file mode 100644 index 0000000000..ca0f66f20a --- /dev/null +++ b/packages/engine/src/__tests__/real-git/integration-branch-validate-exists.real-git.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { isUsableBranchName, resolveIntegrationBranch, resolveIntegrationBranchSync } from "../../merge/integration-branch.js"; + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +/** + * Parity pin for the pure-JS branch-name validator against real git: + * for every probe name, isUsableBranchName must agree with + * `git check-ref-format --branch` AND with whether `git branch -- HEAD` + * actually accepts the name. Git rejects check-ref-format violations even with + * `--` (it only disambiguates options), so creation is the ground truth consumers + * hit — see the BUG-0001/BUG-0002 comments in integration-branch-validate-exists.test.ts. + */ +const VALID_NAMES = [ + "master", + "trunk", + "@", + "@@", + "a/@", + "V1.0", + "a_b-c", + "HEAD-x", + "a.b", + "feature/x", + "a;b", // shell-hostile but legal refname + "a&b", + "x9", + "feature/long_name.with-dots", +]; + +const INVALID_NAMES = [ + "HEAD", // reserved + "-m", // option-like + "-x", + "a b", // space + "a~b", + "a^b", + "a:b", + "a?b", + "a*b", + "a[b", + "a\\b", + "a..b", + ".a", // leading dot + "a.", // trailing dot + "a.lock", // .lock suffix + "a@{b}", // @{ sequence + "a//b", // double slash + "/a", // leading slash + "a/", // trailing slash + "head@{1}", // @{ sequence inside a plausible name + "", // empty +]; + +describeIfGit("integration branch name validation (real git parity)", () => { + const repos: string[] = []; + + afterEach(() => { + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); + }); + + function setupRepo(): string { + const repo = mkdtempSync(path.join(os.tmpdir(), "integration-branch-namecheck-")); + repos.push(repo); + const run = (args: string[]) => spawnSync("git", args, { cwd: repo, stdio: "pipe" }); + run(["init", "-b", "master"]); + run(["config", "user.email", "test@example.com"]); + run(["config", "user.name", "Test"]); + run(["commit", "--allow-empty", "-m", "init"]); + run(["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/master"]); + return repo; + } + + it("agrees with git check-ref-format and git branch creation for every probe name", () => { + const repo = setupRepo(); + const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repo, stdio: "pipe" }).stdout.toString().trim(); + + for (const name of VALID_NAMES) { + const formatStatus = spawnSync("git", ["check-ref-format", "--branch", name], { stdio: "pipe" }).status; + expect(formatStatus, `check-ref-format --branch should accept ${JSON.stringify(name)}`).toBe(0); + expect(isUsableBranchName(name), `validator should accept ${JSON.stringify(name)}`).toBe(true); + // `master` already exists (init -b master): creation would fail for the wrong + // reason, so only assert it when the branch is actually missing. + const exists = spawnSync("git", ["rev-parse", "--verify", "-q", `refs/heads/${name}`], { cwd: repo, stdio: "pipe" }).status === 0; + if (!exists) { + const created = spawnSync("git", ["branch", "--", name, head], { cwd: repo, stdio: "pipe" }); + expect(created.status, `git branch -- ${JSON.stringify(name)} should succeed`).toBe(0); + spawnSync("git", ["branch", "-D", "--", name], { cwd: repo, stdio: "pipe" }); + } + } + + for (const name of INVALID_NAMES) { + const formatStatus = spawnSync("git", ["check-ref-format", "--branch", name], { stdio: "pipe" }).status; + expect(formatStatus, `check-ref-format --branch should reject ${JSON.stringify(name)}`).not.toBe(0); + expect(isUsableBranchName(name), `validator should reject ${JSON.stringify(name)}`).toBe(false); + const created = spawnSync("git", ["branch", "--", name, head], { cwd: repo, stdio: "pipe" }); + expect(created.status, `git branch -- ${JSON.stringify(name)} should fail`).not.toBe(0); + } + }); + + it("falls through a configured candidate git rejects (HEAD) to baseBranch (async)", async () => { + // BUG-0002 end to end: refs/remotes/origin/HEAD exists (normal clone), so the old + // existence probe accepted the configured candidate "HEAD"; materializing it with + // `git branch HEAD ` then failed and the ladder aborted instead of reaching + // baseBranch. The validator must reject the candidate before any probe, and the + // resolver must return baseBranch without creating refs/heads/HEAD. + const repo = setupRepo(); + const resolved = await resolveIntegrationBranch(repo, { integrationBranch: "HEAD", baseBranch: "master" }); + expect(resolved).toBe("master"); + expect(spawnSync("git", ["rev-parse", "--verify", "-q", "refs/heads/HEAD"], { cwd: repo, stdio: "pipe" }).status).not.toBe(0); + }); + + it("falls through a configured candidate git rejects (HEAD) to baseBranch (sync)", () => { + const repo = setupRepo(); + const resolved = resolveIntegrationBranchSync(repo, { integrationBranch: "HEAD", baseBranch: "master" }); + expect(resolved).toBe("master"); + expect(spawnSync("git", ["rev-parse", "--verify", "-q", "refs/heads/HEAD"], { cwd: repo, stdio: "pipe" }).status).not.toBe(0); + }); +}); diff --git a/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts b/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts index 9b374907cf..6dac30b365 100644 --- a/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts +++ b/packages/engine/src/executor/evaluate-workflow-merge-boundary.ts @@ -25,14 +25,35 @@ export type WorkflowMergeBoundaryProof = { complete: boolean; }; +/** + * FNXC:WorkflowMerge 2026-09-19-03:58: + * Resultados pre-merge podem vir de passos opcionais habilitados (source="optional-group"); a prova + * de fronteira deve enxerga-los, senao tarefas com reviews aprovados ficam presas em + * merge-boundary-unproven. + * + * Pre-merge results are produced by TWO graph runtimes: `node` (graph-authored node progress) and + * `optional-group` (an enabled optional step such as the builtin Plan Review / Code Review groups). + * Both are graph-native evidence that the graph ran, so the proof must accept both. Measured on a + * live card (project proj_9ef728e7cc084681) whose only two workflowStepResults were + * `phase="pre-merge"`, `status="passed"`, `source="optional-group"` (plan-review, code-review) with + * enabledWorkflowSteps ["plan-review","code-review"]: this filter dropped them, the failure reported + * `no-node-result`, and the card was parked at `merge-boundary-unproven — operator action required`. + * + * Nothing else is loosened: a non-pre-merge (e.g. post-merge) result stays out of the proof, and + * terminality (`allResultsTerminal`) plus foreach instance coverage stay mandatory. Legacy compiled + * workflow-step results carry no `source` and are deliberately not graph-native evidence. + */ +export function isGraphNativePreMergeResult(result: CoreWorkflowStepResult): boolean { + return (result.source === "node" || result.source === "optional-group") + && (result.phase ?? "pre-merge") === "pre-merge"; +} + export async function evaluateWorkflowMergeBoundary( deps: EvaluateWorkflowMergeBoundaryDeps, task: TaskDetail, runId?: string, ): Promise { - const relevant = (task.workflowStepResults ?? []).filter((result) => - result.source === "node" && (result.phase ?? "pre-merge") === "pre-merge", - ); + const relevant = (task.workflowStepResults ?? []).filter(isGraphNativePreMergeResult); // FNXC:WorkflowMerge 2026-07-27-12:30: FN-8601 keeps required presence // independent from terminality: a failed node result proves execution occurred, // while allResultsTerminal separately rejects it at the merge boundary. diff --git a/packages/engine/src/executor/workflow-merge-boundary-helpers.ts b/packages/engine/src/executor/workflow-merge-boundary-helpers.ts index dc27b7dc64..a20999993d 100644 --- a/packages/engine/src/executor/workflow-merge-boundary-helpers.ts +++ b/packages/engine/src/executor/workflow-merge-boundary-helpers.ts @@ -5,6 +5,7 @@ */ import type { TaskDetail, TaskStore } from "@fusion/core"; import { resolveWorkflowIrForTask } from "@fusion/core"; +import { isGraphNativePreMergeResult } from "./evaluate-workflow-merge-boundary.js"; import { MERGE_REGION_KINDS } from "../workflows/workflow-graph-executor.js"; export type ResolveMergeBoundaryColumnDeps = { @@ -69,6 +70,18 @@ export function shouldCompleteChecklistAtWorkflowMerge( if (!Array.isArray(task.steps) || task.steps.length === 0) return false; if (task.steps.every((step) => step.status === "done" || step.status === "skipped")) return false; if (proof) return proof.complete; - const graphNodeResults = (task.workflowStepResults ?? []).filter((result) => result.source === "node" && (result.phase ?? "pre-merge") === "pre-merge"); + /* + FNXC:WorkflowMerge 2026-09-19-03:58: + Resultados pre-merge podem vir de passos opcionais habilitados (source="optional-group"); a prova + de fronteira deve enxerga-los, senao tarefas com reviews aprovados ficam presas em + merge-boundary-unproven. + + This branch computes the SAME merge decision as the boundary proof when no proof object is + supplied (the production caller at `workflow-merge-boundary.ts` always passes one), so it must not + answer that question with a narrower origin set: a caller that dropped the proof argument would + re-park an approved card in exactly the way measured on the live FUSI-014 card. Origin provenance + therefore shares `isGraphNativePreMergeResult` instead of repeating the literal. + */ + const graphNodeResults = (task.workflowStepResults ?? []).filter(isGraphNativePreMergeResult); return graphNodeResults.length > 0 && graphNodeResults.every((result) => result.status === "passed" || result.status === "skipped"); } diff --git a/packages/engine/src/merge/integration-branch.ts b/packages/engine/src/merge/integration-branch.ts index 41bf3e57d5..46e34affdb 100644 --- a/packages/engine/src/merge/integration-branch.ts +++ b/packages/engine/src/merge/integration-branch.ts @@ -1,8 +1,9 @@ -import { exec, execSync } from "node:child_process"; +import { exec, execFile, execFileSync, execSync } from "node:child_process"; import { promisify } from "node:util"; import { selectIntegrationBranch, type ProjectSettings } from "@fusion/core"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export type IntegrationBranchSettings = | ProjectSettings @@ -10,8 +11,262 @@ export type IntegrationBranchSettings = | undefined | null; +// FNXC:IntegrationBranchValidation 2026-09-04-09:12: +// When `integrationBranch` (or fallback `baseBranch`) names a branch that does not exist +// locally nor in refs/remotes/origin/, `git worktree add ` aborts with +// `fatal: invalid reference: `. The resolver previously trusted settings blindly +// and propagated a ghost ref to every caller (worktree acquisition, merge, recovery, +// branch-conflict paths all route through resolveIntegrationBranch). This guard verifies +// the candidate against the git index and skips the rung when missing, letting the ladder +// fall through to origin/HEAD → inferred → INTEGRATION_BRANCH_FALLBACK instead of aborting. +// +// Only the LOCAL ref form is accepted: `git worktree add ` requires refs/heads/ +// (an origin-only branch still fatals with "invalid reference"), and the merge-time CAS +// advance reads and updates refs/heads/. A remote-only branch therefore falls through +// like a missing one instead of handing consumers a ref they cannot create a worktree from. +// The probe is argv-based (no shell, no interpolation: the settings value is never +// concatenated into a command string). +// +// The same local-ref requirement applies to every non-settings rung: origin/HEAD and the +// inference ladder can both surface a remote-tracking branch as a bare name, which would +// hand consumers the same unusable ref. Each candidate is verified with branchRefExists +// before being returned, so a remote-only branch falls through to the next rung. +/* +FNXC:IntegrationBranchValidation 2026-09-13-19:40: +`git show-ref --verify --quiet` exits 1 for a MISSING ref; every other failure (spawn ENOENT, +timeout, permissions, fatal) is an operational error, not evidence of absence. Treating those as +"missing" made the resolver silently pick another candidate or the fallback under a broken Git +environment, so only exit 1 is consumed as `false` and real failures propagate to the caller. +*/ +function isMissingRefExit(error: unknown): boolean { + // child_process reports numeric exit statuses via `code`; the ErrnoException typing + // widens it to string, so compare through an untyped view. Non-numeric codes (signals, + // ENOENT-style strings) never equal 1 and stay operational errors. + // Node's async execFile reports the exit code on `error.code`; the sync execFileSync + // family reports it on `error.status` (a sync missing ref throws with status=1 and no + // numeric code). Accept both shapes so every probe falls through on a missing ref + // instead of the sync path rethrowing it as an operational failure. + const failure = error as { code?: unknown; status?: unknown } | null; + return failure?.code === 1 || failure?.status === 1; +} + +async function branchRefExists(rootDir: string, branch: string): Promise { + // Mirror materializeLocalBranch: a candidate that failed the usability rungs must fall + // through the ladder even when a plumbing-created local ref for it exists — every + // bare-name consumer command (worktree, merge) would still fail on that name. + if (!branch || !isUsableBranchName(branch)) return false; + try { + await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + cwd: rootDir, + timeout: 5_000, + }); + return true; + } catch (error) { + if (!isMissingRefExit(error)) { + throw error; + } + return false; + } +} + +function branchRefExistsSync(rootDir: string, branch: string): boolean { + if (!branch || !isUsableBranchName(branch)) return false; + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + cwd: rootDir, + timeout: 5_000, + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch (error) { + if (!isMissingRefExit(error)) { + throw error; + } + return false; + } +} + +/* +FNXC:IntegrationBranchValidation 2026-09-14-00:55 (fallback materialization): +A no-checkout or detached clone has NO local default branch (origin/HEAD points at +refs/remotes/origin/master, refs/heads/master is absent). Every non-settings rung is now +validated against the local ref, so the ladder would otherwise return the equally-missing +fallback `main` and worktree acquisition still fails with `invalid reference`. The fallback +run therefore materializes the branch from its remote-tracking start point with +`git branch --no-track` (explicitly never tracking, matching ensureIntegrationBranchLocalRef in core FN-183 — +plain `git branch ` would auto-configure upstream tracking via +branch.autoSetupMerge) before +giving up. Materialization probes the remote-tracking ref first (absent -> false, ladder falls +through), then writes; a failed write is rechecked against the local ref to absorb a lost +creation race, and the ORIGINAL write error propagates when the ref is still absent. +*/ +export function isUsableBranchName(branch: string): boolean { + // `git branch ` rejects any name that violates git-check-ref-format(1), + // even with `--` (it only disambiguates options: `git branch -- -m` still fails with + // "not a valid branch name"), and reserved names like HEAD fail at creation. Candidates + // git would reject must fall through the ladder BEFORE probing or materializing: a + // symbolic ref (refs/remotes/origin/HEAD exists for candidate "HEAD") or a + // plumbing-created ref (refs/heads/-m) can pass an existence probe while every + // bare-name consumer command would fail. Pure-JS on purpose: the mocked suites replace + // node:child_process entirely, and the resolver must never shell out to + // `git check-ref-format` at runtime. Parity with real git is pinned by the real-git + // integration test (integration-branch-validate-exists.real-git.test.ts). + if (branch.length === 0 || branch === "HEAD") { + return false; + } + if (branch.startsWith("-") || branch.startsWith(".") || branch.startsWith("/")) { + return false; + } + // Space and the git-special printable characters are forbidden anywhere. + if (/[ ~^:?*[\\]/.test(branch)) { + return false; + } + // ASCII control characters (0x00-0x1F, which covers tab) and DEL (0x7F) are + // forbidden anywhere; checked by code point so the validator never embeds + // control characters in a regex literal (no-control-regex). + for (let i = 0; i < branch.length; i++) { + const code = branch.charCodeAt(i); + if (code < 0x20 || code === 0x7f) { + return false; + } + } + if (branch.includes("..") || branch.includes("@{")) { + return false; + } + // "@" alone is a valid branch name; inside multi-component names a lone "@" + // component is not. + if (branch === "@") { + return true; + } + // Per-component rules: no empty component (leading/trailing/double slash), no + // leading dot, no trailing dot, no .lock suffix. ("@" is valid as a whole name + // and as a component on git >= 2.30 — verified against git 2.55.) + for (const part of branch.split("/")) { + if (part.length === 0) { + return false; + } + if (part.startsWith(".") || part.endsWith(".") || part.endsWith(".lock")) { + return false; + } + } + return true; +} + +async function materializeLocalBranch(rootDir: string, branch: string): Promise { + // Inferred and origin/HEAD candidates reach this helper without passing the settings + // rung's isUsableBranchName check: guard here so a dash-prefixed name can never be + // parsed by `git branch` as a switch and rename or mutate an unrelated branch. + if (!isUsableBranchName(branch)) { + return false; + } + const remoteRef = `refs/remotes/origin/${branch}`; + try { + await execFileAsync("git", ["show-ref", "--verify", "--quiet", remoteRef], { + cwd: rootDir, + timeout: 5_000, + }); + } catch (error) { + if (!isMissingRefExit(error)) { + throw error; + } + // Remote-tracking ref absent: there is nothing to materialize from. + return false; + } + try { + await execFileAsync("git", ["branch", "--no-track", branch, remoteRef], { + cwd: rootDir, + timeout: 5_000, + }); + return true; + } catch (error) { + // Lost a concurrent creation race or hit a transient ref-lock failure: recheck the + // local ref; only surface the ORIGINAL write error when it is still absent. + try { + await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + cwd: rootDir, + timeout: 5_000, + }); + return true; + } catch (recheck) { + if (isMissingRefExit(recheck)) { + throw error; + } + throw recheck; + } + } +} + +function materializeLocalBranchSync(rootDir: string, branch: string): boolean { + // Mirrors the async guard above: every entry point validates the name before git does. + if (!isUsableBranchName(branch)) { + return false; + } + const remoteRef = `refs/remotes/origin/${branch}`; + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", remoteRef], { + cwd: rootDir, + timeout: 5_000, + stdio: ["ignore", "ignore", "ignore"], + }); + } catch (error) { + if (!isMissingRefExit(error)) { + throw error; + } + return false; + } + try { + execFileSync("git", ["branch", "--no-track", branch, remoteRef], { + cwd: rootDir, + timeout: 5_000, + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch (error) { + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + cwd: rootDir, + timeout: 5_000, + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch (recheck) { + if (isMissingRefExit(recheck)) { + throw error; + } + throw recheck; + } + } +} + +function warnMaterializedBranch(rootDir: string, logger: Pick, branch: string): void { + if (warnedFallbackRootDirs.has(rootDir)) { + return; + } + warnedFallbackRootDirs.add(rootDir); + logger.warn(`[integration-branch] created local branch '${branch}' from refs/remotes/origin/${branch} — the clone had no local default branch.`); +} + export const INTEGRATION_BRANCH_FALLBACK = "main"; const warnedFallbackRootDirs = new Set(); +const warnedSkippedCandidates = new Set(); + +/* +FNXC:IntegrationBranchValidation 2026-09-23-18:01 (skip visibility): +A configured candidate that fails isUsableBranchName or has neither a local ref nor a +refs/remotes/origin start point used to be skipped silently: the operator only saw the +ladder land on another branch with no clue their configured branch was ignored. Each skip +now warns through the module's existing logger seam, deduplicated per rootDir and candidate +so repeated resolutions cannot spam. A materialization write error is not a skip and still +propagates unchanged. +*/ +function warnSkippedCandidate(rootDir: string, logger: Pick, candidate: string, reason: string): void { + const key = `${rootDir}\u0000${candidate}`; + if (warnedSkippedCandidates.has(key)) { + return; + } + warnedSkippedCandidates.add(key); + logger.warn(`[integration-branch] skipped configured branch '${candidate}' — ${reason}`); +} function normalize(value: unknown): string { if (typeof value !== "string") { @@ -46,13 +301,19 @@ function warnFallback(rootDir: string, logger: Pick, remotes: s logger.warn("[integration-branch] falling back to 'main' — origin/HEAD unset and no project override"); } -function resolveFromSettings(settings: IntegrationBranchSettings): string { +/* +FNXC:IntegrationBranchValidation 2026-09-14-01:35 (settings ladder): +The documented resolution order is `integrationBranch -> baseBranch -> origin/HEAD -> main` +(settings-scope.ts). A set-but-missing integrationBranch must not consume the settings rung +alone: the ladder yields both candidates in order so the resolver can try baseBranch next. +*/ +function resolveFromSettings(settings: IntegrationBranchSettings): string[] { const fromIntegration = normalize(settings?.integrationBranch); - if (fromIntegration.length > 0) { - return fromIntegration; + const fromBase = normalize((settings as { baseBranch?: unknown } | null | undefined)?.baseBranch); + if (fromIntegration.length > 0 && fromIntegration === fromBase) { + return [fromIntegration]; } - - return normalize((settings as { baseBranch?: unknown } | null | undefined)?.baseBranch); + return [fromIntegration, fromBase].filter((branch) => branch.length > 0); } async function resolveFromOriginHead(rootDir: string): Promise { @@ -240,24 +501,85 @@ export async function resolveIntegrationBranch( const logger = opts.logger ?? console; const fromSettings = resolveFromSettings(settings); - if (fromSettings.length > 0) { - return fromSettings; + for (const candidate of fromSettings) { + if (!isUsableBranchName(candidate)) { + warnSkippedCandidate(rootDir, logger, candidate, "not a usable git branch name; fix integrationBranch or baseBranch."); + continue; + } + if (await branchRefExists(rootDir, candidate)) { + return candidate; + } + // A configured branch is an authoritative target: a normal clone may only carry it + // under refs/remotes/origin, so materialize it (same contract as the readiness path, + // FN-183) instead of silently falling through to origin/HEAD or main. + if (await materializeLocalBranch(rootDir, candidate)) { + warnMaterializedBranch(rootDir, logger, candidate); + return candidate; + } + warnSkippedCandidate(rootDir, logger, candidate, `no local refs/heads/${candidate} and no refs/remotes/origin/${candidate} to create it from; fetch the branch or fix integrationBranch/baseBranch.`); } const fromOrigin = await resolveFromOriginHead(rootDir); - if (fromOrigin.length > 0) { + if ( + fromOrigin.length > 0 && + isUsableBranchName(fromOrigin) && + (await branchRefExists(rootDir, fromOrigin)) + ) { return fromOrigin; } + // origin/HEAD names the remote's authoritative default: when only the + // remote-tracking ref exists, materialize it here instead of letting local + // inference (which prefers well-known local branches) redirect merges. + if (fromOrigin.length > 0 && isUsableBranchName(fromOrigin)) { + if (await materializeLocalBranch(rootDir, fromOrigin)) { + warnMaterializedBranch(rootDir, logger, fromOrigin); + return fromOrigin; + } + } const inferred = await resolveInferredBranch(rootDir); if (inferred) { - warnInferredBranch(rootDir, logger, inferred.branch, inferred.source); - return inferred.branch; + if (isUsableBranchName(inferred.branch) && (await branchRefExists(rootDir, inferred.branch))) { + warnInferredBranch(rootDir, logger, inferred.branch, inferred.source); + return inferred.branch; + } + // The shared selector deliberately returns a remote-only branch (sole/well-known + // origin branch); materialize it locally so no-checkout clones keep working. + // materializeLocalBranch re-validates the name (defense in depth). + if (await materializeLocalBranch(rootDir, inferred.branch)) { + warnMaterializedBranch(rootDir, logger, inferred.branch); + return inferred.branch; + } } const remotes = await listGitRemotes(rootDir); - warnFallback(rootDir, logger, remotes); - return INTEGRATION_BRANCH_FALLBACK; + const fallbackCandidate = fromOrigin.length > 0 ? fromOrigin : INTEGRATION_BRANCH_FALLBACK; + if (await materializeLocalBranch(rootDir, fallbackCandidate)) { + warnMaterializedBranch(rootDir, logger, fallbackCandidate); + return fallbackCandidate; + } + // Materialization failed (write error, or a concurrent caller won the race). The + // candidate may exist NOW — verify before naming it; otherwise try the plain + // fallback, then materialize THAT, and only give up when no local ref exists. + if (await branchRefExists(rootDir, fallbackCandidate)) { + warnFallback(rootDir, logger, remotes); + return fallbackCandidate; + } + if (fallbackCandidate !== INTEGRATION_BRANCH_FALLBACK) { + if (await materializeLocalBranch(rootDir, INTEGRATION_BRANCH_FALLBACK)) { + warnMaterializedBranch(rootDir, logger, INTEGRATION_BRANCH_FALLBACK); + return INTEGRATION_BRANCH_FALLBACK; + } + } + if (await branchRefExists(rootDir, INTEGRATION_BRANCH_FALLBACK)) { + warnFallback(rootDir, logger, remotes); + return INTEGRATION_BRANCH_FALLBACK; + } + throw new Error( + `[integration-branch] could not establish a local integration branch for ${rootDir}: ` + + `no configured, origin/HEAD, or inferred candidate has a local ref, and 'main' could not be ` + + `verified or created from refs/remotes/origin. Set integrationBranch explicitly or fetch the default branch.`, + ); } export function resolveIntegrationBranchSync( @@ -268,26 +590,75 @@ export function resolveIntegrationBranchSync( const logger = opts.logger ?? console; const fromSettings = resolveFromSettings(settings); - if (fromSettings.length > 0) { - return fromSettings; + for (const candidate of fromSettings) { + if (!isUsableBranchName(candidate)) { + warnSkippedCandidate(rootDir, logger, candidate, "not a usable git branch name; fix integrationBranch or baseBranch."); + continue; + } + if (branchRefExistsSync(rootDir, candidate)) { + return candidate; + } + if (materializeLocalBranchSync(rootDir, candidate)) { + warnMaterializedBranch(rootDir, logger, candidate); + return candidate; + } + warnSkippedCandidate(rootDir, logger, candidate, `no local refs/heads/${candidate} and no refs/remotes/origin/${candidate} to create it from; fetch the branch or fix integrationBranch/baseBranch.`); } const fromOrigin = resolveFromOriginHeadSync(rootDir); - if (fromOrigin.length > 0) { + if (fromOrigin.length > 0 && isUsableBranchName(fromOrigin) && branchRefExistsSync(rootDir, fromOrigin)) { return fromOrigin; } + // origin/HEAD names the remote's authoritative default: when only the + // remote-tracking ref exists, materialize it here instead of letting local + // inference (which prefers well-known local branches) redirect merges. + if (fromOrigin.length > 0 && isUsableBranchName(fromOrigin)) { + if (materializeLocalBranchSync(rootDir, fromOrigin)) { + warnMaterializedBranch(rootDir, logger, fromOrigin); + return fromOrigin; + } + } const inferred = resolveInferredBranchSync(rootDir); if (inferred) { - warnInferredBranch(rootDir, logger, inferred.branch, inferred.source); - return inferred.branch; + if (isUsableBranchName(inferred.branch) && branchRefExistsSync(rootDir, inferred.branch)) { + warnInferredBranch(rootDir, logger, inferred.branch, inferred.source); + return inferred.branch; + } + if (materializeLocalBranchSync(rootDir, inferred.branch)) { + warnMaterializedBranch(rootDir, logger, inferred.branch); + return inferred.branch; + } } const remotes = listGitRemotesSync(rootDir); - warnFallback(rootDir, logger, remotes); - return INTEGRATION_BRANCH_FALLBACK; + const fallbackCandidate = fromOrigin.length > 0 ? fromOrigin : INTEGRATION_BRANCH_FALLBACK; + if (materializeLocalBranchSync(rootDir, fallbackCandidate)) { + warnMaterializedBranch(rootDir, logger, fallbackCandidate); + return fallbackCandidate; + } + if (branchRefExistsSync(rootDir, fallbackCandidate)) { + warnFallback(rootDir, logger, remotes); + return fallbackCandidate; + } + if (fallbackCandidate !== INTEGRATION_BRANCH_FALLBACK) { + if (materializeLocalBranchSync(rootDir, INTEGRATION_BRANCH_FALLBACK)) { + warnMaterializedBranch(rootDir, logger, INTEGRATION_BRANCH_FALLBACK); + return INTEGRATION_BRANCH_FALLBACK; + } + } + if (branchRefExistsSync(rootDir, INTEGRATION_BRANCH_FALLBACK)) { + warnFallback(rootDir, logger, remotes); + return INTEGRATION_BRANCH_FALLBACK; + } + throw new Error( + `[integration-branch] could not establish a local integration branch for ${rootDir}: ` + + `no configured, origin/HEAD, or inferred candidate has a local ref, and 'main' could not be ` + + `verified or created from refs/remotes/origin. Set integrationBranch explicitly or fetch the default branch.`, + ); } export function __resetIntegrationBranchCacheForTests(): void { warnedFallbackRootDirs.clear(); + warnedSkippedCandidates.clear(); }