From d441263f81d9c9487bfdecaece957d3c6dc8f016 Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 16 Apr 2026 12:43:55 -0700 Subject: [PATCH 1/4] Add ahead-of-base PR detection - Track `aheadOfBaseCount` through git status contracts and clients - Let Create PR use remote branch divergence instead of only local ahead count - Preserve push behavior for branches with unpushed commits --- apps/server/src/git/Layers/GitCore.test.ts | 31 ++++++++++ apps/server/src/git/Layers/GitCore.ts | 11 +++- apps/server/src/git/Layers/GitManager.test.ts | 1 + apps/server/src/git/Layers/GitManager.ts | 2 + .../git/Layers/GitStatusBroadcaster.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 6 +- apps/server/src/server.test.ts | 9 +++ .../components/GitActionsControl.browser.tsx | 1 + .../GitActionsControl.logic.test.ts | 56 ++++++++++++++++++- .../src/components/GitActionsControl.logic.ts | 13 ++++- apps/web/src/components/GitActionsControl.tsx | 3 +- apps/web/src/lib/gitStatusState.test.ts | 1 + apps/web/src/localApi.test.ts | 1 + apps/web/src/rpc/wsRpcClient.test.ts | 2 + packages/contracts/src/git.ts | 1 + packages/shared/src/git.test.ts | 5 ++ packages/shared/src/git.ts | 2 + 17 files changed, 135 insertions(+), 11 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 53b881b6666..2c066ec3596 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1642,6 +1642,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(details.branch).toBe("feature/no-upstream-ahead"); expect(details.hasUpstream).toBe(false); expect(details.aheadCount).toBe(1); + expect(details.aheadOfBaseCount).toBe(1); expect(details.behindCount).toBe(0); }), ); @@ -1674,6 +1675,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(details.branch).toBe("feature/remote-base-only"); expect(details.hasUpstream).toBe(false); expect(details.aheadCount).toBe(1); + expect(details.aheadOfBaseCount).toBe(1); expect(details.behindCount).toBe(0); }), ); @@ -1712,10 +1714,39 @@ it.layer(TestLayer)("git integration", (it) => { expect(details.branch).toBe("feature/non-origin-merge-base"); expect(details.hasUpstream).toBe(false); expect(details.aheadCount).toBe(1); + expect(details.aheadOfBaseCount).toBe(1); expect(details.behindCount).toBe(0); }), ); + it.effect("reports ahead-of-base count even when branch is fully pushed to upstream", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + yield* git(source, ["checkout", "-b", "feature/pushed-no-pr"]); + yield* writeTextFile(path.join(source, "feature.txt"), "remote branch ahead of base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature commit"]); + yield* git(source, ["push", "-u", "origin", "feature/pushed-no-pr"]); + + const core = yield* GitCore; + const details = yield* core.statusDetails(source); + expect(details.branch).toBe("feature/pushed-no-pr"); + expect(details.hasUpstream).toBe(true); + expect(details.aheadCount).toBe(0); + expect(details.aheadOfBaseCount).toBe(1); + expect(details.behindCount).toBe(0); + }), + ); + it.effect("skips push when no upstream is configured and branch is not ahead of base", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index e5547377b41..fbff355637e 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1232,6 +1232,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; + let aheadOfBaseCount = 0; let behindCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); @@ -1261,10 +1262,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } } - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + if (branch) { + aheadOfBaseCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( Effect.catch(() => Effect.succeed(0)), ); + } + + if (!upstreamRef && branch) { + aheadCount = aheadOfBaseCount; behindCount = 0; } @@ -1311,6 +1316,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, hasUpstream: upstreamRef !== null, aheadCount, + aheadOfBaseCount, behindCount, }; }); @@ -1337,6 +1343,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, + aheadOfBaseCount: details.aheadOfBaseCount, behindCount: details.behindCount, pr: null, })), diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 38cbd13014d..f294599e6b0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -714,6 +714,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 33e9719804b..6f936fcb3ec 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -709,6 +709,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, } satisfies GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { @@ -759,6 +760,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, + aheadOfBaseCount: details.aheadOfBaseCount, behindCount: details.behindCount, pr, } satisfies GitStatusRemoteResult; diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts index 72a0c24e27b..b9dc9ea1b8f 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -29,6 +29,7 @@ const baseLocalStatus: GitStatusLocalResult = { const baseRemoteStatus: GitStatusRemoteResult = { hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index fb32da78c5e..fa5877a9adc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1629,11 +1629,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; } - if ( - block.type !== "tool_use" && - block.type !== "server_tool_use" && - block.type !== "mcp_tool_use" - ) { + if (block.type !== "tool_use") { return; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8d807afba38..6d71799424a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2088,6 +2088,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.succeed({ hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), @@ -2101,6 +2102,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), @@ -2362,6 +2364,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { return { hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -2378,6 +2381,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -2439,6 +2443,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { return { hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -2455,6 +2460,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -2510,6 +2516,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.as({ hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), @@ -2553,6 +2560,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.as({ hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), @@ -2631,6 +2639,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.as({ hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index c54e63f54ff..6d60ab375dc 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -117,6 +117,7 @@ vi.mock("~/lib/gitStatusState", () => ({ workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 1, + aheadOfBaseCount: 1, behindCount: 0, pr: null, }, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 267cbec180d..cba25bf39d6 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -12,6 +12,7 @@ import { } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): GitStatusResult { + const aheadOfBaseCount = overrides.aheadOfBaseCount ?? overrides.aheadCount ?? 0; return { isRepo: true, hasOriginRemote: true, @@ -25,6 +26,7 @@ function status(overrides: Partial = {}): GitStatusResult { }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount, behindCount: 0, pr: null, ...overrides, @@ -214,7 +216,10 @@ describe("when: branch is clean, ahead, and has an open PR", () => { describe("when: branch is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); + const quick = resolveQuickAction( + status({ aheadCount: 2, aheadOfBaseCount: 2, pr: null }), + false, + ); assert.deepInclude(quick, { kind: "run_action", action: "create_pr", @@ -223,7 +228,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); + const items = buildMenuItems(status({ aheadCount: 2, aheadOfBaseCount: 2, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -253,6 +258,53 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); }); +describe("when: branch is clean, synced with upstream, and remote is ahead of base", () => { + it("resolveQuickAction creates a PR without pushing again", () => { + const quick = resolveQuickAction( + status({ aheadCount: 0, aheadOfBaseCount: 2, behindCount: 0, pr: null }), + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Create PR", + }); + }); + + it("buildMenuItems enables create PR while keeping push disabled", () => { + const items = buildMenuItems( + status({ aheadCount: 0, aheadOfBaseCount: 2, behindCount: 0, pr: null }), + false, + ); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: false, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + describe("when: branch is clean, up to date, and has no open PR", () => { it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index b4b0b98b0b1..2b55362a58e 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -86,6 +86,7 @@ export function buildMenuItems( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; + const isAheadOfBase = gitStatus.aheadOfBaseCount > 0; const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; const canPush = @@ -100,7 +101,7 @@ export function buildMenuItems( hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + isAheadOfBase && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -164,6 +165,7 @@ export function resolveQuickAction( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const isAheadOfBase = gitStatus.aheadOfBaseCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; @@ -264,6 +266,15 @@ export function resolveQuickAction( }; } + if (isAheadOfBase && !hasOpenPr && !isDefaultBranch) { + return { + label: "Create PR", + disabled: false, + kind: "run_action", + action: "create_pr", + }; + } + if (hasOpenPr && gitStatus.hasUpstream) { return { label: "View PR", disabled: false, kind: "open_pr" }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8ff73da785..7f45c22c3f5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -135,6 +135,7 @@ function getMenuActionDisabledReason({ const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const isAheadOfBase = gitStatus.aheadOfBaseCount > 0; const isBehind = gitStatus.behindCount > 0; if (item.id === "commit") { @@ -175,7 +176,7 @@ function getMenuActionDisabledReason({ if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if (!isAheadOfBase) { return "No local commits to include in a PR."; } if (isBehind) { diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index b5317d9ec11..f34046ea623 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -48,6 +48,7 @@ const BASE_STATUS: GitStatusResult = { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 68047f4495e..14c9bce3176 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -258,6 +258,7 @@ const baseGitStatus: GitStatusResult = { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; diff --git a/apps/web/src/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index 56f39b1bd32..441d1107da7 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -30,6 +30,7 @@ const baseLocalStatus: GitStatusLocalResult = { const baseRemoteStatus: GitStatusRemoteResult = { hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -82,6 +83,7 @@ describe("wsRpcClient", () => { ...baseLocalStatus, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 345208acf9e..be95a94ae47 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -223,6 +223,7 @@ const GitStatusLocalShape = { const GitStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, + aheadOfBaseCount: NonNegativeInt, behindCount: NonNegativeInt, pr: Schema.NullOr(GitStatusPr), }; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index dac644e83bb..1703957534f 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -55,6 +55,7 @@ describe("applyGitStatusStreamEvent", () => { const remote: GitStatusRemoteResult = { hasUpstream: true, aheadCount: 2, + aheadOfBaseCount: 2, behindCount: 1, pr: null, }; @@ -68,6 +69,7 @@ describe("applyGitStatusStreamEvent", () => { workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 2, + aheadOfBaseCount: 2, behindCount: 1, pr: null, }); @@ -92,6 +94,7 @@ describe("applyGitStatusStreamEvent", () => { }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -99,6 +102,7 @@ describe("applyGitStatusStreamEvent", () => { const remote: GitStatusRemoteResult = { hasUpstream: true, aheadCount: 2, + aheadOfBaseCount: 2, behindCount: 1, pr: null, }; @@ -107,6 +111,7 @@ describe("applyGitStatusStreamEvent", () => { ...current, hasUpstream: true, aheadCount: 2, + aheadOfBaseCount: 2, behindCount: 1, pr: null, }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index a39c9244477..514806172cb 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -245,6 +245,7 @@ export function detectGitHostingProviderFromRemoteUrl( const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }; @@ -263,6 +264,7 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { return { hasUpstream: status.hasUpstream, aheadCount: status.aheadCount, + aheadOfBaseCount: status.aheadOfBaseCount, behindCount: status.behindCount, pr: status.pr, }; From 9af889b78ec00096932a7efeb6837c72503b01f9 Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 16 Apr 2026 13:20:38 -0700 Subject: [PATCH 2/4] Remove unrelated ClaudeAdapter change --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6205356d8c8..feacfa99ea2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1690,7 +1690,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; } - if (block.type !== "tool_use") { + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { return; } From fd242c06bcedd6747fb857d267f66aaa1f95aa98 Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 16 Apr 2026 13:27:11 -0700 Subject: [PATCH 3/4] Fix ahead-of-base status fixtures --- apps/server/src/git/Layers/GitCore.ts | 1 + .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 1 + apps/server/src/server.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 706ccb4ca8b..98be7a75e17 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -70,6 +70,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7a4913ca32c..e96a88cd182 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -195,6 +195,7 @@ describe("ProviderCommandReactor", () => { }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b8686d91bcd..9179b1c5c85 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3008,6 +3008,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, hasUpstream: true, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }), From b7f5f39f9814264517062a93ee7ebb96b35c104d Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 16 Apr 2026 14:33:44 -0700 Subject: [PATCH 4/4] Stabilize tests for ahead-of-base status --- apps/desktop/src/backendReadiness.test.ts | 4 ++-- apps/server/src/git/Layers/GitCore.test.ts | 4 ++++ apps/server/src/git/Layers/GitManager.test.ts | 4 ++++ apps/server/src/git/testHelpers.ts | 24 +++++++++++++++++++ .../Layers/CheckpointReactor.test.ts | 3 +++ .../service.addSavedEnvironment.test.ts | 2 +- 6 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/git/testHelpers.ts diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 33a5ef6b715..b54a28dd337 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -46,9 +46,9 @@ describe("waitForHttpReady", () => { await waitForHttpReady("http://127.0.0.1:3773", { fetchImpl, - timeoutMs: 100, + timeoutMs: 500, intervalMs: 0, - requestTimeoutMs: 1, + requestTimeoutMs: 10, }); expect(fetchImpl).toHaveBeenCalledTimes(2); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index a64f63da239..bdf141f7a2e 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -9,11 +9,14 @@ import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; +import { preferRealGitOnPathForTests } from "../testHelpers.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; import { ServerConfig } from "../../config.ts"; // ── Helpers ── +preferRealGitOnPathForTests(); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfigLayer), @@ -1707,6 +1710,7 @@ it.layer(TestLayer)("git integration", (it) => { }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, }); expect(localStatus).toEqual(status); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 2d7ab7057cf..f6a17cd29e6 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -24,6 +24,7 @@ import { type TextGenerationShape, TextGeneration } from "../Services/TextGenera import { GitCoreLive } from "./GitCore.ts"; import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; +import { preferRealGitOnPathForTests } from "../testHelpers.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { @@ -53,6 +54,8 @@ interface FakeGhScenario { failWith?: GitHubCliError; } +preferRealGitOnPathForTests(); + interface FakeGitTextGeneration { generateCommitMessage: (input: { cwd: string; @@ -900,6 +903,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, hasUpstream: false, aheadCount: 0, + aheadOfBaseCount: 0, behindCount: 0, pr: null, }); diff --git a/apps/server/src/git/testHelpers.ts b/apps/server/src/git/testHelpers.ts new file mode 100644 index 00000000000..2681d70695c --- /dev/null +++ b/apps/server/src/git/testHelpers.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +export function preferRealGitOnPathForTests(): void { + process.env.GIT_CONFIG_GLOBAL = process.platform === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_NOSYSTEM = "1"; + + const result = spawnSync("git", ["ai", "git-path"], { encoding: "utf8" }); + if (result.status !== 0) { + return; + } + + const gitPath = result.stdout.trim(); + if (gitPath.length === 0) { + return; + } + + const gitDir = path.dirname(gitPath); + const pathEntries = (process.env.PATH ?? "") + .split(path.delimiter) + .filter((entry) => entry.length > 0 && entry !== gitDir); + + process.env.PATH = [gitDir, ...pathEntries].join(path.delimiter); +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 12e11450dd3..840d1db906b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -43,10 +43,13 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import { preferRealGitOnPathForTests } from "../../git/testHelpers.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +preferRealGitOnPathForTests(); + type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 9bcbc4f7133..956d0100679 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -101,5 +101,5 @@ describe("addSavedEnvironment", () => { expect(mockUpsert).not.toHaveBeenCalled(); await resetEnvironmentServiceForTests(); - }); + }, 15_000); });