From 71583cf570a191a491236fdd071ab142b4eca61d Mon Sep 17 00:00:00 2001 From: Henry Chu Date: Mon, 5 Oct 2026 21:23:35 -0400 Subject: [PATCH 1/3] Converge the Git layer onto upstream's driver, GitManager, and broadcaster Restore upstream GitVcsDriverCore, GitVcsDriver, GitManager, GitWorkflowService, VcsStatusBroadcaster, and remoteRefs from 024d49520e, keeping only the Coder seams documented in docs/internals/coder-only.md: - worktree moves, index-preserving ref reads, 32 MiB typed review expansion, single-source review, bounded t3.json reads, and checkpoint identity - GitLab-only process classification and the write-probe hook - a provider write-access gate before any MR action, GitLab MR templates, and refs/merge-requests head fetches - the sidebar ref stream, per-project auto-pull, and a demand-only BackgroundPolicy stand-in Replace coderVcsStatus with the broadcaster, move the MR reactors and storage cleanup onto GitManager, port upstream includeBranch commit/branch generation, and re-export the upstream shared helpers GitManager uses. Co-Authored-By: Claude Opus 5.5 --- .../server/src/background/BackgroundPolicy.ts | 21 + apps/server/src/coderRuntime.ts | 25 +- apps/server/src/coderVcsStatus.test.ts | 416 -- apps/server/src/coderVcsStatus.ts | 235 - apps/server/src/coderWs.projects.test.ts | 99 +- apps/server/src/coderWs.ts | 92 +- apps/server/src/git/GitManager.test.ts | 5832 +++++++++++++++++ apps/server/src/git/GitManager.ts | 2922 +++++++++ .../server/src/git/GitWorkflowService.test.ts | 1525 +---- apps/server/src/git/GitWorkflowService.ts | 1875 +----- apps/server/src/git/remoteRefs.ts | 71 + .../Layers/CheckpointReactor.test.ts | 8 +- .../orchestration/Layers/CheckpointReactor.ts | 6 +- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../Layers/ProviderCommandReactor.ts | 6 +- .../ThreadPullRequestReactor.test.ts | 5 +- .../orchestration/ThreadPullRequestReactor.ts | 6 +- .../ThreadSettlementReactor.test.ts | 12 +- .../orchestration/ThreadSettlementReactor.ts | 4 +- apps/server/src/project/NewProject.ts | 9 +- apps/server/src/serverSettings.ts | 2 + apps/server/src/storageCleanup.test.ts | 6 +- apps/server/src/storageCleanup.ts | 4 +- .../ClaudeTextGeneration.test.ts | 35 + .../textGeneration/ClaudeTextGeneration.ts | 3 + .../src/textGeneration/CodexTextGeneration.ts | 5 +- .../src/textGeneration/TextGeneration.ts | 4 + .../textGeneration/TextGenerationPrompts.ts | 19 +- apps/server/src/vcs/GitVcsDriver.test.ts | 2090 +++--- apps/server/src/vcs/GitVcsDriver.ts | 1864 +----- apps/server/src/vcs/GitVcsDriverCore.test.ts | 3259 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3756 +++++++++++ apps/server/src/vcs/VcsProcess.ts | 31 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 1239 ++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 782 +++ apps/server/src/vcs/projectAutoPull.test.ts | 93 +- apps/server/src/vcs/projectAutoPull.ts | 100 +- docs/internals/coder-only.md | 26 + packages/contracts/src/server.ts | 2 +- packages/shared/src/serverSettings.ts | 16 +- packages/shared/src/sourceControl.ts | 4 +- 41 files changed, 20229 insertions(+), 6288 deletions(-) create mode 100644 apps/server/src/background/BackgroundPolicy.ts delete mode 100644 apps/server/src/coderVcsStatus.test.ts delete mode 100644 apps/server/src/coderVcsStatus.ts create mode 100644 apps/server/src/git/GitManager.test.ts create mode 100644 apps/server/src/git/GitManager.ts create mode 100644 apps/server/src/git/remoteRefs.ts create mode 100644 apps/server/src/vcs/GitVcsDriverCore.test.ts create mode 100644 apps/server/src/vcs/GitVcsDriverCore.ts create mode 100644 apps/server/src/vcs/VcsStatusBroadcaster.test.ts create mode 100644 apps/server/src/vcs/VcsStatusBroadcaster.ts diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts new file mode 100644 index 0000000000000..b2c090196805d --- /dev/null +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -0,0 +1,21 @@ +// Coder: VCS polling is already scoped to browser subscription demand. With no +// client activity leases or host power monitor, scope work is always allowed. +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export interface BackgroundScope { + readonly type: "vcs-status"; + readonly cwd: string; +} + +export class BackgroundPolicy extends Context.Service< + BackgroundPolicy, + { + readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect; + } +>()("t3/background/BackgroundPolicy") {} + +export const layer = Layer.succeed(BackgroundPolicy, { + shouldRunScopeWork: () => Effect.succeed(true), +}); diff --git a/apps/server/src/coderRuntime.ts b/apps/server/src/coderRuntime.ts index a03354e62e0b9..e7031c298b191 100644 --- a/apps/server/src/coderRuntime.ts +++ b/apps/server/src/coderRuntime.ts @@ -19,7 +19,9 @@ import * as ServerConfig from "./config.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as CoderEnvironment from "./coderEnvironment.ts"; -import * as CoderVcsStatus from "./coderVcsStatus.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as GitManager from "./git/GitManager.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -130,13 +132,20 @@ const CoderProjectSetupScriptRunnerLive = ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(CoderTerminalLive), ); -const CoderGitWorkflowLive = GitWorkflowService.layer.pipe( +const CoderGitManagerLive = GitManager.layer.pipe( Layer.provide(SqlitePersistenceLayerLive), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(CoderSourceControlLive), Layer.provideMerge(CoderTextGenerationLive), Layer.provideMerge(CoderSettingsLive), Layer.provideMerge(CoderProjectSetupScriptRunnerLive), + Layer.provideMerge(CoderProviderInstancesLive), +); + +const CoderGitWorkflowLive = GitWorkflowService.layer.pipe( + Layer.provideMerge(CoderGitManagerLive), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(CoderVcsDriverRegistryLive), ); const CoderSourceControlRepositoriesLive = SourceControlRepositoryService.layer.pipe( @@ -152,7 +161,17 @@ const CoderVcsLive = Layer.mergeAll( CoderGitWorkflowLive, CoderSourceControlRepositoriesLive, ProjectCloneTracker.layer.pipe(Layer.provide(CoderSourceControlRepositoriesLive)), - CoderVcsStatus.layer.pipe(Layer.provide(CoderGitWorkflowLive)), + // Coder: provide the demand-only background policy and fork settings resolution. + VcsStatusBroadcaster.layer.pipe( + Layer.provide(CoderGitWorkflowLive), + Layer.provide(BackgroundPolicy.layer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe( + Layer.provide(CoderOrchestrationLayerLive), + Layer.provide(CoderSettingsLive), + ), + ), + ), ReviewService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(CoderVcsDriverRegistryLive), diff --git a/apps/server/src/coderVcsStatus.test.ts b/apps/server/src/coderVcsStatus.test.ts deleted file mode 100644 index 88dbb81bd5204..0000000000000 --- a/apps/server/src/coderVcsStatus.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as SubscriptionRef from "effect/SubscriptionRef"; -import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; -import { - DEFAULT_SERVER_SETTINGS, - GitCommandError, - type OrchestrationProject, -} from "@t3tools/contracts"; - -import { CoderVcsStatus, layer } from "./coderVcsStatus.ts"; -import * as GitWorkflowService from "./git/GitWorkflowService.ts"; -import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as ServerSettings from "./serverSettings.ts"; - -const status = (refName: string) => ({ - isRepo: true, - hasPrimaryRemote: false, - isDefaultRef: refName === "main", - refName, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, -}); - -describe("CoderVcsStatus", () => { - it.effect("automatically pulls an enabled clean default branch when it is behind", () => { - let pullCalls = 0; - let remoteReads = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: () => - Effect.sync(() => ({ - hasUpstream: true, - aheadCount: 0, - behindCount: remoteReads++ === 0 ? 2 : 0, - pr: null, - })), - pull: () => - Effect.sync(() => { - pullCalls += 1; - return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; - }), - }), - ), - Layer.provideMerge( - Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getActiveProjectByWorkspaceRoot: () => - Effect.succeed(Option.some({ autoPull: true } as OrchestrationProject)), - }), - ), - ); - - return Effect.gen(function* () { - const vcsStatus = yield* CoderVcsStatus; - yield* vcsStatus.refresh("/repo"); - expect(pullCalls).toBe(1); - expect(remoteReads).toBe(2); - }).pipe(Effect.provide(testLayer)); - }); - - it.effect("serializes an explicit refresh behind the initial snapshot", () => - Effect.gen(function* () { - const snapshotStarted = yield* Deferred.make(); - const finishSnapshot = yield* Deferred.make(); - let reads = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => { - reads += 1; - if (reads === 1) { - return Deferred.succeed(snapshotStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishSnapshot)), - Effect.as(status("main")), - ); - } - return Effect.succeed(status("feature")); - }, - remoteStatus: () => Effect.succeed(null), - }), - ), - ); - - const events = yield* Effect.gen(function* () { - const vcsStatus = yield* CoderVcsStatus; - const streamFiber = yield* vcsStatus - .stream("/repo") - .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); - yield* Deferred.await(snapshotStarted); - const refresh = yield* vcsStatus.refresh("/repo").pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.zero); - expect(reads).toBe(1); - yield* Deferred.succeed(finishSnapshot, undefined); - yield* Fiber.join(refresh); - return yield* Fiber.join(streamFiber); - }).pipe(Effect.provide(testLayer)); - - expect(Array.from(events)).toEqual([ - { _tag: "snapshot", local: status("main"), remote: null }, - { _tag: "localUpdated", local: status("feature") }, - ]); - }), - ); - - it.effect("polls Git status at the configured interval", () => { - let remoteReads = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: () => - Effect.sync(() => { - remoteReads += 1; - return null; - }), - }), - ), - Layer.provideMerge( - Layer.mock(ServerSettings.ServerSettingsService)({ - subscribeChanges: Effect.succeed(Stream.empty), - getSettings: Effect.succeed({ - ...DEFAULT_SERVER_SETTINGS, - automaticGitFetchInterval: Duration.seconds(5), - }), - }), - ), - ); - - return Effect.gen(function* () { - const vcsStatus = yield* CoderVcsStatus; - const eventsFiber = yield* vcsStatus - .stream("/repo") - .pipe(Stream.take(3), Stream.runCollect, Effect.forkChild); - yield* Effect.yieldNow; - expect(remoteReads).toBe(1); - yield* Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow); - yield* TestClock.adjust(Duration.seconds(5)); - const events = Array.from(yield* Fiber.join(eventsFiber)); - expect(events.map((event) => event._tag)).toEqual([ - "snapshot", - "localUpdated", - "remoteUpdated", - ]); - expect(remoteReads).toBe(2); - }).pipe(Effect.provide(testLayer)); - }); - - it.effect("keeps polling after a transient Git failure", () => { - let remoteReads = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: () => { - remoteReads += 1; - return remoteReads === 2 - ? Effect.fail( - new GitCommandError({ - operation: "test.poll", - command: "git", - cwd: "/repo", - detail: "Transient upstream failure.", - }), - ) - : Effect.succeed(null); - }, - }), - ), - Layer.provideMerge( - Layer.mock(ServerSettings.ServerSettingsService)({ - subscribeChanges: Effect.succeed(Stream.empty), - getSettings: Effect.succeed({ - ...DEFAULT_SERVER_SETTINGS, - automaticGitFetchInterval: Duration.seconds(5), - }), - }), - ), - ); - - return Effect.gen(function* () { - const vcsStatus = yield* CoderVcsStatus; - const eventsFiber = yield* vcsStatus - .stream("/repo") - .pipe(Stream.take(3), Stream.runCollect, Effect.forkChild); - yield* Effect.yieldNow; - yield* Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow); - yield* TestClock.adjust(Duration.seconds(5)); - yield* Effect.yieldNow; - yield* Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow); - yield* TestClock.adjust(Duration.seconds(5)); - const events = Array.from(yield* Fiber.join(eventsFiber)); - - expect(events.map((event) => event._tag)).toEqual([ - "snapshot", - "localUpdated", - "remoteUpdated", - ]); - expect(remoteReads).toBe(3); - }).pipe(Effect.provide(testLayer)); - }); -}); - -it.effect( - "shares polling across subscribers, applies interval changes, and stops after unsubscribe", - () => - Effect.gen(function* () { - const settings = yield* SubscriptionRef.make({ - ...DEFAULT_SERVER_SETTINGS, - automaticGitFetchInterval: Duration.zero, - }); - let reads = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: () => - Effect.sync(() => { - reads++; - return null; - }), - }), - ), - Layer.provide( - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: SubscriptionRef.get(settings), - subscribeChanges: Effect.succeed(SubscriptionRef.changes(settings)), - }), - ), - ); - yield* Effect.gen(function* () { - const vcs = yield* CoderVcsStatus; - const first = yield* vcs.stream("/repo").pipe(Stream.runDrain, Effect.forkChild); - const second = yield* vcs.stream("/repo").pipe(Stream.runDrain, Effect.forkChild); - const settle = Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow); - yield* settle; - expect(reads).toBe(2); - yield* TestClock.adjust(Duration.seconds(10)); - expect(reads).toBe(2); - yield* SubscriptionRef.update(settings, (value) => ({ - ...value, - automaticGitFetchInterval: Duration.seconds(2), - })); - yield* settle; - yield* TestClock.adjust(Duration.seconds(2)); - expect(reads).toBe(3); - yield* Fiber.interrupt(first); - yield* settle; - yield* TestClock.adjust(Duration.seconds(2)); - expect(reads).toBe(4); - yield* Fiber.interrupt(second); - yield* TestClock.adjust(Duration.seconds(10)); - expect(reads).toBe(4); - }).pipe(Effect.provide(testLayer)); - }), -); - -it.effect("local refresh neither fetches nor automatically pulls", () => { - const fetches: boolean[] = []; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: (_input, options) => - Effect.sync(() => { - fetches.push(options?.fetch ?? false); - return { hasUpstream: true, aheadCount: 0, behindCount: 2, pr: null }; - }), - pull: () => Effect.die("A local refresh must not pull"), - }), - ), - Layer.provide( - Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getActiveProjectByWorkspaceRoot: () => - Effect.succeed(Option.some({ autoPull: true } as OrchestrationProject)), - }), - ), - ); - return Effect.gen(function* () { - const vcs = yield* CoderVcsStatus; - yield* vcs.refresh("/repo", { fetch: false }); - expect(fetches).toEqual([false]); - }).pipe(Effect.provide(testLayer)); -}); - -it.effect("serializes automatic pulls for concurrent refreshes of the same repository", () => - Effect.gen(function* () { - const release = yield* Deferred.make(); - let active = 0; - let maximum = 0; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => Effect.succeed(status("main")), - remoteStatus: () => - Effect.succeed({ hasUpstream: true, aheadCount: 0, behindCount: 1, pr: null }), - pull: () => - Effect.gen(function* () { - maximum = Math.max(maximum, ++active); - yield* Deferred.await(release); - active--; - return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; - }), - }), - ), - Layer.provide( - Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getActiveProjectByWorkspaceRoot: () => - Effect.succeed(Option.some({ autoPull: true } as OrchestrationProject)), - }), - ), - ); - yield* Effect.gen(function* () { - const vcs = yield* CoderVcsStatus; - const requests = yield* Effect.all([vcs.refresh("/repo"), vcs.refresh("/repo")], { - concurrency: "unbounded", - }).pipe(Effect.forkChild); - yield* Effect.forEach(Array.from({ length: 20 }), () => Effect.yieldNow); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(requests); - expect(maximum).toBe(1); - }).pipe(Effect.provide(testLayer)); - }), -); - -it.effect("does not publish an older refresh after a newer refresh", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const ready = yield* Deferred.make(); - let reads = 0; - const observed: string[] = []; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: () => { - const index = reads++; - return index === 1 - ? Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.as(status("old")), - ) - : Effect.succeed(status(index === 0 ? "initial" : "new")); - }, - remoteStatus: () => Effect.succeed(null), - }), - ), - ); - yield* Effect.gen(function* () { - const vcs = yield* CoderVcsStatus; - const observer = yield* vcs.stream("/repo").pipe( - Stream.runForEach((event) => { - if ("local" in event) observed.push(event.local.refName!); - return Deferred.succeed(ready, undefined); - }), - Effect.forkChild, - ); - yield* Deferred.await(ready); - const old = yield* vcs.refresh("/repo").pipe(Effect.forkChild); - yield* Deferred.await(started); - const newer = yield* vcs.refresh("/repo").pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.zero); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(old); - yield* Fiber.join(newer); - yield* Effect.forEach(Array.from({ length: 20 }), () => Effect.yieldNow); - yield* Fiber.interrupt(observer); - expect(observed.at(-1)).toBe("new"); - }).pipe(Effect.provide(testLayer)); - }), -); - -it.effect("keeps repositories independent and releases the permit after cancellation", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - let blockFirstRead = true; - const testLayer = layer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - invalidateStatus: () => Effect.void, - localStatus: ({ cwd }) => { - if (cwd === "/repo" && blockFirstRead) { - blockFirstRead = false; - return Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); - } - return Effect.succeed(status(cwd)); - }, - remoteStatus: () => Effect.succeed(null), - }), - ), - ); - yield* Effect.gen(function* () { - const vcs = yield* CoderVcsStatus; - const blocked = yield* vcs.refresh("/repo").pipe(Effect.forkChild); - yield* Deferred.await(started); - expect((yield* vcs.refresh("/other")).refName).toBe("/other"); - yield* Fiber.interrupt(blocked); - expect((yield* vcs.refresh("/repo")).refName).toBe("/repo"); - }).pipe(Effect.provide(testLayer)); - }), -); diff --git a/apps/server/src/coderVcsStatus.ts b/apps/server/src/coderVcsStatus.ts deleted file mode 100644 index 49795022fc584..0000000000000 --- a/apps/server/src/coderVcsStatus.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { - GitManagerServiceError, - VcsRefStatusStreamEvent, - VcsStatusLocalResult, - VcsStatusRemoteResult, - VcsStatusResult, - VcsStatusStreamEvent, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as RcMap from "effect/RcMap"; -import * as Result from "effect/Result"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import { mergeGitStatusParts } from "@t3tools/shared/git"; - -import * as GitWorkflowService from "./git/GitWorkflowService.ts"; -import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as ServerSettings from "./serverSettings.ts"; -import { automaticPullSkipReason } from "./vcs/projectAutoPull.ts"; - -interface StatusChange { - readonly cwd: string; - readonly event: VcsStatusStreamEvent; -} - -export class CoderVcsStatus extends Context.Service< - CoderVcsStatus, - { - readonly refresh: ( - cwd: string, - options?: { readonly fetch?: boolean }, - ) => Effect.Effect; - readonly stream: (cwd: string) => Stream.Stream; - readonly refStream: ( - cwd: string, - ) => Stream.Stream; - } ->()("t3/coderVcsStatus") {} - -export const layer = Layer.effect( - CoderVcsStatus, - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const snapshots = yield* Effect.serviceOption(ProjectionSnapshotQuery.ProjectionSnapshotQuery); - const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const changes = yield* PubSub.unbounded(); - // As in upstream's broadcaster, hold one permit across reads, auto-pull and - // publication so an older operation cannot overwrite a newer result. - const repositoryLocks = new Map(); - const withRepositoryLock = (cwd: string, effect: Effect.Effect) => { - let lock = repositoryLocks.get(cwd); - if (!lock) { - lock = Semaphore.makeUnsafe(1); - repositoryLocks.set(cwd, lock); - } - return lock.withPermits(1)(effect); - }; - const readLocal = (cwd: string) => workflow.localStatus({ cwd }); - const readRemote = (cwd: string, fetch: boolean) => workflow.remoteStatus({ cwd }, { fetch }); - const maybeAutoPull = Effect.fn("CoderVcsStatus.maybeAutoPull")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - ) { - return yield* Effect.gen(function* () { - const enabled = Option.isSome(snapshots) - ? yield* snapshots.value.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => Option.isSome(project) && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ) - : false; - if ( - !enabled || - remote === null || - !remote.hasUpstream || - remote.aheadCount > 0 || - remote.behindCount <= 0 - ) { - return [local, remote] as const; - } - - yield* workflow.invalidateStatus(cwd); - const freshLocal = yield* readLocal(cwd); - if (automaticPullSkipReason(freshLocal, remote) !== null) { - return [freshLocal, remote] as const; - } - yield* workflow.pull({ cwd }, { automatic: true }); - yield* workflow.invalidateStatus(cwd); - return yield* Effect.all([readLocal(cwd), readRemote(cwd, false)] as const, { - concurrency: "unbounded", - }); - }).pipe( - Effect.catch(() => - Effect.logWarning("Automatic project pull failed", { cwd }).pipe( - Effect.as([local, remote] as const), - ), - ), - ); - }); - const readStatus = (cwd: string, fetch: boolean) => - Effect.all([readLocal(cwd), readRemote(cwd, fetch)] as const, { - concurrency: "unbounded", - }).pipe( - Effect.flatMap(([local, remote]) => - fetch ? maybeAutoPull(cwd, local, remote) : Effect.succeed([local, remote] as const), - ), - ); - const refresh = Effect.fn("CoderVcsStatus.refresh")(function* ( - cwd: string, - options?: { readonly fetch?: boolean }, - ) { - return yield* withRepositoryLock( - cwd, - Effect.gen(function* () { - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* readStatus(cwd, options?.fetch !== false); - yield* PubSub.publish(changes, { - cwd, - event: { _tag: "localUpdated" as const, local }, - }); - yield* PubSub.publish(changes, { - cwd, - event: { _tag: "remoteUpdated" as const, remote }, - }); - return mergeGitStatusParts(local, remote); - }), - ); - }); - // One polling fiber per subscribed repository; RcMap stops it with the last subscriber. - const pollers = yield* RcMap.make({ - lookup: (cwd: string) => - Effect.gen(function* () { - const settingsChanges = Option.isSome(settings) - ? yield* settings.value.subscribeChanges - : Stream.empty; - const interval = Option.isSome(settings) - ? yield* settings.value.getSettings.pipe( - Effect.map((value) => value.automaticGitFetchInterval), - Effect.orElseSucceed(() => Duration.seconds(15)), - ) - : Duration.seconds(15); - const polling = Stream.concat( - Stream.succeed(interval), - Stream.concat( - settingsChanges.pipe(Stream.map((value) => value.automaticGitFetchInterval)), - Stream.never, - ), - ).pipe( - Stream.changesWith((a, b) => Duration.toMillis(a) === Duration.toMillis(b)), - Stream.switchMap((interval) => - Duration.toMillis(interval) <= 0 - ? Stream.empty - : Stream.tick(interval).pipe( - Stream.drop(1), - Stream.mapEffect(() => - refresh(cwd).pipe( - Effect.catch((cause) => - Effect.logWarning("Coder VCS status polling cycle failed", { - cwd, - cause, - }), - ), - ), - ), - ), - ), - ); - yield* polling.pipe(Stream.runDrain, Effect.forkScoped); - }), - }); - return CoderVcsStatus.of({ - refresh, - stream: (cwd) => - Stream.unwrap( - withRepositoryLock( - cwd, - Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(changes); - yield* RcMap.get(pollers, cwd); - const [local, remote] = yield* readStatus(cwd, true); - const subscriptionChanges = Stream.fromSubscription(subscription).pipe( - Stream.filter((change) => change.cwd === cwd), - Stream.map((change) => change.event), - ); - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local, - remote, - }), - subscriptionChanges, - ); - }), - ), - ), - refStream: (cwd) => - Stream.unwrap( - withRepositoryLock( - cwd, - Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(changes); - const latest = yield* workflow.localRefStatus({ cwd }); - return Stream.concat( - Stream.make({ _tag: "snapshot" as const, local: latest }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((change) => change.cwd === cwd), - Stream.filterMap((change) => - "local" in change.event - ? Result.succeed({ - _tag: "localUpdated" as const, - local: { - isRepo: change.event.local.isRepo, - refName: change.event.local.refName, - }, - }) - : Result.failVoid, - ), - Stream.changesWith( - (left, right) => - left.local.isRepo === right.local.isRepo && - left.local.refName === right.local.refName, - ), - ), - ); - }), - ), - ), - }); - }), -); diff --git a/apps/server/src/coderWs.projects.test.ts b/apps/server/src/coderWs.projects.test.ts index e07f53ca1715e..cbdee5fea84d5 100644 --- a/apps/server/src/coderWs.projects.test.ts +++ b/apps/server/src/coderWs.projects.test.ts @@ -3,7 +3,10 @@ import { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as RpcTest from "effect/unstable/rpc/RpcTest"; import * as Context from "effect/Context"; +// Coder: verify the flat fetch interval supplied to the upstream status stream. +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -11,6 +14,9 @@ import * as Path from "effect/Path"; import { assert, describe, it } from "@effect/vitest"; import { CoderWsRpcGroup, + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + DEFAULT_SERVER_SETTINGS, + ServerSettingsError, WS_METHODS, ORCHESTRATION_WS_METHODS, ProjectId, @@ -39,7 +45,11 @@ import * as Keybindings from "./keybindings.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as ScreenshotArtifacts from "./workspace/ScreenshotArtifacts.ts"; -import * as CoderVcsStatus from "./coderVcsStatus.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; @@ -69,7 +79,14 @@ function stub(key: Context.Key, overrides: Partial } const harness = ( - options: { repository?: boolean; rejectCreate?: boolean; scratchRace?: boolean } = {}, + options: { + repository?: boolean; + rejectCreate?: boolean; + scratchRace?: boolean; + // Coder: exercise status RPC wiring with settings and broadcaster effects stubbed. + settings?: Partial; + vcsStatus?: Partial; + } = {}, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -79,7 +96,7 @@ const harness = ( ); const commands: OrchestrationCommand[] = []; const projects = new Map(); - const services = Layer.mergeAll( + const dependencies = Layer.mergeAll( stub(EnvironmentThemeService), stub(CoderEnvironment.CoderEnvironment), stub(CoderRuntimeStartup.CoderRuntimeStartup), @@ -136,14 +153,19 @@ const harness = ( stub(ProviderMaintenanceRunner), stub(ProviderInstanceRegistry.ProviderInstanceRegistry), stub(ProviderService), - ServerSettings.layerTest(), + options.settings + ? stub(ServerSettings.ServerSettingsService, options.settings) + : ServerSettings.layerTest(), stub(Keybindings.Keybindings), stub(WorkspaceEntries.WorkspaceEntries), stub(WorkspaceFileSystem.WorkspaceFileSystem), stub(ScreenshotArtifacts.ScreenshotArtifacts), - stub(CoderVcsStatus.CoderVcsStatus), - stub(GitWorkflowService.GitWorkflowService), + stub(VcsStatusBroadcaster.VcsStatusBroadcaster, options.vcsStatus), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + stub(SourceControlProviderRegistry.SourceControlProviderRegistry), + stub(TextGeneration.TextGeneration), stub(GitVcsDriver.GitVcsDriver, { + readConfigValue: () => Effect.succeed(null), execute: () => Effect.succeed({ stdout: options.repository ? "true" : "", @@ -168,6 +190,10 @@ const harness = ( ServerConfig.layer(config), WorkspacePaths.layer, ).pipe(Layer.provideMerge(NodeServices.layer)); + // Exercise the real service layers with workspace/provider effects stubbed. + const services = GitWorkflowService.layer.pipe( + Layer.provideMerge(GitManager.layer.pipe(Layer.provideMerge(dependencies))), + ); const client = yield* RpcTest.makeClient(CoderWsRpcGroup).pipe( Effect.provide(CoderWs.layer.pipe(Layer.provide(services))), ); @@ -272,3 +298,64 @@ describe("Coder project RPCs", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + +// Coder: the broadcaster must receive a fresh settings read, including upstream's fallback. +it.effect( + "reads the current flat fetch interval for each status loop and falls back on failure", + () => + Effect.gen(function* () { + let settings = DEFAULT_SERVER_SETTINGS; + let settingsFail = false; + const intervals: number[] = []; + const h = yield* harness({ + settings: { + getSettings: Effect.suspend(() => + settingsFail + ? Effect.fail( + new ServerSettingsError({ + operation: "read-file", + settingsPath: "", + cause: new Error("Unavailable test settings"), + }), + ) + : Effect.succeed(settings), + ), + }, + vcsStatus: { + streamStatus: (input, options) => + Stream.fromEffect( + Effect.gen(function* () { + assert.equal(input.cwd, "/repo"); + const interval = options!.automaticRemoteRefreshInterval!; + intervals.push(Duration.toMillis(yield* interval)); + settings = { ...settings, automaticGitFetchInterval: Duration.seconds(2) }; + intervals.push(Duration.toMillis(yield* interval)); + settings = { ...settings, automaticGitFetchInterval: Duration.zero }; + intervals.push(Duration.toMillis(yield* interval)); + settingsFail = true; + intervals.push(Duration.toMillis(yield* interval)); + return { + _tag: "snapshot" as const, + local: { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: null, + }; + }), + ), + }, + }); + yield* h.client[WS_METHODS.subscribeVcsStatus]({ cwd: "/repo" }).pipe(Stream.runDrain); + assert.deepEqual(intervals, [ + Duration.toMillis(DEFAULT_SERVER_SETTINGS.automaticGitFetchInterval), + 2000, + 0, + Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL), + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/coderWs.ts b/apps/server/src/coderWs.ts index 2f8ef81d0e715..885d1e2e9ed2b 100644 --- a/apps/server/src/coderWs.ts +++ b/apps/server/src/coderWs.ts @@ -40,6 +40,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { CoderWsRpcGroup, + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, CommandId, GitCommandError, type GitActionProgressEvent, @@ -86,7 +87,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as CoderEnvironment from "./coderEnvironment.ts"; import * as CoderRuntimeStartup from "./coderRuntimeStartup.ts"; -import * as CoderVcsStatus from "./coderVcsStatus.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { renameBranchWithCompensation } from "./git/renameBranchWithCompensation.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -471,8 +472,49 @@ export const layer = CoderWsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const screenshotArtifacts = yield* ScreenshotArtifacts.ScreenshotArtifacts; - const vcsStatus = yield* CoderVcsStatus.CoderVcsStatus; + const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + // Coder: read the flat fetch setting on each upstream poll loop, including live changes. + const automaticGitFetchInterval = settings.getSettings.pipe( + Effect.map((value) => value.automaticGitFetchInterval), + Effect.catch((cause) => + Effect.logWarning("Failed to read automatic Git fetch interval setting", { + detail: cause.message, + }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), + ), + ); + const refreshGitStatus = (cwd: string) => + vcsStatus + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); const git = yield* GitWorkflowService.GitWorkflowService; + const prepareWorktreeBase = Effect.fn("prepareWorktreeBase")(function* (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly startFromOrigin?: boolean; + }) { + if (!(yield* git.isRepository(input.cwd))) return null; + let baseRef = input.baseBranch; + if ( + input.startFromOrigin && + (yield* git.remoteExists({ cwd: input.cwd, remoteName: "origin" })) + ) { + yield* git.fetchRemote({ cwd: input.cwd, remoteName: "origin", refName: input.baseBranch }); + if ( + yield* git.remoteBranchExists({ + cwd: input.cwd, + remoteName: "origin", + refName: input.baseBranch, + }) + ) { + baseRef = (yield* git.resolveRemoteTrackingCommit({ + cwd: input.cwd, + refName: input.baseBranch, + fallbackRemoteName: "origin", + })).commitSha; + } + } + return (yield* git.hasCommit({ cwd: input.cwd, refName: baseRef })) ? baseRef : null; + }); const gitVcs = yield* GitVcsDriver.GitVcsDriver; const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; @@ -741,7 +783,7 @@ export const layer = CoderWsRpcGroup.toLayer( ); const refreshCwd = nextWorktreePath ?? project.workspaceRoot; - yield* Effect.all([vcsStatus.refresh(refreshCwd), vcsStatus.refresh(project.workspaceRoot)], { + yield* Effect.all([refreshGitStatus(refreshCwd), refreshGitStatus(project.workspaceRoot)], { concurrency: "unbounded", }).pipe(Effect.ignore); return { branch: input.newBranch, worktreePath: nextWorktreePath }; @@ -869,7 +911,7 @@ export const layer = CoderWsRpcGroup.toLayer( ), ); const worktreeBase = prepareWorktree - ? yield* git.prepareWorktreeBase({ + ? yield* prepareWorktreeBase({ cwd: prepareWorktree.projectCwd, baseBranch: prepareWorktree.baseBranch, ...(prepareWorktree.startFromOrigin ? { startFromOrigin: true } : {}), @@ -986,7 +1028,7 @@ export const layer = CoderWsRpcGroup.toLayer( branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, }); - yield* vcsStatus.refresh(worktree.worktree.path).pipe(Effect.ignore); + yield* refreshGitStatus(worktree.worktree.path); } if (tracked && !createdWorktree) { yield* worktreeSetupTracker.stageStatus( @@ -1630,7 +1672,7 @@ export const layer = CoderWsRpcGroup.toLayer( }).pipe( Effect.mapError((cause) => toDispatchError(cause, "Failed to create project.")), ), - onCloned: ({ workspaceRoot }) => vcsStatus.refresh(workspaceRoot).pipe(Effect.ignore), + onCloned: ({ workspaceRoot }) => refreshGitStatus(workspaceRoot), }), [WS_METHODS.projectCloneCancel]: ({ projectId }) => projectClones.cancel(projectId).pipe(Effect.map((applied) => ({ applied }))), @@ -1642,35 +1684,39 @@ export const layer = CoderWsRpcGroup.toLayer( [WS_METHODS.sourceControlPublishRepository]: (input) => sourceControlRepositories .publishRepository(input) - .pipe(Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore))), - [WS_METHODS.subscribeVcsStatus]: ({ cwd }) => vcsStatus.stream(cwd), + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.subscribeVcsStatus]: (input) => + vcsStatus.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), [WS_METHODS.subscribeWorktreeSetup]: ({ threadId }) => worktreeSetupTracker.stream(threadId), [WS_METHODS.worktreeSetupCancel]: ({ threadId }) => worktreeSetupTracker.cancel(threadId).pipe(Effect.map((cancelled) => ({ cancelled }))), - [WS_METHODS.subscribeVcsRefStatus]: ({ cwd }) => vcsStatus.refStream(cwd), - [WS_METHODS.vcsRefreshStatus]: ({ cwd }) => vcsStatus.refresh(cwd), + // Coder: the sidebar ref subscription remains non-mutating and never fetches. + [WS_METHODS.subscribeVcsRefStatus]: ({ cwd }) => vcsStatus.streamRefStatus(cwd), + [WS_METHODS.vcsRefreshStatus]: ({ cwd }) => vcsStatus.refreshStatus(cwd), [WS_METHODS.vcsPull]: (input) => - git.pull(input).pipe(Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore))), + git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsListRefs]: (input) => git.listRefs(input), [WS_METHODS.vcsCreateWorktree]: (input) => git.createWorktree(input).pipe( Effect.mapError((cause) => gitCommandError("create-worktree", input.cwd, cause)), - Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore)), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.vcsRemoveWorktree]: (input) => git.removeWorktree(input).pipe( Effect.mapError((cause) => gitCommandError("remove-worktree", input.cwd, cause)), - Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore)), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.vcsCreateRef]: (input) => git.createRef(input).pipe( Effect.mapError((cause) => gitCommandError("create-ref", input.cwd, cause)), - Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore)), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.vcsSwitchRef]: (input) => git.switchRef(input).pipe( Effect.mapError((cause) => gitCommandError("switch-ref", input.cwd, cause)), - Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore)), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.vcsRenameThreadBranch]: (input) => renameThreadBranch(input).pipe( @@ -1689,13 +1735,15 @@ export const layer = CoderWsRpcGroup.toLayer( Effect.andThen(Effect.die(Cause.squash(cause))), ), ), - Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore)), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.gitRunStackedAction]: (input) => Stream.callback((queue) => git .runStackedAction(input, { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, }) .pipe( Effect.tap((result) => @@ -1719,17 +1767,15 @@ export const layer = CoderWsRpcGroup.toLayer( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: () => - vcsStatus - .refresh(input.cwd) - .pipe(Effect.ignore, Effect.andThen(Queue.end(queue).pipe(Effect.asVoid))), + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), }), ), ), [WS_METHODS.gitResolvePullRequest]: (input) => git.resolvePullRequest(input), [WS_METHODS.gitPreparePullRequestThread]: (input) => - git - .preparePullRequestThread(input) - .pipe(Effect.tap(() => vcsStatus.refresh(input.cwd).pipe(Effect.ignore))), + git.preparePullRequestThread(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), [WS_METHODS.pullRequestsSummary]: (input) => pullRequests.summary(input), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts new file mode 100644 index 0000000000000..55cb5e63fcf73 --- /dev/null +++ b/apps/server/src/git/GitManager.test.ts @@ -0,0 +1,5832 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { expect } from "vite-plus/test"; +import type { + GitActionProgressEvent, + SourceControlWriteAccess, + GitPreparePullRequestThreadInput, + ThreadId, +} from "@t3tools/contracts"; + +import { + DEFAULT_SERVER_SETTINGS, + GitCommandError, + ProviderDriverKind, + ProviderInstanceId, + TextGenerationError, +} from "@t3tools/contracts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabWriteProbe from "../sourceControl/GitLabWriteProbe.ts"; +import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitManager from "./GitManager.ts"; + +const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +interface FakeGlabScenario { + writeAccess?: SourceControlWriteAccess; + writeProbeCalls?: string[]; + prListSequence?: string[]; + prListByHeadSelector?: Record; + prListSequenceByHeadSelector?: Record; + createdPrUrl?: string; + defaultBranch?: string; + pullRequest?: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; + isDraft?: boolean; + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; + }; + repositoryCloneUrls?: Record; + failWith?: GitLabCli.GitLabCliError; + /** Let this many glab calls succeed before failWith kicks in (default 0 = fail immediately). */ + failAfterCalls?: number; +} + +function fakeGlabOutput(stdout: string): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +// The fake exercises upstream's optional combined commit/branch generation request. +type FakeGitTextGeneration = Omit< + TextGeneration.TextGeneration["Service"], + "generateCommitMessage" +> & { + generateCommitMessage: ( + input: TextGeneration.CommitMessageGenerationInput & { includeBranch?: boolean }, + ) => Effect.Effect< + TextGeneration.CommitMessageGenerationResult & { branch?: string }, + import("@t3tools/contracts").TextGenerationError + >; +}; + +type FakePullRequest = NonNullable; + +function normalizeFakePullRequestSummary(raw: unknown): GitLabCli.GitLabMergeRequestSummary | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const record = raw as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + const headRepository = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwner = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + + if ( + typeof number !== "number" || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + return null; + } + + const state = + typeof record.state === "string" + ? record.state === "OPEN" || record.state === "open" + ? "open" + : record.state === "CLOSED" || record.state === "closed" + ? "closed" + : "merged" + : undefined; + const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined; + const isCrossRepository = + typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepository?.nameWithOwner === "string" + ? headRepository.nameWithOwner + : undefined; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwner?.login === "string" + ? headRepositoryOwner.login + : undefined; + + return { + number, + title: title.trim(), + url: url.trim(), + baseRefName: baseRefName.trim(), + headRefName: headRefName.trim(), + ...(state ? { state } : {}), + closedAt: typeof record.closedAt === "string" ? record.closedAt : null, + mergedAt: typeof record.mergedAt === "string" ? record.mergedAt : null, + updatedAt: + typeof record.updatedAt === "string" ? DateTime.make(record.updatedAt) : Option.none(), + ...(isDraft === true ? { isDraft: true } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +function runGitSyncForFakeGlab(cwd: string, args: readonly string[]): void { + const result = NodeChildProcess.spawnSync("git", args, { + cwd, + encoding: "utf8", + }); + if (result.status === 0) { + return; + } + throw new Error( + `Failed to simulate glab checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + ); +} + +function makeTempDir( + prefix: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function removePath( + targetPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + +function runGit( + cwd: string, + args: readonly string[], + allowNonZeroExit = false, +): Effect.Effect< + { + readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; + readonly stdout: string; + readonly stderr: string; + }, + GitCommandError, + GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ + operation: "GitManager.test.runGit", + cwd, + args, + allowNonZeroExit, + }); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + }); +} + +function initRepo( + cwd: string, +): Effect.Effect< + void, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); + yield* runGit(cwd, ["add", "README.md"]); + yield* runGit(cwd, ["commit", "-m", "Initial commit"]); + }); +} + +function createBareRemote(): Effect.Effect< + string, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const remoteDir = yield* makeTempDir("t3code-git-remote-"); + yield* runGit(remoteDir, ["init", "--bare"]); + return remoteDir; + }); +} + +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + yield* runGit(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + +function configureVisibleRemoteUrlWithLocalRewrite( + cwd: string, + remoteName: string, + visibleUrl: string, + localRemotePath: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, visibleUrl]); + yield* runGit(cwd, ["config", `url.${localRemotePath}.insteadOf`, visibleUrl]); + }); +} + +function createTextGeneration( + overrides: Partial = {}, +): TextGeneration.TextGeneration["Service"] { + const implementation: FakeGitTextGeneration = { + generateCommitMessage: (input) => + Effect.succeed({ + subject: "Implement stacked git actions", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), + }), + generatePrContent: () => + Effect.succeed({ + title: "Add stacked git actions", + body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", + }), + generateBranchName: () => + Effect.succeed({ + branch: "update-workflow", + }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), + ...overrides, + }; + + return { + generateCommitMessage: (input) => + implementation.generateCommitMessage(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateCommitMessage", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generatePrContent: (input) => + implementation.generatePrContent(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generatePrContent", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generateBranchName: (input) => + implementation.generateBranchName(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBranchName", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + }; +} + +function createGitLabCliWithFakeGlab(scenario: FakeGlabScenario = {}): { + service: GitLabCli.GitLabCli["Service"]; + glabCalls: string[]; +} { + const prListQueue = [...(scenario.prListSequence ?? [])]; + const prListQueueByHeadSelector = new Map( + Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ + headSelector, + [...values], + ]), + ); + const glabCalls: string[] = []; + + const execute: GitLabCli.GitLabCli["Service"]["execute"] = (input) => { + const args = [...input.args]; + glabCalls.push(args.join(" ")); + + if (scenario.failWith && glabCalls.length > (scenario.failAfterCalls ?? 0)) { + return Effect.fail(scenario.failWith); + } + + if (args[0] === "mr" && args[1] === "list") { + const headSelectorIndex = args.findIndex((value) => value === "--source-branch"); + const headSelector = + headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 + ? args[headSelectorIndex + 1] + : undefined; + const mappedQueue = + typeof headSelector === "string" + ? prListQueueByHeadSelector.get(headSelector)?.shift() + : undefined; + const mappedStdout = + typeof headSelector === "string" + ? scenario.prListByHeadSelector?.[headSelector] + : undefined; + const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; + return Effect.succeed(fakeGlabOutput(stdout)); + } + + if (args[0] === "mr" && args[1] === "create") { + return Effect.succeed( + fakeGlabOutput( + (scenario.createdPrUrl ?? + "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/101") + "\n", + ), + ); + } + + if (args[0] === "mr" && args[1] === "view") { + const pullRequest: FakePullRequest = scenario.pullRequest ?? { + number: 101, + title: "Pull request", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/101", + baseRefName: "main", + headRefName: "feature/pull-request", + state: "open", + }; + return Effect.succeed( + fakeGlabOutput( + JSON.stringify({ + ...pullRequest, + ...(pullRequest.headRepositoryNameWithOwner + ? { + headRepository: { + nameWithOwner: pullRequest.headRepositoryNameWithOwner, + }, + } + : {}), + ...(pullRequest.headRepositoryOwnerLogin + ? { + headRepositoryOwner: { + login: pullRequest.headRepositoryOwnerLogin, + }, + } + : {}), + }) + "\n", + ), + ); + } + + if (args[0] === "mr" && args[1] === "checkout") { + return Effect.try({ + try: () => { + const headBranch = scenario.pullRequest?.headRefName; + if (headBranch) { + const existingBranch = NodeChildProcess.spawnSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], + { + cwd: input.cwd, + encoding: "utf8", + }, + ); + if (existingBranch.status === 0) { + runGitSyncForFakeGlab(input.cwd, ["checkout", headBranch]); + } else { + runGitSyncForFakeGlab(input.cwd, ["checkout", "-b", headBranch]); + } + } + return fakeGlabOutput(""); + }, + catch: (error) => + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: input.cwd, + cause: error, + }), + }); + } + + if (args[0] === "repo" && args[1] === "view") { + const repository = args[2]; + if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { + const cloneUrls = scenario.repositoryCloneUrls?.[repository]; + if (!cloneUrls) { + return Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: input.cwd, + cause: new Error(`Unexpected repository lookup: ${repository}`), + }), + ); + } + return Effect.succeed( + fakeGlabOutput( + JSON.stringify({ + nameWithOwner: repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }) + "\n", + ), + ); + } + return Effect.succeed(fakeGlabOutput(`${scenario.defaultBranch ?? "main"}\n`)); + } + + return Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: input.cwd, + cause: new Error(`Unexpected glab command: ${args.join(" ")}`), + }), + ); + }; + + return { + service: { + execute, + executeWrite: execute, + probeWriteAccess: ({ cwd }) => + Effect.sync(() => { + scenario.writeProbeCalls?.push(cwd); + return scenario.writeAccess ?? { status: "writable" as const, writable: true }; + }), + getWriteAccess: Effect.succeed({ status: "writable", writable: true }), + reprobeWriteAccess: () => Effect.succeed({ status: "writable", writable: true }), + // The fake answers the CLI shape, so batched lookups read it the way the fallback does. + listMergeRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 100), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return []; + return (JSON.parse(raw) as unknown[]) + .map(normalizeFakePullRequestSummary) + .filter((entry): entry is GitLabCli.GitLabMergeRequestSummary => entry !== null); + }), + ), + createMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "create", + "--target-branch", + input.baseBranch, + "--source-branch", + input.headSelector, + "--title", + input.title, + "--description-file", + input.bodyFile, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + }).pipe( + Effect.map((result) => { + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + }), + ), + getMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => normalizeFakePullRequestSummary(JSON.parse(result.stdout))!), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + createRepository: (input) => + Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: input.cwd, + cause: new Error(`Unexpected repository create: ${input.repository}`), + }), + ), + checkoutMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: ["mr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), + }, + glabCalls, + }; +} + +function runStackedAction( + manager: GitManager.GitManager["Service"], + input: { + cwd: string; + action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; + actionId?: string; + commitMessage?: string; + featureBranch?: boolean; + filePaths?: readonly string[]; + }, + options?: Parameters[1], +) { + return manager.runStackedAction( + { + ...input, + actionId: input.actionId ?? "test-action-id", + }, + options, + ); +} + +function resolvePullRequest( + manager: GitManager.GitManager["Service"], + input: { cwd: string; reference: string }, +) { + return manager.resolvePullRequest(input); +} + +function preparePullRequestThread( + manager: GitManager.GitManager["Service"], + input: GitPreparePullRequestThreadInput, +) { + return manager.preparePullRequestThread(input); +} + +function makeManager(input?: { + glabScenario?: FakeGlabScenario; + sourceControlProvider?: SourceControlProvider["Service"]; + textGeneration?: Partial; + serverSettings?: Parameters[0]; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; +}) { + const { service: gitLabCli, glabCalls } = createGitLabCliWithFakeGlab(input?.glabScenario); + const textGeneration = createTextGeneration(input?.textGeneration); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-manager-test-", + }); + + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); + + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); + const sourceControlRegistryLayer = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + (input?.sourceControlProvider === undefined + ? GitLabSourceControlProvider.make + : Effect.succeed(input.sourceControlProvider) + ).pipe( + Effect.map((provider) => + SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + resolveLink: (input) => provider.resolveLink?.(input), + get: () => Effect.succeed(provider), + resolveHandle: () => Effect.succeed({ provider, context: null }), + resolve: () => Effect.succeed(provider), + discover: Effect.succeed([]), + }), + ), + Effect.provide(Layer.succeed(GitLabCli.GitLabCli, gitLabCli)), + ), + ); + + const managerLayer = Layer.mergeAll( + Layer.succeed(TextGeneration.TextGeneration, textGeneration), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + Layer.succeed( + ProjectSetupScriptRunner.ProjectSetupScriptRunner, + input?.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), + vcsDriverLayer, + serverSettingsLayer, + ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); + + return GitManager.make.pipe( + Effect.provide(managerLayer), + Effect.map((manager) => ({ manager, glabCalls })), + ); +} + +const asThreadId = (threadId: string) => threadId as ThreadId; + +const GitManagerTestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(GitManagerTestLayer)("GitManager", (it) => { + // Coder: preserve the write gate before repository mutations and progress ordering. + it.effect.each(["create_pr", "commit_push_pr"] as const)( + "blocks %s before branch, commit, or push when GitLab writes are blocked", + (action) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-gate-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + const originalHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + if (action === "commit_push_pr") { + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "blocked change\n"); + } + const events: GitActionProgressEvent[] = []; + const writeProbeCalls: string[] = []; + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + writeAccess: { status: "policy-blocked", writable: false }, + writeProbeCalls, + }, + }); + const error = yield* runStackedAction( + manager, + { + cwd: repoDir, + action, + ...(action === "commit_push_pr" ? { featureBranch: true } : {}), + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "SourceControlProviderError", + provider: "gitlab", + operation: "createChangeRequest", + cwd: repoDir, + detail: "GitLab write operations are blocked by this workspace.", + }); + expect(events.map((event) => event.kind)).toEqual(["action_started", "action_failed"]); + expect(writeProbeCalls).toEqual([repoDir]); + expect(glabCalls).toEqual([]); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim()).toBe(originalHead); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + expect( + (yield* runGit(remoteDir, ["for-each-ref", "--format=%(refname)"])).stdout.trim(), + ).toBe(""); + expect((yield* runGit(repoDir, ["diff", "--cached", "--name-only"])).stdout.trim()).toBe( + "", + ); + }), + ); + + // Coder: a remote that resolves to no registered provider fails before any mutation. + it.effect("blocks an MR action before commit when no provider confirms write access", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-gate-unknown-"); + yield* initRepo(repoDir); + const originalHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "unknown host change\n"); + const unregistered = (operation: string) => + Effect.die(new Error(`unexpected ${operation} on an unregistered provider`)); + const { manager } = yield* makeManager({ + sourceControlProvider: { + kind: "unknown", + probeWriteAccess: () => + Effect.succeed({ + status: "indeterminate", + writable: false, + detail: "No unknown source control provider is registered.", + }), + listChangeRequests: () => unregistered("listChangeRequests"), + getChangeRequest: () => unregistered("getChangeRequest"), + createChangeRequest: () => unregistered("createChangeRequest"), + getRepositoryCloneUrls: () => unregistered("getRepositoryCloneUrls"), + createRepository: () => unregistered("createRepository"), + getDefaultBranch: () => unregistered("getDefaultBranch"), + checkoutChangeRequest: () => unregistered("checkoutChangeRequest"), + }, + }); + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + commitMessage: "Should not commit", + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "SourceControlProviderError", + provider: "unknown", + operation: "createChangeRequest", + detail: "No unknown source control provider is registered.", + }); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim()).toBe(originalHead); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout.trim()).toBe("M README.md"); + }), + ); + + it.effect("status includes draft PR metadata when branch already has a draft PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 13, + title: "Existing PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/13", + baseRefName: "main", + headRefName: "feature/status-open-pr", + isDraft: true, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.isRepo).toBe(true); + expect(status.hasPrimaryRemote).toBe(true); + expect(status.isDefaultRef).toBe(false); + expect(status.refName).toBe("feature/status-open-pr"); + expect(status.pr).toEqual({ + number: 13, + title: "Existing PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/13", + baseRef: "main", + headRef: "feature/status-open-pr", + state: "open", + isDraft: true, + updatedAt: null, + }); + }), + ); + + it.effect("status trims PR metadata returned by glab before publishing it", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 14, + title: " Existing PR title \n", + url: " https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/14 ", + baseRefName: " main ", + headRefName: "\tfeature/status-trimmed-pr\t", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 14, + title: "Existing PR title", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/14", + baseRef: "main", + headRef: "feature/status-trimmed-pr", + state: "open", + updatedAt: null, + }); + }), + ); + + it.effect("status returns an explicit non-repo result for non-git directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }); + }), + ); + + it.effect("status returns an explicit non-repo result for deleted directories", () => + Effect.gen(function* () { + const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); + const cwd = NodePath.join(rootDir, "deleted-repo"); + yield* makeDirectory(cwd); + yield* removePath(cwd); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }); + }), + ); + + it.effect("status briefly caches repeated lookups for the same cwd", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); + + const existingPr = { + number: 113, + title: "Cached PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/113", + baseRefName: "main", + headRefName: "feature/status-cache", + }; + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.pr?.number).toBe(113); + expect(second.pr?.number).toBe(113); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(1); + }), + ); + + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + + it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/turn-refresh", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + "[]", + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 114, + title: "Opened during the turn", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/114", + baseRefName: "main", + headRefName: "feature/turn-refresh", + }, + ]), + ], + }, + }); + expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull(); + expect( + (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr, + ).toBeNull(); + + const refreshed = yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(refreshed?.pr?.number).toBe(114); + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(2); + }), + ); + + it.effect("turn-end refresh preserves failed PR lookup backoff", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/rate-limited"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + }, + }); + yield* manager.remoteStatus({ cwd: repoDir }); + const callsAfterFailure = glabCalls.length; + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(callsAfterFailure).toBeGreaterThan(0); + expect(glabCalls).toHaveLength(callsAfterFailure); + }), + ); + + it.effect("status skips the provider lookup for a branch that was never pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]); + + const { manager, glabCalls } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/never-pushed"); + expect(status.pr).toBeNull(); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, glabCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(glabCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toMatchObject({ + number: 216, + title: "Saved branch PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/216", + baseRef: "main", + headRef: "feature/saved-branch", + state: "open", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + glabScenario: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect( + glabCalls.some((call) => call.includes("--source-branch feature/deleted-local-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@gitlab.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + glabCalls.some((call) => + call.includes("--source-branch feature/deleted-fork-branch --state all"), + ), + ).toBe(true); + expect(glabCalls.some((call) => call.includes("--source-branch contributor:"))).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, glabCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(glabCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://gitlab.com/old-owner/old-repository/-/merge_requests/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + encodeCliJson([ + { + number: 221, + title: "New PR on the same branch", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/221", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(1); + const refreshed = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/shared-pr-cache" }, + { refresh: true }, + ); + expect(refreshed).toMatchObject({ + number: 221, + state: "open", + repositoryKey: "gitlab.com/pingdotgg/codething-mvp", + }); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => glabCalls.filter((call) => call.startsWith("mr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--source-branch feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--source-branch feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--source-branch feature/open-pr"); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("glab is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + const refreshError = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) + .pipe(Effect.flip); + expect(refreshError._tag).toBe("SourceControlProviderError"); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(1); + }), + ); + + it.effect("status finds a merged PR after its remote branch was deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); + + // GitHub commonly deletes a pull request's head branch after merge. Git + // removes the remote-tracking ref, but preserves the local branch's + // remote and merge configuration as evidence that it was published. + yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); + const configuredRemote = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.remote", + ]); + const configuredMerge = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.merge", + ]); + const trackingRef = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/feature/merged-branch-deleted", + ]); + expect(configuredRemote.stdout.trim()).toBe("origin"); + expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); + expect(trackingRef.stdout.trim()).toBe(""); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged branch was deleted", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/215", + baseRefName: "main", + headRefName: "feature/merged-branch-deleted", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 215, + title: "Merged branch was deleted", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/215", + baseRef: "main", + headRef: "feature/merged-branch-deleted", + state: "merged", + updatedAt: "2026-04-02T15:00:00.000Z", + }); + expect(glabCalls.filter((call) => call.startsWith("mr list ")).length).toBeGreaterThan(0); + }), + ); + + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]); + // No `-u`, so the remote-tracking ref exists but branch..merge does + // not. Most terminal and agent pushes land this way, and they can still + // have a PR, so the skip must not trigger here. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 214, + title: "Pushed without upstream", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/214", + baseRefName: "main", + headRefName: "feature/pushed-no-upstream", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.number).toBe(214); + expect(glabCalls.filter((call) => call.startsWith("mr list ")).length).toBeGreaterThan(0); + }), + ); + + it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { + expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); + // The point of the backoff: by the third retry a failing branch must not be + // asking more often than a healthy one, which refreshes every 2 minutes. + expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); + }); + + it.each([ + [ + "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42", + "gitlab.example.com/group/subgroup/repository", + ], + [ + "https://gitlab.example/group/pull/123/repository/-/merge_requests/42", + "gitlab.example/group/pull/123/repository", + ], + ["https://gitlab.example.com/team/repository/issues/42", null], + ] as const)("reads the repository from the returned PR URL %s", (url, expected) => { + expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected); + }); + + it.effect("distinguishes Enterprise forks with the same head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature"]); + yield* runGit(repoDir, ["push", "-u", "fork", "feature"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.example.com:team/repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@gitlab.example.com:alice/repository.git", + forkDir, + ); + const output = encodeCliJson([ + { + number: 2, + title: "Another fork", + url: "https://gitlab.example.com/team/repository/-/merge_requests/2", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "bob/repository" }, + headRepositoryOwner: { login: "bob" }, + }, + { + number: 1, + title: "This fork", + url: "https://gitlab.example.com/team/repository/-/merge_requests/1", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-07T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "alice/repository" }, + headRepositoryOwner: { login: "alice" }, + }, + ]); + const { manager } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + feature: output, + }, + }, + }); + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({ + number: 1, + repositoryKey: "gitlab.example.com/team/repository", + }); + }), + ); + + it.effect.each([ + "git@gitlab.com:Group/Subgroup/Fork.git", + "https://gitlab.com/Group/Subgroup/Fork.git", + ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + const branch = "feature/NestedGroups"; + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", branch]); + yield* runGit(repoDir, ["push", "-u", "fork", branch]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:Group/Upstream/Repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); + const output = encodeCliJson([ + { + iid: 2, + title: "Another subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-08T15:00:00Z", + source_project_id: 102, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Other/Fork" }, + }, + { + iid: 1, + title: "This subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-07T15:00:00Z", + source_project_id: 101, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Subgroup/Fork" }, + }, + ]); + const calls: VcsProcess.VcsProcessInput[] = []; + const provider = yield* GitLabSourceControlProvider.make.pipe( + Effect.provide( + GitLabCli.layer.pipe( + Layer.provide( + Layer.mock(GitLabWriteProbe.GitLabWriteProbe)({ + isPolicyBlockedWriteFailure: () => false, + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + return fakeGlabOutput(output); + }), + }), + ), + ), + ), + ); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); + + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({ + number: 1, + repositoryKey: "gitlab.com/group/upstream/repository", + }); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.command).toBe("glab"); + expect(call.args).toEqual([ + "mr", + "list", + "--source-branch", + branch, + "--all", + "--per-page", + "20", + "--output", + "json", + ]); + } + }), + ); + + it.effect( + "status ignores unrelated fork PRs when the current branch tracks the same repository", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 1661, + title: "Fork PR from main", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1661", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect( + "status detects cross-repo PRs from the upstream remote URL owner", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:jasonLaster/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", + }, + headRepositoryOwner: { + login: "jasonLaster", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/488", + baseRef: "main", + headRef: "statemachine", + state: "open", + updatedAt: "2026-03-10T07:00:00.000Z", + }); + expect(glabCalls).toContain( + "mr list --source-branch statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 20_000, + ); + + it.effect( + "status preserves a fork PR whose head is named after the default branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "fork-seed", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + main: JSON.stringify([ + { + number: 777, + title: "Fork PR from main", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/777", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "contributor/codething-mvp", + }, + headRepositoryOwner: { + login: "contributor", + }, + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("t3code/pr-777/main"); + expect(status.pr).toEqual({ + number: 777, + title: "Fork PR from main", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/777", + baseRef: "main", + headRef: "main", + state: "open", + updatedAt: "2026-03-10T07:00:00.000Z", + }); + expect(glabCalls).toContain( + "mr list --source-branch main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 20_000, + ); + + it.effect( + "status ignores synthetic local branch aliases when the upstream remote name contains slashes", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "my-org/upstream", + "ssh://git@gitlab.com/pingdotgg/codething-mvp.git", + upstreamDir, + ); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1618", + baseRefName: "main", + headRefName: "effect-atom", + state: "OPEN", + updatedAt: "2026-03-01T10:00:00Z", + }, + ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("upstream/effect-atom"); + expect(status.pr).toEqual({ + number: 1618, + title: "Correct PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1618", + baseRef: "main", + headRef: "effect-atom", + state: "open", + updatedAt: "2026-03-01T10:00:00.000Z", + }); + expect( + glabCalls.some((call) => call.includes("mr list --source-branch upstream/effect-atom ")), + ).toBe(false); + expect( + glabCalls.some((call) => + call.includes("mr list --source-branch pingdotgg:upstream/effect-atom "), + ), + ).toBe(false); + expect( + glabCalls.some((call) => + call.includes("mr list --source-branch my-org/upstream:upstream/effect-atom "), + ), + ).toBe(false); + }), + 20_000, + ); + + it.effect("status returns merged PR state when latest PR was merged", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 22, + title: "Merged PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/22", + baseRefName: "main", + headRefName: "feature/status-merged-pr", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-merged-pr"); + expect(status.pr).toEqual({ + number: 22, + title: "Merged PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/22", + baseRef: "main", + headRef: "feature/status-merged-pr", + state: "merged", + updatedAt: "2026-01-30T10:00:00.000Z", + }); + }), + ); + + it.effect("status hides merged PRs on the default branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 23, + title: "Merged PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/23", + baseRefName: "feature/status-default-branch-target", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect("status does not inherit a merged PR from a feature branch's default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/from-main", "origin/main"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 54, + title: "Reverse merge from main", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/54", + baseRefName: "je-filter-list", + headRefName: "main", + state: "MERGED", + mergedAt: "2023-09-28T03:21:10Z", + updatedAt: "2023-09-28T03:21:10Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/from-main"); + expect(status.pr).toBeNull(); + expect(glabCalls.some((call) => call.includes("mr list"))).toBe(false); + }), + ); + + it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-plain", "origin/main"]); + // A plain push (no -u) leaves the upstream on origin/main. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-plain"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/pushed-plain": JSON.stringify([ + { + number: 88, + title: "Pushed without -u", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/88", + baseRefName: "main", + headRefName: "feature/pushed-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/pushed-plain"); + expect(status.pr?.number).toBe(88); + expect(glabCalls.some((call) => call.includes("--source-branch main"))).toBe(false); + }), + ); + + it.effect( + "status finds a fork PR pushed under the branch's own name despite a default upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-plain", "origin/main"]); + // Pushed to the fork without -u: upstream stays origin/main. + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-plain"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@gitlab.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/fork-plain": JSON.stringify([ + { + number: 89, + title: "Fork PR pushed without -u", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/89", + baseRefName: "main", + headRefName: "feature/fork-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.pr?.number).toBe(89); + expect(glabCalls.some((call) => call.includes("--source-branch feature/fork-plain"))).toBe( + true, + ); + expect(glabCalls.some((call) => call.includes("--source-branch contributor:"))).toBe(true); + expect(glabCalls.some((call) => call.includes("--source-branch main"))).toBe(false); + }), + ); + + it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]); + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@gitlab.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/fork-settle": JSON.stringify([ + { + number: 91, + title: "Fork PR to settle", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/91", + baseRefName: "main", + headRefName: "feature/fork-settle", + state: "MERGED", + updatedAt: "2026-05-02T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/fork-settle", + }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: null, + updatedAt: "2026-05-02T10:00:00.000Z", + }); + }), + ); + + it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // Fake glab returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/sticky-plain": JSON.stringify([ + { + number: 90, + title: "Sticky own-name PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/90", + baseRefName: "main", + headRefName: "feature/sticky-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(90); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(90); + }), + ); + + it.effect("status prefers open PR when merged PR has newer updatedAt", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 45, + title: "Merged PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/45", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "MERGED", + mergedAt: "2026-01-31T10:00:00Z", + updatedAt: "2026-02-01T10:00:00Z", + }, + { + number: 46, + title: "Open PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/46", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-open-over-merged"); + expect(status.pr).toEqual({ + number: 46, + title: "Open PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/46", + baseRef: "main", + headRef: "feature/status-open-over-merged", + state: "open", + updatedAt: "2026-01-30T10:00:00.000Z", + }); + }), + ); + + it.effect("status is resilient to gh lookup failures and returns pr null", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); + + const { manager } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("glab is not available on PATH"), + }), + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-no-gh"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect("status logs actionable provider detail without exposing the upstream cause", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]); + + const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; + const { manager } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliRateLimitError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error(upstreamCause), + }), + }, + }); + const logs: Array<{ message: string; annotations: Record }> = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message: String(message), + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + + const status = yield* manager + .status({ cwd: repoDir }) + .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + + expect(status.pr).toBeNull(); + const warning = logs.find((entry) => entry.message.includes("PR lookup failed")); + expect(warning?.annotations).toMatchObject({ + operation: "lookupStatusPr", + branch: "feature/status-rate-limited", + errorTag: "SourceControlProviderError", + provider: "gitlab", + providerOperation: "listChangeRequests", + providerCommand: "glab", + errorDetail: "GitLab API rate limit exceeded.", + }); + const loggedText = [ + warning?.message ?? "", + ...Object.values(warning?.annotations ?? {}).map(String), + ].join("\n"); + expect(loggedText).not.toContain(upstreamCause); + expect(loggedText).not.toContain("secret-value"); + }), + ); + + it.effect("status keeps the last known PR when a later lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; + const { manager } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // An explicit invalidation (user refresh, git action) bypasses the PR + // cache and forces a live lookup — which now fails. The badge must keep + // the last known PR instead of blanking out. + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); + }), + ); + + it.effect( + "status does not reuse a stale PR after the branch is retargeted to a different upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); + + const originRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/214", + baseRefName: "main", + headRefName: "feature/pr-retarget", + }; + const { manager } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // Retarget the branch to a different remote/upstream (e.g. the PR was + // reopened against a fork). The previously cached PR belonged to the + // old upstream and must not be shown against the new one. + const forkRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); + yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to=fork/feature/pr-retarget", + "feature/pr-retarget", + ]); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the branch gains its first upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const existingPr = { + number: 215, + title: "Sticky first-push PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/215", + baseRefName: "main", + headRefName: "feature/pr-sticky-first-push", + }; + const { manager } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(215); + + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(215); + }), + ); + + it.effect("status drops the last known PR when the tracked remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); + + const existingPr = { + number: 216, + title: "Old remote PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/216", + baseRefName: "main", + headRefName: "feature/pr-repointed", + }; + const { manager } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(216); + + const replacementRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); + yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); + yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); + + const existingPr = { + number: 217, + title: "Config hiccup PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/217", + baseRefName: "main", + headRefName: "feature/pr-config-hiccup", + }; + const { manager } = yield* makeManager({ + glabScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(217); + + // `remote.origin.url` reads go through readConfigValueNullable, which + // maps ANY failed read (a real "no remote configured" state or a + // transient git-config hiccup) to null the same way. Unsetting the + // key here reproduces that ambiguity without touching branch + // tracking (refs/remotes/origin/* and branch..remote are + // untouched) — the remote identity has not actually changed, so the + // sticky PR must survive even though the current lookup can no + // longer resolve a remote URL to compare against. + yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(217); + }), + ); + + it.effect("creates a commit when working tree is dirty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Use a direct tone.", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Implement stacked git actions", body: "" }); + }, + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." }); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Push", + action: { + kind: "push", + }, + }, + }); + expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("Implement stacked git actions"); + }), + ); + + it.effect("preserves custom style when instructions are empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Preserve custom style", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + commitInstructions: "", + changeRequestInstructions: "", + }); + }), + ); + + it.effect("falls back when the dedicated source control writer is unavailable", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + const missingInstanceId = ProviderInstanceId.make("missing_writer"); + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + providerInstances: { + [missingInstanceId]: { + driver: ProviderDriverKind.make("missing-driver"), + config: {}, + }, + }, + sourceControlWriterModelSelection: { + instanceId: missingInstanceId, + model: "missing-model", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the available writer", body: "" }); + }, + }, + }); + + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection); + }), + ); + + it.effect("includes local agent instructions when recent history is empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* runGit(repoDir, ["init", "--initial-branch=main"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + const agentInstructions = "Use lowercase source control text."; + const claudeInstructions = "Keep pull request bodies brief."; + NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); + yield* runGit(repoDir, ["add", "README.md"]); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + sourceControlWritingStyle: { + mode: "repo_conventions" as const, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Create initial commit", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + commitInstructions: `Follow the repository's established commit message style.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, + changeRequestInstructions: `Follow the repository's established merge request title and body style.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, + }); + }), + ); + + it.effect("uses custom commit message when provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "this should not be used", + body: "", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); + expect(generatedCount).toBe(0); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feat: custom summary line"); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toContain("- details from user"); + }), + ); + + it.effect("commits only selected files when filePaths is provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "b.txt"), "file b\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + filePaths: ["a.txt"], + }); + + expect(result.commit.status).toBe("created"); + + // b.txt should remain in the working tree + const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((r) => r.stdout), + ); + expect(statusStdout).toContain("b.txt"); + expect(statusStdout).not.toContain("a.txt"); + }), + ); + + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "Implement stacked git actions", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + featureBranch: true, + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Create MR", + action: { + kind: "create_pr", + }, + }, + }); + expect(result.toast.title).toMatch( + /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, + ); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feature/implement-stacked-git-actions"); + + const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + expect(mergeBase).toBe(mainSha); + expect(generatedCount).toBe(1); + }), + ); + + it.effect("featureBranch uses custom commit message and derives branch name", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "unused", + body: "", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + featureBranch: true, + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/feat-custom-summary-line"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); + expect(generatedCount).toBe(0); + + const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + expect(mergeBase).toBe(mainSha); + }), + ); + + it.effect("skips commit when there are no uncommitted changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + }), + ); + + it.effect("featureBranch returns error when worktree is clean", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager(); + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + featureBranch: true, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); + }), + ); + + it.effect("commits and pushes with upstream auto-setup when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("origin/feature/stacked-flow"); + }), + ); + + it.effect( + "pushes and creates PR from a no-upstream branch when local commits are ahead of base", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 77, + title: "Add no-upstream PR flow", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/77", + baseRefName: "main", + headRefName: "feature/no-upstream-pr", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("origin/feature/no-upstream-pr"); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch feature/no-upstream-pr"), + ), + ).toBe(true); + }), + ); + + it.effect("skips push when branch is already up to date", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_up_to_date"); + }), + ); + + it.effect("pushes existing clean commits without rerunning commit logic", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); + yield* runGit(repoDir, ["add", "push-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("origin/feature/push-only"); + }), + ); + + it.effect("pushes existing commits without committing dirty worktree changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); + yield* runGit(repoDir, ["add", "push-dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); + NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel")); + NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toContain("?? .vercel/"); + expect( + yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("Push dirty branch"); + }), + ); + + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); + yield* runGit(repoDir, ["add", "create-pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 303, + title: "Create PR only branch", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/303", + baseRefName: "main", + headRefName: "feature/create-pr-only", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(303); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch feature/create-pr-only"), + ), + ).toBe(true); + }), + ); + + it.effect("create_pr falls back to main when source control provider detection fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "provider-fallback.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 404, + title: "Provider fallback", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/404", + baseRefName: "main", + headRefName: "feature/provider-fallback", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(404); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch feature/provider-fallback"), + ), + ).toBe(true); + }), + ); + + it.effect("create_pr targets the remote default branch when it is not main", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + // A repository whose default branch is master, with no main anywhere. + yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + + yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); + yield* runGit(repoDir, ["add", "master-default.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Master default"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + // Mirrors a provider that cannot report a default branch, as the Azure + // DevOps CLI does when it cannot detect the repository. + defaultBranch: "", + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 505, + title: "Master default", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/505", + baseRefName: "master", + headRefName: "feature/master-default", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch master --source-branch feature/master-default"), + ), + ).toBe(true); + }), + ); + + it.effect("returns existing PR metadata for commit/push/pr action", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 42, + title: "Existing PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/existing-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(42); + expect(result.toast).toEqual({ + title: "Opened MR #42", + description: "Existing PR", + cta: { + kind: "open_pr", + label: "View MR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/42", + }, + }); + expect(glabCalls.some((call) => call.startsWith("mr view "))).toBe(false); + }), + ); + + it.effect( + "returns existing cross-repo PR metadata found under the bare branch name", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:octocat/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + statemachine: + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + expect( + glabCalls.some((call) => + call.includes("mr list --source-branch statemachine --state open --limit 1"), + ), + ).toBe(true); + expect(glabCalls.some((call) => call.startsWith("mr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "returns the correct existing PR when a slash remote checks out to a synthetic local alias", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "my-org/upstream", + "ssh://git@gitlab.com/pingdotgg/codething-mvp.git", + upstreamDir, + ); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1618", + baseRefName: "main", + headRefName: "effect-atom", + }, + ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(1618); + expect( + glabCalls.some((call) => call.includes("mr list --source-branch upstream/effect-atom ")), + ).toBe(false); + }), + 20_000, + ); + + it.effect( + "picks the fork PR among same-named branches from other repositories", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:octocat/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "t3code/pr-142/statemachine": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + statemachine: JSON.stringify([ + { + number: 41, + title: "Unrelated same-repo PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/41", + baseRefName: "main", + headRefName: "statemachine", + }, + { + number: 142, + title: "Existing fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + expect( + glabCalls.some( + (call) => /--source-branch [^ ]*:/u.test(call) && call.startsWith("mr list"), + ), + ).toBe(true); + expect(glabCalls.some((call) => call.startsWith("mr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "stops probing head selectors after finding an existing PR", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:octocat/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "octocat:statemachine": JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + "t3code/pr-142/statemachine": JSON.stringify([]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + + const openLookupCalls = glabCalls.filter((call) => call.includes("--state open --limit 1")); + expect(openLookupCalls).toHaveLength(1); + expect(openLookupCalls[0]).toContain( + "mr list --source-branch octocat:statemachine --state open --limit 1", + ); + }), + 12_000, + ); + + it.effect( + "does not reuse a cross-repo PR when the provider omits head identity metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@gitlab.com:octocat/codething-mvp.git", + ]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequenceByHeadSelector: { + statemachine: [ + `[{"number":41,"title":"Ambiguous fork PR","url":"https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, + `[{"number":142,"title":"Add stacked git actions","url":"https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, + ], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(142); + expect(glabCalls.some((call) => call.startsWith("mr create "))).toBe(true); + }), + 20_000, + ); + + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => + Effect.sync(() => { + const headContext = { + headBranch: "statemachine", + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + isCrossRepository: true, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 41, + title: "Same-repo PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/41", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(false); + + expect( + GitManager.matchesBranchHeadContext( + { + number: 142, + title: "Fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/142", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + + it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => + Effect.sync(() => { + const headContext = { + headBranch: "t3code/git-audit-stability", + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + isCrossRepository: false, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 2284, + title: "Improve branch mismatch warnings", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/2284", + baseRefName: "main", + headRefName: "t3code/git-audit-stability", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + }, + headContext, + ), + ).toBe(true); + }), + ); + + it.effect("creates PR when one does not already exist", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".gitlab", "merge_request_templates"), { + recursive: true, + }); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".gitlab", "merge_request_templates", "default.md"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".gitlab/merge_request_templates/default.md"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); + yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); + yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; + let generatedChangeRequestTemplate: string | undefined; + + const writeProbeCalls: string[] = []; + const { manager, glabCalls } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Lead with user impact.", + }, + }, + textGeneration: { + generatePrContent: (input) => { + generatedPolicy = input.policy; + generatedChangeRequestTemplate = input.changeRequestTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, + glabScenario: { + writeProbeCalls, + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 88, + title: "Add stacked git actions", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/88", + baseRefName: "main", + headRefName: "feature-create-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(writeProbeCalls).toEqual([repoDir]); + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(88); + expect(generatedPolicy).toMatchObject({ + changeRequestInstructions: "Lead with user impact.", + }); + expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); + expect(glabCalls.filter((call) => call.startsWith("mr list "))).toHaveLength(2); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch feature-create-pr"), + ), + ).toBe(true); + expect(glabCalls.some((call) => call.startsWith("mr view "))).toBe(false); + }), + ); + + it.effect("generates PR content from branch changes when the remote base advances", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); + + const peerDir = yield* makeTempDir("t3code-git-peer-"); + yield* runGit(peerDir, ["clone", remoteDir, "."]); + yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); + yield* runGit(peerDir, ["config", "user.name", "Peer User"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n"); + yield* runGit(peerDir, ["add", "remote.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, [ + "checkout", + "--no-track", + "-b", + "feature/remote-base", + "origin/main", + ]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + yield* runGit(repoDir, ["add", "feature.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); + yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: ["[]", "[]"], + }, + textGeneration: { + generatePrContent: (input) => { + generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; + return Effect.succeed({ title: "Feature PR", body: "Feature body" }); + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(generatedCommitSummary).toContain("Feature commit"); + expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); + }), + ); + + it.effect( + "creates a new PR instead of reusing an unrelated fork PR with the same head branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 1661, + title: "Fork PR with same branch name", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/1661", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/188", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect(result.toast).toEqual({ + title: "Created MR #188", + description: "Add stacked git actions", + cta: { + kind: "open_pr", + label: "View MR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/188", + }, + }); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch feature/no-fork-match"), + ), + ).toBe(true); + }), + ); + + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@gitlab.com:octocat/codething-mvp.git", + forkDir, + ); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + prListSequenceByHeadSelector: { + statemachine: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/188", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect( + glabCalls.some((call) => + call.includes("mr create --target-branch main --source-branch octocat:statemachine"), + ), + ).toBe(true); + expect( + glabCalls.some((call) => + call.includes( + "mr create --target-branch statemachine --source-branch octocat:statemachine", + ), + ), + ).toBe(false); + }), + ); + + it.effect("rejects push/pr actions from detached HEAD", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); + + const { manager } = yield* makeManager(); + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("detached HEAD"); + }), + ); + + it.effect("surfaces missing glab binary errors", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); + + const { manager } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliUnavailableError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("glab is not available on PATH"), + }), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("GitLab CLI (`glab`) is required"); + }), + ); + + it.effect("surfaces glab auth errors with guidance", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); + + const { manager } = yield* makeManager({ + glabScenario: { + failWith: new GitLabCli.GitLabCliAuthenticationError({ + operation: "execute", + command: "glab", + cwd: repoDir, + cause: new Error("glab is not authenticated"), + }), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("glab auth login"); + }), + ); + + it.effect("resolves pull requests from #number references", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 42, + title: "Resolve PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/resolve-pr", + state: "open", + }, + }, + }); + + const result = yield* resolvePullRequest(manager, { + cwd: repoDir, + reference: "#42", + }); + + expect(result.pullRequest).toEqual({ + number: 42, + title: "Resolve PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/42", + baseBranch: "main", + headBranch: "feature/resolve-pr", + state: "open", + }); + expect(glabCalls.some((call) => call.startsWith("mr view 42 "))).toBe(true); + }), + ); + + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n"); + yield* runGit(repoDir, ["add", "local.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); + + const { manager, glabCalls } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "#64", + mode: "local", + }); + + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/pr-local"); + expect(glabCalls).toContain("mr checkout 64 --force"); + }), + ); + + it.effect( + "restores same-repository upstream tracking after local PR checkout without a remote ref", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "upstream.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-upstream", + ]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 65, + title: "Local upstream PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/65", + baseRefName: "main", + headRefName: "feature/pr-local-upstream", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + repositoryCloneUrls: { + "pingdotgg/codething-mvp": { + url: remoteDir, + sshUrl: remoteDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "65", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-upstream"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-upstream"); + }), + ); + + it.effect( + "restores same-repository upstream tracking when provider omits head repository metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "no-head-repo.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-no-head-repo", + ]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 66, + title: "Local upstream PR without repo metadata", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/66", + baseRefName: "main", + headRefName: "feature/pr-local-no-head-repo", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "66", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-no-head-repo"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-no-head-repo"); + }), + ); + + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runGit(repoDir, ["add", "worktree.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/77/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(); + expect(worktreeBranch).toBe("feature/pr-worktree"); + }), + ); + + it.effect("preserves both branch materialization failures when the fallback also fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 93, + title: "Missing fork branch", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/93", + baseRefName: "main", + headRefName: "feature/missing-fork-branch", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: missingForkDir, + sshUrl: missingForkDir, + }, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitPullRequestMaterializationError") { + return yield* Effect.die(error); + } + expect(error).toMatchObject({ + cwd: repoDir, + pullRequestNumber: 93, + headRepository: "octocat/codething-mvp", + headBranch: "feature/missing-fork-branch", + localBranch: "t3code/pr-93/feature/missing-fork-branch", + }); + if (!(error.cause instanceof AggregateError)) { + return yield* Effect.die(error.cause); + } + expect(error.cause.errors).toHaveLength(2); + expect(error.cause.errors).toEqual([ + expect.objectContaining({ _tag: "GitCommandError" }), + expect.objectContaining({ _tag: "GitCommandError" }), + ]); + expect(error.cause.cause).toBe(error.cause.errors[0]); + }), + ); + + it.effect("launches setup when creating a new PR worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 177, + title: "Worktree setup PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/177", + baseRefName: "main", + headRefName: "feature/pr-worktree-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "177", + mode: "worktree", + threadId: asThreadId("thread-pr-setup"), + }); + + expect(result.worktreePath).not.toBeNull(); + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork.txt"), "fork\n"); + yield* runGit(repoDir, ["add", "fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 81, + title: "Fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "81", + mode: "worktree", + }); + + expect(result.worktreePath).not.toBeNull(); + const upstreamRef = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); + expect(upstreamRef.startsWith("origin/")).toBe(false); + expect( + (yield* runGit(result.worktreePath as string, [ + "config", + "--get", + "remote.fork-seed.url", + ])).stdout.trim(), + ).toBe(forkDir); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a local PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n"); + yield* runGit(repoDir, ["add", "local-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 82, + title: "Local Fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/82", + baseRefName: "main", + headRefName: "feature/pr-local-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "82", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-fork"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-local-fork"); + }), + ); + + it.effect("reuses an existing dedicated worktree for the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n"); + yield* runGit(repoDir, ["add", "existing.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-existing-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 78, + title: "Existing worktree PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/78", + baseRefName: "main", + headRefName: "feature/pr-existing-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + threadId: asThreadId("thread-pr-existing-worktree"), + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-existing-worktree"); + // Nothing to fetch from, so the checkout keeps the commit it had and setup stays out of a + // worktree another thread may be sitting in. + expect(setupCalls).toHaveLength(0); + expect(result.isOnPullRequestHead).toBe(false); + }), + ); + + it.effect("refreshes a reused PR worktree onto the updated pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "stale.txt"), "stale\n"); + yield* runGit(repoDir, ["add", "stale.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused stale PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-stale"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-stale-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-stale"]); + + yield* runGit(repoDir, ["checkout", "-b", "author-push", "origin/feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "authored.txt"), "authored\n"); + yield* runGit(repoDir, ["add", "authored.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New PR head commit"]); + yield* runGit(repoDir, ["push", "origin", "author-push:feature/pr-reused-stale"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "author-push"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 84, + title: "Reused stale PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/84", + baseRefName: "main", + headRefName: "feature/pr-reused-stale", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "84", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-reused-stale"); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("runs the setup script when a reused PR worktree moves onto the new head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-setup-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]); + + yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]); + yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 85, + title: "Reused setup PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/85", + baseRefName: "main", + headRefName: "feature/pr-reused-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "85", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-setup"), + }); + + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-reused-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + expect(result.isOnPullRequestHead).toBe(true); + }), + ); + + it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-current"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n"); + yield* runGit(repoDir, ["add", "reused-current.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-current-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]); + const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 95, + title: "Reused current PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/95", + baseRefName: "main", + headRefName: "feature/pr-reused-current", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "95", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-current"), + }); + + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "first\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Force-pushed PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-force-pushed"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-force-pushed-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-force-pushed"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "author-rewrite", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten PR head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "author-rewrite:feature/pr-force-pushed", + ]); + const rewrittenHead = (yield* runGit(repoDir, ["rev-parse", "author-rewrite"])).stdout.trim(); + // Pushing from this clone also advanced its remote-tracking ref. A head rewritten by the + // author leaves that ref behind, which is the state a reused worktree is really opened in. + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-force-pushed", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 86, + title: "Force-pushed PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/86", + baseRefName: "main", + headRefName: "feature/pr-force-pushed", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "86", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + rewrittenHead, + ); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe( + "rewritten\n", + ); + }), + ); + + it.effect("keeps a reused PR worktree that carries its own commit off the rewritten head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "first\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local commit PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-commit"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-local-commit-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-local-commit"]); + const upstreamHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "local-commit-rewrite", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten local commit head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "local-commit-rewrite:feature/pr-local-commit", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-local-commit", + upstreamHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + // The work that must survive: a commit made in the worktree, on top of the stale head. + NodeFS.writeFileSync(NodePath.join(worktreePath, "thread-work.txt"), "thread work\n"); + yield* runGit(worktreePath, ["add", "thread-work.txt"]); + yield* runGit(worktreePath, ["commit", "-m", "Work done in the reused worktree"]); + const worktreeHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 87, + title: "Local commit PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/87", + baseRefName: "main", + headRefName: "feature/pr-local-commit", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "87", + mode: "worktree", + threadId: asThreadId("thread-pr-local-commit"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(worktreeHead); + expect(NodeFS.existsSync(NodePath.join(worktreePath, "thread-work.txt"))).toBe(true); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("keeps a dirty reused PR worktree off the rewritten pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "first\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Dirty worktree PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-dirty-worktree"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-dirty-worktree-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-dirty-worktree"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "dirty-rewrite", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten dirty head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "dirty-rewrite:feature/pr-dirty-worktree", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-dirty-worktree", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + NodeFS.writeFileSync(NodePath.join(worktreePath, "dirty.txt"), "uncommitted edit\n"); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 89, + title: "Dirty worktree PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/89", + baseRefName: "main", + headRefName: "feature/pr-dirty-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "89", + mode: "worktree", + threadId: asThreadId("thread-pr-dirty-worktree"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(staleHead); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "dirty.txt"), "utf8")).toBe( + "uncommitted edit\n", + ); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("refreshes a reused PR worktree that has no upstream from the pull request ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-ref-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Pull ref only PR branch"]); + // The head lives at refs/merge-requests/90/head and nowhere else, so nothing can be tracked. + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/90/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-ref-only"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 90, + title: "Pull ref only PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/90", + baseRefName: "main", + headRefName: "feature/pr-ref-only", + state: "open", + }, + }, + }); + + const created = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + const worktreePath = created.worktreePath as string; + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode, + ).not.toBe(0); + + yield* runGit(repoDir, ["fetch", "origin", "refs/merge-requests/90/head"]); + yield* runGit(repoDir, ["checkout", "-b", "ref-only-author", "FETCH_HEAD"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only again\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New pull ref head"]); + yield* runGit(repoDir, ["push", "origin", "ref-only-author:refs/merge-requests/90/head"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "ref-only-author"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("never moves an unrelated local branch that shares the fork head branch name", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-collision"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "contributor.txt"), "contributor\n"); + yield* runGit(repoDir, ["add", "contributor.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Contributor commit on the fork main"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-collision:main"]); + // The user's own main, checked out in its own worktree and behind the fork's main: a + // fast-forward would land the contributor's commits in it. + yield* runGit(repoDir, ["checkout", "-b", "feature/root-work", "main"]); + const mainWorktreePath = NodePath.join( + repoDir, + "..", + `local-main-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", mainWorktreePath, "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 94, + title: "Fork main collision PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/94", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "94", + mode: "worktree", + threadId: asThreadId("thread-pr-fork-main-collision"), + }); + + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect((yield* runGit(mainWorktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + localMainBefore, + ); + expect(NodeFS.existsSync(NodePath.join(mainWorktreePath, "contributor.txt"))).toBe(false); + expect(result.isOnPullRequestHead).toBe(false); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect( + "does not block fork PR worktree prep when the fork head branch collides with root main", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n"); + yield* runGit(repoDir, ["add", "fork-main.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 91, + title: "Fork main PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/91", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "91", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-91/main"); + expect(result.worktreePath).not.toBeNull(); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(), + ).toBe("t3code/pr-91/main"); + }), + ); + + it.effect( + "does not overwrite an existing local main branch when preparing a fork PR worktree", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + yield* runGit(repoDir, ["add", "fork-main-second.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 92, + title: "Fork main overwrite PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/92", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "92", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-92/main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(), + ).toBe("fork-seed/main"); + }), + ); + + it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-fork.txt"), "reused fork\n"); + yield* runGit(repoDir, ["add", "reused-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-fork-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); + yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 83, + title: "Reused Fork PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/83", + baseRefName: "main", + headRefName: "feature/pr-reused-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "83", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-reused-fork"); + }), + ); + + it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n"); + yield* runGit(repoDir, ["add", "setup-failure.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/184/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 184, + title: "Setup failure PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/184", + baseRefName: "main", + headRefName: "feature/pr-setup-failure", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (input) => + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: new Error("terminal start failed"), + }), + ), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "184", + mode: "worktree", + threadId: asThreadId("thread-pr-setup-failure"), + }); + + expect(result.branch).toBe("feature/pr-setup-failure"); + expect(result.worktreePath).not.toBeNull(); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); + }), + ); + + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); + + const { manager } = yield* makeManager({ + glabScenario: { + pullRequest: { + number: 79, + title: "Root-only PR", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/79", + baseRefName: "main", + headRefName: "feature/pr-root-only", + state: "open", + }, + }, + }); + + const errorMessage = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("already checked out in the main repo"); + }), + ); + + it.effect("emits ordered progress events for commit hooks", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + }, + { + actionId: "action-1", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("created"); + expect(events.map((event) => event.kind)).toContain("action_started"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "phase_started", + phase: "commit", + }), + expect.objectContaining({ + kind: "hook_started", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: start", + }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: end", + }), + expect.objectContaining({ + kind: "hook_finished", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "action_finished", + }), + ]), + ); + }), + ); + + it.effect("emits action_failed when a commit hook rejects", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const errorMessage = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + }, + { + actionId: "action-2", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); + expect(errorMessage).not.toContain("hook: fail"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "hook_started", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: fail", + }), + expect.objectContaining({ + kind: "action_failed", + phase: "commit", + }), + ]), + ); + }), + ); + + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "mr only\n"); + yield* runGit(repoDir, ["add", "pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + + const { manager } = yield* makeManager({ + glabScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 201, + title: "PR only branch", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/201", + baseRefName: "main", + headRefName: "feature/pr-only-follow-up", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "create_pr", + }, + { + actionId: "action-pr-only", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect( + events.filter( + (event): event is Extract => + event.kind === "phase_started", + ), + ).toEqual([ + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Preparing MR...", + }), + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Generating MR content...", + }), + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Creating merge request...", + }), + ]); + }), + ); +}); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts new file mode 100644 index 0000000000000..573ff31867611 --- /dev/null +++ b/apps/server/src/git/GitManager.ts @@ -0,0 +1,2922 @@ +import * as Arr from "effect/Array"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as ByteSize from "effect/ByteSize"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Order from "effect/Order"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { + GitActionProgressEvent, + GitActionProgressPhase, + GitCommandError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStackedAction, + VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + VcsStatusResult, + ModelSelection, + type ProjectId, + SourceControlProviderError, + type SourceControlProviderKind, + type SourceControlWritingStyleSettings, + type ThreadId, +} from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + detectSourceControlProviderFromGitRemoteUrl, + mergeGitStatusParts, + normalizeGitRemoteUrl, + resolveAutoFeatureBranchName, + sanitizeBranchFragment, + sanitizeFeatureBranchName, +} from "@t3tools/shared/git"; +import { + getChangeRequestTerminologyForKind, + isSshRemoteUrl, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; + +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { + conventionalCommitsTextGenerationPolicy, + customTextGenerationPolicy, + repositoryConventionsTextGenerationPolicy, +} from "../textGeneration/TextGenerationPresets.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import type { GitManagerServiceError } from "@t3tools/contracts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; +import type { ChangeRequest } from "@t3tools/contracts"; + +export interface GitActionProgressReporter { + readonly publish: (event: GitActionProgressEvent) => Effect.Effect; +} + +export interface GitRunStackedActionOptions { + readonly actionId?: string; + readonly progressReporter?: GitActionProgressReporter; +} + +export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { + /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ + readonly refreshMissingPullRequest?: boolean; +} + +export type GitBranchPullRequest = NonNullable & { + readonly repositoryKey: string | null; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; +}; + +interface SourceControlTextGenerationSettings { + readonly modelSelection: ModelSelection; + readonly style: SourceControlWritingStyleSettings; +} + +export class GitManager extends Context.Service< + GitManager, + { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitRemoteStatusOptions, + ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: ( + input: { readonly cwd: string; readonly branch: string }, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + } +>()("t3/git/GitManager") {} + +const COMMIT_TIMEOUT_MS = 10 * 60_000; +const MAX_PROGRESS_TEXT_LENGTH = 500; +const SHORT_SHA_LENGTH = 7; +const TOAST_DESCRIPTION_MAX = 72; +const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); +const STATUS_RESULT_CACHE_CAPACITY = 2_048; +// Matches the automatic settlement sweep cadence so every background sweep +// reads fresh branch state: an external merge settles within about a minute +// instead of waiting out a longer cache. Unpublished branches never reach the +// host (a local probe answers first), and failed lookups still back off +// exponentially via prLookupFailureTtl, so throttling pressure still drops +// under 429s instead of amplifying it. +const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); +const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); +const PR_LOOKUP_CACHE_CAPACITY = 2_048; +const isSourceControlProviderError = Schema.is(SourceControlProviderError); + +/** + * How long a failed PR lookup is cached, given the number of consecutive + * failures for that branch. + * + * A hosting provider rejects a throttled request immediately, so caching every + * failure for a flat 20s made a rate-limited poller re-ask *faster* than a + * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 + * into sustained pressure. Backing off per branch keeps the retry rate below + * the healthy rate once a branch has failed more than a couple of times. + */ +export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); + return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); +} +type StripProgressContext = T extends any ? Omit : never; +type GitActionProgressPayload = StripProgressContext; +type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; + +function isNotGitRepositoryError(error: GitCommandError): boolean { + return error.message.toLowerCase().includes("not a git repository"); +} + +interface OpenPrInfo { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +} + +interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { + state: "open" | "closed" | "merged"; + isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; + updatedAt: Option.Option; +} + +const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( + Order.flip(Option.makeOrder(DateTime.Order)), + (pullRequest) => pullRequest.updatedAt, +); + +interface ResolvedPullRequest { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +interface PullRequestHeadRemoteInfo { + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; +} + +interface BranchHeadContext { + localBranch: string; + headBranch: string; + headSelectors: ReadonlyArray; + preferredHeadSelector: string; + remoteName: string | null; + headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; + headRepositoryNameWithOwner: string | null; + headRepositoryOwnerLogin: string | null; + isCrossRepository: boolean; +} + +export function pullRequestRepositoryKey(value: string): string | null { + try { + const url = new URL(value); + const match = + /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + url.pathname, + ); + if (match?.[1] === undefined) return null; + url.pathname = match[1]; + url.search = ""; + url.hash = ""; + return normalizeGitRemoteUrl(url.toString()); + } catch { + return null; + } +} + +function parseRepositoryNameFromPullRequestUrl(url: string): string | null { + const trimmed = url.trim(); + const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const repositoryName = match?.[1]?.trim() ?? ""; + return repositoryName.length > 0 ? repositoryName : null; +} + +function resolveHeadRepositoryNameWithOwner( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string | null { + const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + if (explicitRepository.length > 0) { + return explicitRepository; + } + + if (!pullRequest.isCrossRepository) { + return null; + } + + const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; + const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); + if (ownerLogin.length === 0 || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function resolvePullRequestWorktreeLocalBranchName( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string { + if (!pullRequest.isCrossRepository) { + return pullRequest.headBranch; + } + + const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); + const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; + return `t3code/pr-${pullRequest.number}/${suffix}`; +} + +export function parseRepositoryNameWithOwnerFromRemoteUrl( + url: string | null, + // Coder: preserve upstream's unreachable Forgejo path without expanding the provider schema. + providerKind?: ChangeRequest["provider"] | "forgejo", +): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + // Forgejo HTTP paths can include an installation mount; its API always names owner/repo. + if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) { + return repositoryNameWithOwner.length > 0 + ? repositoryNameWithOwner.split("/").slice(-2).join("/") + : null; + } + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + +function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { + const trimmed = nameWithOwner?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + // GitLab reports the top-level group as owner. The full path distinguishes subgroups. + const [ownerLogin] = trimmed.split("/"); + const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; + return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function resolvePullRequestHeadRepositoryNameWithOwner( + pr: PullRequestHeadRemoteInfo & { url: string }, +) { + const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); + if (explicitRepository) { + return explicitRepository; + } + + if (!pr.isCrossRepository) { + return null; + } + + const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); + const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); + if (!ownerLogin || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +interface PullRequestHeadIdentity { + readonly repositoryNameWithOwner: string | null; + readonly ownerLogin: string | null; +} + +function resolveExpectedHeadIdentity( + headContext: Pick, +): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +export function matchesBranchHeadContext( + pr: PullRequestInfo, + headContext: Pick< + BranchHeadContext, + "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + >, +): boolean { + if (pr.headRefName !== headContext.headBranch) { + return false; + } + + const expectedHead = resolveExpectedHeadIdentity(headContext); + const pullRequestHead = resolvePullRequestHeadIdentity(pr); + + if (expectedHead.repositoryNameWithOwner) { + if (pullRequestHead.repositoryNameWithOwner) { + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } + } + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } + } + } + + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } + } + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { + return false; + } + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { + return false; + } + return true; + } + + if (pr.isCrossRepository === true) { + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + return false; + } + } + + return true; +} + +function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { + return { + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: summary.updatedAt, + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function limitContext(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function truncateText( + value: string | undefined, + maxLength = TOAST_DESCRIPTION_MAX, +): string | undefined { + if (!value) return undefined; + if (value.length <= maxLength) return value; + if (maxLength <= 3) return "...".slice(0, maxLength); + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function withDescription(title: string, description: string | undefined) { + return description ? { title, description } : { title }; +} + +function summarizeGitActionResult( + result: Pick, + terms: ChangeRequestTerminology, +): { + title: string; + description?: string; +} { + if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; + const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; + return withDescription(title, truncateText(result.pr.title)); + } + + if (result.push.status === "pushed") { + const shortSha = shortenSha(result.commit.commitSha); + const branch = result.push.upstreamBranch ?? result.push.branch; + const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; + const branchPart = branch ? ` to ${branch}` : ""; + return withDescription( + `Pushed${pushedCommitPart}${branchPart}`, + truncateText(result.commit.subject), + ); + } + + if (result.commit.status === "created") { + const shortSha = shortenSha(result.commit.commitSha); + const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; + return withDescription(title, truncateText(result.commit.subject)); + } + + return { title: "Done" }; +} + +function sanitizeCommitMessage(generated: { + subject: string; + body: string; + branch?: string | undefined; +}): { + subject: string; + body: string; + branch?: string | undefined; +} { + const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const subject = rawSubject.replace(/[.]+$/g, "").trim(); + const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; + return { + subject: safeSubject, + body: generated.body.trim(), + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + }; +} + +function sanitizeProgressText(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { + return trimmed; + } + return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); +} + +interface CommitAndBranchSuggestion { + subject: string; + body: string; + branch?: string | undefined; + commitMessage: string; +} + +function isCommitAction( + action: GitStackedAction, +): action is "commit" | "commit_push" | "commit_push_pr" { + return action === "commit" || action === "commit_push" || action === "commit_push_pr"; +} + +function formatCommitMessage(subject: string, body: string): string { + const trimmedBody = body.trim(); + if (trimmedBody.length === 0) { + return subject; + } + return `${subject}\n\n${trimmedBody}`; +} + +function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return null; + } + + const [firstLine, ...rest] = normalized.split("\n"); + const subject = firstLine?.trim() ?? ""; + if (subject.length === 0) { + return null; + } + + return { + subject, + body: rest.join("\n").trim(), + }; +} + +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + +function appendUnique(values: string[], next: string | null | undefined): void { + const trimmed = next?.trim() ?? ""; + if (trimmed.length === 0 || values.includes(trimmed)) { + return; + } + values.push(trimmed); +} + +function toStatusPr(pr: PullRequestInfo): { + number: number; + title: string; + url: string; + baseRef: string; + headRef: string; + state: "open" | "closed" | "merged"; + isDraft?: boolean; + updatedAt: string | null; +} { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseRef: pr.baseRefName, + headRef: pr.headRefName, + state: pr.state, + ...(pr.isDraft === true ? { isDraft: true } : {}), + updatedAt: Option.match(pr.updatedAt, { + onNone: () => null, + onSome: (updatedAt) => DateTime.formatIso(updatedAt), + }), + }; +} + +function normalizePullRequestReference(reference: string): string { + const trimmed = reference.trim(); + const hashNumber = /^#(\d+)$/.exec(trimmed); + return hashNumber?.[1] ?? trimmed; +} + +function toResolvedPullRequest(pr: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; +}): ResolvedPullRequest { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state ?? "open", + }; +} + +function shouldPreferSshRemote(url: string | null): boolean { + if (!url) return false; + return isSshRemoteUrl(url); +} + +function toPullRequestHeadRemoteInfo(pr: { + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; +}): PullRequestHeadRemoteInfo { + return { + ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), + ...(pr.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } + : {}), + ...(pr.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.gen(function* () { + const gitCore = yield* GitVcsDriver.GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + // Coder: GitLab exposes MR heads under refs/merge-requests, not refs/pull. + const fetchPullRequestBranch = Effect.fn("GitManager.fetchPullRequestBranch")(function* ( + input: Parameters[0], + ) { + if ((yield* sourceControlProvider(input.cwd)).kind !== "gitlab") { + return yield* gitCore.fetchPullRequestBranch(input); + } + const remoteName = yield* gitCore.resolvePrimaryRemoteName(input.cwd); + yield* gitCore.execute({ + operation: "GitManager.fetchPullRequestBranch", + cwd: input.cwd, + args: [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/merge-requests/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + }); + }); + // Coder: read an MR head into FETCH_HEAD without moving a checked-out branch. + const fetchPullRequestHeadCommit = Effect.fn("GitManager.fetchPullRequestHeadCommit")(function* ( + input: Parameters[0], + ) { + if ((yield* sourceControlProvider(input.cwd)).kind !== "gitlab") { + return yield* gitCore.fetchPullRequestHeadCommit(input); + } + const remoteName = yield* gitCore.resolvePrimaryRemoteName(input.cwd); + yield* gitCore.execute({ + operation: "GitManager.fetchPullRequestHeadCommit", + cwd: input.cwd, + args: [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `refs/merge-requests/${input.prNumber}/head`, + ], + }); + return yield* gitCore.resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); + + const serverSettingsService = yield* ServerSettings.ServerSettingsService; + // Optional: git actions also run from the CLI and tests without orchestration. + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); + /** Environment settings with the acting project's overrides applied. */ + const projectSettingsFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }) { + const settings = yield* serverSettingsService.getSettings; + if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + const projectId = yield* ( + input.threadId !== undefined + ? projectionQuery.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + : projectionQuery.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + }); + const readRepositoryInstructions = (cwd: string, fileName: string) => + Effect.gen(function* () { + const root = yield* fileSystem.realPath(cwd); + const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); + if (!instructionPath.startsWith(`${root}${path.sep}`)) { + return ""; + } + const info = yield* fileSystem.stat(instructionPath); + if (info.type !== "File" || info.size > ByteSize.bytes(20_000)) { + return ""; + } + return (yield* fileSystem.readFileString(instructionPath)).trim(); + }).pipe(Effect.orElseSucceed(() => "")); + + const readRecentCommitSubjects = (cwd: string) => + gitCore + .execute({ + operation: "GitManager.readRecentCommitSubjects", + cwd, + args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], + }) + .pipe( + Effect.map((result) => + result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + Effect.orElseSucceed(() => []), + ); + + const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => + Effect.gen(function* () { + switch (settings.style.mode) { + case "conventional_commits": + return conventionalCommitsTextGenerationPolicy; + case "custom": + // Coder: the fork's writing-policy preset accepts a single instruction string. + return customTextGenerationPolicy(settings.style.customInstructions); + case "repo_conventions": { + const subjects = yield* readRecentCommitSubjects(cwd); + const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); + const isClaudeWriter = + settings.modelSelection.instanceId === "claudeAgent" || + (yield* providerRegistry.getProviders).some( + (provider) => + provider.instanceId === settings.modelSelection.instanceId && + provider.driver === "claudeAgent", + ); + const claudeInstructions = isClaudeWriter + ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") + : ""; + const examples = [ + ...(subjects.length > 0 + ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] + : []), + ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), + ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), + ].join("\n\n"); + if (!examples) { + return repositoryConventionsTextGenerationPolicy; + } + return { + ...repositoryConventionsTextGenerationPolicy, + commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, + changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, + }; + } + } + }); + const randomUUIDv4 = (cwd: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); + + const createProgressEmitter = ( + input: { cwd: string; action: GitStackedAction }, + options?: GitRunStackedActionOptions, + ) => + (options?.actionId === undefined + ? randomUUIDv4(input.cwd) + : Effect.succeed(options.actionId) + ).pipe( + Effect.map((actionId) => { + const reporter = options?.progressReporter; + const emit = (event: GitActionProgressPayload) => + reporter + ? reporter.publish({ + actionId, + cwd: input.cwd, + action: input.action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + return { + actionId, + emit, + }; + }), + ); + + const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + return; + } + + if (repositoryNameWithOwner.length === 0) { + return; + } + + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( + Effect.catch((error) => + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), + ), + ); + + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + + if (repositoryNameWithOwner.length === 0) { + yield* fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }); + return; + } + + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( + Effect.catch((primaryCause) => + fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }).pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), + ), + ); + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + const normalizeStatusCacheKey = canonicalizeExistingPath; + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + } satisfies GitVcsDriver.GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; + + return { + isRepo: details.isRepo, + ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + } satisfies VcsStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), + ); + // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh + // on their own, slower cadence: ahead/behind counts stay fresh on every + // status poll while the PR association is re-fetched at most once per + // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump + // the epoch (invalidateStatus) to bypass the cache immediately. + const prLookupEpochByCwd = new Map(); + const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; + const bumpPrLookupEpoch = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheKey) => { + prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + }), + ); + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. + const prLookupCacheKey = ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; + }, + ) => + [ + cwd, + details.branch, + details.upstreamRef ?? "", + details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", + String(prLookupEpoch(cwd)), + ].join("\u0000"); + // Consecutive failures per cache key, so a branch that keeps failing waits + // longer before the next attempt. Cleared as soon as a lookup succeeds. + const prLookupFailureStreakByKey = new Map(); + const nextPrLookupFailureTtl = (key: string) => { + if ( + !prLookupFailureStreakByKey.has(key) && + prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = prLookupFailureStreakByKey.keys().next().value; + if (oldestKey !== undefined) { + prLookupFailureStreakByKey.delete(oldestKey); + } + } + const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; + prLookupFailureStreakByKey.set(key, streak); + return prLookupFailureTtl(streak); + }; + const prLookupCache = yield* Cache.makeWith( + (key: string) => { + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); + const details = { + branch, + upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, + defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), + }; + return Effect.gen(function* () { + const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); + if (!lookup) { + return { latest: null, headContext }; + } + // Only skip when the branch is untracked as well: anything carrying an + // upstream keeps the old behaviour. + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { + return { latest: null, headContext }; + } + const latest = yield* findLatestPrForHeadContext(cwd, headContext); + return { latest, headContext }; + }); + }, + { + capacity: PR_LOOKUP_CACHE_CAPACITY, + timeToLive: (exit, key) => { + if (Exit.isSuccess(exit)) { + prLookupFailureStreakByKey.delete(key); + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; + } + return nextPrLookupFailureTtl(key); + }, + }, + ); + // A transient lookup failure (rate limit, network blip) must not clear an + // already-known PR badge, so the last successful answer per branch sticks + // around as the fallback. Keep the resolved head context with it so a + // branch retargeted to another remote/fork cannot inherit the old badge. + interface LastKnownPr { + readonly pr: ReturnType | null; + readonly upstreamRef: string | null; + readonly headBranch: string; + readonly remoteName: string | null; + readonly headRemoteUrlKey: string | null; + } + const lastKnownPrByBranchKey = new Map(); + const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { + if ( + !lastKnownPrByBranchKey.has(branchKey) && + lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = lastKnownPrByBranchKey.keys().next().value; + if (oldestKey !== undefined) { + lastKnownPrByBranchKey.delete(oldestKey); + } + } + lastKnownPrByBranchKey.set(branchKey, entry); + }; + const resolveLastKnownPr = ( + branchKey: string, + current: Pick, + ): ReturnType | null => { + const lastKnown = lastKnownPrByBranchKey.get(branchKey); + if (!lastKnown) return null; + if (lastKnown.headBranch !== current.headBranch) { + return null; + } + + // The normalized URL catches both remote-alias changes and an existing + // alias being repointed. Both sides must be resolved before treating a + // mismatch as real: `readConfigValueNullable` swallows any git-config + // read failure into `null`, so a transient failure to resolve the + // *current* remote URL must read as "unknown", not as "no remote" — the + // latter would otherwise drop an already-known PR badge on every hiccup. + if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { + return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; + } + + // If the remote URL can't be compared, fall back to the remote identity + // encoded by tracked branches — same "both sides known" requirement, for + // the same reason. A null-to-non-null transition (upstream/remoteName) + // is allowed because that is the expected first-push case. + if ( + lastKnown.upstreamRef !== null && + current.upstreamRef !== null && + lastKnown.remoteName !== null && + current.remoteName !== null + ) { + return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; + } + return lastKnown.pr; + }; + const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + isDefaultBranch: boolean; + }, + refreshMissingPullRequest = false, + ) { + // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first + // `push -u`) must not orphan the fallback value for the same branch. + const branchKey = `${cwd}\u0000${details.branch}`; + const cacheKey = prLookupCacheKey(cwd, details); + if (refreshMissingPullRequest) { + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached) && cached.value.latest === null) { + yield* Cache.invalidate(prLookupCache, cacheKey); + } + } + return yield* Cache.get(prLookupCache, cacheKey).pipe( + Effect.map(({ latest, headContext }) => { + if (!latest) return { pr: null, headContext }; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") { + return { pr: null, headContext }; + } + return { pr: toStatusPr(latest), headContext }; + }), + Effect.tap(({ pr, headContext }) => + Effect.sync(() => + rememberLastKnownPr(branchKey, { + pr, + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + Effect.map(({ pr }) => pr), + Effect.catch((error) => + Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.annotateLogs({ + operation: "lookupStatusPr", + branch: details.branch, + errorTag: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : typeof error, + ...(isSourceControlProviderError(error) + ? { + provider: error.provider, + providerOperation: error.operation, + providerCommand: error.command ?? "unknown", + errorDetail: error.detail, + } + : {}), + }), + Effect.andThen(resolveLookupHeadContext(cwd, details)), + Effect.map(({ headContext }) => + resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + ), + ); + }); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( + cwd: string, + options?: GitRemoteStatusOptions, + ) { + const details = yield* gitCore + .statusDetailsRemote(cwd, options) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } + + const pr = + details.branch !== null + ? yield* lookupStatusPr( + cwd, + { + branch: details.branch, + upstreamRef: details.upstreamRef, + defaultBranch: details.defaultBranch, + isDefaultBranch: details.isDefaultBranch, + }, + options?.refreshMissingPullRequest, + ) + : null; + + return { + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, + pr, + } satisfies VcsStatusRemoteResult; + }); + const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateRemoteStatusResultCache = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); + + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + if (!remoteUrl || provider?.kind !== "unknown") return provider; + const handle = yield* sourceControlProviders + .resolveHandle({ + cwd, + context: { provider, remoteName: preferredRemoteName, remoteUrl }, + }) + .pipe(Effect.orElseSucceed(() => null)); + return handle?.context?.provider ?? provider; + }); + + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( + cwd: string, + remoteName: string | null, + ) { + if (!remoteName) { + return { + remoteUrlKey: null, + repositoryNameWithOwner: null, + ownerLogin: null, + }; + } + + const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + if ( + remoteUrl !== null && + /^https?:\/\//iu.test(remoteUrl) && + (repositoryNameWithOwner?.split("/").length ?? 0) > 2 + ) { + const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); + const kind = + detected?.kind === "unknown" + ? yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => provider.kind), + Effect.orElseSucceed(() => undefined), + ) + : detected?.kind; + repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); + } + return { + remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, + repositoryNameWithOwner, + ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; + }); + + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, + ) { + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); + const headBranchFromUpstream = details.upstreamRef + ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) + : ""; + const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + const shouldProbeLocalBranchSelector = + headBranchFromUpstream.length === 0 || headBranch === details.branch; + + const [remoteRepository, originRepository] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + + const isCrossRepository = + remoteRepository.repositoryNameWithOwner !== null && + originRepository.repositoryNameWithOwner !== null + ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== + originRepository.repositoryNameWithOwner.toLowerCase() + : remoteName !== null && + remoteName !== "origin" && + remoteRepository.repositoryNameWithOwner !== null; + + const ownerHeadSelector = + remoteRepository.ownerLogin && headBranch.length > 0 + ? `${remoteRepository.ownerLogin}:${headBranch}` + : null; + const remoteAliasHeadSelector = + remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; + const shouldProbeRemoteOwnedSelectors = + isCrossRepository || (remoteName !== null && remoteName !== "origin"); + + const headSelectors: string[] = []; + if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + if (shouldProbeLocalBranchSelector) { + appendUnique(headSelectors, details.branch); + } + appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); + if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + + return { + localBranch: details.branch, + headBranch, + headSelectors, + preferredHeadSelector: + ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, + remoteName, + headRemoteUrlKey: + remoteRepository.remoteUrlKey ?? + (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, + headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, + headRepositoryOwnerLogin: remoteRepository.ownerLogin, + isCrossRepository, + } satisfies BranchHeadContext; + }); + + // The remote that holds a ref named after the local branch, or null when + // none does. Remote names may contain slashes, so refs are matched literally + // per remote instead of with a glob. When several remotes hold the name, the + // preferred remote wins, then origin, then the first configured remote. + const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* ( + cwd: string, + branch: string, + preferredRemoteName: string | null, + ) { + if (branch.length === 0) return null; + return yield* Effect.gen(function* () { + const remoteNames = (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (remoteNames.length === 0) return null; + const refs = new Set( + (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.refs", + cwd, + args: [ + "for-each-ref", + "--format=%(refname)", + ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), + ], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((ref) => ref.trim()) + .filter((ref) => ref.length > 0), + ); + const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`)); + if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) { + return preferredRemoteName; + } + if (matching.includes("origin")) return "origin"; + return matching[0] ?? null; + }).pipe(Effect.orElseSucceed(() => null)); + }); + + // `git worktree add -b feature origin/main` makes the new local branch track + // origin/main. That upstream is the branch's base, not its published PR + // head. Looking up PRs for it can attach an old reverse merge from main and + // auto-settle an unrelated feature thread. + // + // The branch may still have been pushed under its own name by a plain + // `git push feature` that never moved the upstream. When a remote + // holds a ref for the local name, look the PR up by that name on that + // remote. Without such a ref there is nothing to ask the host about, so + // `lookup` is false and no API call is spent. Both the cached lookup and the + // failure fallback resolve through here so the last-known PR compares + // against the same head branch. + const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + remoteName?: string; + }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const upstreamHeadIsDefault = + headContext.headBranch === details.defaultBranch || + (details.defaultBranch === null && + (headContext.headBranch === "main" || headContext.headBranch === "master")); + if ( + headContext.headBranch === details.branch || + !upstreamHeadIsDefault || + headContext.isCrossRepository + ) { + return { headContext, lookup: true }; + } + const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); + if (remoteName === null) { + return { headContext, lookup: false }; + } + const ownNameContext = yield* resolveBranchHeadContext(cwd, { + branch: details.branch, + upstreamRef: null, + remoteName, + }); + return { headContext: ownNameContext, lookup: true }; + }); + + /** + * Whether git has no record of this branch on any remote, so a change request + * cannot exist for it and asking the provider is a guaranteed-empty API call. + * + * `git push` writes the remote-tracking ref even without `-u` (how most + * terminal and agent pushes land), and configured upstream metadata survives + * when a merged change request's remote branch is deleted. Together they + * distinguish branches known to have reached a host from genuinely local + * branches. The ref glob spans every remote so a fork branch still counts. A + * repository that tracks no remotes at all cannot answer the question, + * because then every branch looks unpublished; it, and any failed probe, + * keeps the lookup. + */ + const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( + cwd: string, + headContext: Pick, + ) { + if (headContext.headBranch.length === 0) { + return false; + } + const matchesRef = (pattern: string) => + gitCore + .execute({ + operation: "GitManager.isUnpublishedBranch", + cwd, + args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], + timeoutMs: 5_000, + }) + .pipe(Effect.map((result) => result.stdout.trim().length > 0)); + + return yield* Effect.gen(function* () { + const [configuredRemote, configuredMerge] = yield* Effect.all( + [ + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), + ], + { concurrency: "unbounded" }, + ); + if (configuredRemote !== null && configuredMerge !== null) { + return false; + } + + const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ); + return tracksAnyRemote && !tracksThisBranch; + }).pipe(Effect.orElseSucceed(() => false)); + }); + + const findOpenPr = Effect.fn("findOpenPr")(function* ( + cwd: string, + headContext: Pick< + BranchHeadContext, + | "headBranch" + | "headSelectors" + | "headRepositoryNameWithOwner" + | "headRepositoryOwnerLogin" + | "isCrossRepository" + >, + ) { + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ + cwd, + headSelector, + state: "open", + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, + }); + const normalizedPullRequests = pullRequests.map(toPullRequestInfo); + + const firstPullRequest = normalizedPullRequests.find((pullRequest) => + matchesBranchHeadContext(pullRequest, headContext), + ); + if (firstPullRequest) { + return { + ...firstPullRequest, + state: "open", + updatedAt: Option.none(), + } satisfies PullRequestInfo; + } + } + + return null; + }); + + const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( + cwd: string, + headContext: BranchHeadContext, + ) { + const parsedByNumber = new Map(); + + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ + cwd, + headSelector, + state: "all", + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, + }); + + for (const pr of pullRequests.map(toPullRequestInfo)) { + if (!matchesBranchHeadContext(pr, headContext)) { + continue; + } + parsedByNumber.set(pr.number, pr); + } + } + + const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); + + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } + return parsed[0] ?? null; + }); + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( + cwd: string, + result: Pick, + ) { + const terms = yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + ); + const summary = summarizeGitActionResult(result, terms); + let latestOpenPr: PullRequestInfo | null = null; + let currentBranchIsDefault = false; + let finalBranchContext: { + branch: string; + upstreamRef: string | null; + hasUpstream: boolean; + } | null = null; + + if (result.action !== "commit") { + const finalStatus = yield* gitCore.statusDetails(cwd); + if (finalStatus.branch) { + finalBranchContext = { + branch: finalStatus.branch, + upstreamRef: finalStatus.upstreamRef, + hasUpstream: finalStatus.hasUpstream, + }; + currentBranchIsDefault = finalStatus.isDefaultBranch; + } + } + + const explicitResultPr = + (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url + ? { + url: result.pr.url, + state: "open" as const, + } + : null; + const shouldLookupExistingOpenPr = + (result.action === "commit_push" || result.action === "push") && + result.push.status === "pushed" && + result.branch.status !== "created" && + !currentBranchIsDefault && + explicitResultPr === null && + finalBranchContext?.hasUpstream === true; + + if (shouldLookupExistingOpenPr && finalBranchContext) { + latestOpenPr = yield* resolveBranchHeadContext(cwd, { + branch: finalBranchContext.branch, + upstreamRef: finalBranchContext.upstreamRef, + }).pipe( + Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), + Effect.orElseSucceed(() => null), + ); + } + + const openPr = latestOpenPr ?? explicitResultPr; + + const cta = + result.action === "commit" && result.commit.status === "created" + ? { + kind: "run_action" as const, + label: "Push", + action: { kind: "push" as const }, + } + : (result.action === "push" || + result.action === "create_pr" || + result.action === "commit_push" || + result.action === "commit_push_pr") && + openPr?.url && + (!currentBranchIsDefault || + result.pr.status === "created" || + result.pr.status === "opened_existing") + ? { + kind: "open_pr" as const, + label: `View ${terms.shortLabel}`, + url: openPr.url, + } + : (result.action === "push" || result.action === "commit_push") && + result.push.status === "pushed" && + !currentBranchIsDefault + ? { + kind: "run_action" as const, + label: `Create ${terms.shortLabel}`, + action: { kind: "create_pr" as const }, + } + : { + kind: "none" as const, + }; + + return { + ...summary, + cta, + }; + }); + + const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( + cwd: string, + branch: string, + upstreamRef: string | null, + headContext: Pick, + ) { + const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + if (configured) return configured; + + if (upstreamRef && !headContext.isCrossRepository) { + const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { + remoteName: headContext.remoteName, + }); + if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + return upstreamBranch; + } + } + + const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.orElseSucceed(() => null), + ); + if (defaultFromProvider) { + return defaultFromProvider; + } + + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + + return "main"; + }); + + const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( + cwd: string, + baseBranch: string, + ) { + const remoteName = yield* gitCore + .resolvePrimaryRemoteName(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!remoteName) return baseBranch; + + return yield* gitCore + .resolveRemoteTrackingCommit({ + cwd, + refName: baseBranch, + fallbackRemoteName: remoteName, + }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => baseBranch), + ); + }); + + const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( + function* (input: { + cwd: string; + branch: string | null; + commitMessage?: string; + /** When true, also produce a semantic feature branch name. */ + includeBranch?: boolean; + filePaths?: readonly string[]; + settings: SourceControlTextGenerationSettings; + }) { + const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); + if (!context) { + return null; + } + + const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + if (customCommit) { + return { + subject: customCommit.subject, + body: customCommit.body, + ...(input.includeBranch + ? { branch: sanitizeFeatureBranchName(customCommit.subject) } + : {}), + commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + }; + } + + const policy = yield* resolveStylePolicy(input.cwd, input.settings); + + const generated = yield* textGeneration + .generateCommitMessage({ + cwd: input.cwd, + branch: input.branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.includeBranch ? { includeBranch: true } : {}), + ...(policy ? { policy } : {}), + modelSelection: input.settings.modelSelection, + }) + .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + + return { + subject: generated.subject, + body: generated.body, + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + commitMessage: formatCommitMessage(generated.subject, generated.body), + }; + }, + ); + + const runCommitStep = Effect.fn("runCommitStep")(function* ( + settings: SourceControlTextGenerationSettings, + cwd: string, + action: "commit" | "commit_push" | "commit_push_pr", + branch: string | null, + commitMessage?: string, + preResolvedSuggestion?: CommitAndBranchSuggestion, + filePaths?: readonly string[], + progressReporter?: GitActionProgressReporter, + actionId?: string, + ) { + const emit = (event: GitActionProgressPayload) => + progressReporter && actionId + ? progressReporter.publish({ + actionId, + cwd, + action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; + if (!suggestion) { + const needsGeneration = !commitMessage?.trim(); + if (needsGeneration) { + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Generating commit message...", + }); + } + suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + settings, + }); + } + if (!suggestion) { + return { status: "skipped_no_changes" as const }; + } + + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Committing...", + }); + + let currentHookName: string | null = null; + const commitProgress = + progressReporter && actionId + ? { + onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName: currentHookName, + stream, + text: sanitized, + }); + }, + onHookStarted: (hookName: string) => { + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }, + onHookFinished: ({ + hookName, + exitCode, + durationMs, + }: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => { + if (currentHookName === hookName) { + currentHookName = null; + } + return emit({ + kind: "hook_finished", + hookName, + exitCode, + durationMs, + }); + }, + } + : null; + const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(commitProgress ? { progress: commitProgress } : {}), + }); + if (currentHookName !== null) { + yield* emit({ + kind: "hook_finished", + hookName: currentHookName, + exitCode: 0, + durationMs: null, + }); + currentHookName = null; + } + return { + status: "created" as const, + commitSha, + subject: suggestion.subject, + }; + }); + + const runPrStep = Effect.fn("runPrStep")(function* ( + settings: SourceControlTextGenerationSettings, + cwd: string, + fallbackBranch: string | null, + emit: GitActionProgressEmitter, + ) { + const provider = yield* sourceControlProvider(cwd); + const terms = getChangeRequestTerminologyForKind(provider.kind); + const details = yield* gitCore.statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); + } + if (!details.hasUpstream) { + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); + } + + const headContext = yield* resolveBranchHeadContext(cwd, { + branch, + upstreamRef: details.upstreamRef, + }); + + const existing = yield* findOpenPr(cwd, headContext); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } + + const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Generating ${terms.shortLabel} content...`, + }); + const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); + const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); + const policy = yield* resolveStylePolicy(cwd, settings); + // Coder: GitLab also uses repository merge-request templates. + const changeRequestTemplate = + settings.style.followChangeRequestTemplates && + (provider.kind === "github" || provider.kind === "gitlab") + ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) + : undefined; + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(changeRequestTemplate ? { changeRequestTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, + }); + + const bodyFile = path.join( + tempDir, + `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, + ); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Creating ${terms.singular}...`, + }); + yield* provider + .createChangeRequest({ + cwd, + baseRefName: baseBranch, + headSelector: headContext.preferredHeadSelector, + title: generated.title, + bodyFile, + }) + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore))); + + const created = yield* findOpenPr(cwd, headContext); + if (!created) { + return { + status: "created" as const, + baseBranch, + headBranch: headContext.headBranch, + title: generated.title, + }; + } + + return { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + }; + }); + + const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( + function* (input) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); + }, + ); + const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input, options) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) { + return yield* readRemoteStatus(cacheKey, options); + } + return yield* Cache.get(remoteStatusResultCache, cacheKey); + }, + ); + const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { + concurrency: "unbounded", + }); + return mergeGitStatusParts(local, remote); + }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }, options) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + if (options?.refresh) { + // A completed turn can create a PR or reuse a merged PR's branch. + // Refresh successful answers, but keep failed lookups' retry backoff. + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); + } + let cached = yield* Cache.get(prLookupCache, cacheKey); + // The cached head context may have resolved on a different remote than + // the saved upstream: a branch tracking origin/main but pushed to a fork + // is looked up on the fork. Verify against the remote the lookup used. + const identityRemoteName = (headContext: BranchHeadContext) => + headContext.remoteName ?? remoteName ?? undefined; + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + identityRemoteName(cached.headContext), + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + identityRemoteName(cached.headContext), + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + return { + ...toStatusPr(latest), + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + // Hosting CLIs can select an upstream repository instead of origin. + // The returned PR URL names the repository that actually owns it. + repositoryKey: pullRequestRepositoryKey(latest.url), + }; + }); + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + }); + const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); + }); + const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); + // Full invalidation is the explicit-freshness path (git actions, user + // refresh); it also bypasses the slow PR-lookup cache. The periodic + // status poll only invalidates local/remote and keeps the PR cache warm. + yield* bumpPrLookupEpoch(cwd); + }, + ); + + const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( + "resolvePullRequest", + )(function* (input) { + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + + return { pullRequest }; + }); + + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( + "preparePullRequestThread", + )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), + ), + ); + }; + return yield* Effect.gen(function* () { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ + cwd: input.cwd, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + + if (input.mode === "local") { + yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( + input.cwd, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + isOnPullRequestHead: true, + }; + } + + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); + + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + // Git refuses to move a branch that is checked out in a worktree, so the + // reuse paths cannot go through materializePullRequestHeadBranch and instead + // advance the checkout from inside the worktree. A worktree that cannot be + // moved (no reachable head, local commits, dirty tree) is still handed + // back, because stranding the thread is worse than reporting the staleness. + const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* ( + worktreePath: string, + checkedOutBranch: string, + ) { + if (checkedOutBranch !== localPullRequestBranch) { + // findLocalHeadBranch also accepts a branch that merely shares the head's bare name — + // a fork PR opened from "main" matches the user's own local main. That checkout is + // somebody else's work, so it keeps its tracking config and nothing else. + yield* ensureExistingWorktreeUpstream(worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: false, + }; + } + + // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref, + // and once that has jumped to a rewritten head there is no way left to tell a checkout + // that holds nothing of its own from one carrying local commits. + const upstreamCommitBeforeFetch = yield* gitCore + .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => null), + ); + + yield* ensureExistingWorktreeUpstream(worktreePath); + + // The pull request's own ref, because it is the only thing that certainly names its + // head. The branch's upstream does not: configuring it is best-effort, so a branch cut + // from `origin/main` whose head branch has since been deleted still resolves — and + // following it would move the checkout onto main and call that the pull request. + const refreshed = yield* fetchPullRequestHeadCommit({ + cwd: worktreePath, + prNumber: pullRequest.number, + }).pipe( + // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, + // taken only where it is the head branch's own rather than whatever the checkout + // happened to be cut from. + Effect.catch(() => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + if ( + details.upstreamRef === null || + !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) + ) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: worktreePath, + detail: "The pull request head could not be resolved for this checkout.", + }); + } + return yield* gitCore.resolveCommit({ + cwd: worktreePath, + revision: details.upstreamRef, + }); + }), + ), + Effect.flatMap((target) => + gitCore.refreshCheckedOutBranch({ + cwd: worktreePath, + targetCommit: target.commitSha, + resetWhenHeadCommit: upstreamCommitBeforeFetch, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "GitManager.preparePullRequestThread reused worktree refresh failed", + { + worktreePath, + localBranch: localPullRequestBranch, + cause: error, + }, + ).pipe(Effect.as({ moved: false, onTarget: false })), + ), + ); + + // Only when the checkout actually moved: another thread may be running in this worktree, + // and re-running the setup script under it buys nothing when the code did not change. + if (refreshed.moved) { + yield* maybeRunSetupScript(worktreePath); + } + + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: refreshed.onTarget, + }; + }); + + const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { + const result = yield* gitCore.listRefs({ cwd, refresh: true }); + const localBranch = result.refs.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + + for (const branch of result.refs) { + if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) { + continue; + } + + const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + if (worktreePath !== rootWorktreePath) { + return branch; + } + } + + return null; + }); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + return yield* reuseExistingWorktree( + existingBranchBeforeFetch.worktreePath, + existingBranchBeforeFetch.name, + ); + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + return yield* reuseExistingWorktree( + existingBranchAfterFetch.worktreePath, + existingBranchAfterFetch.name, + ); + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); + } + + const worktree = yield* gitCore.createWorktree( + { + cwd: input.cwd, + refName: localPullRequestBranch, + path: null, + }, + { + // Best effort: a settings read failure falls back to the checkout's t3.json. + submodules: yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.refName, + worktreePath: worktree.worktree.path, + isOnPullRequestHead: true, + }; + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); + }); + + const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( + settings: SourceControlTextGenerationSettings, + cwd: string, + branch: string | null, + commitMessage?: string, + filePaths?: readonly string[], + ) { + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + includeBranch: true, + settings, + }); + if (!suggestion) { + return yield* new GitManagerError({ + operation: "runFeatureBranchStep", + cwd, + detail: "Cannot create a feature branch because there are no changes to commit.", + }); + } + + const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); + const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); + + yield* gitCore.createRef({ cwd, refName: resolvedBranch }); + yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); + + return { + branchStep: { status: "created" as const, name: resolvedBranch }, + resolvedCommitMessage: suggestion.commitMessage, + resolvedCommitSuggestion: suggestion, + }; + }); + + const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")( + function* (input, options) { + const progress = yield* createProgressEmitter(input, options); + const currentPhase = yield* Ref.make>(Option.none()); + + const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< + GitRunStackedActionResult, + GitManagerServiceError + > { + const initialStatus = yield* gitCore.statusDetails(input.cwd); + const wantsCommit = isCommitAction(input.action); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && + (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + + if (input.featureBranch && !wantsCommit) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Feature-branch checkout is only supported for commit actions.", + }); + } + if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Commit local changes before creating a PR.", + }); + } + + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ]; + + yield* progress.emit({ + kind: "action_started", + phases, + }); + + // Coder: reject MR actions before any branch, commit, or push unless the resolved provider + // confirms write access (unregistered providers never do). + if (wantsPr) { + const provider = yield* sourceControlProviders.resolve({ cwd: input.cwd }); + const writeAccess = yield* provider.probeWriteAccess({ cwd: input.cwd }); + if (!writeAccess.writable) { + const detail = + writeAccess.detail ?? + (writeAccess.status === "policy-blocked" + ? "GitLab write operations are blocked by this workspace." + : writeAccess.status === "unauthenticated" + ? "The GitLab CLI is not authenticated." + : "GitLab write access could not be verified for this workspace."); + return yield* new SourceControlProviderError({ + provider: provider.kind, + operation: "createChangeRequest", + cwd: input.cwd, + detail, + }); + } + } + + if (!input.featureBranch && wantsPush && !initialStatus.branch) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot push from detached HEAD.", + }); + } + if (!input.featureBranch && wantsPr && !initialStatus.branch) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); + } + + let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; + let commitMessageForStep = input.commitMessage; + let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; + + const textGenerationSettings = yield* projectSettingsFor(input).pipe( + Effect.flatMap((settings) => + settings.sourceControlWriterModelSelection === null + ? Effect.succeed({ + modelSelection: settings.textGenerationModelSelection, + style: settings.sourceControlWritingStyle, + }) + : providerRegistry.getProviders.pipe( + Effect.map((providers) => ({ + modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + settings, + providers, + ), + style: settings.sourceControlWritingStyle, + })), + ), + ), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), + ), + ); + + if (input.featureBranch) { + yield* Ref.set(currentPhase, Option.some("branch")); + yield* progress.emit({ + kind: "phase_started", + phase: "branch", + label: "Preparing feature branch...", + }); + const result = yield* runFeatureBranchStep( + textGenerationSettings, + input.cwd, + initialStatus.branch, + input.commitMessage, + input.filePaths, + ); + branchStep = result.branchStep; + commitMessageForStep = result.resolvedCommitMessage; + preResolvedCommitSuggestion = result.resolvedCommitSuggestion; + } else { + branchStep = { status: "skipped_not_requested" as const }; + } + + const currentBranch = branchStep.name ?? initialStatus.branch; + const commitAction = isCommitAction(input.action) ? input.action : null; + const changeRequestTerms = wantsPr + ? yield* sourceControlProvider(input.cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + ) + : null; + + const commit = commitAction + ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( + Effect.flatMap(() => + runCommitStep( + textGenerationSettings, + input.cwd, + commitAction, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + options?.progressReporter, + progress.actionId, + ), + ), + ) + : { status: "skipped_not_requested" as const }; + + const push = wantsPush + ? yield* progress + .emit({ + kind: "phase_started", + phase: "push", + label: "Pushing...", + }) + .pipe( + Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), + Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), + ) + : { status: "skipped_not_requested" as const }; + + const pr = wantsPr + ? yield* progress + .emit({ + kind: "phase_started", + phase: "pr", + label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, + }) + .pipe( + Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), + Effect.flatMap(() => + runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), + ), + ) + : { status: "skipped_not_requested" as const }; + + const toast = yield* buildCompletionToast(input.cwd, { + action: input.action, + branch: branchStep, + commit, + push, + pr, + }); + + const result = { + action: input.action, + branch: branchStep, + commit, + push, + pr, + toast, + }; + yield* progress.emit({ + kind: "action_finished", + result, + }); + return result; + }); + + return yield* runAction().pipe( + Effect.ensuring(invalidateStatus(input.cwd)), + Effect.tapError((error) => + Effect.flatMap(Ref.get(currentPhase), (phase) => + progress.emit({ + kind: "action_failed", + phase: Option.getOrNull(phase), + message: error.message, + }), + ), + ), + ); + }, + ); + + return GitManager.of({ + localStatus, + remoteStatus, + status, + branchPullRequest, + invalidateLocalStatus, + invalidateRemoteStatus, + invalidateStatus, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + }); +}); + +export const layer = Layer.effect(GitManager, make); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 6b8cd8fb72463..40199af6cf916 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,1376 +1,277 @@ -import * as ServerSettings from "../serverSettings.ts"; -import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import { beforeEach, expect, it, vi } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, ThreadId } from "@t3tools/contracts"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import { TestClock } from "effect/testing"; -import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import * as ServerConfig from "../config.ts"; -import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; -import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitWorkflowService from "./GitWorkflowService.ts"; - -const getChangeRequest = vi.fn(() => - Effect.succeed({ - provider: "gitlab" as const, - number: 42, - title: "Restore the panel", - url: "https://gitlab.example.com/group/project/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/panel", - state: "open" as const, - updatedAt: Option.none(), - }), -); -const listChangeRequests = - vi.fn(); -const probeWriteAccess = - vi.fn(); -const checkoutChangeRequest = vi.fn(() => Effect.void); -const getRepositoryCloneUrls = vi.fn(() => - Effect.succeed({ - nameWithOwner: "contributor/project", - url: "https://gitlab.example.com/contributor/project.git", - sshUrl: "git@gitlab.example.com:contributor/project.git", - }), -); -const provider = { - kind: "gitlab" as const, - probeWriteAccess, - getChangeRequest, - listChangeRequests, - checkoutChangeRequest, - getRepositoryCloneUrls, -} as unknown as SourceControlProvider.SourceControlProvider["Service"]; - -const listRefs = vi.fn(); -const createWorktree = vi.fn(); -const refStatusLocal = vi.fn(); -const execute = vi.fn(); -const ensureRemote = vi.fn(); -const runSetupScript = - vi.fn(); - -function gitOutput(stdout = "", exitCode = 0): GitVcsDriver.ExecuteGitResult { - return { - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; -} - -beforeEach(() => { - execute.mockReset(); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - return Effect.succeed(gitOutput("git@gitlab.example.com:group/project.git\n")); - } - return Effect.succeed(gitOutput()); - }); - ensureRemote.mockReset(); - ensureRemote.mockReturnValue(Effect.succeed("fork")); - runSetupScript.mockReset(); - runSetupScript.mockReturnValue(Effect.succeed({ status: "no-script" })); - listRefs.mockReset(); - createWorktree.mockReset(); - refStatusLocal.mockReset(); - checkoutChangeRequest.mockClear(); - getRepositoryCloneUrls.mockClear(); - getChangeRequest.mockClear(); - listChangeRequests.mockReset(); - probeWriteAccess.mockReset(); - probeWriteAccess.mockReturnValue(Effect.succeed({ status: "writable", writable: true })); - listChangeRequests.mockReturnValue( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 42, - title: "Restore the panel", - url: "https://gitlab.example.com/group/project/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/panel", - state: "open" as const, - updatedAt: Option.none(), - }, - ]), - ); -}); +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; -const workflowSupport = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - }), - Layer.mock(TextGeneration.TextGeneration)({ - generatePrContent: () => Effect.succeed({ title: "Restore Git actions", body: "" }), - }), - Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread: runSetupScript }), -); +import * as GitManager from "./GitManager.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -const layer = it.layer( - GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), +function makeLayer(input: { + readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; +}) { + return GitWorkflowService.layer.pipe( Layer.provide( - Layer.mergeAll( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute, - ensureRemote, - listRefs, - createWorktree, - refStatusLocal, - }), - Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ - runForThread: runSetupScript, - }), - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), - }), - ), - ), - ), -); - -layer("GitWorkflowService.runStackedAction", (it) => { - it.effect( - "rejects MR workflows before changing the repository when GitLab writes are blocked", - () => - Effect.gen(function* () { - probeWriteAccess.mockReturnValueOnce( - Effect.succeed({ status: "policy-blocked", writable: false }), - ); - const service = yield* GitWorkflowService.GitWorkflowService; - - const error = yield* service - .runStackedAction({ - actionId: "blocked-action", - cwd: "/repo", - action: "commit_push_pr", - commitMessage: "Must not commit", - }) - .pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "SourceControlProviderError", - provider: "gitlab", - operation: "createChangeRequest", - detail: "GitLab write operations are blocked by this workspace.", - }); - expect(execute).not.toHaveBeenCalled(); + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: input.detect, }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager.GitManager)({})), ); -}); - -layer("GitWorkflowService.preparePullRequestThread", (it) => { - it.effect("checks an MR out in the project checkout", () => - Effect.gen(function* () { - refStatusLocal.mockReturnValueOnce( - Effect.succeed({ isRepo: true, refName: "feature/panel" }), - ); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "local", - }); - - expect(checkoutChangeRequest).toHaveBeenCalledWith({ - cwd: "/repo", - reference: "42", - force: true, - }); - expect(result).toMatchObject({ - branch: "feature/panel", - worktreePath: null, - isOnPullRequestHead: true, - }); - }), - ); +} - it.effect("creates an isolated branch and checks the MR out inside its worktree", () => +describe("GitWorkflowService", () => { + it.effect("reports a non-Git VCS repository as not a Git repository", () => Effect.gen(function* () { - listRefs.mockReturnValueOnce( - Effect.succeed({ - refs: [], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 0, - }), - ); - listRefs.mockReturnValueOnce( - Effect.succeed({ - refs: [ - { - name: "feature/panel", - isRemote: false, - worktreePath: null, - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - }), - ); - createWorktree.mockReturnValueOnce( - Effect.succeed({ - worktree: { - path: "/worktrees/project/feature-panel", - refName: "feature/panel", - }, - }), - ); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - threadId: ThreadId.make("thread-1"), - }); + const workflow = yield* GitWorkflowService.GitWorkflowService; + const isRepository = yield* workflow.isRepository("/jj-repo"); - expect(createWorktree).toHaveBeenCalledWith({ - cwd: "/repo", - refName: "feature/panel", - path: null, - }); - expect(execute).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: "/repo", - args: ["fetch", "origin", "+refs/merge-requests/42/head:refs/heads/feature/panel"], + assert.equal(isRepository, false); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => + Effect.succeed({ + kind: "jj", + repository: { + kind: "jj", + rootPath: "/jj-repo", + metadataPath: "/jj-repo/.jj", + freshness: { + source: "live-local", + observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], + }), }), - ); - expect(runSetupScript).toHaveBeenCalledWith({ - threadId: "thread-1", - projectCwd: "/repo", - worktreePath: "/worktrees/project/feature-panel", - }); - expect(result).toMatchObject({ - branch: "feature/panel", - worktreePath: "/worktrees/project/feature-panel", - isOnPullRequestHead: true, - }); - }), + ), + ), ); - it.effect("refreshes a clean reused worktree after the MR head is force-pushed", () => + it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { - listRefs.mockReturnValue( - Effect.succeed({ - refs: [ - { - name: "feature/panel", - isRemote: false, - worktreePath: "/worktrees/project/feature-panel", - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - }), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "rev-parse") { - const revision = input.args[1]; - return Effect.succeed( - gitOutput(revision === "refs/t3code/merge-requests/42/head" ? "new\n" : "old\n"), - ); - } - if (input.args[0] === "status") return Effect.succeed(gitOutput()); - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - threadId: ThreadId.make("thread-1"), - }); - - expect(execute).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: "/worktrees/project/feature-panel", - args: ["reset", "--hard", "new"], + const workflow = yield* GitWorkflowService.GitWorkflowService; + const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), }), - ); - expect(runSetupScript).toHaveBeenCalledOnce(); - expect(result).toMatchObject({ - worktreePath: "/worktrees/project/feature-panel", - isOnPullRequestHead: true, - }); - }), + ), + ), ); - it.effect("preserves dirty work in a reused worktree and reports that it is stale", () => + it.effect("returns an empty full status when no VCS repository is detected", () => Effect.gen(function* () { - listRefs.mockReturnValue( - Effect.succeed({ - refs: [ - { - name: "feature/panel", - isRemote: false, - worktreePath: "/worktrees/project/feature-panel", - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, + const workflow = yield* GitWorkflowService.GitWorkflowService; + const status = yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), }), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "rev-parse") { - const revision = input.args[1]; - return Effect.succeed( - gitOutput(revision === "refs/t3code/merge-requests/42/head" ? "new\n" : "old\n"), - ); - } - if (input.args[0] === "status") return Effect.succeed(gitOutput(" M src/panel.ts\n")); - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - threadId: ThreadId.make("thread-1"), - }); - - expect(execute.mock.calls.some((call) => call[0].args[0] === "reset")).toBe(false); - expect(runSetupScript).not.toHaveBeenCalled(); - expect(result.isOnPullRequestHead).toBe(false); - }), + ), + ), ); - it.effect("preserves local commits in a reused worktree", () => - Effect.gen(function* () { - listRefs.mockReturnValue( - Effect.succeed({ - refs: [ - { - name: "feature/panel", - isRemote: false, - worktreePath: "/worktrees/project/feature-panel", - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - }), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "rev-parse") { - const revision = input.args[1]; - return Effect.succeed( - gitOutput( - revision === "@{upstream}" - ? "base\n" - : revision === "refs/t3code/merge-requests/42/head" - ? "new\n" - : "local\n", - ), - ); - } - if (input.args[0] === "merge-base") return Effect.succeed(gitOutput("", 1)); - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - }); + it.effect("does not call GitManager status methods when no VCS repository is detected", () => { + const localStatus = vi.fn(); + const remoteStatus = vi.fn(); + const status = vi.fn(); - expect( - execute.mock.calls.some( - (call) => call[0].args[0] === "reset" || call[0].args[0] === "merge", - ), - ).toBe(false); - expect(result.isOnPullRequestHead).toBe(false); - }), - ); - - it.effect("preserves local commits on a branch that is not attached to a worktree", () => - Effect.gen(function* () { - listRefs.mockReturnValue( - Effect.succeed({ - refs: [ - { - name: "feature/panel", - isRemote: false, - worktreePath: null, - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, + const testLayer = GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: () => Effect.succeed(null), }), - ); - createWorktree.mockReturnValueOnce( - Effect.succeed({ - worktree: { - path: "/worktrees/project/feature-panel", - refName: "feature/panel", - }, + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager.GitManager)({ + localStatus, + remoteStatus, + status, }), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "rev-parse") { - const revision = input.args[1]; - return Effect.succeed( - gitOutput( - revision === "@{upstream}" - ? "base\n" - : revision === "refs/t3code/merge-requests/42/head" - ? "new\n" - : "local\n", - ), - ); - } - if (input.args[0] === "merge-base") return Effect.succeed(gitOutput("", 1)); - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; + ), + ); - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - threadId: ThreadId.make("thread-1"), - }); + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.localStatus({ cwd: "/not-a-repo" }); + yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); + yield* workflow.status({ cwd: "/not-a-repo" }); - expect(createWorktree).toHaveBeenCalledWith({ - cwd: "/repo", - refName: "feature/panel", - path: null, - }); - expect( - execute.mock.calls.some( - (call) => - call[0].args[0] === "fetch" && - call[0].args.some((arg) => arg.endsWith(":refs/heads/feature/panel")), - ), - ).toBe(false); - expect( - execute.mock.calls.some( - (call) => call[0].args[0] === "reset" || call[0].args[0] === "merge", - ), - ).toBe(false); - expect(runSetupScript).toHaveBeenCalledOnce(); - expect(result).toMatchObject({ - worktreePath: "/worktrees/project/feature-panel", - isOnPullRequestHead: false, - }); - }), - ); + assert.equal(localStatus.mock.calls.length, 0); + assert.equal(remoteStatus.mock.calls.length, 0); + assert.equal(status.mock.calls.length, 0); + }).pipe(Effect.provide(testLayer)); + }); - it.effect("uses a namespaced local branch and fork remote for cross-project MRs", () => + it.effect("returns an empty ref list when no VCS repository is detected", () => Effect.gen(function* () { - getChangeRequest.mockReturnValueOnce( - Effect.succeed({ - provider: "gitlab" as const, - number: 42, - title: "Restore the panel", - url: "https://gitlab.example.com/group/project/-/merge_requests/42", - baseRefName: "main", - headRefName: "Feature/Panel", - state: "open" as const, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "contributor/project", - headRepositoryOwnerLogin: "contributor", - }), - ); - listRefs - .mockReturnValueOnce( - Effect.succeed({ - refs: [], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 0, - }), - ) - .mockReturnValueOnce( - Effect.succeed({ - refs: [ - { - name: "t3code/pr-42/feature/panel", - isRemote: false, - worktreePath: null, - current: false, - isDefault: false, - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - }), - ); - createWorktree.mockReturnValueOnce( - Effect.succeed({ - worktree: { - path: "/worktrees/project/fork-panel", - refName: "t3code/pr-42/feature/panel", - }, + const workflow = yield* GitWorkflowService.GitWorkflowService; + const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(refs, { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), }), - ); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.preparePullRequestThread({ - cwd: "/repo", - reference: "42", - mode: "worktree", - }); - - expect(ensureRemote).toHaveBeenCalledWith({ - cwd: expect.any(String), - preferredName: "contributor", - url: expect.any(String), - }); - expect(createWorktree).toHaveBeenCalledWith({ - cwd: "/repo", - refName: "t3code/pr-42/feature/panel", - path: null, - }); - expect(result.branch).toBe("t3code/pr-42/feature/panel"); - }), - ); -}); - -layer("GitWorkflowService.branchPullRequest", (it) => { - it.effect("resolves a saved branch merge request without changing the checkout", () => - Effect.gen(function* () { - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/feature/panel") { - return Effect.succeed( - gitOutput( - "refs/heads/feature/panel\0origin/feature/panel\0origin\0refs/heads/feature/panel\n", - ), - ); - } - if (input.args[0] === "symbolic-ref") { - return Effect.succeed(gitOutput("origin/main\n")); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - return Effect.succeed(gitOutput("git@gitlab.example.com:group/project.git\n")); - } - return Effect.succeed(gitOutput()); - }); - listChangeRequests.mockReturnValueOnce( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 42, - title: "Restore the panel", - url: "https://gitlab.example.com/group/project/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/panel", - state: "merged" as const, - updatedAt: Option.none(), - closedAt: null, - mergedAt: "2026-09-05T10:00:00.000Z", - }, - ]), - ); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.branchPullRequest({ - cwd: "/repo", - branch: "feature/panel", - }); - - expect(result).toMatchObject({ - state: "merged", - updatedAt: null, - closedAt: null, - mergedAt: "2026-09-05T10:00:00.000Z", - }); - expect(listChangeRequests).toHaveBeenCalledWith({ - cwd: "/repo", - headSelector: "feature/panel", - state: "all", - limit: 20, - }); - expect( - execute.mock.calls.some(([input]) => - ["checkout", "switch", "fetch", "pull", "push"].includes(input.args[0] ?? ""), - ), - ).toBe(false); - }), + ), + ), ); - it.effect("rejects an unqualified branch tracked by multiple remotes", () => - Effect.gen(function* () { - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\nfork\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/feature/panel") { - return Effect.succeed(gitOutput()); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/remotes") { - return Effect.succeed( - gitOutput("refs/remotes/origin/feature/panel\nrefs/remotes/fork/feature/panel\n"), - ); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; + it.effect("structures workflow detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream detail must stay in the cause chain", + }); - const error = yield* service - .branchPullRequest({ cwd: "/repo", branch: "feature/panel" }) - .pipe(Effect.flip); + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "GitManagerError", - detail: "Multiple remotes track feature/panel. Its merge request is ambiguous.", - }); - expect(listChangeRequests).not.toHaveBeenCalled(); - }), - ); - - it.effect("does not query GitLab for a known unpublished local branch", () => - Effect.gen(function* () { - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/feature/panel") { - return Effect.succeed(gitOutput("refs/heads/feature/panel\0\0\0\n")); - } - if (input.args[0] === "symbolic-ref") { - return Effect.succeed(gitOutput("origin/main\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/remotes") { - return Effect.succeed(gitOutput("refs/remotes/origin/main\n")); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.branchPullRequest({ + operation: "GitWorkflowService.status", cwd: "/repo", - branch: "feature/panel", + detail: "Failed to detect a VCS repository for this Git workflow.", }); - - expect(result).toBeNull(); - expect(listChangeRequests).not.toHaveBeenCalled(); - }), - ); - - it.effect("uses the default branch of a non-origin remote", () => - Effect.gen(function* () { - listChangeRequests.mockReturnValueOnce( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 43, - title: "Merged main branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/43", - baseRefName: "develop", - headRefName: "main", - state: "merged" as const, - updatedAt: Option.none(), - }, - ]), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("upstream\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/main") { - return Effect.succeed( - gitOutput("refs/heads/main\0upstream/main\0upstream\0refs/heads/main\n"), - ); - } - if (input.args[0] === "symbolic-ref") { - return Effect.succeed(gitOutput("upstream/develop\n")); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - return Effect.succeed(gitOutput("git@gitlab.example.com:group/project.git\n")); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.branchPullRequest({ cwd: "/repo", branch: "main" }); - - expect(result).toMatchObject({ - state: "merged", - updatedAt: null, - closedAt: null, - mergedAt: null, - number: 43, - url: "https://gitlab.example.com/group/project/-/merge_requests/43", - repositoryKey: "gitlab.example.com/group/project", - }); - expect(execute).toHaveBeenCalledWith( - expect.objectContaining({ - args: ["symbolic-ref", "--quiet", "--short", "refs/remotes/upstream/HEAD"], + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), }), - ); - }), - ); - - it.effect("suppresses terminal master MRs when the remote default cannot be resolved", () => - Effect.gen(function* () { - listChangeRequests.mockReturnValueOnce( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 48, - title: "Merged master branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/48", - baseRefName: "master", - headRefName: "master", - state: "merged" as const, - updatedAt: Option.none(), - }, - ]), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/master") { - return Effect.succeed( - gitOutput("refs/heads/master\0origin/master\0origin\0refs/heads/master\n"), - ); - } - if (input.args[0] === "show-ref") { - return Effect.succeed(gitOutput("", 1)); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - return Effect.succeed(gitOutput("git@gitlab.example.com:group/project.git\n")); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.branchPullRequest({ cwd: "/repo", branch: "master" }); + ), + ); + }); - expect(result).toBeNull(); - }), - ); + it.effect("structures command detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); - it.effect("disambiguates fork merge requests by remote repository identity", () => - Effect.gen(function* () { - listChangeRequests.mockReturnValueOnce( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 44, - title: "Unrelated branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/44", - baseRefName: "main", - headRefName: "feature/panel", - headRepositoryNameWithOwner: "group/project", - state: "closed" as const, - updatedAt: Option.none(), - }, - { - provider: "gitlab" as const, - number: 45, - title: "Fork branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/45", - baseRefName: "main", - headRefName: "feature/panel", - headRepositoryNameWithOwner: "contributor/project", - state: "merged" as const, - updatedAt: Option.none(), - }, - ]), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\nfork\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/feature/panel") { - return Effect.succeed( - gitOutput( - "refs/heads/feature/panel\0fork/feature/panel\0fork\0refs/heads/feature/panel\n", - ), - ); - } - if (input.args[0] === "symbolic-ref") { - return Effect.succeed(gitOutput("origin/main\n")); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - const key = input.args[2]; - return Effect.succeed( - gitOutput( - key === "remote.fork.url" - ? "git@gitlab.example.com:contributor/project.git\n" - : "git@gitlab.example.com:group/project.git\n", - ), - ); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); - const result = yield* service.branchPullRequest({ + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.listRefs", + command: "vcs-route", cwd: "/repo", - branch: "feature/panel", - }); - - expect(result).toMatchObject({ - state: "merged", - updatedAt: null, - closedAt: null, - mergedAt: null, - number: 45, - url: "https://gitlab.example.com/group/project/-/merge_requests/45", - repositoryKey: "gitlab.example.com/group/project", + detail: "Failed to detect a VCS repository for this Git command.", }); - }), - ); - - it.effect("does not attach a fork MR to a same-named target-repository branch", () => - Effect.gen(function* () { - listChangeRequests.mockReturnValueOnce( - Effect.succeed([ - { - provider: "gitlab" as const, - number: 46, - title: "Fork branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/46", - baseRefName: "main", - headRefName: "feature/panel", - isCrossRepository: true, - headRepositoryNameWithOwner: "contributor/project", - headRepositoryOwnerLogin: "contributor", - state: "closed" as const, - updatedAt: Option.none(), - }, - { - provider: "gitlab" as const, - number: 47, - title: "Target branch", - url: "https://gitlab.example.com/group/project/-/merge_requests/47", - baseRefName: "main", - headRefName: "feature/panel", - isCrossRepository: false, - headRepositoryNameWithOwner: "group/project", - headRepositoryOwnerLogin: "group", - state: "merged" as const, - updatedAt: Option.none(), - }, - ]), - ); - execute.mockImplementation((input) => { - if (input.args[0] === "remote" && input.args.length === 1) { - return Effect.succeed(gitOutput("origin\n")); - } - if (input.args[0] === "for-each-ref" && input.args.at(-1) === "refs/heads/feature/panel") { - return Effect.succeed( - gitOutput( - "refs/heads/feature/panel\0origin/feature/panel\0origin\0refs/heads/feature/panel\n", - ), - ); - } - if (input.args[0] === "symbolic-ref") { - return Effect.succeed(gitOutput("origin/main\n")); - } - if (input.args[0] === "config" && input.args[1] === "--get") { - return Effect.succeed(gitOutput("git@gitlab.example.com:group/project.git\n")); - } - return Effect.succeed(gitOutput()); - }); - const service = yield* GitWorkflowService.GitWorkflowService; - - const result = yield* service.branchPullRequest({ - cwd: "/repo/same-target", - branch: "feature/panel", - }); - - expect(result).toMatchObject({ - state: "merged", - updatedAt: null, - closedAt: null, - mergedAt: null, - number: 47, - url: "https://gitlab.example.com/group/project/-/merge_requests/47", - repositoryKey: "gitlab.example.com/group/project", - }); - }), - ); -}); - -it.effect("coalesces status reads and invalidates them explicitly", () => { - let statusReads = 0; - const testLayer = GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), - Layer.provide( - Layer.mergeAll( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - statusDetailsLocal: () => - Effect.sync(() => { - statusReads += 1; - return { - isRepo: true, - isDefaultBranch: false, - branch: "feature/cache", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }; - }), - execute: () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "git@gitlab.example.com:group/project.git\n", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - }), - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), }), ), - ), - ); - - return Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - yield* Effect.all( - Array.from({ length: 8 }, () => workflow.localStatus({ cwd: "/repo" })), - { concurrency: "unbounded" }, ); - expect(statusReads).toBe(1); - - yield* workflow.invalidateStatus("/repo"); - yield* workflow.localStatus({ cwd: "/repo" }); - expect(statusReads).toBe(2); - }).pipe(Effect.provide(testLayer)); + }); }); -it.effect("commits, pushes, and creates a GitLab merge request through the workflow", () => { - let listCalls = 0; - const createdRequests: unknown[] = []; - const mergeRequest = { - provider: "gitlab" as const, - number: 17, - title: "Restore Git actions", - url: "https://gitlab.example.com/group/project/-/merge_requests/17", - baseRefName: "main", - headRefName: "feature/panel", - state: "open" as const, - updatedAt: Option.none(), - }; - const workflowProvider = { - kind: "gitlab" as const, - probeWriteAccess: () => Effect.succeed({ status: "writable" as const, writable: true }), - listChangeRequests: () => { - listCalls += 1; - return Effect.succeed(listCalls === 1 ? [] : [mergeRequest]); - }, - createChangeRequest: (input: unknown) => - Effect.sync(() => { - createdRequests.push(input); - }), - } as unknown as SourceControlProvider.SourceControlProvider["Service"]; - const configLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-git-workflow-actions-", - }); - const gitLayer = GitVcsDriver.layer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), +// Coder: the two helper passthroughs retain Git-only routing. +it.effect("routes worktree moves and lightweight ref reads through the Git command guard", () => { + let kind: "git" | "jj" = "git"; + let moves = 0; + let reads = 0; + const move = vi.fn(() => + Effect.sync(() => { + moves += 1; + }), ); - const workflowLayer = GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), - Layer.provideMerge(gitLayer), - Layer.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(workflowProvider), - }), - ), + const ref = vi.fn(() => + Effect.sync(() => { + reads += 1; + return { isRepo: true, refName: "feature" }; + }), ); - + const input = { cwd: "/repo", oldPath: "/repo/old", newPath: "/repo/new" }; return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const git = yield* GitVcsDriver.GitVcsDriver; const workflow = yield* GitWorkflowService.GitWorkflowService; - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-workflow-action-" }); - const repo = path.join(root, "repo"); - const remote = path.join(root, "remote.git"); - yield* fileSystem.makeDirectory(repo); - yield* fileSystem.makeDirectory(remote); - const run = (cwd: string, args: readonly string[]) => - git.execute({ operation: "GitWorkflowService.test", cwd, args }); - yield* run(repo, ["init", "--initial-branch=main"]); - yield* run(repo, ["config", "user.email", "test@test.com"]); - yield* run(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "initial\n"); - yield* run(repo, ["add", "README.md"]); - yield* run(repo, ["commit", "-m", "Initial"]); - yield* run(remote, ["init", "--bare", "--initial-branch=main"]); - yield* run(repo, ["remote", "add", "origin", remote]); - yield* run(repo, ["push", "--set-upstream", "origin", "main"]); - yield* run(repo, ["switch", "-c", "feature/panel"]); - yield* fileSystem.writeFileString(path.join(repo, "panel.ts"), "export const panel = true;\n"); - yield* fileSystem.writeFileString( - path.join(repo, "excluded.ts"), - "export const excluded = true;\n", - ); - const hookPath = path.join(repo, ".git", "hooks", "pre-commit"); - yield* fileSystem.writeFileString(hookPath, "#!/bin/sh\necho checking-panel >&2\n"); - yield* fileSystem.chmod(hookPath, 0o755); - const progress: string[] = []; - - const result = yield* workflow.runStackedAction( - { - actionId: "action-1", - cwd: repo, - action: "commit_push_pr", - commitMessage: "Restore Git actions", - filePaths: ["panel.ts"], - }, - { - publish: (event) => - Effect.sync(() => { - progress.push(event.kind); - }), - }, - ); - - expect(result.commit.status).toBe("created"); - expect(result.push).toMatchObject({ - status: "pushed", - branch: "feature/panel", - upstreamBranch: "origin/feature/panel", - setUpstream: true, + yield* workflow.moveWorktree(input); + expect(yield* workflow.localRefStatus({ cwd: "/repo" })).toEqual({ + isRepo: true, + refName: "feature", }); - expect(result.pr).toMatchObject({ - status: "created", - number: 17, - headBranch: "feature/panel", - baseBranch: "main", + expect(move).toHaveBeenCalledExactlyOnceWith(input); + expect(ref).toHaveBeenCalledExactlyOnceWith("/repo"); + kind = "jj"; + expect(yield* workflow.moveWorktree(input).pipe(Effect.flip)).toMatchObject({ + _tag: "GitCommandError", + command: "vcs-route", }); - expect(createdRequests).toEqual([ - { - cwd: repo, - baseRefName: "main", - headSelector: "feature/panel", - title: "Restore Git actions", - bodyFile: "/dev/null", - }, - ]); - expect(progress[0]).toBe("action_started"); - expect(progress).toContain("hook_started"); - expect(progress).toContain("hook_output"); - expect(progress).toContain("hook_finished"); - expect(progress.at(-1)).toBe("action_finished"); - const remaining = yield* run(repo, ["status", "--short"]); - expect(remaining.stdout).toContain("?? excluded.ts"); - }).pipe(Effect.scoped, Effect.provide(workflowLayer), Effect.provide(NodeServices.layer)); -}); - -it.effect("serializes a branch switch behind an in-flight pull", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let branch = "main"; - let pulledBranch = ""; - const testLayer = GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), - Layer.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), - }), - ), - Layer.provide( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - statusDetailsLocal: () => - Effect.succeed({ - isRepo: true, - branch, - isDefaultBranch: branch === "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - } as never), - switchRef: (input) => - Effect.sync(() => { - if (input.cwd === "/repo") branch = input.refName; - return { refName: input.refName }; - }), - execute: (input) => { - if (input.operation === "GitWorkflowService.pull.upstream") - return Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.as(gitOutput("origin/main")), - ); - if (input.args[0] === "pull") - return Effect.sync(() => { - pulledBranch = branch; - return gitOutput(); - }); - return Effect.succeed(gitOutput()); - }, - }), - ), - ); - yield* Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const pulling = yield* workflow.pull({ cwd: "/repo" }).pipe(Effect.forkChild); - yield* Deferred.await(started); - const switching = yield* workflow - .switchRef({ cwd: "/repo", refName: "feature" }) - .pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.zero); - expect(branch).toBe("main"); - expect((yield* workflow.switchRef({ cwd: "/other", refName: "independent" })).refName).toBe( - "independent", - ); - yield* Deferred.succeed(release, undefined); - const result = yield* Fiber.join(pulling); - yield* Fiber.join(switching); - expect(branch).toBe("feature"); - expect({ pulledBranch, reportedBranch: result.refName }).toEqual({ - pulledBranch: "main", - reportedBranch: "main", - }); - }).pipe(Effect.provide(testLayer)); - }), -); - -for (const scenario of [ - { name: "branch changed", branch: "feature", dirty: false, counts: "0 1", allowed: false }, - { name: "working tree changed", branch: "main", dirty: true, counts: "0 1", allowed: false }, - { name: "local commits appeared", branch: "main", dirty: false, counts: "1 1", allowed: false }, - { name: "already current", branch: "main", dirty: false, counts: "0 0", allowed: false }, - { name: "still eligible", branch: "main", dirty: false, counts: "0 1", allowed: true }, -]) { - it.effect(`rechecks automatic pull after a queued mutation: ${scenario.name}`, () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let changed = false; - let pulls = 0; - const testLayer = GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), + expect(yield* workflow.localRefStatus({ cwd: "/repo" }).pipe(Effect.flip)).toMatchObject({ + _tag: "GitCommandError", + command: "vcs-route", + }); + expect(moves).toBe(1); + expect(reads).toBe(1); + }).pipe( + Effect.provide( + GitWorkflowService.layer.pipe( Layer.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => Effect.succeed({ kind } as VcsDriverRegistry.VcsDriverHandle), }), ), Layer.provide( - Layer.mock(GitVcsDriver.GitVcsDriver)({ - statusDetailsLocal: () => - Effect.sync( - () => - ({ - isRepo: true, - branch: changed ? scenario.branch : "main", - isDefaultBranch: !changed || scenario.branch === "main", - hasWorkingTreeChanges: changed && scenario.dirty, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }) as never, - ), - switchRef: () => - Effect.gen(function* () { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - changed = true; - return { refName: scenario.branch }; - }), - execute: (input) => - Effect.sync(() => { - if (input.operation === "GitWorkflowService.pull.automaticCounts") - return gitOutput(scenario.counts); - if (input.operation === "GitWorkflowService.pull.upstream") - return gitOutput("origin/main"); - if (input.args[0] === "pull") pulls++; - return gitOutput(); - }), - }), - ), - ); - yield* Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - yield* workflow.localStatus({ cwd: "/repo" }); - const changing = yield* workflow - .switchRef({ cwd: "/repo", refName: scenario.branch }) - .pipe(Effect.forkChild); - yield* Deferred.await(started); - const pulling = yield* workflow - .pull({ cwd: "/repo" }, { automatic: true }) - .pipe(Effect.result, Effect.forkChild); - yield* TestClock.adjust(Duration.zero); - expect(pulls).toBe(0); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(changing); - const result = yield* Fiber.join(pulling); - expect(result._tag).toBe(scenario.allowed ? "Success" : "Failure"); - expect(pulls).toBe(scenario.allowed ? 1 : 0); - }).pipe(Effect.provide(testLayer)); - }), - ); -} - -layer("GitWorkflowService.prepareWorktreeBase", (it) => { - it.effect("falls back to the project checkout for a non-repository or unborn branch", () => - Effect.gen(function* () { - const service = yield* GitWorkflowService.GitWorkflowService; - execute.mockReturnValue(Effect.succeed(gitOutput("", 128))); - expect(yield* service.prepareWorktreeBase({ cwd: "/repo", baseBranch: "main" })).toBeNull(); - expect(execute).toHaveBeenCalledTimes(1); - execute.mockClear(); - execute.mockImplementation((input) => - Effect.succeed( - input.args.includes("--is-inside-work-tree") ? gitOutput("true") : gitOutput("", 128), + Layer.mock(GitVcsDriver.GitVcsDriver)({ moveWorktree: move, refStatusLocal: ref }), ), - ); - expect(yield* service.prepareWorktreeBase({ cwd: "/repo", baseBranch: "main" })).toBeNull(); - expect(createWorktree).not.toHaveBeenCalled(); - }), - ); - it.effect( - "uses the fetched origin commit and falls back to the local branch when unavailable", - () => - Effect.gen(function* () { - const service = yield* GitWorkflowService.GitWorkflowService; - const local = "a".repeat(40), - remote = "b".repeat(40); - let remoteAvailable = true; - execute.mockImplementation((input) => { - if (input.args.includes("--is-inside-work-tree")) - return Effect.succeed(gitOutput("true")); - if (input.args[0] === "remote") return Effect.succeed(gitOutput("origin\n")); - if (input.args.at(-1) === "refs/remotes/origin/main^{commit}") - return Effect.succeed(remoteAvailable ? gitOutput(remote) : gitOutput("", 128)); - if (input.args.at(-1) === "main^{commit}") return Effect.succeed(gitOutput(local)); - return Effect.succeed(gitOutput()); - }); - expect( - yield* service.prepareWorktreeBase({ - cwd: "/repo", - baseBranch: "main", - startFromOrigin: true, - }), - ).toBe(remote); - expect( - execute.mock.calls.some(([input]) => input.args.join(" ") === "fetch --no-tags origin"), - ).toBe(true); - remoteAvailable = false; - expect( - yield* service.prepareWorktreeBase({ - cwd: "/repo", - baseBranch: "main", - startFromOrigin: true, - }), - ).toBe(local); - }), - ); -}); - -it.effect("does not start Git auto-maintenance from status fetches", () => { - const driverLayer = GitVcsDriver.layer.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-workflow-" })), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - ); - const testLayer = GitWorkflowService.layer.pipe( - Layer.provide(workflowSupport), - Layer.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => undefined, - get: () => Effect.succeed(provider), - }), + Layer.provide(Layer.mock(GitManager.GitManager)({})), + ), ), - Layer.provideMerge(driverLayer), ); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const git = (cwd: string, args: ReadonlyArray) => - driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 }); - const remote = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-status-remote-" }); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-status-fetch-" }); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["init", "--initial-branch=main"]); - yield* git(cwd, ["config", "user.email", "test@test.com"]); - yield* git(cwd, ["config", "user.name", "Test"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - for (const name of ["first", "second"]) { - yield* fileSystem.writeFileString(path.join(cwd, `${name}.txt`), `${name}\n`); - yield* git(cwd, ["add", `${name}.txt`]); - yield* git(cwd, ["commit", "-m", name]); - yield* git(cwd, ["push", "-u", "origin", "main"]); - yield* git(cwd, ["repack", "-d"]); - } - // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. - yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); - yield* git(cwd, ["config", "gc.autoDetach", "false"]); - yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); - const packCount = git(cwd, ["count-objects", "-v"]).pipe( - Effect.map((result) => result.stdout.match(/^packs: (\d+)$/m)?.[1]), - ); - expect(yield* packCount).toBe("2"); - - const workflow = yield* GitWorkflowService.GitWorkflowService; - yield* workflow.remoteStatus({ cwd }, { fetch: true }); - - expect(yield* packCount).toBe("2"); - }).pipe(Effect.scoped, Effect.provide(testLayer)); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 68a04dc95df8b..dd4353462ebc1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,126 +1,77 @@ -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { ProjectId } from "@t3tools/contracts"; -import type { - ChangeRequest, - GitActionProgressEvent, - GitActionProgressPhase, - GitPreparePullRequestThreadInput, - GitPreparePullRequestThreadResult, - GitManagerServiceError, - GitPullRequestRefInput, - GitResolvePullRequestResult, - GitRunStackedActionInput, - GitRunStackedActionResult, - ServerSettings as ServerSettingsValue, - VcsCreateRefInput, - VcsCreateRefResult, - VcsCreateWorktreeInput, - VcsCreateWorktreeResult, - VcsListRefsInput, - VcsListRefsResult, - VcsRemoveWorktreeInput, - VcsRefStatusResult, - VcsStatusInput, - VcsStatusLocalResult, - VcsStatusRemoteResult, - VcsStatusResult, - VcsPullInput, - VcsPullResult, - VcsSwitchRefInput, - VcsSwitchRefResult, -} from "@t3tools/contracts"; -import { GitCommandError, GitManagerError, SourceControlProviderError } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Cache from "effect/Cache"; -import * as Duration from "effect/Duration"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as RcMap from "effect/RcMap"; -import * as Semaphore from "effect/Semaphore"; import { - detectSourceControlProviderFromGitRemoteUrl, - mergeGitStatusParts, - normalizeGitRemoteUrl, - resolveAutoFeatureBranchName, - sanitizeBranchFragment, -} from "@t3tools/shared/git"; -import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; + GitManagerError, + GitCommandError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsListRefsInput, + type VcsListRefsResult, + type GitManagerServiceError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, + type GitPullRequestRefInput, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type GitResolvePullRequestResult, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, + type VcsRefStatusResult, +} from "@t3tools/contracts"; +import * as GitManager from "./GitManager.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import { - conventionalCommitsTextGenerationPolicy, - customTextGenerationPolicy, - repositoryConventionsTextGenerationPolicy, -} from "../textGeneration/TextGenerationPresets.ts"; - -export interface GitActionProgressReporter { - readonly publish: (event: GitActionProgressEvent) => Effect.Effect; -} +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, { - readonly prepareWorktreeBase: (input: { + readonly isRepository: (cwd: string) => Effect.Effect; + readonly hasCommit: (input: { readonly cwd: string; - readonly baseBranch: string; - readonly startFromOrigin?: boolean; - }) => Effect.Effect; + readonly refName: string; + }) => Effect.Effect; + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: { readonly fetch?: boolean }, + options?: GitManager.GitRemoteStatusOptions, ) => Effect.Effect; - readonly status: ( - input: VcsStatusInput, - options?: { readonly fetch?: boolean }, - ) => Effect.Effect; - /** Resolve the MR for a saved branch without changing the current checkout. */ - readonly branchPullRequest: ( - input: { - readonly cwd: string; - readonly branch: string; - }, - options?: { readonly refresh?: boolean }, - ) => Effect.Effect< - { - readonly number?: number; - readonly url?: string; - readonly repositoryKey?: string | null; - readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - } | null, - GitManagerServiceError - >; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly pull: ( - input: VcsPullInput, - options?: { readonly automatic?: boolean }, - ) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - progressReporter?: GitActionProgressReporter, - ) => Effect.Effect; + // Coder: worktree moves and lightweight ref reads serve existing helper RPC callers. + readonly moveWorktree: ( + input: GitVcsDriver.GitMoveWorktreeInput, + ) => Effect.Effect; readonly localRefStatus: ( input: VcsStatusInput, ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitManager.GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; @@ -128,6 +79,28 @@ export class GitWorkflowService extends Context.Service< input: VcsCreateWorktreeInput, options?: GitVcsDriver.CreateWorktreeOptions, ) => Effect.Effect; + readonly fetchRemote: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName?: string; + }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; + readonly remoteBranchExists: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName: string; + }) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect< + { readonly commitSha: string; readonly remoteRefName: string }, + GitCommandError + >; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; @@ -144,1469 +117,287 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly oldBranch: string; readonly newBranch: string; - }) => Effect.Effect<{ readonly branch: string }, GitCommandError>; - readonly moveWorktree: (input: { - readonly cwd: string; - readonly oldPath: string; - readonly newPath: string; - }) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect< - GitPreparePullRequestThreadResult, - GitCommandError | GitManagerError | SourceControlProviderError - >; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; } >()("t3/git/GitWorkflowService") {} -export const layer = Layer.effect( - GitWorkflowService, - Effect.gen(function* () { - const sqlOption = yield* Effect.serviceOption(SqlClient.SqlClient); - const git = yield* GitVcsDriver.GitVcsDriver; - const sourceControls = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const textGeneration = yield* TextGeneration.TextGeneration; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const fileSystem = yield* FileSystem.FileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const mutationLocks = yield* RcMap.make({ lookup: (_cwd: string) => Semaphore.make(1) }); - // Keep the complete mutation (including eligibility reads) under the same - // permit. Different checkouts remain independent, and idle locks are released. - const mutate = (cwd: string, operation: Effect.Effect) => - Effect.scoped( - Effect.gen(function* () { - const key = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); - const lock = yield* RcMap.get(mutationLocks, key); - return yield* lock.withPermits(1)( - invalidateStatus(cwd).pipe( - Effect.andThen(operation), - Effect.ensuring(invalidateStatus(cwd)), - ), - ); - }), - ); - const readGenerationSettings = (cwd: string) => - serverSettings.getSettings.pipe( - Effect.flatMap((settings) => - Effect.gen(function* () { - if (Option.isNone(sqlOption)) return settings; - const sql = sqlOption.value; - const rows = yield* sql<{ projectId: string }>` - SELECT p.project_id AS "projectId" FROM projection_projects p - WHERE p.deleted_at IS NULL AND (p.workspace_root = ${cwd} OR EXISTS ( - SELECT 1 FROM projection_threads t WHERE t.project_id = p.project_id - AND t.deleted_at IS NULL AND t.worktree_path = ${cwd} - )) LIMIT 1`; - return resolveProjectSettings( - settings, - rows[0] ? ProjectId.make(rows[0].projectId) : null, - ).settings; - }), - ), - Effect.mapError( - (cause) => - new GitManagerError({ - operation: "readGenerationSettings", - cwd, - detail: "Could not read text-generation settings.", - cause, - }), - ), - ); - const resolveWritingPolicy = Effect.fn("GitWorkflowService.resolveWritingPolicy")(function* ( - cwd: string, - settings: ServerSettingsValue, - ) { - const style = settings.sourceControlWritingStyle; - if (style.mode === "conventional_commits") { - return conventionalCommitsTextGenerationPolicy; - } - if (style.mode === "custom") { - return customTextGenerationPolicy(style.customInstructions); - } - const recent = yield* git.execute({ - operation: "GitWorkflowService.resolveWritingPolicy.recentCommits", - cwd, - args: ["log", "-20", "--pretty=%s"], - allowNonZeroExit: true, - maxOutputBytes: 16 * 1024, - }); - const examples = recent.stdout.trim(); - return examples - ? { - ...repositoryConventionsTextGenerationPolicy, - commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\nRecent commit subjects:\n${examples}`, - } - : repositoryConventionsTextGenerationPolicy; - }); - - const parseCount = (value: string): number => { - const count = Number.parseInt(value.trim(), 10); - return Number.isSafeInteger(count) && count >= 0 ? count : 0; - }; - - const STATUS_CACHE_TTL = Duration.seconds(1); - const STATUS_CACHE_CAPACITY = 2_048; - const FETCH_CACHE_TTL = Duration.seconds(1); - // Status fetches run unattended, so a credential prompt must fail instead of waiting. - const STATUS_FETCH_ENV = Object.freeze({ - GCM_INTERACTIVE: "never", - GIT_ASKPASS: "", - GIT_TERMINAL_PROMPT: "0", - SSH_ASKPASS: "", - SSH_ASKPASS_REQUIRE: "never", - } satisfies NodeJS.ProcessEnv); - // Match the automatic settlement sweep cadence so an external merge is - // observed on the next sweep instead of waiting on an older cache entry. - const PR_CACHE_TTL = Duration.seconds(60); - const FAILURE_BASE_TTL = Duration.seconds(20); - const FAILURE_MAX_TTL = Duration.minutes(15); - const failureTtl = (failures: number) => - Duration.min( - Duration.millis( - Duration.toMillis(FAILURE_BASE_TTL) * Math.pow(2, Math.max(0, failures - 1)), - ), - FAILURE_MAX_TTL, - ); - const setBounded = (map: Map, key: K, value: V) => { - if (!map.has(key) && map.size >= STATUS_CACHE_CAPACITY) { - const oldest = map.keys().next().value; - if (oldest !== undefined) map.delete(oldest); - } - map.set(key, value); - }; +function nonRepositoryLocalStatus(): VcsStatusLocalResult { + return { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }; +} - const readLocalStatus = Effect.fn("GitWorkflowService.readLocalStatus")(function* ({ - cwd, - }: VcsStatusInput) { - const details = yield* git.statusDetailsLocal(cwd); - if (!details.isRepo) { - return { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: details.workingTree, - } satisfies VcsStatusLocalResult; - } - const remote = yield* git.execute({ - operation: "GitWorkflowService.localStatus.remote", - cwd, - args: ["remote", "get-url", "origin"], - allowNonZeroExit: true, - }); - const remoteUrl = remote.exitCode === 0 ? remote.stdout.trim() : ""; - const sourceControlProvider = remoteUrl - ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) - : null; - return { - isRepo: true, - ...(sourceControlProvider ? { sourceControlProvider } : {}), - hasPrimaryRemote: remote.exitCode === 0, - isDefaultRef: details.isDefaultBranch, - refName: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - } satisfies VcsStatusLocalResult; - }); - const localStatusCache = yield* Cache.makeWith((cwd: string) => readLocalStatus({ cwd }), { - capacity: STATUS_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_CACHE_TTL : Duration.zero), - }); - const localStatus = ({ cwd }: VcsStatusInput) => Cache.get(localStatusCache, cwd); +function nonRepositoryStatus(): VcsStatusResult { + return { + ...nonRepositoryLocalStatus(), + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }; +} - const resolveDefaultBranch = Effect.fn("GitWorkflowService.resolveDefaultBranch")(function* ( - cwd: string, - remoteName = "origin", - ) { - const symbolic = yield* git.execute({ - operation: "GitWorkflowService.resolveDefaultBranch.symbolic", - cwd, - args: ["symbolic-ref", "--quiet", "--short", `refs/remotes/${remoteName}/HEAD`], - allowNonZeroExit: true, - }); - if (symbolic.exitCode === 0) { - const value = symbolic.stdout.trim(); - const prefix = `${remoteName}/`; - if (value.startsWith(prefix) && value.length > prefix.length) { - return value.slice(prefix.length); - } - } - for (const candidate of ["main", "master"] as const) { - const exists = yield* git.execute({ - operation: "GitWorkflowService.resolveDefaultBranch.exists", - cwd, - args: ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${candidate}`], - allowNonZeroExit: true, - }); - if (exists.exitCode === 0) return candidate; - } - return null; - }); +function nonRepositoryListRefs(): VcsListRefsResult { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; +} - const prEpochByCwd = new Map(); - const prFailureStreak = new Map(); - const lastKnownPr = new Map(); - const prLookupCache = yield* Cache.makeWith( - (key: string) => { - const [cwd = "", branch = ""] = key.split("\0"); - return Effect.gen(function* () { - const provider = yield* sourceControls.get("gitlab"); - const requests = yield* provider.listChangeRequests({ +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + const gitManager = yield* GitManager.GitManager; + + const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation, cwd, - headSelector: branch, - state: "all", - limit: 20, - }); - return requests.find((candidate) => candidate.headRefName === branch) ?? null; - }); - }, - { - capacity: STATUS_CACHE_CAPACITY, - timeToLive: (exit, key) => { - if (Exit.isSuccess(exit)) { - prFailureStreak.delete(key); - return PR_CACHE_TTL; - } - const failures = (prFailureStreak.get(key) ?? 0) + 1; - setBounded(prFailureStreak, key, failures); - return failureTtl(failures); - }, - }, + detail: "Failed to resolve the VCS driver for this Git workflow.", + cause, + }), + ), ); - const lookupMergeRequest = (cwd: string, branch: string) => { - const branchKey = `${cwd}\0${branch}`; - const cacheKey = `${branchKey}\0${prEpochByCwd.get(cwd) ?? 0}`; - return Cache.get(prLookupCache, cacheKey).pipe( - Effect.tap((request) => Effect.sync(() => setBounded(lastKnownPr, branchKey, request))), - Effect.catch((error) => - Effect.logWarning("GitLab merge request lookup failed; keeping last known state.", { - operation: "GitWorkflowService.lookupMergeRequest", - errorTag: - typeof error === "object" && error !== null && "_tag" in error - ? String(error._tag) - : typeof error, - }).pipe(Effect.as(lastKnownPr.get(branchKey) ?? null)), - ), - ); - }; - - const readGitConfig = Effect.fn("GitWorkflowService.readGitConfig")(function* ( - cwd: string, - key: string, - ) { - const result = yield* git.execute({ - operation: "GitWorkflowService.readGitConfig", + if (handle.kind !== "git") { + return yield* new GitManagerError({ + operation, cwd, - args: ["config", "--get", key], - allowNonZeroExit: true, + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, }); - return result.exitCode === 0 ? result.stdout.trim() || null : null; - }); - const repositoryPathFromRemoteKey = (remoteKey: string): string | null => { - const separator = remoteKey.indexOf("/"); - return separator > 0 && separator < remoteKey.length - 1 - ? remoteKey.slice(separator + 1) - : null; - }; - const branchPrFailureStreak = new Map(); - const branchPrCache = yield* Cache.makeWith( - (key: string) => { - const [cwd = "", headBranch = "", headRemoteKey = "", targetRemoteKey = ""] = - key.split("\0"); - return Effect.gen(function* () { - const provider = yield* sourceControls.get("gitlab"); - const requests = yield* provider.listChangeRequests({ + } + }); + + const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation, + command: "vcs-route", cwd, - headSelector: headBranch, - state: "all", - limit: 20, - }); - const headRepository = repositoryPathFromRemoteKey(headRemoteKey); - const targetRepository = repositoryPathFromRemoteKey(targetRemoteKey); - const headOwner = headRepository?.split("/")[0] ?? null; - const isCrossRepository = - headRepository !== null && - targetRepository !== null && - headRepository !== targetRepository; - const matching = requests.filter((candidate) => { - if (candidate.headRefName !== headBranch) return false; - const candidateRepository = - candidate.headRepositoryNameWithOwner?.toLowerCase() ?? null; - const candidateOwner = candidate.headRepositoryOwnerLogin?.toLowerCase() ?? null; - if (headRepository !== null && candidateRepository !== null) { - if (candidateRepository !== headRepository) return false; - } - if (headOwner !== null && candidateOwner !== null && candidateOwner !== headOwner) { - return false; - } - if (isCrossRepository) { - if (candidate.isCrossRepository === false) return false; - return candidateRepository !== null || candidateOwner !== null; - } - if ( - candidate.isCrossRepository === true && - candidateRepository === null && - candidateOwner === null - ) { - return false; - } - return true; - }); - const request = - matching.toSorted((left, right) => { - const leftAt = Option.match(left.updatedAt, { - onNone: () => Number.NEGATIVE_INFINITY, - onSome: DateTime.toEpochMillis, - }); - const rightAt = Option.match(right.updatedAt, { - onNone: () => Number.NEGATIVE_INFINITY, - onSome: DateTime.toEpochMillis, - }); - return rightAt - leftAt; - })[0] ?? null; - return { request, headRemoteKey, targetRemoteKey }; - }); - }, - { - capacity: STATUS_CACHE_CAPACITY, - timeToLive: (exit, key) => { - if (Exit.isSuccess(exit)) { - branchPrFailureStreak.delete(key); - return PR_CACHE_TTL; - } - const failures = (branchPrFailureStreak.get(key) ?? 0) + 1; - setBounded(branchPrFailureStreak, key, failures); - return failureTtl(failures); - }, - }, + detail: "Failed to resolve the VCS driver for this Git command.", + cause, + }), + ), ); - - const branchPullRequest: GitWorkflowService["Service"]["branchPullRequest"] = Effect.fn( - "GitWorkflowService.branchPullRequest", - )(function* ({ cwd, branch }, options) { - const remotes = yield* git.execute({ - operation: "GitWorkflowService.branchPullRequest.remotes", - cwd, - args: ["remote"], - }); - const remoteNames = remotes.stdout - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean); - const firstRemote = remoteNames[0]; - if (firstRemote === undefined) return null; - const targetRemote = remoteNames.includes("origin") ? "origin" : firstRemote; - - const branchRef = yield* git.execute({ - operation: "GitWorkflowService.branchPullRequest.branchRef", + if (handle.kind !== "git") { + return yield* new GitCommandError({ + operation, + command: "vcs-route", cwd, - args: [ - "for-each-ref", - "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", - `refs/heads/${branch}`, - ], + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); - const exactBranch = branchRef.stdout - .split(/\r?\n/u) - .find((line) => line.split("\0", 1)[0] === `refs/heads/${branch}`); - const [refName = "", savedUpstream = "", savedRemote = "", savedRemoteRef = ""] = - exactBranch?.split("\0") ?? []; - const localBranchExists = refName.length > 0; - let headRemote = savedRemote || targetRemote; - let headBranch = branch; + } + }); - if (savedUpstream.length > 0) { - if (savedRemote.length === 0 || savedRemoteRef.length === 0) { - return yield* new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: `Saved upstream for ${branch} is incomplete.`, - }); - } - headBranch = savedRemoteRef.replace(/^refs\/heads\//u, ""); - } else if (!localBranchExists) { - const trackingRefs = yield* git.execute({ - operation: "GitWorkflowService.branchPullRequest.remoteTrackingRefs", - cwd, - args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], - }); - const refs = new Set( - trackingRefs.stdout - .split(/\r?\n/u) - .map((ref) => ref.trim()) - .filter(Boolean), - ); - const matchingRemotes = remoteNames.filter((remote) => - refs.has(`refs/remotes/${remote}/${branch}`), - ); - if (matchingRemotes.length > 1) { - return yield* new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: `Multiple remotes track ${branch}. Its merge request is ambiguous.`, - }); - } - headRemote = matchingRemotes[0] ?? targetRemote; - } - - const defaultBranch = yield* resolveDefaultBranch(cwd, targetRemote); - const headBranchIsDefault = - headBranch === defaultBranch || - (defaultBranch === null && (headBranch === "main" || headBranch === "master")); - if (headBranch !== branch && headBranchIsDefault && headRemote === targetRemote) { - return null; - } - if (localBranchExists && savedUpstream.length === 0) { - const trackingRefs = yield* git.execute({ - operation: "GitWorkflowService.branchPullRequest.publishedRef", - cwd, - args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], - }); - const refs = trackingRefs.stdout - .split(/\r?\n/u) - .map((ref) => ref.trim()) - .filter(Boolean); - if ( - refs.length > 0 && - !remoteNames.some((remote) => refs.includes(`refs/remotes/${remote}/${headBranch}`)) - ) { - return null; - } + const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( + function* (operation: string, cwd: string) { + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation, + cwd, + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ); + if (!handle) { + return false; } - - const readIdentity = Effect.fn("GitWorkflowService.branchPullRequest.identity")(function* () { - const [headUrl, targetUrl] = yield* Effect.all( - [ - readGitConfig(cwd, `remote.${headRemote}.url`), - readGitConfig(cwd, `remote.${targetRemote}.url`), - ], - { concurrency: "unbounded" }, - ); - if (headUrl === null || targetUrl === null) { - return yield* new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: `Repository identity for ${branch} could not be verified.`, - }); - } - return { - headRemoteKey: normalizeGitRemoteUrl(headUrl), - targetRemoteKey: normalizeGitRemoteUrl(targetUrl), - }; - }); - const identity = yield* readIdentity(); - const cacheKey = [ - cwd, - headBranch, - identity.headRemoteKey, - identity.targetRemoteKey, - String(prEpochByCwd.get(cwd) ?? 0), - ].join("\0"); - if (options?.refresh) yield* Cache.invalidate(branchPrCache, cacheKey); - const cached = yield* Cache.get(branchPrCache, cacheKey); - const currentIdentity = yield* readIdentity(); - if ( - cached.headRemoteKey !== currentIdentity.headRemoteKey || - cached.targetRemoteKey !== currentIdentity.targetRemoteKey - ) { + if (handle.kind !== "git") { return yield* new GitManagerError({ - operation: "branchPullRequest", + operation, cwd, - detail: `Repository identity for ${branch} changed during merge request lookup.`, + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, }); } - if (cached.request === null) return null; - const branchIsDefault = - branch === defaultBranch || - (defaultBranch === null && (branch === "main" || branch === "master")); - if (branchIsDefault && cached.request.state !== "open") return null; - return { - number: cached.request.number, - url: cached.request.url, - repositoryKey: currentIdentity.targetRemoteKey, - state: cached.request.state, - closedAt: cached.request.closedAt ?? null, - mergedAt: cached.request.mergedAt ?? null, - updatedAt: Option.match(cached.request.updatedAt, { - onNone: () => null, - onSome: DateTime.formatIso, - }), - }; - }); - - const readRemoteStatus = Effect.fn("GitWorkflowService.readRemoteStatus")(function* ({ - cwd, - }: VcsStatusInput) { - const local = yield* localStatus({ cwd }); - if (!local.isRepo || !local.hasPrimaryRemote || local.refName === null) return null; - const upstream = yield* git.execute({ - operation: "GitWorkflowService.remoteStatus.upstream", - cwd, - args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - allowNonZeroExit: true, - }); - const hasUpstream = upstream.exitCode === 0 && upstream.stdout.trim().length > 0; - let aheadCount = 0; - let behindCount = 0; - if (hasUpstream) { - const counts = yield* git.execute({ - operation: "GitWorkflowService.remoteStatus.counts", - cwd, - args: ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], - }); - const [behind = "0", ahead = "0"] = counts.stdout.trim().split(/\s+/u); - behindCount = parseCount(behind); - aheadCount = parseCount(ahead); - } - const defaultBranch = (yield* resolveDefaultBranch(cwd)) ?? "main"; - const aheadOfDefault = yield* git.execute({ - operation: "GitWorkflowService.remoteStatus.aheadOfDefault", - cwd, - args: ["rev-list", "--count", `origin/${defaultBranch}..HEAD`], - allowNonZeroExit: true, - }); - const request = yield* lookupMergeRequest(cwd, local.refName); - return { - hasUpstream, - aheadCount, - behindCount, - aheadOfDefaultCount: aheadOfDefault.exitCode === 0 ? parseCount(aheadOfDefault.stdout) : 0, - pr: request - ? { - number: request.number, - title: request.title, - url: request.url, - baseRef: request.baseRefName, - headRef: request.headRefName, - state: request.state, - } - : null, - } satisfies VcsStatusRemoteResult; - }); - - const fetchFailureStreak = new Map(); - const fetchCache = yield* Cache.makeWith( - (cwd: string) => - git.execute({ - operation: "GitWorkflowService.remoteStatus.fetch", - cwd, - // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps - // this poll from starting `git gc --auto`. When that gc fails, for example on a repository - // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` - // behind each time, so a background poll could fill the disk. - args: ["fetch", "--prune", "--no-auto-gc", "origin"], - env: STATUS_FETCH_ENV, - timeoutMs: 300_000, - maxOutputBytes: 512 * 1024, - }), - { - capacity: STATUS_CACHE_CAPACITY, - timeToLive: (exit, key) => { - if (Exit.isSuccess(exit)) { - fetchFailureStreak.delete(key); - return FETCH_CACHE_TTL; - } - const failures = (fetchFailureStreak.get(key) ?? 0) + 1; - setBounded(fetchFailureStreak, key, failures); - return failureTtl(failures); - }, - }, - ); - const remoteStatusCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus({ cwd }), { - capacity: STATUS_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_CACHE_TTL : Duration.zero), - }); - const remoteStatus = Effect.fn("GitWorkflowService.remoteStatus")(function* ( - { cwd }: VcsStatusInput, - options?: { readonly fetch?: boolean }, - ) { - if (options?.fetch === true) { - yield* Cache.get(fetchCache, cwd).pipe( - Effect.catch((error) => - Effect.logWarning("Git fetch failed; using the last fetched refs.", { - operation: "GitWorkflowService.remoteStatus.fetch", - errorTag: - typeof error === "object" && error !== null && "_tag" in error - ? String(error._tag) - : typeof error, - }), - ), - ); - yield* Cache.invalidate(remoteStatusCache, cwd); - } - return yield* Cache.get(remoteStatusCache, cwd); - }); - - const invalidateStatus = Effect.fn("GitWorkflowService.invalidateStatus")(function* ( - cwd: string, - ) { - yield* Cache.invalidate(localStatusCache, cwd); - yield* Cache.invalidate(remoteStatusCache, cwd); - setBounded(prEpochByCwd, cwd, (prEpochByCwd.get(cwd) ?? 0) + 1); - }); - - const status = Effect.fn("GitWorkflowService.status")(function* ( - input: VcsStatusInput, - options?: { readonly fetch?: boolean }, - ) { - const local = yield* localStatus(input); - const remote = yield* remoteStatus(input, options); - return mergeGitStatusParts(local, remote); - }); - - const pull = Effect.fn("GitWorkflowService.pull")(function* ( - { cwd }: VcsPullInput, - options?: { readonly automatic?: boolean }, - ) { - const local = yield* readLocalStatus({ cwd }); - const branch = local.refName; - if (options?.automatic) { - if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) { - return yield* new GitCommandError({ - operation: "GitWorkflowService.pull", - command: "git pull", - cwd, - detail: "The checkout is no longer eligible for automatic pull.", - }); - } - // Read the current upstream counts after waiting for other mutations. - const counts = yield* git.execute({ - operation: "GitWorkflowService.pull.automaticCounts", - cwd, - args: ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], - }); - const [ahead, behind] = counts.stdout.trim().split(/\s+/).map(Number); - if (ahead !== 0 || behind === undefined || !Number.isSafeInteger(behind) || behind <= 0) { - return yield* new GitCommandError({ - operation: "GitWorkflowService.pull", - command: "git pull", + return true; + }, + ); + + const detectGitRepositoryForCommand = Effect.fn( + "GitWorkflowService.detectGitRepositoryForCommand", + )(function* (operation: string, cwd: string) { + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation, + command: "vcs-route", cwd, - detail: "The checkout is no longer behind its upstream without local commits.", - }); - } - } - if (branch === null) { - return yield* new GitCommandError({ - operation: "GitWorkflowService.pull", - command: "git pull", - cwd, - detail: "A checked-out branch is required before pulling.", - }); - } - const upstream = yield* git.execute({ - operation: "GitWorkflowService.pull.upstream", - cwd, - args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - allowNonZeroExit: true, - }); - const upstreamRef = upstream.exitCode === 0 ? upstream.stdout.trim() || null : null; - if (upstreamRef === null) { - return yield* new GitCommandError({ - operation: "GitWorkflowService.pull", - command: "git pull", - cwd, - detail: "The current branch has no upstream branch.", - }); - } - yield* git.execute({ - operation: "GitWorkflowService.pull", + detail: "Failed to detect a VCS repository for this Git command.", + cause, + }), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* new GitCommandError({ + operation, + command: "vcs-route", cwd, - args: ["pull", "--ff-only"], - timeoutMs: 300_000, - maxOutputBytes: 512 * 1024, + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); - return { status: "pulled", refName: branch, upstreamRef } satisfies VcsPullResult; - }); - - const resolvePullRequest = Effect.fn("GitWorkflowService.resolvePullRequest")(function* ( - input: GitPullRequestRefInput, - ) { - const provider = yield* sourceControls.get("gitlab"); - const request = yield* provider.getChangeRequest(input); - return { - pullRequest: { - number: request.number, - title: request.title, - url: request.url, - baseBranch: request.baseRefName, - headBranch: request.headRefName, - state: request.state, - }, - } satisfies GitResolvePullRequestResult; - }); - - const runStackedAction = Effect.fn("GitWorkflowService.runStackedAction")(function* ( - input: GitRunStackedActionInput, - progressReporter?: GitActionProgressReporter, - ) { - const report = (event: GitActionProgressEvent) => - progressReporter?.publish(event) ?? Effect.void; - const base = { actionId: input.actionId, cwd: input.cwd, action: input.action } as const; - const makeGitProgress = () => { - let currentHookName: string | null = null; - const reportOutput = (stream: "stdout" | "stderr", text: string) => { - const trimmed = text.trim(); - if (!trimmed) return Effect.void; - return report({ - ...base, - kind: "hook_output", - hookName: currentHookName, - stream, - text: trimmed.slice(0, 500).trimEnd(), - }); - }; - return { - onStdoutLine: (line: string) => reportOutput("stdout", line), - onStderrLine: (line: string) => reportOutput("stderr", line), - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return report({ ...base, kind: "hook_started", hookName }); - }, - onHookFinished: (event: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => { - if (currentHookName === event.hookName) currentHookName = null; - return report({ ...base, kind: "hook_finished", ...event }); - }, - } satisfies GitVcsDriver.ExecuteGitProgress; - }; - const wantsCommit = - input.action === "commit" || - input.action === "commit_push" || - input.action === "commit_push_pr"; - const wantsPush = input.action !== "commit"; - const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - ...(wantsCommit ? (["commit"] as const) : []), - ...(wantsPush ? (["push"] as const) : []), - ...(wantsPr ? (["pr"] as const) : []), - ]; - yield* report({ ...base, kind: "action_started", phases }); - - const changeRequestProvider = wantsPr ? yield* sourceControls.get("gitlab") : null; - if (changeRequestProvider !== null) { - const writeAccess = yield* changeRequestProvider.probeWriteAccess({ cwd: input.cwd }); - if (!writeAccess.writable) { - const detail = - writeAccess.detail ?? - (writeAccess.status === "policy-blocked" - ? "GitLab write operations are blocked by this workspace." - : writeAccess.status === "unauthenticated" - ? "The GitLab CLI is not authenticated." - : "GitLab write access could not be verified for this workspace."); - return yield* new SourceControlProviderError({ - provider: "gitlab", - operation: "createChangeRequest", - cwd: input.cwd, - detail, - }); - } - } - - let branchStatus: GitRunStackedActionResult["branch"] = { - status: "skipped_not_requested", - }; - let commitStatus: GitRunStackedActionResult["commit"] = { - status: "skipped_not_requested", - }; - let pushStatus: GitRunStackedActionResult["push"] = { - status: "skipped_not_requested", - }; - let prStatus: GitRunStackedActionResult["pr"] = { - status: "skipped_not_requested", - }; - - if (input.featureBranch) { - yield* report({ - ...base, - kind: "phase_started", - phase: "branch", - label: "Preparing feature branch...", - }); - const refs = yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.listBranches", - cwd: input.cwd, - args: ["for-each-ref", "--format=%(refname:short)", "refs/heads"], - }); - let preferred = input.commitMessage?.split("\n")[0] ?? "update"; - if (input.commitMessage === undefined) { - const changes = yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.branchContext", - cwd: input.cwd, - args: ["status", "--short"], - }); - const settings = yield* readGenerationSettings(input.cwd); - const generated = yield* textGeneration.generateBranchName({ - cwd: input.cwd, - message: changes.stdout.trim() || "Update project files", - modelSelection: resolveSourceControlWriterModelSelection(settings), - }); - preferred = generated.branch; - } - const name = resolveAutoFeatureBranchName( - refs.stdout - .split("\n") - .map((value) => value.trim()) - .filter(Boolean), - preferred, - ); - yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.createBranch", - cwd: input.cwd, - args: ["switch", "-c", name], - }); - branchStatus = { status: "created", name }; - } - - if (wantsCommit) { - if (input.filePaths?.length) { - yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.unstageExcludedFiles", - cwd: input.cwd, - args: ["reset", "--mixed"], - }); - } - yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.stage", - cwd: input.cwd, - args: input.filePaths?.length ? ["add", "--", ...input.filePaths] : ["add", "--all"], - }); - const staged = yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.staged", - cwd: input.cwd, - args: ["diff", "--cached", "--quiet"], - allowNonZeroExit: true, - }); - if (staged.exitCode === 0) { - commitStatus = { status: "skipped_no_changes" }; - } else { - const names = yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.changedFiles", - cwd: input.cwd, - args: ["diff", "--cached", "--name-only"], - }); - const firstPath = names.stdout - .split("\n") - .map((value) => value.trim()) - .find(Boolean); - const fallbackSubject = firstPath - ? `Update ${firstPath.split("/").at(-1) ?? "project files"}` - : "Update project files"; - let subject = input.commitMessage?.trim() || fallbackSubject; - let body = ""; - if (input.commitMessage === undefined) { - yield* report({ - ...base, - kind: "phase_started", - phase: "commit", - label: "Generating commit message...", - }); - const [summary, patch] = yield* Effect.all([ - git.execute({ - operation: "GitWorkflowService.runStackedAction.commitSummary", - cwd: input.cwd, - args: ["diff", "--cached", "--stat"], - maxOutputBytes: 64 * 1024, - }), - git.execute({ - operation: "GitWorkflowService.runStackedAction.commitPatch", - cwd: input.cwd, - args: ["diff", "--cached"], - maxOutputBytes: 512 * 1024, - appendTruncationMarker: true, - }), - ]); - const settings = yield* readGenerationSettings(input.cwd); - const generated = yield* textGeneration.generateCommitMessage({ - cwd: input.cwd, - branch: (yield* localStatus({ cwd: input.cwd })).refName, - stagedSummary: summary.stdout, - stagedPatch: patch.stdout, - modelSelection: resolveSourceControlWriterModelSelection(settings), - policy: yield* resolveWritingPolicy(input.cwd, settings), - }); - subject = generated.subject; - body = generated.body; - } - yield* report({ - ...base, - kind: "phase_started", - phase: "commit", - label: "Committing...", - }); - yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.commit", - cwd: input.cwd, - args: ["commit", "-m", subject, ...(body ? ["-m", body] : [])], - timeoutMs: 600_000, - maxOutputBytes: 1024 * 1024, - progress: makeGitProgress(), - }); - const sha = yield* git.execute({ - operation: "GitWorkflowService.runStackedAction.commitSha", - cwd: input.cwd, - args: ["rev-parse", "HEAD"], - }); - commitStatus = { - status: "created", - commitSha: sha.stdout.trim(), - subject: subject.split("\n")[0] ?? subject, - }; - } - } - - const localAfterCommit = yield* localStatus({ cwd: input.cwd }); - const branch = localAfterCommit.refName; - if (wantsPush) { - yield* report({ ...base, kind: "phase_started", phase: "push", label: "Pushing..." }); - const pushed = yield* git.pushCurrentBranch(input.cwd, branch, { - progress: makeGitProgress(), - }); - pushStatus = { - status: "pushed", - branch: pushed.branch, - ...(pushed.upstreamBranch ? { upstreamBranch: pushed.upstreamBranch } : {}), - setUpstream: pushed.setUpstream, - }; - } - - if (wantsPr) { - yield* report({ - ...base, - kind: "phase_started", - phase: "pr", - label: "Creating GitLab merge request...", - }); - if (branch === null) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "A checked-out branch is required before creating a merge request.", - }); - } - const provider = changeRequestProvider; - if (provider === null) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "The GitLab source control provider is unavailable.", - }); - } - const baseBranch = (yield* resolveDefaultBranch(input.cwd)) ?? "main"; - const existing = yield* provider.listChangeRequests({ - cwd: input.cwd, - headSelector: branch, - state: "open", - limit: 20, - }); - let request = existing.find((candidate) => candidate.headRefName === branch) ?? null; - const openedExisting = request !== null; - if (request === null) { - yield* report({ - ...base, - kind: "phase_started", - phase: "pr", - label: "Generating merge request content...", - }); - const baseRef = `origin/${baseBranch}`; - const settings = yield* readGenerationSettings(input.cwd); - const [commitSummary, diffSummary, diffPatch, template] = yield* Effect.all([ - git.execute({ - operation: "GitWorkflowService.runStackedAction.mergeRequestCommits", - cwd: input.cwd, - args: ["log", "--oneline", `${baseRef}..HEAD`], - maxOutputBytes: 128 * 1024, - appendTruncationMarker: true, - }), - git.execute({ - operation: "GitWorkflowService.runStackedAction.mergeRequestSummary", - cwd: input.cwd, - args: ["diff", "--stat", `${baseRef}...HEAD`], - maxOutputBytes: 128 * 1024, - appendTruncationMarker: true, - }), - git.execute({ - operation: "GitWorkflowService.runStackedAction.mergeRequestPatch", - cwd: input.cwd, - args: ["diff", `${baseRef}...HEAD`], - maxOutputBytes: 512 * 1024, - appendTruncationMarker: true, + } + return true; + }); + + const routeGitManager = + ( + operation: string, + run: (input: Input) => Effect.Effect, + ) => + (input: Input) => + ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + + return GitWorkflowService.of({ + isRepository: (cwd) => + registry.detect({ cwd }).pipe( + Effect.map((handle) => handle?.kind === "git"), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "GitWorkflowService.isRepository", + cwd, + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, }), - settings.sourceControlWritingStyle.followChangeRequestTemplates - ? detectPrTemplate(input.cwd, baseRef, git.execute) - : Effect.succeed(Option.none()), - ]); - const { title, body } = yield* textGeneration.generatePrContent({ - cwd: input.cwd, - baseBranch, - headBranch: branch, - commitSummary: commitSummary.stdout, - diffSummary: diffSummary.stdout, - diffPatch: diffPatch.stdout, - ...(Option.isSome(template) ? { changeRequestTemplate: template.value } : {}), - modelSelection: resolveSourceControlWriterModelSelection(settings), - policy: yield* resolveWritingPolicy(input.cwd, settings), - }); - const fs = fileSystem; - const bodyFile = body - ? yield* fs.makeTempFile({ prefix: "t3-gitlab-mr-", suffix: ".md" }).pipe( - Effect.mapError( - (cause) => - new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Could not create the merge request body file.", - cause, - }), - ), - ) - : "/dev/null"; - if (bodyFile !== "/dev/null") { - yield* fs.writeFileString(bodyFile, body).pipe( - Effect.mapError( - (cause) => - new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Could not write the merge request body file.", - cause, - }), - ), - ); - } - yield* provider - .createChangeRequest({ - cwd: input.cwd, - baseRefName: baseBranch, - headSelector: branch, - title, - bodyFile, - }) - .pipe( - Effect.ensuring( - bodyFile === "/dev/null" ? Effect.void : fs.remove(bodyFile).pipe(Effect.ignore), - ), - ); - const created = yield* provider.listChangeRequests({ - cwd: input.cwd, - headSelector: branch, - state: "open", - limit: 20, - }); - request = created.find((candidate) => candidate.headRefName === branch) ?? null; - } - prStatus = { - status: openedExisting ? "opened_existing" : "created", - ...(request - ? { - url: request.url, - number: request.number, - baseBranch: request.baseRefName, - headBranch: request.headRefName, - title: request.title, - } - : { baseBranch, headBranch: branch }), - }; - } - - const title = wantsPr - ? prStatus.status === "opened_existing" - ? "Merge request already open" - : "Merge request created" - : wantsPush - ? "Changes pushed" - : commitStatus.status === "skipped_no_changes" - ? "No changes to commit" - : "Changes committed"; - const result: GitRunStackedActionResult = { - action: input.action, - branch: branchStatus, - commit: commitStatus, - push: pushStatus, - pr: prStatus, - toast: { - title, - cta: prStatus.url - ? { kind: "open_pr", label: "Open merge request", url: prStatus.url } - : { kind: "none" }, - }, - }; - yield* report({ ...base, kind: "action_finished", result }); - return result; - }); - - const preparePullRequestThreadImpl = Effect.fn("GitWorkflowService.preparePullRequestThread")( - function* (input: GitPreparePullRequestThreadInput) { - const provider = yield* sourceControls.get("gitlab"); - const summary = yield* provider.getChangeRequest({ - cwd: input.cwd, - reference: input.reference, - }); - const pullRequest = { - number: summary.number, - title: summary.title, - url: summary.url, - baseBranch: summary.baseRefName, - headBranch: summary.headRefName, - state: summary.state, - } as const; - - const canonicalizeExistingPath = (value: string) => - fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); - const execute = ( - cwd: string, - operation: string, - args: ReadonlyArray, - allowNonZeroExit = false, - ) => + ), + ), + hasCommit: (input) => + ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( + Effect.andThen( git.execute({ - cwd, - operation: `GitWorkflowService.preparePullRequestThread.${operation}`, - args, - allowNonZeroExit, - maxOutputBytes: 64 * 1024, - }); - const primaryRemoteName = Effect.fn("preparePullRequestThread.primaryRemoteName")( - function* (cwd: string) { - const remotes = yield* execute(cwd, "listRemotes", ["remote"]); - const names = remotes.stdout - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean); - const remote = names.includes("origin") ? "origin" : names[0]; - if (!remote) { - return yield* new GitManagerError({ - operation: "preparePullRequestThread", - cwd, - detail: "The repository has no Git remote for fetching the merge request.", - }); - } - return remote; - }, - ); - const readConfig = Effect.fn("preparePullRequestThread.readConfig")(function* ( - cwd: string, - key: string, - ) { - const result = yield* execute(cwd, "readConfig", ["config", "--get", key], true); - return Number(result.exitCode) === 0 ? result.stdout.trim() || null : null; - }); - const configureUpstreamBase = Effect.fn("preparePullRequestThread.configureUpstream")( - function* (cwd: string, localBranch: string) { - let remoteName = yield* primaryRemoteName(cwd); - if (summary.isCrossRepository && summary.headRepositoryNameWithOwner) { - const cloneUrls = yield* provider.getRepositoryCloneUrls({ - cwd, - repository: summary.headRepositoryNameWithOwner, - }); - const originUrl = yield* readConfig(cwd, "remote.origin.url"); - const remoteUrl = /^(?:git@|ssh:)/iu.test(originUrl ?? "") - ? cloneUrls.sshUrl - : cloneUrls.url; - remoteName = yield* git.ensureRemote({ - cwd, - preferredName: - summary.headRepositoryOwnerLogin?.trim() || - summary.headRepositoryNameWithOwner.split("/")[0]?.trim() || - "fork", - url: remoteUrl, - }); - } - yield* execute(cwd, "fetchHeadTrackingBranch", [ - "fetch", - remoteName, - `+refs/heads/${pullRequest.headBranch}:refs/remotes/${remoteName}/${pullRequest.headBranch}`, - ]); - yield* execute(cwd, "setHeadUpstream", [ - "branch", - "--set-upstream-to", - `${remoteName}/${pullRequest.headBranch}`, - localBranch, - ]); - }, - ); - const configureUpstream = (cwd: string, localBranch: string) => - configureUpstreamBase(cwd, localBranch).pipe( - Effect.catch((cause) => - Effect.logWarning("GitWorkflowService prepare MR upstream configuration failed", { - cwd, - localBranch, - cause, - }).pipe(Effect.asVoid), - ), - ); - const maybeRunSetupScript = (worktreePath: string) => { - if (!input.threadId) return Effect.void; - return projectSetupScriptRunner - .runForThread({ - threadId: input.threadId, - projectCwd: input.cwd, - worktreePath, - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("GitWorkflowService prepare MR setup script failed", { - threadId: input.threadId, - worktreePath, - cause, - }).pipe(Effect.asVoid), - ), - Effect.asVoid, - ); - }; - - if (input.mode === "local") { - yield* provider.checkoutChangeRequest({ + operation: "GitWorkflowService.hasCommit", cwd: input.cwd, - reference: input.reference, - force: true, - }); - const status = yield* git.refStatusLocal(input.cwd); - const branch = status.refName ?? pullRequest.headBranch; - yield* configureUpstream(input.cwd, branch); - return { - pullRequest, - branch, - worktreePath: null, - isOnPullRequestHead: true, - }; - } - - const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); - const localBranch = summary.isCrossRepository - ? `t3code/pr-${pullRequest.number}/${sanitizedHeadBranch || "head"}` - : pullRequest.headBranch; - const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); - const listLocalRefs = () => - git.listRefs({ cwd: input.cwd, query: localBranch, refresh: true, limit: 500 }); - const findLocalHeadBranch = Effect.fn("preparePullRequestThread.findLocalHeadBranch")( - function* () { - const refs = yield* listLocalRefs(); - const exact = refs.refs.find((ref) => !ref.isRemote && ref.name === localBranch); - if (exact || localBranch === pullRequest.headBranch) return exact ?? null; - return ( - refs.refs.find( - (ref) => - !ref.isRemote && ref.name === pullRequest.headBranch && ref.worktreePath !== null, - ) ?? null - ); - }, - ); - const resolveCommit = Effect.fn("preparePullRequestThread.resolveCommit")(function* ( - cwd: string, - revision: string, - ) { - const result = yield* execute(cwd, "resolveCommit", ["rev-parse", revision], true); - return Number(result.exitCode) === 0 ? result.stdout.trim() || null : null; - }); - const fetchPullRequestHead = Effect.fn("preparePullRequestThread.fetchHead")(function* ( - cwd: string, - ) { - const remoteName = yield* primaryRemoteName(cwd); - const targetRef = `refs/t3code/merge-requests/${pullRequest.number}/head`; - yield* execute(cwd, "fetchHead", [ - "fetch", - remoteName, - `+refs/merge-requests/${pullRequest.number}/head:${targetRef}`, - ]); - const commit = yield* resolveCommit(cwd, targetRef); - if (!commit) { - return yield* new GitManagerError({ - operation: "preparePullRequestThread", - cwd, - detail: "The merge request head could not be resolved after fetching it.", - }); - } - return commit; - }); - const refreshReusedWorktree = Effect.fn("preparePullRequestThread.refreshReusedWorktree")( - function* (worktreePath: string, upstreamCommitBeforeFetch: string | null) { - const targetCommit = yield* fetchPullRequestHead(worktreePath); - const headCommit = yield* resolveCommit(worktreePath, "HEAD"); - if (headCommit === targetCommit) return { moved: false, onTarget: true } as const; - const dirty = yield* execute(worktreePath, "statusPorcelain", [ - "status", - "--porcelain=v1", - "--untracked-files=normal", - ]); - if (dirty.stdout.trim()) return { moved: false, onTarget: false } as const; - if (headCommit && upstreamCommitBeforeFetch === headCommit) { - yield* execute(worktreePath, "resetRewrittenHead", ["reset", "--hard", targetCommit]); - return { moved: true, onTarget: true } as const; - } - const ancestor = yield* execute( - worktreePath, - "headIsAncestor", - ["merge-base", "--is-ancestor", "HEAD", targetCommit], - true, - ); - if (Number(ancestor.exitCode) !== 0) { - return { moved: false, onTarget: false } as const; - } - yield* execute(worktreePath, "fastForwardHead", ["merge", "--ff-only", targetCommit]); - return { moved: true, onTarget: true } as const; - }, - ); - const reuseExistingWorktree = Effect.fn("preparePullRequestThread.reuseExistingWorktree")( - function* (worktreePath: string, checkedOutBranch: string, newlyCreated = false) { - if (checkedOutBranch !== localBranch) { - yield* configureUpstream(worktreePath, checkedOutBranch); - return { - pullRequest, - branch: localBranch, - worktreePath, - isOnPullRequestHead: false, - }; - } - const upstreamCommitBeforeFetch = yield* resolveCommit(worktreePath, "@{upstream}"); - yield* configureUpstream(worktreePath, localBranch); - const refreshed = yield* refreshReusedWorktree( - worktreePath, - upstreamCommitBeforeFetch, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("GitWorkflowService reused MR worktree refresh failed", { - worktreePath, - localBranch, - cause, - }).pipe(Effect.as({ moved: false, onTarget: false } as const)), - ), - ); - if (newlyCreated || refreshed.moved) yield* maybeRunSetupScript(worktreePath); - return { - pullRequest, - branch: localBranch, - worktreePath, - isOnPullRequestHead: refreshed.onTarget, - }; - }, - ); - const reuseOrReject = Effect.fn("preparePullRequestThread.reuseOrReject")(function* ( - candidate: VcsListRefsResult["refs"][number] | null, - ) { - if (!candidate?.worktreePath) return null; - const candidatePath = yield* canonicalizeExistingPath(candidate.worktreePath); - if (candidatePath === rootWorktreePath) { - return yield* new GitManagerError({ - operation: "preparePullRequestThread", - cwd: input.cwd, - detail: - "This merge-request branch is already checked out in the main repository. Use Local or switch the main repository before creating a worktree.", - }); - } - return yield* reuseExistingWorktree(candidate.worktreePath, candidate.name); - }); - - const beforeFetch = yield* findLocalHeadBranch(); - if (beforeFetch && beforeFetch.worktreePath === null) { - const worktree = yield* git.createWorktree({ - cwd: input.cwd, - refName: beforeFetch.name, - path: null, - }); - return yield* reuseExistingWorktree( - worktree.worktree.path, - worktree.worktree.refName, - true, - ); - } - const reusedBeforeFetch = yield* reuseOrReject(beforeFetch); - if (reusedBeforeFetch) return reusedBeforeFetch; - - const remoteName = yield* primaryRemoteName(input.cwd); - yield* execute(input.cwd, "materializeHead", [ - "fetch", - remoteName, - `+refs/merge-requests/${pullRequest.number}/head:refs/heads/${localBranch}`, - ]); - yield* configureUpstream(input.cwd, localBranch); - - const afterFetch = yield* findLocalHeadBranch(); - const reusedAfterFetch = yield* reuseOrReject(afterFetch); - if (reusedAfterFetch) return reusedAfterFetch; - - const worktree = yield* git.createWorktree({ - cwd: input.cwd, - refName: localBranch, - path: null, - }); - yield* configureUpstream(worktree.worktree.path, localBranch); - yield* maybeRunSetupScript(worktree.worktree.path); - return { - pullRequest, - branch: worktree.worktree.refName, - worktreePath: worktree.worktree.path, - isOnPullRequestHead: true, - }; - }, - ); - const preparePullRequestThread: GitWorkflowService["Service"]["preparePullRequestThread"] = ( - input, - ) => preparePullRequestThreadImpl(input).pipe(Effect.ensuring(invalidateStatus(input.cwd))); - - const prepareWorktreeBase: GitWorkflowService["Service"]["prepareWorktreeBase"] = Effect.fn( - "GitWorkflowService.prepareWorktreeBase", - )(function* (input) { - const inspect = (args: ReadonlyArray) => - git.execute({ - operation: "GitWorkflowService.prepareWorktreeBase", - cwd: input.cwd, - args, - allowNonZeroExit: true, - maxOutputBytes: 65536, - }); - const repository = yield* inspect(["rev-parse", "--is-inside-work-tree"]); - if (repository.exitCode !== 0 || repository.stdout.trim() !== "true") return null; - const resolve = (ref: string) => - inspect(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).pipe( - Effect.map((result) => - result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(result.stdout.trim()) - ? result.stdout.trim() - : null, - ), - ); - if (input.startFromOrigin) { - const remotes = yield* inspect(["remote"]); - if (remotes.stdout.split("\n").includes("origin")) { - yield* git.execute({ - operation: "GitWorkflowService.prepareWorktreeBase.fetch", - cwd: input.cwd, - args: ["fetch", "--no-tags", "origin"], - maxOutputBytes: 65536, - }); - const branch = input.baseBranch.replace(/^(?:refs\/remotes\/)?origin\//, ""); - const remote = yield* resolve(`refs/remotes/origin/${branch}`); - if (remote) return remote; - } - } - return yield* resolve(input.baseBranch); - }); - - return GitWorkflowService.of({ - prepareWorktreeBase, - localStatus, - remoteStatus, - status, - branchPullRequest, - invalidateStatus, - pull: (input, options) => mutate(input.cwd, pull(input, options)), - resolvePullRequest, - runStackedAction: (input, reporter) => mutate(input.cwd, runStackedAction(input, reporter)), - localRefStatus: ({ cwd }) => git.refStatusLocal(cwd), - listRefs: git.listRefs, - createWorktree: (input, options) => mutate(input.cwd, git.createWorktree(input, options)), - removeWorktree: (input) => mutate(input.cwd, git.removeWorktree(input)), - pruneWorktrees: (input) => mutate(input.cwd, git.pruneWorktrees(input)), - createRef: (input) => mutate(input.cwd, git.createRef(input)), - switchRef: (input) => mutate(input.cwd, git.switchRef(input)), - renameBranch: (input) => mutate(input.cwd, git.renameBranch(input)), - moveWorktree: (input) => mutate(input.cwd, git.moveWorktree(input)), - preparePullRequestThread: (input) => mutate(input.cwd, preparePullRequestThread(input)), - }); - }), -); + args: ["rev-parse", "--verify", `${input.refName}^{commit}`], + allowNonZeroExit: true, + }), + ), + Effect.map((result) => result.exitCode === 0), + ), + status: (input) => + detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + ), + ), + localStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? gitManager.localStatus(input) + : Effect.succeed(nonRepositoryLocalStatus()), + ), + ), + remoteStatus: (input, options) => + detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), + ), + ), + invalidateLocalStatus: gitManager.invalidateLocalStatus, + invalidateRemoteStatus: gitManager.invalidateRemoteStatus, + invalidateStatus: gitManager.invalidateStatus, + // Coder: route these driver seams through the same Git-only command guard. + moveWorktree: (input) => + ensureGitCommand("GitWorkflowService.moveWorktree", input.cwd).pipe( + Effect.andThen(git.moveWorktree(input)), + ), + localRefStatus: (input) => + ensureGitCommand("GitWorkflowService.localRefStatus", input.cwd).pipe( + Effect.andThen(git.refStatusLocal(input.cwd)), + ), + pullCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( + Effect.andThen(git.pullCurrentBranch(cwd)), + ), + runStackedAction: (input, options) => + ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( + Effect.andThen(gitManager.runStackedAction(input, options)), + ), + resolvePullRequest: routeGitManager( + "GitWorkflowService.resolvePullRequest", + gitManager.resolvePullRequest, + ), + preparePullRequestThread: routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + ), + listRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), + ), + ), + createWorktree: (input, options) => + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input, options)), + ), + fetchRemote: (input) => + ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( + Effect.andThen(git.fetchRemote(input)), + ), + remoteExists: (input) => + ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + Effect.andThen(git.remoteExists(input)), + ), + remoteBranchExists: (input) => + ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( + Effect.andThen(git.remoteBranchExists(input)), + ), + resolveRemoteTrackingCommit: (input) => + ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( + Effect.andThen(git.resolveRemoteTrackingCommit(input)), + ), + removeWorktree: (input) => + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + Effect.andThen(git.removeWorktree(input)), + ), + pruneWorktrees: (input) => + ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + Effect.andThen(git.pruneWorktrees(input)), + ), + createRef: (input) => + ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + Effect.andThen(git.createRef(input)), + ), + switchRef: (input) => + ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.switchRef(input))), + ), + renameBranch: (input) => + ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + Effect.andThen(git.renameBranch(input)), + ), + }); +}); + +export const layer = Layer.effect(GitWorkflowService, make); diff --git a/apps/server/src/git/remoteRefs.ts b/apps/server/src/git/remoteRefs.ts new file mode 100644 index 0000000000000..e3873effe475e --- /dev/null +++ b/apps/server/src/git/remoteRefs.ts @@ -0,0 +1,71 @@ +export function parseRemoteNamesInGitOrder(stdout: string): ReadonlyArray { + const remoteNames: Array = []; + for (const line of stdout.split("\n")) { + const remoteName = line.trim(); + if (remoteName.length > 0) { + remoteNames.push(remoteName); + } + } + return remoteNames; +} + +export function parseRemoteNames(stdout: string): ReadonlyArray { + return parseRemoteNamesInGitOrder(stdout).toSorted((a, b) => b.length - a.length); +} + +export function parseRemoteRefWithRemoteNames( + ref: string, + remoteNames: ReadonlyArray, +): { remoteRef: string; remoteName: string; branchName: string } | null { + const trimmedRef = ref.trim(); + if (trimmedRef.length === 0) { + return null; + } + + for (const remoteName of remoteNames) { + const remotePrefix = `${remoteName}/`; + if (!trimmedRef.startsWith(remotePrefix)) { + continue; + } + const branchName = trimmedRef.slice(remotePrefix.length).trim(); + if (branchName.length === 0) { + return null; + } + return { + remoteRef: trimmedRef, + remoteName, + branchName, + }; + } + + return null; +} + +export function extractBranchNameFromRemoteRef( + ref: string, + options?: { + remoteName?: string | null; + remoteNames?: ReadonlyArray; + }, +): string { + const normalized = ref.trim(); + if (normalized.length === 0) { + return ""; + } + + if (normalized.startsWith("refs/remotes/")) { + return extractBranchNameFromRemoteRef(normalized.slice("refs/remotes/".length), options); + } + + const remoteNames = options?.remoteName ? [options.remoteName] : (options?.remoteNames ?? []); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(normalized, remoteNames); + if (parsedRemoteRef) { + return parsedRemoteRef.branchName; + } + + const firstSlash = normalized.indexOf("/"); + if (firstSlash === -1) { + return normalized; + } + return normalized.slice(firstSlash + 1).trim(); +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ad776979d88ba..9abd1208156d3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -37,7 +37,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { CoderVcsStatus } from "../../coderVcsStatus.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -331,8 +331,8 @@ describe("CheckpointReactor", () => { const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1)).pipe( Effect.andThen(options?.pullRequestRefresh ?? Effect.void), ); - const vcsStatusLayer = Layer.succeed(CoderVcsStatus, { - refresh: (cwd: string) => + const vcsStatusLayer = Layer.mock(VcsStatusBroadcaster)({ + refreshLocalStatus: (cwd: string) => Effect.sync(() => { options?.gitStatusRefreshCalls?.push(cwd); }).pipe( @@ -351,8 +351,6 @@ describe("CheckpointReactor", () => { pr: null, }), ), - stream: () => Stream.empty, - refStream: () => Stream.empty, }); const layer = CheckpointReactorLive.pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index e98e6d0eeefce..d8d18ed32695c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -37,7 +37,7 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; -import { CoderVcsStatus } from "../../coderVcsStatus.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; @@ -97,7 +97,7 @@ const make = Effect.gen(function* () { const checkpointStore = yield* CheckpointStore.CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const vcsStatus = yield* CoderVcsStatus; + const vcsStatus = yield* VcsStatusBroadcaster; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const pullRequests = yield* PullRequestService.PullRequestService; @@ -527,7 +527,7 @@ const make = Effect.gen(function* () { readonly cwd: string; readonly followWorktreePath: boolean; }) { - const local = yield* vcsStatus.refresh(input.cwd, { fetch: false }).pipe( + const local = yield* vcsStatus.refreshLocalStatus(input.cwd).pipe( Effect.catch((error) => Effect.logWarning("failed to refresh local git status after agent command", { threadId: input.event.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d1430e71a0514..27e5324baf872 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -69,7 +69,7 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { CoderVcsStatus } from "../../coderVcsStatus.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -492,10 +492,8 @@ describe("ProviderCommandReactor", () => { } satisfies Partial), ), Layer.provideMerge( - Layer.succeed(CoderVcsStatus, { - refresh: refreshStatus, - stream: () => Stream.die("stream should not be called in this test"), - refStream: () => Stream.die("refStream should not be called in this test"), + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus, }), ), Layer.provideMerge( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5baf0d0169ad0..63f9d8c4cdb1f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -60,7 +60,7 @@ import { } from "../../textGeneration/ThreadTitleContext.ts"; import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { CoderVcsStatus } from "../../coderVcsStatus.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import { renameBranchWithCompensation } from "../../git/renameBranchWithCompensation.ts"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -218,7 +218,7 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const fileSystem = yield* FileSystem.FileSystem; - const vcsStatus = yield* CoderVcsStatus; + const vcsStatus = yield* VcsStatusBroadcaster; const path = yield* Path.Path; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -1003,7 +1003,7 @@ const make = Effect.gen(function* () { ), ), }); - yield* vcsStatus.refresh(cwd).pipe(Effect.ignoreCause({ log: true })); + yield* vcsStatus.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( reportGeneratedNameFailure({ threadId: input.threadId, diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index 8417d53e56676..f43a3c3426a22 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -29,7 +29,7 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { GitWorkflowService as GitManager } from "../git/GitWorkflowService.ts"; +import { GitManager } from "../git/GitManager.ts"; type GitBranchPullRequest = NonNullable< Effect.Success> >; @@ -67,6 +67,9 @@ function branchPullRequest( ): GitBranchPullRequest { return { ...reference(number), + title: "Merge request", + baseRef: "main", + headRef: "feature", state, updatedAt: NOW, repositoryKey: REPOSITORY_KEY, diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index b308999fd9a84..8a215584c77c6 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -23,7 +23,7 @@ import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; @@ -62,7 +62,7 @@ interface RefreshRequest { export function pullRequestMatchesProject( pullRequest: NonNullable< - Effect.Success> + Effect.Success> >, project: OrchestrationProjectShell, ): boolean { @@ -108,7 +108,7 @@ export const readSweepSnapshot = ( export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const git = yield* GitWorkflow.GitWorkflowService; + const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index fa79584ab6e8c..a06b79b8ee685 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -22,7 +22,7 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as GitWorkflow from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -149,7 +149,7 @@ describe("ThreadSettlementReactor", () => { Effect.andThen(Effect.succeed(snapshot)), ), }), - Layer.mock(GitWorkflow.GitWorkflowService)({ + Layer.mock(GitWorkflow.GitManager)({ branchPullRequest: (input) => Ref.update(branchCalls, (calls) => [...calls, input]).pipe(Effect.as(null)), invalidateStatus: () => Effect.void, @@ -260,7 +260,7 @@ describe("ThreadSettlementReactor", () => { getShellSnapshot: () => Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Effect.succeed(snapshot))), }), - Layer.mock(GitWorkflow.GitWorkflowService)({ + Layer.mock(GitWorkflow.GitManager)({ branchPullRequest: () => Effect.die(new Error("No branch lookup expected.")), invalidateStatus: () => Effect.void, }), @@ -370,13 +370,17 @@ describe("ThreadSettlementReactor", () => { getShellSnapshot: () => Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Effect.succeed(snapshot))), }), - Layer.mock(GitWorkflow.GitWorkflowService)({ + Layer.mock(GitWorkflow.GitManager)({ branchPullRequest: (_input, options) => Effect.sync(() => { assert.isTrue(options?.refresh); return { state: "open" as const, number: 43, + title: "Merge request", + url: "https://gitlab.example.test/owner/repository/-/merge_requests/43", + baseRef: "main", + headRef: "feature", updatedAt: NOW, repositoryKey: "gitlab.example.test/owner/repository", }; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index f07988cad6deb..521713cb0fcc1 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -17,7 +17,7 @@ import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; @@ -82,7 +82,7 @@ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settingsService = yield* ServerSettings.ServerSettingsService; - const git = yield* GitWorkflow.GitWorkflowService; + const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/NewProject.ts b/apps/server/src/project/NewProject.ts index f57c3b51c3822..d59be024e5e31 100644 --- a/apps/server/src/project/NewProject.ts +++ b/apps/server/src/project/NewProject.ts @@ -138,14 +138,7 @@ export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFold return yield* Effect.gen(function* () { const branch = (yield* git - .execute({ - operation: "NewProject.defaultBranch", - cwd, - args: ["config", "--get", "init.defaultBranch"], - allowNonZeroExit: true, - timeoutMs: 10_000, - }) - .pipe(Effect.map((result) => result.stdout.trim() || null)) + .readConfigValue(cwd, "init.defaultBranch") .pipe(Effect.orElseSucceed(() => null))) ?? "main"; yield* git.execute({ operation: "NewProject.init", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 050ba0aa0fb4f..ebe7d12541d83 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -48,6 +48,8 @@ import { deriveLegacyProjectOverrides, } from "@t3tools/shared/serverSettings"; +export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; + const encodeServerSettings = Schema.encodeEffect(ServerSettings); const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index dd7999438a5db..dadd21aa39257 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -22,7 +22,7 @@ import * as Deletion from "./orchestration/Services/ThreadDeletionReactor.ts"; import * as Providers from "./provider/Services/ProviderService.ts"; import * as Terminals from "./terminal/Manager.ts"; import * as Git from "./vcs/GitVcsDriver.ts"; -import * as Workflow from "./git/GitWorkflowService.ts"; +import * as Workflow from "./git/GitManager.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; const emptySnapshot = { projects: [], threads: [] } as unknown as OrchestrationShellSnapshot; @@ -38,9 +38,9 @@ function fixtures(snapshot = emptySnapshot) { } as unknown as Engine.OrchestrationEngineShape), Layer.succeed(Deletion.ThreadDeletionReactor, {} as Deletion.ThreadDeletionReactor["Service"]), Layer.succeed(Providers.ProviderService, {} as Providers.ProviderService["Service"]), - Layer.succeed(Workflow.GitWorkflowService, { + Layer.succeed(Workflow.GitManager, { invalidateStatus: () => Effect.void, - } as unknown as Workflow.GitWorkflowService["Service"]), + } as unknown as Workflow.GitManager["Service"]), Layer.succeed(Terminals.TerminalManager, { subscribeMetadata: () => Effect.succeed(() => {}), } as unknown as Terminals.TerminalManager["Service"]), diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 4c58e0fb465e5..f3267c67164eb 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -24,7 +24,7 @@ import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; -import * as GitManager from "./git/GitWorkflowService.ts"; +import * as GitManager from "./git/GitManager.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ThreadDeletionReactor from "./orchestration/Services/ThreadDeletionReactor.ts"; @@ -113,7 +113,7 @@ export const make = Effect.gen(function* () { const threadDeletion = yield* ThreadDeletionReactor.ThreadDeletionReactor; const providers = yield* ProviderService.ProviderService; const git = yield* GitVcsDriver.GitVcsDriver; - const gitManager = yield* GitManager.GitWorkflowService; + const gitManager = yield* GitManager.GitManager; const terminals = yield* TerminalManager.TerminalManager; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index bb2a3ebe70155..84cf925d3946a 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -520,6 +520,41 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); + it.effect("returns a semantic branch with the commit message when requested", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + subject: "Restore Git actions", + body: "", + branch: "Restore Git Actions", + }, + }), + stdinMustContain: "Return a JSON object with keys: subject, body, branch.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "1 file changed", + stagedPatch: "+restore", + includeBranch: true, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated).toEqual({ + subject: "Restore Git actions", + body: "", + branch: "feature/restore-git-actions", + }); + }), + ), + ); + it.effect("honors custom descriptors without resolving a shadowed Claude alias", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 12f83ac303b90..d1014546de8c1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -334,6 +334,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), }; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 1560932dc19a7..f3309c8108d24 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -14,7 +14,7 @@ import { type ModelSelection, TextGenerationError, } from "@t3tools/contracts"; -import { sanitizeBranchFragment } from "@t3tools/shared/git"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -272,6 +272,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), }; }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 276e2572f7ae1..c35c878815c1d 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -17,6 +17,8 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + /** When true, the model also returns a semantic branch name for the change. */ + includeBranch?: boolean; modelSelection: ModelSelection; policy?: TextGenerationPolicy; } @@ -24,6 +26,8 @@ export interface CommitMessageGenerationInput { export interface CommitMessageGenerationResult { subject: string; body: string; + /** Only present when `includeBranch` was set on the input. */ + branch?: string | undefined; } export interface PrContentGenerationInput { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 4be6de278686c..4566207c50741 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -22,14 +22,21 @@ export function buildCommitMessagePrompt(input: { readonly branch: string | null; readonly stagedSummary: string; readonly stagedPatch: string; + readonly includeBranch?: boolean; readonly policy?: TextGenerationPolicy; }) { + const wantsBranch = input.includeBranch === true; const prompt = [ "You write concise git commit messages.", - "Return a JSON object with keys: subject, body.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be an empty string or short markdown bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), "- capture the primary user-visible or developer-visible change", ...policyInstruction(input.policy?.commitInstructions), "", @@ -41,6 +48,16 @@ export function buildCommitMessagePrompt(input: { "Staged patch:", limitSection(input.stagedPatch, 40_000), ].join("\n"); + if (wantsBranch) { + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }), + }; + } return { prompt, outputSchema: Schema.Struct({ subject: Schema.String, body: Schema.String }), diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index bd52c470d85f2..2d06d618ba7c1 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,5 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -7,7 +14,12 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; -import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; +import { + CheckpointRef, + GitCommandError, + MAX_REVIEW_DIFF_FILE_BYTES, + VcsProcessExitError, +} from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -41,17 +53,21 @@ const runGit = (cwd: string, args: ReadonlyArray) => }); }); -const readGit = (cwd: string, args: ReadonlyArray) => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const result = yield* driver.execute({ - operation: "GitVcsDriver.test.readGit", - cwd, - args, - timeoutMs: 10_000, - }); - return result.stdout.trim(); - }); +const makeCaptureStore = Effect.fn("test.makeCaptureStore")(function* ( + driver: VcsDriver.VcsDriver["Service"], + cwd: string, +) { + const repository = yield* driver.detectRepository(cwd); + if (repository === null) return yield* Effect.die("Expected a test Git repository"); + const handle = { kind: repository.kind, repository, driver }; + return yield* CheckpointStore.make.pipe( + Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, { + get: () => Effect.succeed(driver), + detect: () => Effect.succeed(handle), + resolve: () => Effect.succeed(handle), + }), + ); +}); type GitContractError = GitCommandError | PlatformError.PlatformError; @@ -85,6 +101,851 @@ runVcsDriverContractSuite({ }, }); +const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( + driver: Effect.Success>, + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n"); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "."]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + return { git, checkpointRef }; +}); + +it.effect("checkpoint capture skips untracked nested repositories without a commit", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-unborn-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const nested = "scratch/empty [repo]"; + yield* git(["init", nested]); + yield* git(["init", "another empty"]); + yield* fileSystem.writeFileString(path.join(cwd, nested, "private.txt"), "nested\n"); + yield* git(["init", "committed"]); + yield* git([ + "-C", + "committed", + "-c", + "user.name=Test", + "-c", + "user.email=test@test.com", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + const nestedHead = (yield* git(["-C", "committed", "rev-parse", "HEAD"])).stdout.trim(); + yield* fileSystem.writeFileString(path.join(cwd, "untracked.txt"), "new\n"); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, "new\n"); + assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", nested])).stdout, ""); + assert.strictEqual((yield* git(["ls-tree", checkpointRef, "--", "another empty"])).stdout, ""); + assert.strictEqual( + (yield* git(["ls-tree", checkpointRef, "--", "committed"])).stdout, + `160000 commit ${nestedHead}\tcommitted\n`, + ); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, nested, "private.txt")), + "nested\n", + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint recovery discovers nested HEAD independently of inherited GIT_DIR", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-git-dir-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["init", "empty"]); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.GIT_DIR; + process.env.GIT_DIR = path.join(cwd, ".git"); + return previous; + }), + () => driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = previous; + }), + ); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, ""); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint capture still fails when a clean filter rejects a file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-checkpoint-filter-failure-", + }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fileSystem.writeFileString(path.join(cwd, ".gitattributes"), "file.txt filter=reject\n"); + yield* git(["config", "filter.reject.clean", "false"]); + yield* git(["config", "filter.reject.required", "true"]); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + const result = yield* Effect.result( + driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + ); + + assert.strictEqual(result._tag, "Failure"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint capture refuses a truncated nested repository listing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-truncated-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["init", "empty"]); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + liveProcess + .run(input) + .pipe( + Effect.map((result) => + input.args.includes("--others") ? { ...result, stdoutTruncated: true } : result, + ), + ), + }), + ); + + const result = yield* Effect.result( + captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + ); + + assert.strictEqual(result._tag, "Failure"); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint recovery refuses excessive candidates before probing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["init", "empty0"]); + for (let i = 1; i < 65; i++) + yield* fs.copy(path.join(cwd, "empty0"), path.join(cwd, `empty${i}`)); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + let stageError: VcsProcessExitError | undefined; + let nestedProbes = 0; + let stageAttempts = 0; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.cwd !== cwd && input.args.includes("rev-parse")) nestedProbes++; + if (input.args.includes("add") && input.args.includes("-A")) stageAttempts++; + return liveProcess.run(input).pipe( + Effect.tapError((error) => { + if (error._tag === "VcsProcessExitError") stageError = error; + return Effect.void; + }), + ); + }, + }), + ); + const result = yield* Effect.result( + captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), + ); + assert.strictEqual(nestedProbes, 0); + assert.strictEqual(stageAttempts, 1); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") assert.strictEqual(result.failure, stageError); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect.each([ + { phase: "add", nestedRecovery: false, expireRecovery: false }, + { phase: "update-ref", nestedRecovery: false, expireRecovery: false }, + { phase: "update-ref", nestedRecovery: true, expireRecovery: false }, + { phase: "add", nestedRecovery: true, expireRecovery: false }, + { phase: "add", nestedRecovery: true, expireRecovery: true }, +])( + "checkpoint handles a $phase lock with nestedRecovery=$nestedRecovery, expireRecovery=$expireRecovery", + ({ phase, nestedRecovery, expireRecovery }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveRunner = yield* ProcessRunner.ProcessRunner; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + if (nestedRecovery) yield* git(["init", "empty"]); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const refLockPath = path.join(cwd, ".git", `${checkpointRef}.lock`); + const failed = yield* Deferred.make(); + const retryReached = yield* Deferred.make(); + const allowRetry = yield* Deferred.make(); + const clock = yield* Clock.Clock; + const privateIndexes = new Set(); + let racedAttempts = 0; + let discoveries = 0; + let stageError: VcsProcessExitError | undefined; + const captureProcess = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + if (input.args.includes("--others")) discoveries += 1; + if (input.env?.GIT_INDEX_FILE) privateIndexes.add(input.env.GIT_INDEX_FILE); + const initialStage = + phase === "add" && + nestedRecovery && + !input.args.some((arg) => arg.startsWith(":(exclude,literal)")); + if (!input.args.includes(phase) || initialStage || ++racedAttempts !== 1) { + return liveRunner.run(input); + } + const lockPath = phase === "add" ? `${input.env!.GIT_INDEX_FILE!}.lock` : refLockPath; + return Effect.gen(function* () { + yield* fs + .makeDirectory(path.dirname(lockPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(lockPath, "concurrent ref writer").pipe(Effect.orDie); + return yield* liveRunner.run(input).pipe( + Effect.ensuring(fs.remove(lockPath).pipe(Effect.orDie)), + Effect.tap(() => Deferred.succeed(failed, undefined)), + ); + }); + }, + }), + ); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + captureProcess.run(input).pipe( + Effect.tapError((error) => { + if (input.args.includes("add") && error._tag === "VcsProcessExitError") + stageError = error; + return Effect.void; + }), + ), + }), + ); + const captureStore = yield* makeCaptureStore(captureDriver, cwd); + const fiber = yield* captureStore.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: (duration) => + Duration.toMillis(duration) === 75 + ? Deferred.succeed(retryReached, undefined).pipe( + Effect.andThen(Deferred.await(allowRetry)), + ) + : clock.sleep(duration), + }), + Effect.exit, + Effect.forkScoped, + ); + yield* Deferred.await(failed); + yield* Deferred.await(retryReached); + if (expireRecovery) yield* TestClock.adjust("5 seconds"); + else yield* Deferred.succeed(allowRetry, undefined); + const result = yield* Fiber.join(fiber); + if (expireRecovery) { + if (Exit.isSuccess(result)) + return yield* Effect.die("Expected the recovery deadline to expire"); + const error = Cause.findErrorOption(result.cause); + assert.isTrue(error._tag === "Some"); + if (error._tag === "Some") assert.strictEqual(error.value, stageError); + } else assert.isTrue(Exit.isSuccess(result)); + assert.strictEqual(racedAttempts, expireRecovery ? 1 : 2); + assert.strictEqual(discoveries, nestedRecovery ? 1 : 0); + assert.strictEqual(privateIndexes.size, 1); + assert.strictEqual( + yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }), + !expireRecovery, + ); + for (const index of privateIndexes) { + assert.isFalse(yield* fs.exists(index)); + assert.isFalse(yield* fs.exists(`${index}.lock`)); + } + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), +); + +for (const blockedPhase of ["discovery", "probe", "retry"] as const) { + it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["init", "empty"]); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const entered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); + let stageError: VcsProcessExitError | undefined; + let privateIndex: string | undefined; + let interrupted = false; + let stagingAttempts = 0; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + const staging = input.args.includes("add") && input.args.includes("-A"); + if (staging) { + privateIndex = input.env?.GIT_INDEX_FILE; + stagingAttempts += 1; + } + const block = + (blockedPhase === "discovery" && input.args.includes("--others")) || + (blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) || + (blockedPhase === "retry" && + staging && + input.args.some((arg) => arg.startsWith(":(exclude,literal)"))); + if (block) + return ( + blockedPhase === "retry" + ? fs + .writeFileString( + `${input.env!.GIT_INDEX_FILE!}.lock`, + "interrupted index write", + ) + .pipe(Effect.orDie) + : Effect.void + ).pipe( + Effect.andThen(Deferred.succeed(entered, undefined)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted = true; + }), + ), + ); + return liveProcess.run(input).pipe( + Effect.tap(() => + blockedPhase === "probe" && input.args.includes("--others") + ? Deferred.succeed(discovered, undefined).pipe( + Effect.andThen(Effect.sleep("3 seconds")), + ) + : Effect.void, + ), + Effect.tapError((error) => { + if (staging && error._tag === "VcsProcessExitError") stageError = error; + return Effect.void; + }), + ); + }, + }), + ); + const captureStore = yield* makeCaptureStore(captureDriver, cwd); + const fiber = yield* captureStore + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.flip, Effect.forkScoped); + if (blockedPhase === "probe") { + yield* Deferred.await(discovered); + yield* TestClock.adjust("3 seconds"); + } + yield* Deferred.await(entered); + yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds"); + const error = yield* Fiber.join(fiber); + assert.strictEqual(error, stageError); + assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1); + assert.isTrue(interrupted); + assert.isDefined(privateIndex); + assert.isFalse(yield* fs.exists(privateIndex!)); + assert.isFalse(yield* fs.exists(`${privateIndex!}.lock`)); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + +it.effect("checkpoint recovery preserves interruption and removes the private index", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-interrupt-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["init", "empty"]); + const entered = yield* Deferred.make(); + let privateIndex: string | undefined; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.args.includes("add") && input.args.includes("-A")) + privateIndex = input.env?.GIT_INDEX_FILE; + return input.args.includes("--others") + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)) + : liveProcess.run(input); + }, + }), + ); + const fiber = yield* captureDriver.checkpoints + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.forkScoped); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + assert.isDefined(privateIndex); + assert.isFalse(yield* fs.exists(privateIndex!)); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fileSystem.writeFileString( + path.join(cwd, ".gitattributes"), + "stable.txt filter=probe\n", + ); + yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); + yield* fileSystem.writeFileString( + path.join(cwd, ".git", "filter.cjs"), + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record stable file"]); + yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const nested of [false, true]) { + for (const indexState of [ + "sparse", + "flags", + "manual-skip", + "missing", + "non-cone-missing", + ] as const) { + it.effect( + `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + }); + for (const name of [ + "scope/in/edit", + "scope/in/delete", + "scope/out/deep/absent", + "scope/out/present", + "elsewhere/file", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git([ + "sparse-checkout", + "set", + "--cone", + "--sparse-index", + "scope/in", + "elsewhere", + ]); + if (indexState === "non-cone-missing") + yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); + yield* write("scope/in/edit", "staged\n"); + yield* write("elsewhere/file", "staged outside\n"); + yield* git(["add", "."]); + if (indexState === "flags") + yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); + if (indexState === "manual-skip") + yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* write("scope/in/edit", "working\n"); + yield* write("scope/out/present", "modified skipped\n"); + yield* write("scope/out/new file", "new outside cone\n"); + yield* write("elsewhere/file", "working outside\n"); + yield* fs.remove(path.join(cwd, "scope/in/delete")); + const indexPath = path.join(cwd, ".git/index"); + if (indexState.endsWith("missing")) yield* fs.remove(indexPath); + const originalIndex = yield* fs + .readFile(indexPath) + .pipe(Effect.orElseSucceed(() => null)); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + for (const turn of [1, 2]) { + const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); + if (turn === 2) { + yield* write("scope/in/edit", "second\n"); + yield* fs.remove(path.join(cwd, "scope/out/new file")); + yield* write("scope/out/second", "second addition\n"); + } + const capture = driver.checkpoints.captureCheckpoint({ + cwd: captureCwd, + checkpointRef: ref, + }); + if (indexState === "non-cone-missing") { + assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), + ); + assert.isFalse(yield* fs.exists(indexPath)); + break; + } + yield* capture; + for (const [name, content] of [ + ["scope/out/deep/absent", "original\n"], + ["scope/out/present", "modified skipped\n"], + ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], + ["elsewhere/file", nested ? "original\n" : "working outside\n"], + [ + turn === 1 ? "scope/out/new file" : "scope/out/second", + turn === 1 ? "new outside cone\n" : "second addition\n", + ], + ]) { + assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); + } + const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); + assert.notInclude(files, "scope/in/delete"); + if (turn === 2) assert.notInclude(files, "scope/out/new file"); + assert.deepEqual( + yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), + originalIndex, + ); + assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "elsewhere/file")), + "working outside\n", + ); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); + } +} + +it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["sparse-checkout", "set", "--cone", "included"]); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.args.includes("-h")) + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(129), + stdout: "usage: git add", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + return liveProcess.run( + input.args.includes("--sparse") + ? { + ...input, + args: input.args.map((arg) => + arg === "--sparse" ? "--unsupported-sparse" : arg, + ), + } + : input, + ); + }, + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const indexMode of ["normal", "flags", "sparse"] as const) { + it.effect( + `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); + yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); + yield* fs.makeDirectory(path.join(cwd, "excluded")); + yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); + yield* fs.writeFileString( + path.join(cwd, ".git/filter.cjs"), + 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "inspection fixture"]); + if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); + if (indexMode === "sparse") + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); + yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + liveProcess.run( + input.args.includes("ls-files") + ? { + ...input, + maxOutputBytes: 8, + onStdoutChunk: (chunk) => { + for (let i = 0; i < chunk.length; i++) + input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + }, + } + : input, + ), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, + "modified\n", + ); + if (indexMode !== "flags") + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + +for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { + it.effect( + `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const filePath = path.join(cwd, "file.txt"); + const indexPath = path.join(cwd, ".git", "index"); + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(filePath, "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(["add", "file.txt"]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* fileSystem.writeFileString(filePath, "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + +it.effect("checkpoint capture preserves racy edits made after resetting the index", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const racyPath = path.join(cwd, "racy.txt"); + const indexPath = path.join(cwd, ".git", "index"); + const timestamp = 1_700_000_000; + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(racyPath, "before\n"); + yield* fileSystem.utimes(racyPath, timestamp, timestamp); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "file.txt"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { + const result = yield* liveProcess.run(input); + if (input.args.includes("read-tree") && input.args.includes("--reset")) { + yield* fileSystem.writeFileString(racyPath, "after!\n").pipe(Effect.orDie); + yield* fileSystem.utimes(racyPath, timestamp, timestamp).pipe(Effect.orDie); + } + return result; + }), + }), + ); + + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:racy.txt`])).stdout, "after!\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "staged\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const nested of [false, true]) { + for (const indexMode of ["normal", "flags", "split"] as const) { + it.effect( + `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, name), contents); + yield* fileSystem.makeDirectory(path.join(cwd, "scope")); + for (const name of [ + "scope/staged", + "scope/deleted", + "scope/assumed", + "scope/skipped", + "outside", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial scoped files"]); + yield* write("scope/staged", "staged\n"); + yield* write("scope/new-deleted", "staged then deleted\n"); + yield* write("outside", "staged outside\n"); + yield* git(["add", "."]); + if (indexMode === "flags") { + yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); + yield* git(["update-index", "--skip-worktree", "scope/skipped"]); + } + if (indexMode === "split") { + yield* git(["update-index", "--split-index"]); + } + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { + yield* write(name, "working\n"); + } + yield* write("scope/new", "first\n"); + yield* fileSystem.remove(path.join(cwd, "scope/deleted")); + yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); + const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { + assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); + } + assert.strictEqual( + (yield* git(["show", `${first}:outside`])).stdout, + nested ? "original\n" : "working\n", + ); + const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); + assert.notInclude(files, "scope/deleted"); + assert.notInclude(files, "scope/new-deleted"); + assert.include(files, "scope/new"); + + yield* write("scope/staged", "second\n"); + yield* fileSystem.remove(path.join(cwd, "scope/new")); + yield* write("scope/second", "added in second turn\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); + assert.strictEqual( + (yield* git(["diff", "--name-only", first, second])).stdout, + "scope/new\nscope/second\nscope/staged\n", + ); + assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); + assert.strictEqual( + (yield* git(["show", `${second}:scope/second`])).stdout, + "added in second turn\n", + ); + assert.deepEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); + } +} + +for (const indexState of ["missing", "invalid"] as const) { + it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const indexPath = path.join(cwd, ".git", "index"); + if (indexState === "missing") { + yield* fileSystem.remove(indexPath); + } else { + yield* fileSystem.writeFileString(indexPath, "invalid index"); + } + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + if (indexState === "missing") { + assert.isFalse(yield* fileSystem.exists(indexPath)); + } else { + assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -197,163 +1058,94 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { ); }); -it.effect("GitVcsDriver derives the default worktree path from the repo and branch", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-naming-" }); - const repo = path.join(fixtureRoot, "semantic-repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); +it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publishing them", () => { + const observedArgs: ReadonlyArray[] = []; - const progress: string[] = []; - const created = yield* driver.createWorktree( - { - cwd: repo, - refName: "main", - newRefName: "t3code/fix-reconnect", - baseRefName: "main", - path: null, - }, - { - progress: { - onWorktreeClaimed: (claimed) => - Effect.gen(function* () { - assert.strictEqual(yield* fileSystem.exists(path.join(claimed, "README.md")), true); - progress.push("claimed"); - }).pipe(Effect.orDie), - onSubmodulesFinished: ({ ok, detail }) => + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + yield* driver.checkpoints.captureCheckpoint({ + cwd: "/repo", + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/turn/1"), + }); + + const writeCommands = ["add", "write-tree", "commit-tree", "update-ref"]; + const writes = observedArgs.filter((args) => + writeCommands.some((command) => args.includes(command)), + ); + assert.strictEqual(writes.length, 4); + for (const args of writes) { + const command = args.findIndex((arg) => writeCommands.includes(arg)); + for (const setting of ["core.fsync=objects,reference", "core.fsyncMethod=fsync"]) { + const index = args.indexOf(setting); + assert.strictEqual(args[index - 1], "-c", args.join(" ")); + assert.isBelow(index, command); + } + } + assert.deepStrictEqual(observedArgs.at(-1), [ + "-C", + "/repo", + "-c", + "core.fsync=objects,reference", + "-c", + "core.fsyncMethod=fsync", + "update-ref", + "refs/t3/checkpoints/thread/turn/1", + "commit0000", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => Effect.sync(() => { - assert.strictEqual(ok, true); - progress.push(detail ?? "finished"); + observedArgs.push(input.args); + const stdout = input.args.includes("write-tree") + ? "tree0000\n" + : input.args.includes("commit-tree") + ? "commit0000\n" + : input.args.includes("--git-common-dir") + ? ".git\n" + : ""; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; }), - }, - }, - ); - assert.deepStrictEqual(progress, ["claimed", "no submodules"]); - assert.strictEqual( - created.worktree.path.endsWith( - path.join("worktrees", "semantic-repo", "t3code-fix-reconnect"), + }), ), - true, - ); - assert.strictEqual(yield* fileSystem.exists(created.worktree.path), true); - - yield* driver.removeWorktree({ cwd: repo, path: created.worktree.path, force: true }); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); + ), + ); +}); -it.effect("GitVcsDriver treats an already-gone worktree as a no-op", () => +// Coder: retained regression for thread branch/worktree renames. +it.effect("GitVcsDriver renames a checked-out branch and moves its worktree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-missing-" }); + const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-rename-" }); const repo = path.join(fixtureRoot, "repo"); + const oldWorktreePath = path.join(fixtureRoot, "old-worktree"); + const newWorktreePath = path.join(fixtureRoot, "renamed-worktree"); yield* fileSystem.makeDirectory(repo); yield* runGit(repo, ["init", "--initial-branch=main"]); + yield* runGit(repo, ["config", "user.email", "test@test.com"]); + yield* runGit(repo, ["config", "user.name", "Test"]); + yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); + yield* runGit(repo, ["add", "README.md"]); + yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* driver.removeWorktree({ + yield* driver.createWorktree({ cwd: repo, - path: path.join(fixtureRoot, "missing-worktree"), - }); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver rejects an existing directory that is not a worktree", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-invalid-" }); - const repo = path.join(fixtureRoot, "repo"); - const notAWorktree = path.join(fixtureRoot, "not-a-worktree"); - yield* fileSystem.makeDirectory(repo); - yield* fileSystem.makeDirectory(notAWorktree); - yield* runGit(repo, ["init", "--initial-branch=main"]); - - const error = yield* driver.removeWorktree({ cwd: repo, path: notAWorktree }).pipe(Effect.flip); - assert.strictEqual(error.detail, "git worktree remove failed"); - assert.notProperty(error, "cause"); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver removes the same worktree twice and prunes stale registrations", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-repeat-" }); - const repo = path.join(fixtureRoot, "repo"); - const sharedPath = path.join(fixtureRoot, "shared"); - const stalePath = path.join(fixtureRoot, "stale"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - - yield* driver.createWorktree({ - cwd: repo, - refName: "main", - newRefName: "feature/shared", - path: sharedPath, - }); - yield* driver.removeWorktree({ cwd: repo, path: sharedPath }); - yield* driver.removeWorktree({ cwd: repo, path: sharedPath }); - - yield* driver.createWorktree({ - cwd: repo, - refName: "main", - newRefName: "feature/stale", - path: stalePath, - }); - yield* fileSystem.remove(stalePath, { recursive: true }); - yield* driver.removeWorktree({ - cwd: repo, - path: path.join(fixtureRoot, "never-registered"), - }); - - const registered = yield* driver.execute({ - operation: "GitVcsDriver.test.worktreeList", - cwd: repo, - args: ["worktree", "list", "--porcelain"], - }); - assert.notInclude(registered.stdout, "feature/stale"); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver renames a checked-out branch and moves its worktree", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-rename-" }); - const repo = path.join(fixtureRoot, "repo"); - const oldWorktreePath = path.join(fixtureRoot, "old-worktree"); - const newWorktreePath = path.join(fixtureRoot, "renamed-worktree"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - - yield* driver.createWorktree({ - cwd: repo, - refName: "main", - newRefName: "feature/old-name", - baseRefName: "main", - path: oldWorktreePath, + refName: "main", + newRefName: "feature/old-name", + baseRefName: "main", + path: oldWorktreePath, }); yield* driver.renameBranch({ cwd: oldWorktreePath, @@ -373,858 +1165,234 @@ it.effect("GitVcsDriver renames a checked-out branch and moves its worktree", () }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver adds a remote and pushes the current branch with upstream tracking", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-push-" }); - const repo = path.join(fixtureRoot, "repo"); - const remote = path.join(fixtureRoot, "remote.git"); - yield* fileSystem.makeDirectory(repo); - yield* fileSystem.makeDirectory(remote); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(remote, ["init", "--bare", "--initial-branch=main"]); - - const remoteName = yield* driver.ensureRemote({ - cwd: repo, - preferredName: "origin", - url: remote, - }); - const pushed = yield* driver.pushCurrentBranch(repo, null, { remoteName }); - - assert.strictEqual(remoteName, "origin"); - assert.strictEqual(pushed.branch, "main"); - assert.strictEqual(pushed.upstreamBranch, "origin/main"); - assert.strictEqual(pushed.setUpstream, true); - assert.strictEqual( - yield* readGit(remote, ["rev-parse", "refs/heads/main"]), - yield* readGit(repo, ["rev-parse", "HEAD"]), - ); - assert.strictEqual( - yield* driver.ensureRemote({ cwd: repo, preferredName: "origin", url: remote }), - "origin", - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver pushes to an explicitly requested remote instead of an old upstream", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-republish-" }); - const repo = path.join(fixtureRoot, "repo"); - const oldRemote = path.join(fixtureRoot, "old.git"); - const requestedRemote = path.join(fixtureRoot, "requested.git"); - yield* fileSystem.makeDirectory(repo); - yield* fileSystem.makeDirectory(oldRemote); - yield* fileSystem.makeDirectory(requestedRemote); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "first\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "First"]); - yield* runGit(oldRemote, ["init", "--bare", "--initial-branch=main"]); - yield* runGit(requestedRemote, ["init", "--bare", "--initial-branch=main"]); - yield* driver.ensureRemote({ cwd: repo, preferredName: "origin", url: oldRemote }); - yield* driver.pushCurrentBranch(repo, null, { remoteName: "origin" }); - const oldHead = yield* readGit(oldRemote, ["rev-parse", "refs/heads/main"]); - - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "second\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Second"]); - const remoteName = yield* driver.ensureRemote({ - cwd: repo, - preferredName: "published", - url: requestedRemote, - }); - const pushed = yield* driver.pushCurrentBranch(repo, null, { remoteName }); - - assert.strictEqual(remoteName, "published"); - assert.strictEqual(pushed.upstreamBranch, "published/main"); - assert.strictEqual(pushed.setUpstream, true); - assert.strictEqual(yield* readGit(oldRemote, ["rev-parse", "refs/heads/main"]), oldHead); - assert.strictEqual( - yield* readGit(requestedRemote, ["rev-parse", "refs/heads/main"]), - yield* readGit(repo, ["rev-parse", "HEAD"]), - ); - assert.strictEqual( - yield* readGit(repo, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "published/main", - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver uses the remote HEAD when determining the default branch", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-default-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=trunk"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["branch", "main"]); - yield* runGit(repo, ["remote", "add", "origin", repo]); - yield* runGit(repo, ["update-ref", "refs/remotes/origin/trunk", "HEAD"]); - yield* runGit(repo, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); - - const status = yield* driver.statusDetailsLocal(repo); - assert.strictEqual(status.branch, "trunk"); - assert.strictEqual(status.isDefaultBranch, true); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver uses a non-origin remote HEAD as the default branch", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-upstream-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=develop"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["branch", "main"]); - yield* runGit(repo, ["remote", "add", "upstream", repo]); - yield* runGit(repo, ["update-ref", "refs/remotes/upstream/develop", "HEAD"]); - yield* runGit(repo, [ - "symbolic-ref", - "refs/remotes/upstream/HEAD", - "refs/remotes/upstream/develop", - ]); - - const status = yield* driver.statusDetailsLocal(repo); - assert.strictEqual(status.branch, "develop"); - assert.strictEqual(status.isDefaultBranch, true); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver reports the destination of a rename with spaces", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-status-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(path.join(repo, "old dir"), { recursive: true }); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "old dir", "old name.txt"), "fixture\n"); - yield* runGit(repo, ["add", "."]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* fileSystem.makeDirectory(path.join(repo, "new dir")); - yield* runGit(repo, ["mv", "old dir/old name.txt", "new dir/new name.txt"]); - - const status = yield* driver.statusDetailsLocal(repo); - assert.deepEqual( - status.workingTree.files.map((file) => file.path), - ["new dir/new name.txt"], - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver preserves tabs in renamed paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-tab-path-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "old\tname.txt"), "fixture\n"); - yield* runGit(repo, ["add", "."]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["mv", "old\tname.txt", "new\tname.txt"]); - - const status = yield* driver.statusDetailsLocal(repo); - assert.deepEqual( - status.workingTree.files.map((file) => file.path), - ["new\tname.txt"], - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); +// Coder: fixtures for the retained workspace Git/review seams. +const makeSeamRepo = Effect.fn(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-seam-" }); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* runGit(cwd, ["config", "user.email", "test@example.test"]); + yield* runGit(cwd, ["commit", "--allow-empty", "-m", "initial"]); + return cwd; +}); -it.effect("GitVcsDriver checks out cached submodules in a new worktree", () => +// Coder: ref polling must never refresh the real index, even under its lock. +it.effect("refStatusLocal preserves the index and handles unborn/detached/non-repositories", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-submodule-" }); - const submoduleRepo = path.join(fixtureRoot, "shared-source"); - const repo = path.join(fixtureRoot, "repo"); - const worktree = path.join(fixtureRoot, "worktree"); - yield* fileSystem.makeDirectory(submoduleRepo); - yield* runGit(submoduleRepo, ["init", "--initial-branch=main"]); - yield* runGit(submoduleRepo, ["config", "user.email", "test@test.com"]); - yield* runGit(submoduleRepo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(submoduleRepo, "SHARED.md"), "shared\n"); - yield* runGit(submoduleRepo, ["add", "."]); - yield* runGit(submoduleRepo, ["commit", "-m", "Initial"]); - - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "."]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, [ - "-c", - "protocol.file.allow=always", - "submodule", - "add", - submoduleRepo, - "shared", - ]); - yield* runGit(repo, ["commit", "-am", "Add submodule"]); - - yield* driver.createWorktree({ - cwd: repo, - path: worktree, - refName: "main", - newRefName: "feature/submodule", + const cwd = yield* makeSeamRepo(); + const file = path.join(cwd, "tracked.txt"); + yield* fs.writeFileString(file, "before\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "tracked"]); + const index = path.join(cwd, ".git", "index"); + const before = yield* fs.readFile(index); + const beforeStat = yield* fs.stat(index); + yield* fs.writeFileString(file, "after\n"); + yield* fs.writeFileString(`${index}.lock`, "locked"); + assert.deepStrictEqual(yield* driver.refStatusLocal(cwd), { isRepo: true, refName: "main" }); + assert.deepStrictEqual(yield* fs.readFile(index), before); + assert.deepStrictEqual((yield* fs.stat(index)).mtime, beforeStat.mtime); + yield* fs.remove(`${index}.lock`); + yield* runGit(cwd, ["checkout", "--detach"]); + assert.deepStrictEqual(yield* driver.refStatusLocal(cwd), { isRepo: true, refName: null }); + const unborn = yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-unborn-" }); + yield* runGit(unborn, ["init", "--initial-branch=unborn"]); + assert.deepStrictEqual(yield* driver.refStatusLocal(unborn), { + isRepo: true, + refName: "unborn", }); - - assert.strictEqual(yield* fileSystem.exists(path.join(worktree, "shared", "SHARED.md")), true); + const nonRepo = yield* fs.makeTempDirectoryScoped({ prefix: "t3-nonrepo-" }); + assert.deepStrictEqual(yield* driver.refStatusLocal(nonRepo), { isRepo: false, refName: null }); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver keeps a worktree when an uncached network submodule is blocked", () => +// Coder: full expansion uses 32 MiB and typed errors for both workspace files and Git blobs. +it.effect("review expansion accepts files beyond 1 MiB and reports typed oversized errors", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-broken-sub-" }); - const repo = path.join(fixtureRoot, "repo"); - const worktree = path.join(fixtureRoot, "worktree"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "fixture\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - const gitlinkCommit = yield* readGit(repo, ["rev-parse", "HEAD"]); - yield* fileSystem.writeFileString( - path.join(repo, ".gitmodules"), - '[submodule "missing"]\n\tpath = missing\n\turl = https://example.invalid/missing.git\n', - ); - yield* runGit(repo, ["add", ".gitmodules"]); - yield* runGit(repo, [ - "update-index", - "--add", - "--cacheinfo", - "160000", - gitlinkCommit, - "missing", - ]); - yield* runGit(repo, ["commit", "-m", "Add unavailable submodule"]); - - const created = yield* driver.createWorktree({ - cwd: repo, - path: worktree, - refName: "main", - newRefName: "feature/broken-submodule", - }); - - assert.strictEqual(created.worktree.path, worktree); - assert.strictEqual(yield* fileSystem.exists(worktree), true); - assert.strictEqual( - yield* fileSystem.exists(path.join(worktree, "missing", "README.md")), - false, + const cwd = yield* makeSeamRepo(); + const input = { + cwd, + sourceKind: "working-tree" as const, + changeType: "new" as const, + baseRef: "HEAD", + headRef: null, + oldPath: "large.txt", + newPath: "large.txt", + }; + const contents = "x".repeat(1024 * 1024 + 1); + yield* fs.writeFileString(path.join(cwd, "large.txt"), contents); + assert.strictEqual((yield* driver.getReviewDiffFileContents(input)).newContents, contents); + yield* fs.writeFileString( + path.join(cwd, "large.txt"), + "x".repeat(MAX_REVIEW_DIFF_FILE_BYTES + 1), ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver resolves the automatic review base from local remote refs", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-review-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - const baseCommit = yield* driver.execute({ - operation: "GitVcsDriver.test.baseCommit", - cwd: repo, - args: ["rev-parse", "HEAD"], + const workspaceError = yield* driver.getReviewDiffFileContents(input).pipe(Effect.flip); + assert.deepInclude(workspaceError, { + _tag: "ReviewDiffFileTooLargeError", + path: "large.txt", + maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, }); - yield* runGit(repo, ["remote", "add", "origin", repo]); - yield* runGit(repo, ["update-ref", "refs/remotes/origin/main", baseCommit.stdout.trim()]); - yield* runGit(repo, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); - yield* runGit(repo, ["switch", "-c", "feature/review"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\nfeature\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Feature"]); - - const preview = yield* driver.getReviewDiffPreview({ - cwd: repo, - sourceKind: "branch-range", + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "large blob"]); + const blobError = yield* driver + .getReviewDiffFileContents({ + ...input, + sourceKind: "branch-range", + baseRef: "HEAD~1", + headRef: "HEAD", + }) + .pipe(Effect.flip); + assert.deepInclude(blobError, { + _tag: "ReviewDiffFileTooLargeError", + path: "large.txt", + maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, }); - - assert.lengthOf(preview.sources, 1); - assert.strictEqual(preview.sources[0]?.kind, "branch-range"); - assert.strictEqual(preview.sources[0]?.baseRef, "origin/main"); - assert.include(preview.sources[0]?.diff ?? "", "+feature"); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver preserves empty review sources and includes untracked files", () => +// Coder: review text rejects binary and malformed UTF-8 in workspace files and committed blobs. +it.effect("review expansion rejects binary and invalid UTF-8 on both sides", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-preview-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["switch", "-c", "feature/preview"]); - - const cleanBranch = yield* driver.getReviewDiffPreview({ - cwd: repo, - sourceKind: "branch-range", - }); - assert.lengthOf(cleanBranch.sources, 1); - assert.deepInclude(cleanBranch.sources[0], { - kind: "branch-range", - baseRef: "main", - headRef: "feature/preview", - diff: "", - }); - - yield* fileSystem.writeFileString(path.join(repo, "untracked.txt"), "untracked\n"); - const workingTree = yield* driver.getReviewDiffPreview({ - cwd: repo, - sourceKind: "working-tree", - }); - assert.lengthOf(workingTree.sources, 1); - assert.strictEqual(workingTree.sources[0]?.kind, "working-tree"); - assert.include(workingTree.sources[0]?.diff ?? "", "untracked.txt"); - assert.include(workingTree.sources[0]?.diff ?? "", "+untracked"); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -const writeReviewIndexFile = (cwd: string, relativePath: string, contents: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fileSystem.writeFileString(path.join(cwd, relativePath), contents); - }); - -const makeReviewIndexRepo = () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-review-index-" }); - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@test.com"]); - yield* runGit(cwd, ["config", "user.name", "Test"]); - yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Initial"]); - return cwd; - }); - -for (const splitIndex of [false, true]) { - it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => - Effect.gen(function* () { - const cwd = yield* makeReviewIndexRepo(); - const driver = yield* GitVcsDriver.GitVcsDriver; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* writeReviewIndexFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); - yield* writeReviewIndexFile(cwd, "stable.txt", "unchanged\n"); - yield* writeReviewIndexFile( + const cwd = yield* makeSeamRepo(); + for (const bytes of [new Uint8Array([65, 0, 66]), new Uint8Array([65, 255, 66])]) { + yield* fs.writeFile(path.join(cwd, "binary.txt"), bytes); + const input = { cwd, - ".git/filter.cjs", - 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + sourceKind: "working-tree" as const, + changeType: "new" as const, + baseRef: "HEAD", + headRef: null, + oldPath: "binary.txt", + newPath: "binary.txt", + }; + assert.equal( + (yield* driver.getReviewDiffFileContents(input).pipe(Effect.flip))._tag, + "GitCommandError", ); - yield* runGit(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); - yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "cache stable file"]); - if (splitIndex) yield* runGit(cwd, ["update-index", "--split-index"]); - const indexPath = path.join(cwd, ".git", "index"); - yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); - const originalIndex = yield* fs.readFile(indexPath); - const originalMtime = (yield* fs.stat(indexPath)).mtime; - yield* writeReviewIndexFile(cwd, ".git/filter-runs", ""); - yield* writeReviewIndexFile(cwd, "untracked.txt", "new\n"); - const preview = yield* driver.getReviewDiffPreview({ cwd, sourceKind: "working-tree" }); - assert.deepStrictEqual( - preview.sources.find((source) => source.kind === "working-tree")!.files, - [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + yield* runGit(cwd, ["commit", "-m", "binary blob"]); + assert.equal( + (yield* driver + .getReviewDiffFileContents({ + ...input, + sourceKind: "branch-range", + baseRef: "HEAD~1", + headRef: "HEAD", + }) + .pipe(Effect.flip))._tag, + "GitCommandError", ); - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); - assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); - assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} - -for (const [timestamp, splitIndex] of [ - [1_700_000_000, false], - [1_700_000_000.9999, false], - [1_700_000_000, true], - [1_700_000_000.9999, true], -] as const) { - it.effect( - `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, - () => - Effect.gen(function* () { - const cwd = yield* makeReviewIndexRepo(); - const driver = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = path.join(cwd, "tracked.txt"); - const indexPath = path.join(cwd, ".git", "index"); - // Reproduce a same-timestamp edit without relying on filesystem clock resolution. - yield* runGit(cwd, ["config", "core.trustctime", "false"]); - yield* writeReviewIndexFile(cwd, "tracked.txt", "before\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* runGit(cwd, ["add", "tracked.txt"]); - yield* runGit(cwd, ["commit", "-m", "record racy file"]); - if (splitIndex) yield* runGit(cwd, ["update-index", "--split-index"]); - yield* fileSystem.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fileSystem.readFile(indexPath); - const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; - yield* writeReviewIndexFile(cwd, "tracked.txt", "after!\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* writeReviewIndexFile(cwd, "untracked.txt", "new\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd, sourceKind: "working-tree" }); - const dirty = preview.sources.find((source) => source.kind === "working-tree")!; - assert.deepStrictEqual(dirty.files, [ - { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, - { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, - ]); - assert.include(dirty.diff, "-before"); - assert.include(dirty.diff, "+after!"); - assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); - assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} - -it.effect("GitVcsDriver bypasses textconv filters in review previews", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-textconv-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* runGit(repo, ["config", "diff.review.textconv", "false"]); - yield* fileSystem.writeFileString(path.join(repo, ".gitattributes"), "*.txt diff=review\n"); - yield* fileSystem.writeFileString(path.join(repo, "tracked.txt"), "base\n"); - yield* runGit(repo, ["add", ".gitattributes", "tracked.txt"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* fileSystem.writeFileString(path.join(repo, "tracked.txt"), "changed\n"); - - const preview = yield* driver.getReviewDiffPreview({ - cwd: repo, - sourceKind: "working-tree", - }); - - assert.include(preview.sources[0]?.diff ?? "", "+changed"); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver expands branch files from the merge base", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-merge-base-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "common base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["switch", "-c", "feature/context"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "feature contents\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Feature"]); - yield* runGit(repo, ["switch", "main"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "advanced main\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Advance main"]); - yield* runGit(repo, ["switch", "feature/context"]); - - const contents = yield* driver.getReviewDiffFileContents({ - cwd: repo, - sourceKind: "branch-range", - changeType: "change", - baseRef: "main", - headRef: "feature/context", - oldPath: "README.md", - newPath: "README.md", - }); - - assert.deepStrictEqual(contents, { - oldContents: "common base\n", - newContents: "feature contents\n", - }); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect("GitVcsDriver contains working-tree expansion and rejects binary files", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-safe-diff-" }); - const repo = path.join(fixtureRoot, "repo"); - const outsideFile = path.join(fixtureRoot, "secret.txt"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* fileSystem.writeFileString(outsideFile, "secret\n"); - yield* fileSystem.symlink(outsideFile, path.join(repo, "linked.txt")); - - const escaped = yield* driver - .getReviewDiffFileContents({ - cwd: repo, - sourceKind: "working-tree", - changeType: "new", - baseRef: "HEAD", - headRef: null, - oldPath: "linked.txt", - newPath: "linked.txt", - }) - .pipe(Effect.flip); - assert.strictEqual(escaped._tag, "GitCommandError"); - assert.include(escaped.message, "escapes the workspace"); - - const missing = yield* driver - .getReviewDiffFileContents({ - cwd: repo, - sourceKind: "working-tree", - changeType: "new", - baseRef: "HEAD", - headRef: null, - oldPath: "missing.txt", - newPath: "missing.txt", - }) - .pipe(Effect.flip); - assert.strictEqual(missing._tag, "GitCommandError"); - assert.include(missing.message, "Could not resolve diff file 'missing.txt'"); - - yield* fileSystem.writeFile(path.join(repo, "binary.dat"), Uint8Array.from([0, 1, 2])); - const binary = yield* driver - .getReviewDiffFileContents({ - cwd: repo, - sourceKind: "working-tree", - changeType: "new", - baseRef: "HEAD", - headRef: null, - oldPath: "binary.dat", - newPath: "binary.dat", - }) - .pipe(Effect.flip); - assert.strictEqual(binary._tag, "GitCommandError"); - assert.include(binary.message, "Cannot expand binary file 'binary.dat'"); + } }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver lists local and remote refs with upstream metadata", () => +// Coder: top-level sourceKind avoids work and exposes only that review source. +it.effect("review preview computes only the requested top-level source", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-refs-" }); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - yield* runGit(repo, ["remote", "add", "origin", repo]); - yield* runGit(repo, ["update-ref", "refs/remotes/origin/main", "HEAD"]); - yield* runGit(repo, ["update-ref", "refs/remotes/origin/release", "HEAD"]); - yield* runGit(repo, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); - yield* runGit(repo, ["switch", "-c", "feature/refs"]); - - const local = yield* driver.listRefs({ - cwd: repo, - refKind: "local", - includeMatchingRemoteRefs: true, + const cwd = yield* makeSeamRepo(); + yield* fs.writeFileString(path.join(cwd, "untracked.txt"), "new\n"); + const working = yield* driver.getReviewDiffPreview({ + cwd, + sourceKind: "working-tree", + baseRef: "missing-ref", }); - assert.strictEqual( - local.refs.every((ref) => ref.isRemote !== true), - true, + assert.deepStrictEqual( + working.sources.map((source) => source.kind), + ["working-tree"], ); - assert.strictEqual(local.refs.find((ref) => ref.name === "feature/refs")?.current, true); - - const remote = yield* driver.listRefs({ - cwd: repo, - refKind: "remote", - includeMatchingRemoteRefs: true, + assert.include(working.sources[0]!.diff, "untracked.txt"); + const branch = yield* driver.getReviewDiffPreview({ + cwd, + sourceKind: "branch-range", + baseRef: "HEAD", }); - assert.strictEqual(remote.hasPrimaryRemote, true); - assert.deepInclude( - remote.refs.find((ref) => ref.name === "origin/main"), - { - name: "origin/main", - isRemote: true, - remoteName: "origin", - isDefault: true, - }, - ); - assert.strictEqual( - remote.refs.some((ref) => ref.name === "origin/release"), - true, - ); - - const deduplicated = yield* driver.listRefs({ cwd: repo }); - assert.strictEqual( - deduplicated.refs.some((ref) => ref.name === "origin/main"), - false, - ); - assert.strictEqual( - deduplicated.refs.some((ref) => ref.name === "origin/release"), - true, + assert.deepStrictEqual( + branch.sources.map((source) => source.kind), + ["branch-range"], ); + assert.notInclude(branch.sources[0]!.diff, "untracked.txt"); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver checks out the requested commit and records the worktree base", () => +// Coder: unsafe or invalid checkout t3.json falls back through the bounded metadata reader. +it.effect("createWorktree rejects symlinked, invalid and oversized t3.json metadata", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-git-worktree-base-", - }); - const repo = path.join(fixtureRoot, "repo"); - const worktreePath = path.join(fixtureRoot, "worktree"); - yield* fileSystem.makeDirectory(repo); - yield* runGit(repo, ["init", "--initial-branch=main"]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "base\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Initial"]); - const requestedCommit = yield* readGit(repo, ["rev-parse", "HEAD"]); - yield* fileSystem.writeFileString(path.join(repo, "README.md"), "advanced\n"); - yield* runGit(repo, ["add", "README.md"]); - yield* runGit(repo, ["commit", "-m", "Advance"]); - yield* runGit(repo, ["remote", "add", "origin", repo]); - yield* runGit(repo, ["update-ref", "refs/remotes/origin/main", "HEAD"]); - - yield* driver.createWorktree({ - cwd: repo, - path: worktreePath, - refName: requestedCommit, - newRefName: "feature/from-base", - baseRefName: "origin/main", - }); - - assert.strictEqual(yield* readGit(worktreePath, ["rev-parse", "HEAD"]), requestedCommit); - assert.strictEqual( - yield* readGit(worktreePath, ["config", "--get", "branch.feature/from-base.gh-merge-base"]), - "main", - ); - yield* driver.removeWorktree({ cwd: repo, path: worktreePath, force: true }); + const cwd = yield* makeSeamRepo(); + yield* fs.writeFileString(path.join(cwd, ".gitmodules"), ""); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "submodule metadata"]); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-config-seam-" }); + const outside = path.join(root, "outside.json"); + yield* fs.writeFileString(outside, '{"worktreeSubmodules":"none"}'); + for (const mode of ["valid", "invalid", "oversized", "symlink"] as const) { + let started = false; + yield* driver.createWorktree( + { + cwd, + refName: "main", + newRefName: `feature/${mode}`, + path: path.join(root, mode), + }, + { + progress: { + onWorktreeClaimed: (worktreePath) => { + const config = path.join(worktreePath, "t3.json"); + return ( + mode === "symlink" + ? fs.symlink(outside, config) + : fs.writeFileString( + config, + mode === "invalid" + ? "{" + : mode === "oversized" + ? " ".repeat(64 * 1024) + '{"worktreeSubmodules":"none"}' + : '{"worktreeSubmodules":"none"}', + ) + ).pipe(Effect.orDie); + }, + onSubmodulesStarted: () => + Effect.sync(() => { + started = true; + }), + }, + }, + ); + assert.equal(started, mode !== "valid"); + } }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -it.effect("GitVcsDriver switchRef resolves remote-tracking refs to local branches", () => +// Coder: checkpoints keep the fork's workspace-local identity. +it.effect("checkpoint commits use the T3 Coder author and committer identity", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const fixtureRoot = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-git-switch-ref-", - }); - const upstream = path.join(fixtureRoot, "upstream"); - const repo = path.join(fixtureRoot, "repo"); - yield* fileSystem.makeDirectory(upstream); - yield* fileSystem.makeDirectory(repo); - yield* runGit(upstream, ["init", "--initial-branch=main"]); - yield* runGit(upstream, ["config", "user.email", "test@test.com"]); - yield* runGit(upstream, ["config", "user.name", "Test"]); - yield* fileSystem.writeFileString(path.join(upstream, "README.md"), "base\n"); - yield* runGit(upstream, ["add", "README.md"]); - yield* runGit(upstream, ["commit", "-m", "Initial"]); - yield* runGit(upstream, ["branch", "feature/only-remote"]); - yield* runGit(upstream, ["branch", "feature/existing-local"]); - yield* runGit(upstream, ["branch", "feature/twin"]); - yield* runGit(upstream, ["branch", "feature/untracked"]); - yield* runGit(repo, ["clone", upstream, "."]); - yield* runGit(repo, ["config", "user.email", "test@test.com"]); - yield* runGit(repo, ["config", "user.name", "Test"]); - yield* runGit(repo, ["branch", "feature/existing-local", "origin/feature/existing-local"]); - yield* runGit(repo, ["branch", "twin-alias", "origin/feature/twin"]); - yield* runGit(repo, ["branch", "feature/untracked", "origin/feature/untracked"]); - yield* runGit(repo, ["branch", "--unset-upstream", "feature/untracked"]); - - // Remote-only ref: creates and checks out a local branch tracking it. - const created = yield* driver.switchRef({ cwd: repo, refName: "origin/feature/only-remote" }); - assert.strictEqual(created.refName, "feature/only-remote"); - assert.strictEqual( - yield* readGit(repo, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]), - "origin/feature/only-remote", - ); - - // Remote ref tracked by a differently-named local branch: checks that out. - const alias = yield* driver.switchRef({ cwd: repo, refName: "origin/feature/twin" }); - assert.strictEqual(alias.refName, "twin-alias"); - - // Local twin exists but does not track the remote ref: upstream checks out - // the remote ref directly (detached HEAD, so no branch name comes back). - const untracked = yield* driver.switchRef({ cwd: repo, refName: "origin/feature/untracked" }); - assert.strictEqual(untracked.refName, null); - - // Remote ref tracked by a same-named local branch: checks that out. - const existing = yield* driver.switchRef({ - cwd: repo, - refName: "origin/feature/existing-local", + const cwd = yield* makeSeamRepo(); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/identity"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ + operation: "test.checkpointIdentity", + cwd, + args: ["show", "-s", "--format=%an <%ae>%n%cn <%ce>", checkpointRef], }); - assert.strictEqual(existing.refName, "feature/existing-local"); assert.strictEqual( - yield* readGit(repo, ["symbolic-ref", "--short", "HEAD"]), - "feature/existing-local", + result.stdout.trim(), + "T3 Coder \nT3 Coder ", ); - - // Plain local ref: unchanged behavior. - const local = yield* driver.switchRef({ cwd: repo, refName: "main" }); - assert.strictEqual(local.refName, "main"); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); - -it.effect("Git status pauses for a linked worktree index lock and resumes after removal", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-index-lock-" }); - const repo = path.join(root, "repo"); - const worktree = path.join(root, "worktree"); - yield* fs.makeDirectory(repo); - yield* runGit(repo, ["init"]); - yield* runGit(repo, [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "-m", - "initial", - ]); - yield* runGit(repo, ["worktree", "add", "-b", "test-lock", worktree]); - const index = yield* readGit(worktree, ["rev-parse", "--git-path", "index"]); - const lock = `${path.resolve(worktree, index)}.lock`; - yield* fs.writeFileString(lock, ""); - const failure = yield* Effect.flip(driver.statusDetailsLocal(worktree)); - assert.instanceOf(failure, GitCommandError); - assert.include(failure.message, "Git index is locked"); - // The main checkout has a different index and must remain usable. - assert.isTrue((yield* driver.statusDetailsLocal(repo)).isRepo); - yield* fs.remove(lock); - assert.isTrue((yield* driver.statusDetailsLocal(worktree)).isRepo); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), -); - -it.effect( - "review recognizes renames and pathspec-like names without changing the real split index", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-coder-review-rename-" }); - yield* runGit(cwd, ["init"]); - yield* runGit(cwd, ["config", "user.name", "Test"]); - yield* runGit(cwd, ["config", "user.email", "test@example.test"]); - yield* fs.writeFileString(path.join(cwd, "before.ts"), "one\ntwo\nthree\nfour\nfive\n"); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "Initial"]); - yield* runGit(cwd, ["config", "core.splitIndex", "true"]); - yield* runGit(cwd, ["config", "splitIndex.sharedIndexExpire", "now"]); - yield* runGit(cwd, ["update-index", "--split-index"]); - const before = yield* fs.readFile(path.join(cwd, ".git", "index")); - const sharedBefore = (yield* fs.readDirectory(path.join(cwd, ".git"))) - .filter((name) => name.startsWith("sharedindex.")) - .sort(); - yield* fs.rename(path.join(cwd, "before.ts"), path.join(cwd, "after.ts")); - yield* fs.writeFileString(path.join(cwd, "after.ts"), "one\ntwo\nTHREE\nfour\nfive\n"); - yield* fs.writeFileString(path.join(cwd, ":(exclude)literal.ts"), "literal file\n"); - const result = yield* driver.getReviewDiffPreview({ cwd, sourceKind: "working-tree" }); - const diff = result.sources[0]?.diff ?? ""; - assert.include(diff, "rename from before.ts"); - assert.include(diff, "rename to after.ts"); - assert.include(diff, "+THREE"); - assert.include(diff, "+literal file"); - assert.isAtLeast(result.sources[0]?.files?.length ?? 0, 2); - const selected = yield* driver.getReviewDiffPreview({ - cwd, - file: { path: "after.ts", previousPath: "before.ts", sourceKind: "working-tree" }, - }); - assert.include(selected.sources[0]?.diff ?? "", "rename from before.ts"); - assert.notInclude(selected.sources[0]?.diff ?? "", "literal file"); - const literal = yield* driver.getReviewDiffPreview({ - cwd, - file: { path: ":(exclude)literal.ts", previousPath: null, sourceKind: "working-tree" }, - }); - assert.include(literal.sources[0]?.diff ?? "", "+literal file"); - assert.notInclude(literal.sources[0]?.diff ?? "", "+THREE"); - const invalid = yield* driver - .getReviewDiffPreview({ - cwd, - file: { path: "../outside", previousPath: null, sourceKind: "working-tree" }, - }) - .pipe(Effect.flip); - assert.include(invalid.message, "Invalid diff path"); - - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), before); - assert.deepEqual( - (yield* fs.readDirectory(path.join(cwd, ".git"))) - .filter((name) => name.startsWith("sharedindex.")) - .sort(), - sharedBefore, - ); - }).pipe(Effect.provide(GitContractLayer)), -); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 8909b4201dfd1..d0e22dba8daaf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,53 +1,17 @@ -const REVIEW_METADATA_MAX_OUTPUT_BYTES = 512 * 1024; -const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; -function isUnbornHeadStderr(stderr: string): boolean { - const normalized = stderr.toLowerCase(); - return ( - normalized.includes("bad revision 'head'") || - (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) - ); -} - -function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { - const fields = stdout.split("\0"); - const files: ReviewDiffFileStat[] = []; - for (let index = 0; index < fields.length; index++) { - const field = fields[index]!; - const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field); - if (!match) continue; - const previousPath = match[3] === "" ? fields[++index]! : null; - const path = previousPath !== null ? fields[++index]! : match[3]!; - files.push({ - path, - previousPath, - additions: match[1] === "-" ? 0 : Number(match[1]), - deletions: match[2] === "-" ? 0 : Number(match[2]), - }); - } - return files; -} - -import type { ReviewDiffFileStat, ReviewDiffPreviewSource } from "@t3tools/contracts"; import * as NodeCrypto from "node:crypto"; import * as NodeBuffer from "node:buffer"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, - MAX_REVIEW_DIFF_FILE_BYTES, - ReviewDiffFileTooLargeError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -59,39 +23,25 @@ import { type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, + // Coder: chunked review expansion retains its typed size error. type ReviewDiffFileError, + type VcsRefStatusResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, - type VcsRef, - type VcsRefStatusResult, + type VcsPullResult, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; import { - dedupeRemoteBranchesWithLocalMatches, - deriveLocalBranchNameFromRemoteRef, -} from "@t3tools/shared/git"; + makeGitVcsDriverCore, + PATCH_RENDER_PREFIX_ARGS, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; -import * as ServerConfig from "../config.ts"; -import { readProjectConfig } from "../project/configMetadata.ts"; -import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; - -const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); - -/** The checkout's own t3.json through the bounded, symlink-checked metadata reader. */ -const readWorktreeProjectFile = (worktreePath: string) => - Effect.sync(() => readProjectConfig(worktreePath)).pipe( - Effect.tap((result) => - result.status === "invalid" - ? Effect.logWarning("t3.json is invalid; initializing submodules recursively") - : Effect.void, - ), - Effect.map((result) => result.file), - ); export interface ExecuteGitInput { readonly operation: string; @@ -118,14 +68,40 @@ export interface ExecuteGitResult { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + // Coder: review expansion rejects malformed UTF-8 from Git blobs. + readonly stdoutInvalidUtf8?: boolean; } export interface GitStatusDetails { isRepo: boolean; + sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; + hasOriginRemote: boolean; isDefaultBranch: boolean; branch: string | null; + upstreamRef: string | null; hasWorkingTreeChanges: boolean; workingTree: VcsStatusResult["workingTree"]; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; + aheadOfDefaultCount: number; +} + +export interface GitRemoteStatusDetails { + isRepo: boolean; + defaultBranch: string | null; + isDefaultBranch: boolean; + branch: string | null; + upstreamRef: string | null; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; + aheadOfDefaultCount: number; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; } export interface ExecuteGitProgress { @@ -139,6 +115,11 @@ export interface ExecuteGitProgress { }) => Effect.Effect; } +/** + * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)` + * to stderr during checkout, and `Submodule path 'x': checked out` during + * submodule init. The tracker uses these to drive the worktree setup card. + */ export interface CreateWorktreeProgress { /** * Fires once `git worktree add` has created and registered the directory, @@ -174,26 +155,58 @@ export interface CreateWorktreeOptions { readonly submodules?: WorktreeSubmodules | null; } -export interface GitRenameBranchInput { - cwd: string; - oldBranch: string; - newBranch: string; +export interface GitCommitProgress { + readonly onOutputLine?: (input: { + stream: "stdout" | "stderr"; + text: string; + }) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; } -export interface GitRenameBranchResult { +export interface GitCommitOptions { + readonly timeoutMs?: number; + readonly progress?: GitCommitProgress; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; } +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +// Coder: thread branch renames also move their workspace-owned worktree. export interface GitMoveWorktreeInput { cwd: string; oldPath: string; newPath: string; } -export interface GitPushResult { - readonly branch: string; - readonly upstreamBranch: string | null; - readonly setUpstream: boolean; +export interface GitRenameBranchInput { + cwd: string; + oldBranch: string; + newBranch: string; +} + +export interface GitRenameBranchResult { + branch: string; +} + +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; } export interface GitFetchPullRequestHeadCommitInput { @@ -286,18 +299,33 @@ export class GitVcsDriver extends Context.Service< GitVcsDriver, { readonly execute: (input: ExecuteGitInput) => Effect.Effect; - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; readonly statusDetails: (cwd: string) => Effect.Effect; - readonly ensureRemote: (input: { - readonly cwd: string; - readonly preferredName: string; - readonly url: string; - }) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly statusDetailsRemote: ( + cwd: string, + options?: GitRemoteStatusOptions, + ) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; readonly pushCurrentBranch: ( cwd: string, - branch: string | null, - options?: { readonly remoteName?: string; readonly progress?: ExecuteGitProgress }, + fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + // Coder: ref-only polling must never refresh or rewrite the real index. readonly refStatusLocal: (cwd: string) => Effect.Effect; readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, @@ -305,27 +333,55 @@ export class GitVcsDriver extends Context.Service< readonly getReviewDiffFileContents: ( input: ReviewDiffFileContentsInput, ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; - readonly resolveCommit: (input: { - cwd: string; - revision: string; - }) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + /** Fetches `refs/pull//head` without writing a branch, for heads that exist nowhere else. */ + readonly fetchPullRequestHeadCommit: ( + input: GitFetchPullRequestHeadCommitInput, + ) => Effect.Effect; + readonly resolveCommit: ( + input: GitResolveCommitInput, + ) => Effect.Effect; + /** Moves the branch checked out in `cwd` onto `targetCommit`, from inside that worktree. */ + readonly refreshCheckedOutBranch: ( + input: GitRefreshCheckedOutBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly resolveDefaultBranchName: ( cwd: string, remoteName: string, ) => Effect.Effect; - readonly fetchRemoteTrackingBranch: (input: { - cwd: string; - remoteName: string; - remoteBranch: string; - }) => Effect.Effect; + readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; + readonly remoteBranchExists: ( + input: GitRemoteBranchExistsInput, + ) => Effect.Effect; + readonly resolveRemoteTrackingCommit: ( + input: GitResolveRemoteTrackingCommitInput, + ) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; @@ -333,10 +389,11 @@ export class GitVcsDriver extends Context.Service< readonly pruneWorktrees: (input: { readonly cwd: string; }) => Effect.Effect; + // Coder: thread branch renames also move their worktree. + readonly moveWorktree: (input: GitMoveWorktreeInput) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; - readonly moveWorktree: (input: GitMoveWorktreeInput) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -344,6 +401,7 @@ export class GitVcsDriver extends Context.Service< input: VcsSwitchRefInput, ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } >()("t3/vcs/GitVcsDriver") {} @@ -352,13 +410,6 @@ const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; -const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; -const REVIEW_UNTRACKED_PATHS_MAX_OUTPUT_BYTES = 120_000; -const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; -// Rendered patches are parsed using Git's conventional a/ and b/ path prefixes. -// Override repository or global prefix settings so paths remain parseable. -const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; -const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", "core.fsmonitor=false", @@ -366,128 +417,6 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "core.untrackedCache=false", ] as const; -interface Trace2Monitor { - readonly env: NodeJS.ProcessEnv; - readonly flush: Effect.Effect; -} - -interface Trace2TailState { - readonly processedChars: number; - readonly remainder: string; -} - -function trace2ChildKey(record: Record): string | null { - const childId = record.child_id; - if (typeof childId === "number" || typeof childId === "string") return String(childId); - const hookName = record.hook_name; - return typeof hookName === "string" && hookName.trim() ? hookName.trim() : null; -} - -const createTrace2Monitor = Effect.fn("GitVcsDriver.createTrace2Monitor")(function* ( - fileSystem: FileSystem.FileSystem, - path: Path.Path, - progress: ExecuteGitProgress | undefined, -) { - if (!progress?.onHookStarted && !progress?.onHookFinished) { - return { env: {}, flush: Effect.void } satisfies Trace2Monitor; - } - - const traceFilePath = yield* fileSystem.makeTempFileScoped({ - prefix: `t3-coder-git-trace2-${process.pid}-`, - suffix: ".json", - }); - const starts = new Map(); - const tail = yield* Ref.make({ processedChars: 0, remainder: "" }); - - const handleLine = Effect.fn("GitVcsDriver.handleTrace2Line")(function* (line: string) { - let record: Record; - try { - const parsed: unknown = JSON.parse(line); - if (typeof parsed !== "object" || parsed === null) return; - record = parsed as Record; - } catch { - return; - } - const childKey = trace2ChildKey(record); - if (childKey === null) return; - const started = starts.get(childKey); - if (record.child_class !== "hook" && started === undefined) return; - const eventHookName = typeof record.hook_name === "string" ? record.hook_name.trim() : ""; - const hookName = eventHookName || started?.hookName || ""; - if (!hookName) return; - - if (record.event === "child_start") { - starts.set(childKey, { hookName, startedAtMs: Date.now() }); - if (progress.onHookStarted) yield* progress.onHookStarted(hookName); - } else if (record.event === "child_exit") { - starts.delete(childKey); - const rawCode = record.code ?? record.exitCode; - const exitCode = typeof rawCode === "number" && Number.isInteger(rawCode) ? rawCode : null; - const rawDuration = record.t_rel; - const durationMs = - typeof rawDuration === "number" - ? Math.max(0, Math.round(rawDuration * 1_000)) - : started - ? Math.max(0, Date.now() - started.startedAtMs) - : null; - if (progress.onHookFinished) { - yield* progress.onHookFinished({ - hookName: started?.hookName ?? hookName, - exitCode, - durationMs, - }); - } - } - }); - - const mutex = yield* Semaphore.make(1); - const readDelta = mutex.withPermit( - fileSystem.readFileString(traceFilePath).pipe( - Effect.flatMap((contents) => - Ref.modify(tail, (state) => { - if (contents.length <= state.processedChars) return [[] as string[], state] as const; - const lines = `${state.remainder}${contents.slice(state.processedChars)}`.split("\n"); - const remainder = lines.pop() ?? ""; - return [ - lines.map((item) => item.replace(/\r$/, "")), - { processedChars: contents.length, remainder }, - ] as const; - }).pipe(Effect.flatMap((lines) => Effect.forEach(lines, handleLine, { discard: true }))), - ), - Effect.ignore, - ), - ); - const traceFileName = path.basename(traceFilePath); - yield* Stream.runForEach(fileSystem.watch(traceFilePath), (event) => { - const eventPath = event.path; - return eventPath === traceFilePath || path.basename(eventPath) === traceFileName - ? readDelta - : Effect.void; - }).pipe(Effect.ignore, Effect.forkScoped); - - const flush = Effect.gen(function* () { - yield* readDelta; - const remainder = yield* Ref.modify( - tail, - (state) => [state.remainder.trim(), { ...state, remainder: "" }] as const, - ); - if (remainder) yield* handleLine(remainder); - }).pipe(Effect.ignore); - yield* Effect.addFinalizer(() => flush); - return { env: { GIT_TRACE2_EVENT: traceFilePath }, flush } satisfies Trace2Monitor; -}); - -// Matches `git worktree remove` on a path git no longer tracks: "is not a -// working tree" when the registration is gone, "cannot remove working tree" -// when older Git versions fail validation on a registered-but-deleted directory. -function isMissingWorktreeStderr(stderr: string): boolean { - const normalized = stderr.toLowerCase(); - return ( - normalized.includes("is not a working tree") || - normalized.includes("cannot remove working tree") - ); -} - const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { const now = yield* DateTime.now; return { @@ -497,171 +426,6 @@ const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { }; }); -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); - let currentPath: string | null = null; - let currentBranch: string | null = null; - let currentPrunable = false; - - const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); - } - currentPath = null; - currentBranch = null; - currentPrunable = false; - }; - - for (const field of stdout.split("\0")) { - if (field === "") { - flush(); - } else if (field.startsWith("worktree ")) { - currentPath = field.slice("worktree ".length); - } else if (field.startsWith("branch refs/heads/")) { - currentBranch = field.slice("branch refs/heads/".length); - } else if (field === "prunable" || field.startsWith("prunable ")) { - currentPrunable = true; - } - } - flush(); - - return worktreePaths; -} - -function parseRemoteNames(stdout: string): ReadonlyArray { - return stdout - .split("\n") - .map((name) => name.trim()) - .filter(Boolean) - .toSorted((left, right) => right.length - left.length); -} - -function parseRemoteRef( - ref: string, - remoteNames: ReadonlyArray, -): { remoteName: string; branchName: string } | null { - for (const remoteName of remoteNames) { - const prefix = `${remoteName}/`; - if (!ref.startsWith(prefix)) continue; - const branchName = ref.slice(prefix.length); - return branchName.length > 0 ? { remoteName, branchName } : null; - } - return null; -} - -// Ported from upstream GitVcsDriverCore. -function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { - for (const line of stdout.split("\n")) { - const trimmedLine = line.trim(); - if (trimmedLine.length === 0) { - continue; - } - const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); - const branchName = branchNameRaw?.trim() ?? ""; - const candidateUpstreamRef = upstreamBranchRaw.trim(); - if (branchName.length === 0 || candidateUpstreamRef.length === 0) { - continue; - } - if (candidateUpstreamRef === upstreamRef) { - return branchName; - } - } - - return null; -} - -function parseGitRemoteVerboseOutput( - output: string, -): Map { - const remotes = new Map(); - for (const line of output.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/u.exec(line.trim()); - if (!match) continue; - const [, name, url, direction] = match; - if (!name || !url) continue; - const remote = remotes.get(name) ?? {}; - if (direction === "fetch") remote.url = url; - else remote.pushUrl = url; - remotes.set(name, remote); - } - return remotes; -} - -function parsePorcelainV2Paths(stdout: string): ReadonlyArray { - const pathAfterFields = (field: string, fieldCount: number): string | null => { - let separatorIndex = -1; - for (let count = 0; count < fieldCount; count += 1) { - separatorIndex = field.indexOf(" ", separatorIndex + 1); - if (separatorIndex < 0) return null; - } - const filePath = field.slice(separatorIndex + 1); - return filePath || null; - }; - const fields = stdout.split("\0"); - const paths: string[] = []; - for (let index = 0; index < fields.length; index += 1) { - const field = fields[index] ?? ""; - if (field.startsWith("? ") || field.startsWith("! ")) { - const filePath = field.slice(2); - if (filePath) paths.push(filePath); - continue; - } - const filePath = field.startsWith("1 ") - ? pathAfterFields(field, 8) - : field.startsWith("2 ") - ? pathAfterFields(field, 9) - : field.startsWith("u ") - ? pathAfterFields(field, 10) - : null; - if (filePath) paths.push(filePath); - // Rename/copy records carry the original path as the next NUL field. The - // working-tree UI should report the destination path only. - if (field.startsWith("2 ")) index += 1; - } - return paths; -} - -function parseNullSeparatedNumstat( - stdout: string, -): ReadonlyArray<{ path: string; insertions: number; deletions: number }> { - const fields = stdout.split("\0"); - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (let index = 0; index < fields.length; index += 1) { - const field = fields[index] ?? ""; - if (!field) continue; - const firstTab = field.indexOf("\t"); - const secondTab = firstTab < 0 ? -1 : field.indexOf("\t", firstTab + 1); - if (firstTab < 0 || secondTab < 0) continue; - const added = field.slice(0, firstTab); - const deleted = field.slice(firstTab + 1, secondTab); - let filePath = field.slice(secondTab + 1); - if (!filePath) { - // With `--numstat -z`, renames are encoded as a header followed by the - // original and destination path. Keep the destination. - index += 2; - filePath = fields[index] ?? ""; - } - if (!filePath) continue; - entries.push({ - path: filePath, - insertions: added === "-" ? 0 : Number.parseInt(added, 10) || 0, - deletions: deleted === "-" ? 0 : Number.parseInt(deleted, 10) || 0, - }); - } - return entries; -} - function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { const chunks: string[][] = []; let chunk: string[] = []; @@ -692,6 +456,38 @@ function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): stri return chunks; } +function parseGitRemoteVerboseOutput( + output: string, +): Map { + const remotes = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) { + continue; + } + + const name = match[1]; + const url = match[2]; + const direction = match[3]; + if (!name || !url || !direction) { + continue; + } + const remote = remotes.get(name) ?? {}; + if (direction === "fetch") { + remote.url = url; + } else { + remote.pushUrl = url; + } + remotes.set(name, remote); + } + return remotes; +} + const gitCommand = ( process: VcsProcess.VcsProcess["Service"], operation: string, @@ -705,8 +501,6 @@ const gitCommand = ( readonly maxOutputBytes?: number; readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; readonly appendTruncationMarker?: boolean; - readonly onStdoutLine?: (line: string) => Effect.Effect; - readonly onStderrLine?: (line: string) => Effect.Effect; }, ) => process.run({ @@ -726,8 +520,6 @@ const gitCommand = ( ...(options?.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } : {}), - ...(options?.onStdoutLine ? { onStdoutLine: options.onStdoutLine } : {}), - ...(options?.onStderrLine ? { onStderrLine: options.onStderrLine } : {}), }); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { @@ -739,6 +531,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( supportsWorktrees: true, supportsBookmarks: false, supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, ignoreClassifier: "native" as const, }; @@ -819,7 +612,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ? Effect.gen(function* () { const freshness = yield* nowFreshness(); return { - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + paths: splitNullSeparatedGitStdoutPaths(result), truncated: result.stdoutTruncated, freshness, }; @@ -843,8 +636,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], - { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 64 * 1024 }, + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }, ); + if (result.exitCode !== 0) { return yield* new VcsProcessExitError({ operation: "GitVcsDriver.listRemotes", @@ -854,19 +652,26 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( detail: result.stderr.trim() || "git remote -v failed", }); } - const remotes = [...parseGitRemoteVerboseOutput(result.stdout)].flatMap(([name, remote]) => - remote.url - ? [ - { - name, - url: remote.url, - pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), - isPrimary: name === "origin", - }, - ] - : [], - ); - return { remotes, freshness: yield* nowFreshness() }; + + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); + + return { + remotes, + freshness: yield* nowFreshness(), + }; }, ); @@ -905,7 +710,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) { ignoredPaths.add(ignoredPath); } } @@ -1003,6 +808,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const commitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_INDEX_FILE: tempIndexPath, + // Coder: workspace checkpoints use the fork's local commit identity. GIT_AUTHOR_NAME: "T3 Coder", GIT_AUTHOR_EMAIL: "t3-coder@localhost", GIT_COMMITTER_NAME: "T3 Coder", @@ -1043,8 +849,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim()); if (Option.isNone(mtime)) return false; - // Node stat truncates to milliseconds; preserve the source second for Git's racy check. - const indexTime = Math.floor(mtime.value.getTime() / 1000); + // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. + const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); // Retain stat data only where the copied index already matches HEAD. @@ -1173,10 +979,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, }); if (untracked.stdoutTruncated) return yield* error; - const candidates = splitNullSeparatedPaths( - untracked.stdout, - untracked.stdoutTruncated, - ).filter((entry) => entry.endsWith("/")); + const candidates = splitNullSeparatedGitStdoutPaths(untracked).filter((entry) => + entry.endsWith("/"), + ); // Refuse excessive recovery work before probing any nested repositories. if (candidates.length > CHECKPOINT_RECOVERY_MAX_CANDIDATES) return yield* error; // Discover each child's repository instead of inheriting the server's Git bindings. @@ -1438,1257 +1243,8 @@ export const makeVcsDriver = Effect.gen(function* () { return VcsDriver.VcsDriver.of(driver); }); -const makeLocalGitService = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const vcsProcess = yield* VcsProcess.VcsProcess; - const { worktreesDir } = yield* ServerConfig.ServerConfig; - - const run = ( - operation: string, - cwd: string, - args: ReadonlyArray, - options: { - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - readonly appendTruncationMarker?: boolean; - readonly progress?: ExecuteGitProgress; - } = {}, - ) => - Effect.scoped( - Effect.gen(function* () { - const monitor = yield* createTrace2Monitor(fileSystem, path, options.progress); - return yield* gitCommand(vcsProcess, operation, cwd, args, { - ...options, - ...(Object.keys(monitor.env).length > 0 - ? { env: { ...options.env, ...monitor.env } } - : {}), - ...(options.progress?.onStdoutLine - ? { onStdoutLine: options.progress.onStdoutLine } - : {}), - ...(options.progress?.onStderrLine - ? { onStderrLine: options.progress.onStderrLine } - : {}), - }).pipe(Effect.ensuring(monitor.flush)); - }), - ).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation, - command: `git ${args[0] ?? ""}`.trim(), - cwd, - detail: cause instanceof Error ? cause.message : "Git command failed.", - cause, - }), - ), - ); - - const execute: GitVcsDriver["Service"]["execute"] = (input) => - run(input.operation, input.cwd, input.args, { - ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), - ...(input.env !== undefined ? { env: input.env } : {}), - ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), - ...(input.timeoutMs != null ? { timeoutMs: input.timeoutMs } : {}), - ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), - ...(input.appendTruncationMarker !== undefined - ? { appendTruncationMarker: input.appendTruncationMarker } - : {}), - ...(input.progress ? { progress: input.progress } : {}), - }); - - const currentBranch = (cwd: string) => - run("GitVcsDriver.currentBranch", cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], { - allowNonZeroExit: true, - }).pipe(Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() || null : null))); - - const localBranchExists = (cwd: string, branch: string) => - run( - "GitVcsDriver.localBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); - - const resolveAutomaticBaseRef = Effect.fn("GitVcsDriver.resolveAutomaticBaseRef")(function* ( - cwd: string, - branch: string, - ) { - const [configuredBaseResult, remotesResult] = yield* Effect.all([ - run( - "GitVcsDriver.resolveAutomaticBaseRef.config", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - { allowNonZeroExit: true }, - ), - run("GitVcsDriver.resolveAutomaticBaseRef.remotes", cwd, ["remote"], { - allowNonZeroExit: true, - }), - ]); - const remotes = remotesResult.stdout - .split("\n") - .map((name) => name.trim()) - .filter(Boolean); - const primaryRemote = remotes.includes("origin") ? "origin" : (remotes[0] ?? null); - const remoteDefaultBranch = primaryRemote - ? yield* run( - "GitVcsDriver.resolveAutomaticBaseRef.remoteDefault", - cwd, - ["symbolic-ref", `refs/remotes/${primaryRemote}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - const prefix = `refs/remotes/${primaryRemote}/`; - const ref = result.stdout.trim(); - return result.exitCode === 0 && ref.startsWith(prefix) - ? ref.slice(prefix.length) - : null; - }), - ) - : null; - const candidates = [ - configuredBaseResult.stdout.trim() || null, - remoteDefaultBranch, - ...DEFAULT_BASE_BRANCH_CANDIDATES, - ]; - - for (const candidate of candidates) { - if (!candidate) continue; - const remotePrefix = primaryRemote ? `${primaryRemote}/` : null; - const normalizedCandidate = candidate.startsWith("origin/") - ? candidate.slice("origin/".length) - : remotePrefix && candidate.startsWith(remotePrefix) - ? candidate.slice(remotePrefix.length) - : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) continue; - - if (primaryRemote) { - const remoteBranch = yield* run( - "GitVcsDriver.resolveAutomaticBaseRef.remoteBranch", - cwd, - [ - "show-ref", - "--verify", - "--quiet", - `refs/remotes/${primaryRemote}/${normalizedCandidate}`, - ], - { allowNonZeroExit: true }, - ); - if (remoteBranch.exitCode === 0) return `${primaryRemote}/${normalizedCandidate}`; - } - if (yield* localBranchExists(cwd, normalizedCandidate)) return normalizedCandidate; - } - - return null; - }); - - const statusDetailsLocal: GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( - "GitVcsDriver.statusDetailsLocal", - )(function* (cwd) { - const inside = yield* run( - "GitVcsDriver.status.inside", - cwd, - ["rev-parse", "--is-inside-work-tree"], - { allowNonZeroExit: true }, - ); - if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") { - return { - isRepo: false, - isDefaultBranch: false, - branch: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }; - } - - const index = yield* run("GitVcsDriver.status.indexPath", cwd, [ - "rev-parse", - "--git-path", - "index", - ]); - const lockPath = `${path.resolve(cwd, index.stdout.trim())}.lock`; - const indexLocked = yield* fileSystem.exists(lockPath).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.status.indexPath", - command: "git", - cwd, - detail: "Failed to check the Git index lock.", - cause, - }), - ), - ); - if (indexLocked) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.status.indexPath", - command: "git", - cwd, - detail: "Git index is locked. Status will resume when the index lock is removed.", - }); - } - const branch = yield* currentBranch(cwd); - const [porcelain, numstat, remotesResult, hasMain, hasMaster] = yield* Effect.all([ - run("GitVcsDriver.status.porcelain", cwd, [ - "status", - "--porcelain=v2", - "-z", - "--untracked-files=all", - ]), - run("GitVcsDriver.status.numstat", cwd, ["diff", "--numstat", "-z", "HEAD", "--"], { - allowNonZeroExit: true, - }), - run("GitVcsDriver.status.remotes", cwd, ["remote"], { allowNonZeroExit: true }), - localBranchExists(cwd, "main"), - localBranchExists(cwd, "master"), - ]); - - const changedPaths = parsePorcelainV2Paths(porcelain.stdout); - const stats = new Map(); - for (const entry of parseNullSeparatedNumstat(numstat.stdout)) { - const existing = stats.get(entry.path) ?? { insertions: 0, deletions: 0 }; - stats.set(entry.path, { - insertions: existing.insertions + entry.insertions, - deletions: existing.deletions + entry.deletions, - }); - } - const remoteNames = remotesResult.exitCode === 0 ? parseRemoteNames(remotesResult.stdout) : []; - const primaryRemote = remoteNames.includes("origin") ? "origin" : (remoteNames[0] ?? null); - const remoteDefaultBranch = primaryRemote - ? yield* run( - "GitVcsDriver.status.remoteDefault", - cwd, - ["symbolic-ref", `refs/remotes/${primaryRemote}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - const prefix = `refs/remotes/${primaryRemote}/`; - const ref = result.stdout.trim(); - return result.exitCode === 0 && ref.startsWith(prefix) - ? ref.slice(prefix.length) - : null; - }), - ) - : null; - const allPaths = [...new Set([...changedPaths, ...stats.keys()])].toSorted(); - const files = allPaths.map((filePath) => ({ - path: filePath, - insertions: stats.get(filePath)?.insertions ?? 0, - deletions: stats.get(filePath)?.deletions ?? 0, - })); - const defaultBranch = remoteDefaultBranch ?? (hasMain ? "main" : hasMaster ? "master" : branch); - return { - isRepo: true, - isDefaultBranch: branch !== null && branch === defaultBranch, - branch, - hasWorkingTreeChanges: files.length > 0, - workingTree: { - files, - insertions: files.reduce((sum, file) => sum + file.insertions, 0), - deletions: files.reduce((sum, file) => sum + file.deletions, 0), - }, - }; - }); - - const runGitStdout = (operation: string, cwd: string, args: ReadonlyArray) => - run(operation, cwd, args).pipe(Effect.map((result) => result.stdout)); - const runGitStdoutWithOptions = ( - operation: string, - cwd: string, - args: ReadonlyArray, - options: { maxOutputBytes: number; allowNonZeroExit?: boolean; env?: NodeJS.ProcessEnv }, - ) => - run(operation, cwd, args, options).pipe( - Effect.flatMap((result) => - result.stdoutTruncated - ? Effect.fail( - new GitCommandError({ - operation, - cwd, - command: "git diff", - detail: "Diff statistics exceed the read limit.", - }), - ) - : Effect.succeed(result.stdout), - ), - ); - - const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* ( - cwd: string, - untrackedPaths: ReadonlyArray, - ) { - const [stagedDeletionsStdout, indexValue] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", - cwd, - ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"], - { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ), - runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [ - "rev-parse", - "--git-path", - "index", - ]), - ], - { concurrency: 2 }, - ); - const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); - const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); - if (pathsToAdd.length === 0) return undefined; - - const indexPath = path.isAbsolute(indexValue.trim()) - ? indexValue.trim() - : path.resolve(cwd, indexValue.trim()); - const tempIndexPath = yield* fileSystem.makeTempFileScoped({ - prefix: `t3code-review-index-${process.pid}-`, - }); - const indexExists = yield* fileSystem.exists(indexPath); - if (indexExists) { - const { mtime } = yield* fileSystem.stat(indexPath); - yield* fileSystem.copyFile(indexPath, tempIndexPath); - // Node stat truncates to milliseconds. Preserve the source second so Git checks - // same-timestamp edits without rereading unchanged files from the preceding second. - const indexTime = Option.isSome(mtime) - ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) - : 0; - yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); - } - const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; - const tempIndexConfig = [ - "-c", - "core.splitIndex=false", - "-c", - "splitIndex.sharedIndexExpire=never", - ]; - if (!indexExists) { - yield* run("GitVcsDriver.review.emptyIndex", cwd, ["read-tree", "--empty"], { env }); - } - yield* run( - "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.expandSplitIndex", - cwd, - [...tempIndexConfig, "update-index", "--no-split-index"], - { env }, - ); - yield* run( - "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.addUntracked", - cwd, - [ - ...tempIndexConfig, - "--literal-pathspecs", - "add", - "--intent-to-add", - "--pathspec-from-file=-", - "--pathspec-file-nul", - ], - { env, stdin: `${pathsToAdd.join("\0")}\0` }, - ); - return env; - }); - - const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( - input: ReviewDiffPreviewInput, - ) { - const pathArgs = input.file - ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map( - (path) => `:(top,literal)${path}`, - ) - : []; - const patchLimit = input.file - ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES - : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; - // Resolve the repository without a status scan, which can rewrite the user's index. - const details = yield* refStatusLocal(input.cwd); - if (!details.isRepo) return { cwd: input.cwd, generatedAt: yield* DateTime.now, sources: [] }; - const cwd = (yield* runGitStdout("GitVcsDriver.review.root", input.cwd, [ - "rev-parse", - "--show-toplevel", - ])).trim(); - const branch = details.refName; - const sourceKind = input.file?.sourceKind ?? input.sourceKind; - const baseRef = - input.baseRef ?? - (branch && sourceKind !== "working-tree" - ? yield* resolveAutomaticBaseRef(cwd, branch) - : null); - for (const requested of input.file - ? [input.file.path, input.file.previousPath].filter( - (value): value is string => value !== null, - ) - : []) { - if ( - path.isAbsolute(requested) || - requested.includes("\0") || - requested.split("/").some((part) => part === ".." || part === "." || !part) - ) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview", - cwd, - command: "git diff", - detail: "Invalid diff path.", - }); - } - } - const diffArgs = [ - "diff", - "--find-renames", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...PATCH_RENDER_PREFIX_ARGS, - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - ]; - const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( - ref: string, - env?: NodeJS.ProcessEnv, - ) { - const args = [...diffArgs, "--numstat", "-z"]; - const result = yield* run( - "GitVcsDriver.getReviewDiffPreview.stat", - cwd, - [...args, ref, "--", ...pathArgs], - { - allowNonZeroExit: true, - maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, - ...(env ? { env } : {}), - }, - ); - if (result.exitCode === 0 && !result.stdoutTruncated) - return { ref, files: parseReviewNumstat(result.stdout) }; - if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { - const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [ - "hash-object", - "-t", - "tree", - "/dev/null", - ])).trim(); - const stdout = yield* runGitStdoutWithOptions( - "GitVcsDriver.getReviewDiffPreview.unbornStat", - cwd, - [...args, emptyTree, "--", ...pathArgs], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, ...(env ? { env } : {}) }, - ); - return { ref: emptyTree, files: parseReviewNumstat(stdout) }; - } - return yield* new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview.stat", - cwd, - command: "git diff --numstat", - detail: "Could not read complete diff statistics.", - exitCode: result.exitCode, - }); - }); - const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( - ref: string | null, - env?: NodeJS.ProcessEnv, - ) { - if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] }; - const stat = yield* readStats(ref, env); - if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] }; - const patch = yield* run( - "GitVcsDriver.getReviewDiffPreview.patch", - cwd, - [...diffArgs, "--patch", stat.ref, "--", ...pathArgs], - { maxOutputBytes: patchLimit, appendTruncationMarker: true, ...(env ? { env } : {}) }, - ); - return { ...patch, files: stat.files }; - }); - const readDirty = Effect.gen(function* () { - if (sourceKind === "branch-range") return yield* readTrackedDiff(null); - const untracked = yield* run( - "GitVcsDriver.review.listUntracked", - cwd, - ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ); - if (untracked.stdoutTruncated) { - const tracked = yield* readTrackedDiff("HEAD"); - return { ...tracked, files: undefined, stdoutTruncated: true }; - } - const paths = splitNullSeparatedPaths(untracked.stdout, untracked.stdoutTruncated).filter( - (candidate) => !input.file || candidate === input.file.path, - ); - if (paths.length === 0) return yield* readTrackedDiff("HEAD"); - const env = yield* prepareReviewIndex(cwd, paths).pipe( - Effect.catchTags({ - PlatformError: (cause) => - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.prepareReviewIndex", - cwd, - command: "git diff", - detail: "Could not prepare the review index.", - cause, - }), - ), - }), - ); - return yield* readTrackedDiff("HEAD", env); - }).pipe(Effect.scoped); - const [dirtyTrackedResult, baseResult] = yield* Effect.all( - [ - readDirty, - readTrackedDiff( - baseRef && branch && sourceKind !== "working-tree" ? `${baseRef}...HEAD` : null, - ), - ], - { concurrency: 2 }, - ); - const dirtyFiles = dirtyTrackedResult.files; - const baseFiles = baseResult.files; - const dirtyDiff = dirtyTrackedResult.stdout; - const baseDiff = baseResult.stdout; - const hashDiff = (diff: string, files: ReadonlyArray) => - NodeCrypto.createHash("sha256") - .update(JSON.stringify([diff, files])) - .digest("hex"); - const dirtyDiffHash = hashDiff(dirtyDiff, dirtyFiles ?? []); - const baseDiffHash = hashDiff(baseDiff, baseFiles); - - const sources: ReviewDiffPreviewSource[] = [ - { - id: "working-tree", - kind: "working-tree", - title: "Dirty worktree", - baseRef: "HEAD", - headRef: null, - diff: dirtyDiff, - ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }), - diffHash: dirtyDiffHash, - truncated: dirtyTrackedResult.stdoutTruncated, - }, - { - id: "branch-range", - kind: "branch-range", - title: baseRef ? `Against ${baseRef}` : "Against base branch", - baseRef, - headRef: branch ?? "HEAD", - diff: baseDiff, - files: baseFiles, - diffHash: baseDiffHash, - truncated: baseResult.stdoutTruncated, - }, - ]; - - return { - cwd: input.cwd, - generatedAt: yield* DateTime.now, - sources: sourceKind ? sources.filter((source) => source.kind === sourceKind) : sources, - }; - }); - - const readRevision = Effect.fn("GitVcsDriver.readRevision")(function* ( - cwd: string, - revision: string, - filePath: string, - ) { - const result = yield* run( - "GitVcsDriver.readRevision", - cwd, - ["show", `${revision}:${filePath}`], - { - maxOutputBytes: MAX_REVIEW_DIFF_FILE_BYTES, - }, - ); - if (result.stdoutTruncated) { - return yield* new ReviewDiffFileTooLargeError({ - path: filePath, - maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, - }); - } - if (result.stdoutInvalidUtf8 || result.stdout.includes("\0")) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.readRevision", - command: "git show", - cwd, - detail: `Cannot expand binary file '${filePath}'.`, - }); - } - return result.stdout; - }); - - const isPathWithinRoot = (root: string, candidate: string) => { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); - }; - - const readWorkspaceFile = ( - cwd: string, - repositoryRoot: string, - filePath: string, - ): Effect.Effect => { - const candidate = path.resolve(repositoryRoot, filePath); - if (!isPathWithinRoot(repositoryRoot, candidate)) { - return Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile", - command: "read workspace file", - cwd, - detail: "Diff path escapes the workspace.", - }), - ); - } - return Effect.gen(function* () { - const [realRepositoryRoot, realCandidate] = yield* Effect.all([ - fileSystem.realPath(repositoryRoot), - fileSystem.realPath(candidate), - ]).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.realPath", - command: "fs.realPath", - cwd, - detail: `Could not resolve diff file '${filePath}'.`, - cause, - }), - ), - ); - if (!isPathWithinRoot(realRepositoryRoot, realCandidate)) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.realPath", - command: "fs.realPath", - cwd, - detail: "Diff path escapes the workspace.", - }); - } - const stat = yield* fileSystem.stat(realCandidate).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.stat", - command: "fs.stat", - cwd, - detail: `Could not inspect diff file '${filePath}'.`, - cause, - }), - ), - ); - if (stat.type !== "File" || stat.size > MAX_REVIEW_DIFF_FILE_BYTES) { - if (stat.type !== "File") { - return yield* new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile", - command: "read workspace file", - cwd, - detail: "Diff path is not a regular file.", - }); - } - return yield* new ReviewDiffFileTooLargeError({ - path: filePath, - maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, - }); - } - const bytes = yield* fileSystem.readFile(realCandidate).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.readFile", - command: "fs.readFile", - cwd, - detail: `Could not read diff file '${filePath}'.`, - cause, - }), - ), - ); - if (bytes.includes(0)) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.readFile", - command: "fs.readFile", - cwd, - detail: `Cannot expand binary file '${filePath}'.`, - }); - } - return yield* Effect.try({ - try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), - catch: (cause) => - new GitCommandError({ - operation: "GitVcsDriver.readWorkspaceFile.readFile", - command: "fs.readFile", - cwd, - detail: `Cannot expand binary file '${filePath}'.`, - cause, - }), - }); - }); - }; - - const getReviewDiffFileContents: GitVcsDriver["Service"]["getReviewDiffFileContents"] = Effect.fn( - "GitVcsDriver.getReviewDiffFileContents", - )(function* (input) { - if (input.sourceKind === "working-tree") { - const repositoryRoot = yield* run( - "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", - input.cwd, - ["rev-parse", "--show-toplevel"], - ).pipe(Effect.map((result) => result.stdout.trim())); - if (repositoryRoot.length === 0) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", - command: "git rev-parse", - cwd: input.cwd, - detail: "Could not resolve the Git repository root.", - }); - } - return { - oldContents: - input.changeType === "new" - ? "" - : yield* readRevision(input.cwd, input.baseRef ?? "HEAD", input.oldPath), - newContents: - input.changeType === "deleted" - ? "" - : yield* readWorkspaceFile(input.cwd, repositoryRoot, input.newPath), - }; - } - if (!input.baseRef || !input.headRef) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffFileContents", - command: "git merge-base", - cwd: input.cwd, - detail: "Branch diff file expansion requires both base and head refs.", - }); - } - const mergeBase = yield* run("GitVcsDriver.getReviewDiffFileContents.mergeBase", input.cwd, [ - "merge-base", - input.baseRef, - input.headRef, - ]).pipe(Effect.map((result) => result.stdout.trim())); - if (mergeBase.length === 0) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffFileContents.mergeBase", - command: "git merge-base", - cwd: input.cwd, - detail: "Could not resolve the branch comparison base.", - }); - } - return { - oldContents: - input.changeType === "new" ? "" : yield* readRevision(input.cwd, mergeBase, input.oldPath), - newContents: - input.changeType === "deleted" - ? "" - : yield* readRevision(input.cwd, input.headRef, input.newPath), - }; - }); - - const listRefs: GitVcsDriver["Service"]["listRefs"] = Effect.fn("GitVcsDriver.listRefs")( - function* (input) { - const inside = yield* run( - "GitVcsDriver.listRefs.inside", - input.cwd, - ["rev-parse", "--is-inside-work-tree"], - { allowNonZeroExit: true }, - ); - if (inside.exitCode !== 0) { - return { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - const [branches, worktrees, activeBranch, defaultRef, remotesResult] = yield* Effect.all([ - run("GitVcsDriver.listRefs.branches", input.cwd, [ - "for-each-ref", - "--format=%(refname)%09%(committerdate:unix)%09%(symref)", - "refs/heads", - "refs/remotes", - ]), - run("GitVcsDriver.listRefs.worktrees", input.cwd, [ - "worktree", - "list", - "--porcelain", - "-z", - ]), - currentBranch(input.cwd), - run( - "GitVcsDriver.listRefs.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ), - run("GitVcsDriver.listRefs.remotes", input.cwd, ["remote"], { - allowNonZeroExit: true, - }), - ]); - const parsedWorktrees = [...parseWorktreeBranchPaths(worktrees.stdout)].map( - ([branch, worktreePath]) => [branch, path.normalize(path.resolve(worktreePath))] as const, - ); - const existingWorktrees = yield* Effect.filter( - parsedWorktrees, - ([, worktreePath]) => - fileSystem.stat(worktreePath).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ), - { concurrency: 16 }, - ); - const worktreeByBranch = new Map(existingWorktrees); - const remoteNames = - remotesResult.exitCode === 0 ? parseRemoteNames(remotesResult.stdout) : []; - const defaultBranch = - defaultRef.exitCode === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - const query = input.query?.toLocaleLowerCase() ?? ""; - const refsWithDates: Array<{ timestamp: number; ref: VcsRef }> = []; - for (const line of branches.stdout.split("\n")) { - if (line.length === 0) continue; - const [fullName = "", timestamp = "0", symbolicTarget = ""] = line.split("\t"); - if (symbolicTarget) continue; - if (fullName.startsWith("refs/heads/")) { - const name = fullName.slice("refs/heads/".length); - refsWithDates.push({ - timestamp: Number.parseInt(timestamp, 10) || 0, - ref: { - name, - current: name === activeBranch, - isRemote: false, - isDefault: name === defaultBranch, - worktreePath: worktreeByBranch.get(name) ?? null, - }, - }); - continue; - } - if (!fullName.startsWith("refs/remotes/")) continue; - const name = fullName.slice("refs/remotes/".length); - const remoteRef = parseRemoteRef(name, remoteNames); - refsWithDates.push({ - timestamp: Number.parseInt(timestamp, 10) || 0, - ref: { - name, - current: false, - isRemote: true, - isDefault: - defaultBranch !== null && - remoteRef?.remoteName === "origin" && - remoteRef.branchName === defaultBranch, - worktreePath: null, - ...(remoteRef ? { remoteName: remoteRef.remoteName } : {}), - }, - }); - } - const sortedRefsWithDates = refsWithDates.toSorted( - (left, right) => - right.timestamp - left.timestamp || left.ref.name.localeCompare(right.ref.name), - ); - const allRefs = input.includeMatchingRemoteRefs - ? sortedRefsWithDates.map(({ ref }) => ref) - : dedupeRemoteBranchesWithLocalMatches(sortedRefsWithDates.map(({ ref }) => ref)); - const prioritizedRefs = allRefs.toSorted((left, right) => { - const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; - const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; - return leftPriority - rightPriority; - }); - const refsForKind = - input.refKind === "local" - ? prioritizedRefs.filter((ref) => !ref.isRemote) - : input.refKind === "remote" - ? prioritizedRefs.filter((ref) => ref.isRemote) - : prioritizedRefs; - const matchingRefs = refsForKind.filter( - (ref) => query.length === 0 || ref.name.toLocaleLowerCase().includes(query), - ); - const offset = input.cursor ?? 0; - const limit = input.limit ?? 100; - const refs = matchingRefs.slice(offset, offset + limit); - const nextOffset = offset + refs.length; - return { - refs, - isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), - nextCursor: nextOffset < matchingRefs.length ? nextOffset : null, - totalCount: matchingRefs.length, - }; - }, - ); - - const refStatusLocal: GitVcsDriver["Service"]["refStatusLocal"] = Effect.fn( - "GitVcsDriver.refStatusLocal", - )(function* (cwd) { - const inside = yield* run( - "GitVcsDriver.refStatus.inside", - cwd, - ["rev-parse", "--is-inside-work-tree"], - { allowNonZeroExit: true }, - ); - if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") { - return { isRepo: false, refName: null }; - } - return { isRepo: true, refName: yield* currentBranch(cwd) }; - }); - - const createWorktree: GitVcsDriver["Service"]["createWorktree"] = Effect.fn( - "GitVcsDriver.createWorktree", - )(function* (input, options) { - const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const targetPath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = ["worktree", "add"]; - if (input.newRefName) args.push("-b", input.newRefName); - args.push(targetPath, input.refName); - yield* run("GitVcsDriver.createWorktree", input.cwd, args, { - timeoutMs: 300_000, - env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, - progress: { - onStderrLine: (line) => { - const match = /Updating files:\s*(\d+)%\s*\((\d+)\/(\d+)\)/.exec(line); - return match && options?.progress?.onCheckoutProgress - ? options.progress.onCheckoutProgress({ - percent: Math.max(0, Math.min(100, Number(match[1]))), - completed: Number(match[2]), - total: Number(match[3]), - }) - : Effect.void; - }, - }, - }); - yield* options?.progress?.onWorktreeClaimed?.(targetPath) ?? Effect.void; - const hasSubmodules = yield* fileSystem - .exists(path.join(targetPath, ".gitmodules")) - .pipe(Effect.orElseSucceed(() => false)); - // Repos with hundreds of nested submodules opt out or stop at the top - // level; the caller resolves that from settings, or the checkout's t3.json - // decides. - const submoduleMode = !hasSubmodules - ? { value: "none" as const, source: "environment" as const } - : resolveProjectFileBackedSetting( - "worktreeSubmodules", - options?.submodules ?? null, - options?.submodules != null ? null : yield* readWorktreeProjectFile(targetPath), - ); - if (hasSubmodules && submoduleMode.value === "none") { - yield* options?.progress?.onSubmodulesDisabled?.({ - source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", - }) ?? Effect.void; - } else if (hasSubmodules) { - yield* options?.progress?.onSubmodulesStarted?.() ?? Effect.void; - // Populate already-cached or local submodules without allowing Git to - // open a non-loopback connection outside the Coder CLI boundary. - const submodules = yield* run( - "GitVcsDriver.createWorktree.updateSubmodules", - targetPath, - [ - "-c", - "protocol.allow=never", - "-c", - "protocol.file.allow=always", - "submodule", - "update", - "--init", - ...(submoduleMode.value === "recursive" ? ["--recursive"] : []), - "--no-fetch", - ], - { - progress: { - onStdoutLine: (line) => options?.progress?.onSubmoduleLine?.(line) ?? Effect.void, - onStderrLine: (line) => options?.progress?.onSubmoduleLine?.(line) ?? Effect.void, - }, - }, - ).pipe( - Effect.catch(() => - Effect.logWarning( - "Worktree submodule checkout failed; the worktree was created with empty submodule paths.", - ), - ), - ); - yield* ( - options?.progress?.onSubmodulesFinished?.({ - ok: submodules !== undefined, - detail: submodules ? null : "Some submodules are unavailable in the workspace cache.", - }) ?? Effect.void - ); - } else if (!hasSubmodules) { - yield* ( - options?.progress?.onSubmodulesFinished?.({ ok: true, detail: "no submodules" }) ?? - Effect.void - ); - } - if (input.newRefName && input.baseRefName) { - const remotes = yield* run("GitVcsDriver.createWorktree.remotes", input.cwd, ["remote"], { - allowNonZeroExit: true, - }); - const parsedBaseRef = parseRemoteRef(input.baseRefName, parseRemoteNames(remotes.stdout)); - yield* run("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ - "config", - `branch.${input.newRefName}.gh-merge-base`, - parsedBaseRef?.branchName ?? input.baseRefName, - ]); - } - return { worktree: { path: targetPath, refName: input.newRefName ?? input.refName } }; - }); - - const pruneWorktrees: GitVcsDriver["Service"]["pruneWorktrees"] = (input) => - run("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { - timeoutMs: 15_000, - }).pipe(Effect.asVoid); - - const resolveCommit: GitVcsDriver["Service"]["resolveCommit"] = (input) => - runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [ - "rev-parse", - "--verify", - `${input.revision}^{commit}`, - ]).pipe(Effect.map((stdout) => ({ commitSha: stdout.trim() }))); - const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { - const remotes = (yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"])) - .split("\n") - .map((name) => name.trim()) - .filter(Boolean); - if (remotes.includes("origin")) return "origin"; - if (remotes[0]) return remotes[0]; - return yield* new GitCommandError({ - operation: "GitVcsDriver.resolvePrimaryRemoteName", - cwd, - command: "git remote", - detail: "No git remote is configured for this repository.", - }); - }); - const resolveDefaultBranchName: GitVcsDriver["Service"]["resolveDefaultBranchName"] = ( - cwd, - remoteName, - ) => - run( - "GitVcsDriver.resolveDefaultBranchName", - cwd, - ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - const prefix = `refs/remotes/${remoteName}/`; - const ref = result.stdout.trim(); - return result.exitCode === 0 && ref.startsWith(prefix) ? ref.slice(prefix.length) : null; - }), - ); - const fetchRemoteTrackingBranch: GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = (input) => - run("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]).pipe(Effect.asVoid); - - const removeWorktree: GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( - "GitVcsDriver.removeWorktree", - )(function* (input) { - const args = ["worktree", "remove", ...(input.force ? ["--force"] : []), input.path]; - const result = yield* run("GitVcsDriver.removeWorktree", input.cwd, args, { - // Dependency-heavy worktrees can take minutes to remove. Keep the - // operation bounded without interrupting git midway through cleanup. - timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, - allowNonZeroExit: true, - }); - if (result.exitCode === 0) { - return; - } - // Threads can share a worktree path, and worktrees get removed or pruned - // outside the app, so an already-gone worktree is a no-op. Prune any stale - // registration so a later `worktree add` can reuse the path. - const alreadyGone = - isMissingWorktreeStderr(result.stderr) && - !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); - if (alreadyGone) { - yield* pruneWorktrees({ cwd: input.cwd }); - return; - } - // Raw stderr stays out of both the wire error and logs because it can carry - // secrets; retain only bounded diagnostics for genuine failures. - yield* Effect.logWarning( - `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, - ); - return yield* new GitCommandError({ - operation: "GitVcsDriver.removeWorktree", - command: "git worktree", - cwd: input.cwd, - argumentCount: args.length, - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - detail: "git worktree remove failed", - }); - }); - - const switchRef: GitVcsDriver["Service"]["switchRef"] = Effect.fn("GitVcsDriver.switchRef")( - function* (input) { - // Ported from upstream GitVcsDriverCore.switchRef: the picked ref may be - // a local branch or a remote-tracking branch such as "origin/feature", - // which git cannot check out directly. Resolve the checkout target the - // same way upstream does: prefer the local branch of the same name, then - // a local branch that already tracks the remote ref, then a fresh - // tracking branch created from the remote ref. - const [localInputExists, remoteExists] = yield* Effect.all( - [ - localBranchExists(input.cwd, input.refName), - run( - "GitVcsDriver.switchRef.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - ], - { concurrency: "unbounded" }, - ); - - const localTrackingBranch = remoteExists - ? yield* run( - "GitVcsDriver.switchRef.localTrackingBranch", - input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => - result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) - : null, - ), - ) - : null; - - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate - ? yield* localBranchExists(input.cwd, localTrackedBranchCandidate) - : false; - - const checkoutArgs = localInputExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; - - yield* run("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 300_000, - }); - return { refName: yield* currentBranch(input.cwd) }; - }, - ); - - const ensureRemote: GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( - "GitVcsDriver.ensureRemote", - )(function* (input) { - const remotes = yield* run("GitVcsDriver.ensureRemote.list", input.cwd, ["remote"]); - const names = new Set(parseRemoteNames(remotes.stdout)); - let candidate = input.preferredName; - let suffix = 2; - while (names.has(candidate)) { - const existing = yield* run( - "GitVcsDriver.ensureRemote.getUrl", - input.cwd, - ["remote", "get-url", candidate], - { allowNonZeroExit: true }, - ); - if (existing.exitCode === 0 && existing.stdout.trim() === input.url) { - return candidate; - } - candidate = `${input.preferredName}-${suffix}`; - suffix += 1; - } - yield* run("GitVcsDriver.ensureRemote.add", input.cwd, ["remote", "add", candidate, input.url]); - return candidate; - }); - - const pushCurrentBranch: GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( - "GitVcsDriver.pushCurrentBranch", - )(function* (cwd, requestedBranch, options) { - const branch = requestedBranch ?? (yield* currentBranch(cwd)); - if (branch === null) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.pushCurrentBranch", - command: "git push", - cwd, - detail: "A checked-out branch is required before pushing.", - }); - } - const upstream = yield* run( - "GitVcsDriver.pushCurrentBranch.upstream", - cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - { allowNonZeroExit: true }, - ); - const upstreamBranch = upstream.exitCode === 0 ? upstream.stdout.trim() || null : null; - const remoteName = options?.remoteName ?? "origin"; - const targetUpstream = `${remoteName}/${branch}`; - const setUpstream = - upstreamBranch === null || - (options?.remoteName !== undefined && upstreamBranch !== targetUpstream); - const pushArgs = setUpstream - ? ["push", "--set-upstream", remoteName, branch] - : options?.remoteName !== undefined - ? ["push", remoteName, branch] - : ["push"]; - yield* run("GitVcsDriver.pushCurrentBranch.push", cwd, pushArgs, { - timeoutMs: 300_000, - ...(options?.progress ? { progress: options.progress } : {}), - }); - return { - branch, - upstreamBranch: setUpstream ? targetUpstream : upstreamBranch, - setUpstream, - }; - }); - - return GitVcsDriver.of({ - execute, - statusDetailsLocal, - statusDetails: statusDetailsLocal, - ensureRemote, - pushCurrentBranch, - refStatusLocal, - getReviewDiffPreview, - getReviewDiffFileContents, - listRefs, - createWorktree, - resolveCommit, - resolvePrimaryRemoteName, - resolveDefaultBranchName, - fetchRemoteTrackingBranch, - removeWorktree, - pruneWorktrees, - renameBranch: (input) => - input.oldBranch === input.newBranch - ? Effect.succeed({ branch: input.newBranch }) - : run("GitVcsDriver.renameBranch", input.cwd, [ - "branch", - "-m", - "--", - input.oldBranch, - input.newBranch, - ]).pipe(Effect.as({ branch: input.newBranch })), - moveWorktree: (input) => - input.oldPath === input.newPath - ? Effect.void - : run( - "GitVcsDriver.moveWorktree", - input.cwd, - ["worktree", "move", input.oldPath, input.newPath], - { timeoutMs: 300_000 }, - ).pipe(Effect.asVoid), - createRef: Effect.fn("GitVcsDriver.createRef")(function* (input) { - yield* run("GitVcsDriver.createRef", input.cwd, ["branch", input.refName]); - if (input.switchRef) yield* switchRef(input); - return { refName: input.refName }; - }), - switchRef, - initRepo: (input) => run("GitVcsDriver.initRepo", input.cwd, ["init"]).pipe(Effect.asVoid), - }); -}); - -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const git = yield* makeLocalGitService; + const git = yield* makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts new file mode 100644 index 0000000000000..2890f26ebddf2 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -0,0 +1,3259 @@ +// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. +import * as NodeFS from "node:fs"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { assert, it, describe } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + ReviewDiffPreviewInput, + type ReviewDiffFileContentsInput, + type WorktreeSubmodules, +} from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import { + makeGitVcsDriverCore, + parseGitCheckoutProgressLine, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeNonRepositoryHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("fatal: not a git repository")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeSuccessfulHandle = (stdout: string) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + cwd: string, + relativePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +const makeReviewDiffFileContentsInput = ( + cwd: string, + overrides: Partial> = {}, +): ReviewDiffFileContentsInput => ({ + cwd, + sourceKind: "working-tree", + changeType: "change", + baseRef: "HEAD", + headRef: null, + oldPath: "README.md", + newPath: "README.md", + ...overrides, +}); + +const git = ( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + { readonly initialBranch: string }, + GitCommandError | PlatformError.PlatformError, + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; + }); + +it.effect("bounds Git bursts across drivers without timing out queued commands", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const starts = yield* Queue.unbounded(); + let active = 0; + let peak = 0; + const spawner = ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.gen(function* () { + peak = Math.max(peak, ++active); + yield* Queue.offer(starts, active); + return ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle("ok"), + exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + () => Effect.sync(() => active--), + ), + ); + const drivers = yield* Effect.all( + Array.from({ length: 16 }, () => + makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + const burst = yield* Effect.forEach( + drivers, + (driver, index) => + driver.execute({ + operation: "test.gitBurst", + cwd: "/repo", + args: ["rev-parse", "HEAD"], + ...(index < 4 ? {} : { timeoutMs: index < 8 ? 30_000 : 1_000 }), + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + yield* TestClock.adjust("2 seconds"); + assert.equal(yield* Queue.size(starts), 8); + assert.equal(peak, 8); + yield* Deferred.succeed(gate, undefined); + const results = yield* Fiber.join(burst); + assert.equal(results.length, 16); + assert.isTrue(results.every((result) => result.stdout === "ok" && result.exitCode === 0)); + assert.equal(peak, 8); + assert.equal(active, 0); + // Coder: metrics are stripped; retain all concurrency and completion assertions. + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( + "keeps all Git slots available with a pending command whose timeout is $timeoutMs", + ({ timeoutMs }) => + Effect.gen(function* () { + const slowGate = yield* Deferred.make(); + const fastGate = yield* Deferred.make(); + const starts = yield* Queue.unbounded(); + let active = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.acquireRelease( + Effect.gen(function* () { + active++; + yield* Queue.offer(starts, undefined); + const gate = + ChildProcess.isStandardCommand(command) && command.args[0] === "push" + ? slowGate + : fastGate; + return ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle("ok"), + exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + () => Effect.sync(() => active--), + ), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const slow = yield* driver + .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs }) + .pipe(Effect.forkChild); + yield* Queue.take(starts); + const burst = yield* Effect.all( + Array.from({ length: 8 }, () => + driver.execute({ operation: "test.fastGit", cwd: "/repo", args: ["status"] }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + yield* TestClock.adjust("0 seconds"); + assert.equal(yield* Queue.size(starts), 8); + assert.equal(active, 9); + yield* Deferred.succeed(fastGate, undefined); + assert.equal((yield* Fiber.join(burst)).length, 8); + assert.equal(active, 1); + yield* Deferred.succeed(slowGate, undefined); + assert.equal((yield* Fiber.join(slow)).stdout, "ok"); + assert.equal(active, 0); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +for (const location of ["root", "nested", "worktree"] as const) { + it.effect( + `skips clean filters while the ${location} index is locked and resumes after unlock`, + () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repository = yield* makeTmpDir(); + yield* initRepoWithCommit(repository); + const cwd = location === "worktree" ? yield* makeTmpDir() : repository; + if (location === "worktree") { + yield* git(repository, ["worktree", "add", "--detach", cwd]); + } + yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); + yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); + yield* writeTextFile(cwd, "asset.bin", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); + const runsPath = path.join(cwd, ".filter-runs"); + yield* fs.remove(runsPath, { force: true }); + const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); + const lockPath = `${path.resolve(cwd, indexPath)}.lock`; + yield* fs.writeFileString(lockPath, ""); + const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; + yield* fs.makeDirectory(statusCwd, { recursive: true }); + + for (let poll = 0; poll < 3; poll++) { + const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); + } + assert.isFalse(yield* fs.exists(runsPath)); + assert.isTrue(yield* fs.exists(lockPath)); + + yield* fs.remove(lockPath); + const status = yield* driver.statusDetailsLocal(statusCwd); + assert.isFalse(status.hasWorkingTreeChanges); + assert.include(yield* fs.readFileString(runsPath), "clean"); + }).pipe(Effect.provide(TestLayer)), + ); +} + +it.effect("uses stable diagnostics for every parsed non-repository command", () => { + const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + commands.push({ + args: command.args, + ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), + }); + return makeNonRepositoryHandle(); + }), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = "/repo"; + + yield* driver.statusDetailsLocal(cwd); + yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + yield* driver.listRefs({ cwd }); + + assert.deepStrictEqual(commands, [ + { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, + { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("invalidates origin remote cache when a driver mutation adds origin", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + const before = yield* driver.statusDetailsLocal(cwd); + assert.equal(before.hasOriginRemote, false); + + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); + + const after = yield* driver.statusDetailsLocal(cwd); + assert.equal(after.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + // First call caches hasOriginRemote = false (5-min TTL) + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); + + // Add origin via raw git (bypasses invalidation hook) + yield* git(cwd, ["remote", "add", "origin", remote]); + + // Cache still has the stale false (TTL not yet expired) + const stillCached = yield* driver.statusDetailsLocal(cwd); + assert.equal(stillCached.hasOriginRemote, false); + + // Advance past the 5-minute TTL so the cache entry expires + yield* TestClock.adjust("6 minutes"); + + // After expiry, the next call re-executes and picks up the remote + const afterExpiry = yield* driver.statusDetailsLocal(cwd); + assert.equal(afterExpiry.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("coalesces concurrent ref pages into one repository snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs = yield* Ref.make>>([]); + const firstWorktreeScanStarted = yield* Deferred.make(); + const remoteNamesScanCompleted = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const countingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + const shouldDelay = + isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); + if (shouldDelay) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Effect.sleep("8 seconds"); + } + const handle = yield* delegate.spawn(command); + const isRemoteNamesScan = + command.args.length === 3 && + command.args[0] === "--git-dir" && + command.args[2] === "remote"; + return isRemoteNamesScan + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), + ); + const cwd = yield* makeTmpDir(); + const runGit = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.coalescedListRefs", + cwd, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(["config", "user.email", "test@test.com"]); + yield* runGit(["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(["add", "."]); + yield* runGit(["commit", "-m", "initial commit"]); + yield* Ref.set(spawnedArgs, []); + + const initialRequest = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(remoteNamesScanCompleted); + yield* TestClock.adjust("6 seconds"); + const laterRequests = yield* Effect.all( + Array.from({ length: 30 }, (_, index) => + driver.listRefs({ + cwd, + refresh: true, + query: `missing-${index}`, + limit: 100, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(initialRequest); + yield* Fiber.join(laterRequests); + yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); + + const firstSnapshotCommands = yield* Ref.get(spawnedArgs); + const snapshotRefScans = firstSnapshotCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ); + const worktreeScans = firstSnapshotCommands.filter( + (args) => args.includes("worktree") && args.includes("--porcelain"), + ); + assert.equal(snapshotRefScans.length, 1); + assert.equal(worktreeScans.length, 1); + + yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); + const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); + assert.equal( + refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), + true, + ); + const allCommands = yield* Ref.get(spawnedArgs); + assert.equal( + allCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ).length, + 2, + ); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const firstWorktreeScanStarted = yield* Deferred.make(); + const firstRefScanCompleted = yield* Deferred.make(); + const releaseFirstWorktreeScan = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const refScans = yield* Ref.make(0); + const coordinatingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false))) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Deferred.await(releaseFirstWorktreeScan); + } + const handle = yield* delegate.spawn(command); + const isRefScan = + command.args.includes("for-each-ref") && + command.args.includes("refs/heads") && + command.args.includes("refs/remotes"); + if (!isRefScan) return handle; + const scan = yield* Ref.updateAndGet(refScans, (count) => count + 1); + return scan === 1 + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(firstRefScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const inFlight = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(firstRefScanCompleted); + + yield* driver.createRef({ cwd, refName: "feature/during-refresh" }); + yield* Deferred.succeed(releaseFirstWorktreeScan, undefined); + + const refs = yield* Fiber.join(inFlight); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); + assert.equal(yield* Ref.get(refScans), 2); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const partiallyFailingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") { + const handle = yield* delegate.spawn(command); + yield* handle.exitCode; + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* driver.listRefs({ cwd, refresh: true }); + + yield* driver.createRef({ cwd, refName: "feature/partial-failure" }).pipe(Effect.flip); + + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const snapshotAttempts = yield* Ref.make(0); + const failingSnapshotSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("for-each-ref")) { + yield* Ref.update(snapshotAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listRefs.snapshotRefs", + detail: "Git ref snapshot enumeration failed.", + exitCode: 128, + }); + assert.equal(yield* Ref.get(snapshotAttempts), 1); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("marks the current branch when worktree metadata is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeRoot = + command.args.includes("rev-parse") && command.args.includes("--show-toplevel"); + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeRoot || isWorktreeList) { + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.isTrue(refs.isRepo); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("ignores worktree metadata for directories that no longer exist", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const missingWorktreePath = "/missing/deleted-worktree"; + const staleWorktreeSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeList) { + return makeSuccessfulHandle( + `worktree ${missingWorktreePath}\0HEAD deadbeef\0branch refs/heads/stale-worktree\0\0`, + ); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* git(cwd, ["branch", "stale-worktree"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("refreshes the current branch after an external checkout", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "external-checkout"]); + + const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); + + // Raw execute intentionally bypasses the driver's mutation invalidation, + // matching a checkout performed by another process. + yield* driver.execute({ + operation: "GitVcsDriver.test.externalCheckout", + cwd, + args: ["checkout", "external-checkout"], + timeoutMs: 10_000, + }); + yield* TestClock.adjust("6 seconds"); + + const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); + assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("backs off and logs failed fetch attempts across linked worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchAttempts = yield* Ref.make(0); + const failingFetchSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("fetch") && command.args.includes("--quiet")) { + yield* Ref.update(fetchAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), + ); + const warnings: string[] = []; + const logger = Logger.make(({ message }) => { + warnings.push(String(message)); + }); + const readRemoteStatus = (workingDirectory: string) => + driver + .statusDetailsRemote(workingDirectory) + .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked"); + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.upstreamRefreshBackoff", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); + yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* runGit(worktreePath, [ + "branch", + "--set-upstream-to", + `origin/${initialBranch}`, + "feature/linked", + ]); + const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); + const linkedCommonDir = (yield* runGit(worktreePath, [ + "rev-parse", + "--git-common-dir", + ])).stdout.trim(); + // Native realpath, since git reports the long form of a directory the + // temp dir may name by its 8.3 short form on Windows. + assert.equal( + NodeFS.realpathSync.native(pathService.resolve(cwd, rootCommonDir)), + NodeFS.realpathSync.native(pathService.resolve(worktreePath, linkedCommonDir)), + ); + yield* Ref.set(fetchAttempts, 0); + + yield* readRemoteStatus(cwd); + yield* readRemoteStatus(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + assert.lengthOf(warnings, 1); + assert.include(warnings[0], "Background Git fetch failed"); + + yield* TestClock.adjust("29 seconds"); + yield* readRemoteStatus(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + assert.lengthOf(warnings, 1); + + yield* TestClock.adjust("1 second"); + yield* readRemoteStatus(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 2); + assert.lengthOf(warnings, 2); + + yield* TestClock.adjust("59 seconds"); + yield* readRemoteStatus(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 2); + assert.lengthOf(warnings, 2); + + yield* TestClock.adjust("1 second"); + yield* readRemoteStatus(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 3); + assert.lengthOf(warnings, 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +for (const scenario of [ + { + name: "HTTPS credentials", + stderr: "fatal: Authentication failed for", + expected: "could not authenticate", + }, + { + name: "SSH credentials", + stderr: "git@example.com: Permission denied (publickey).", + expected: "could not authenticate", + }, + { + name: "disabled prompts", + stderr: "fatal: could not read Username: terminal prompts disabled", + expected: "could not authenticate", + }, + { + name: "DNS failure", + stderr: "fatal: Could not resolve host: example.com", + expected: "could not reach the remote", + }, + { + name: "connection failure", + stderr: "ssh: connect to host example.com port 22: Connection refused", + expected: "could not reach the remote", + }, + { + name: "missing remote", + stderr: "remote: Repository not found.", + expected: "could not access the remote repository", + }, + { + name: "invalid remote", + stderr: "fatal: remote does not appear to be a git repository", + expected: "could not access the remote repository", + }, + { + name: "reference lock", + stderr: "error: cannot lock ref 'refs/remotes/origin/main': is at abc but expected def", + expected: "could not update a local reference", + }, + { + name: "lock file", + stderr: "fatal: Unable to create '/repo/.git/FETCH_HEAD.lock': File exists.", + expected: "could not update a local reference", + }, + { + name: "unrelated remote chatter", + stderr: + "remote: Help: authentication failed, connection refused, cannot lock ref\nremote: unrelated service error", + expected: "git fetch origin failed", + }, + { + name: "HTTPS DNS failure", + stderr: + "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com", + expected: "could not reach the remote", + }, + { + name: "unknown failure", + stderr: "fatal: unexpected remote failure", + expected: "git fetch origin failed", + }, +] as const) { + it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => + Effect.gen(function* () { + const secret = "secret-fetch-token"; + const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; + const attempts = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); + assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); + assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); + yield* Ref.update(attempts, (count) => count + 1); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(secret)), + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, scenario.expected); + assert.equal(error.exitCode, 128); + assert.equal(error.stderrLength, stderr.length); + assert.equal(error.stdoutLength, secret.length); + assert.notInclude(error.message, secret); + assert.notInclude(yield* encodeGitCommandError(error), secret); + assert.notProperty(error, "stderr"); + assert.notProperty(error, "args"); + assert.equal(yield* Ref.get(attempts), 1); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +} + +it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("process environment", () => { + it.effect("preserves the caller locale for general Git subprocesses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + + const locale = yield* git( + cwd, + ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], + { LC_ALL: "zh_CN.UTF-8" }, + ); + + assert.equal(locale, "zh_CN.UTF-8"); + }), + ); + }); + + describe("structured errors", () => { + it.effect("preserves structured spawn context and the platform cause", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.missingCwd", + cwd, + args: ["status", "--short"], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.missingCwd", + command: "git", + argumentCount: 2, + cwd, + detail: "Failed to spawn Git process.", + }); + if (!(error.cause instanceof PlatformError.PlatformError)) { + return assert.fail("expected the original platform error cause"); + } + assert.equal(error.cause.reason._tag, "NotFound"); + assert.notInclude(error.detail, error.cause.message); + }), + ); + + it.effect("does not retain git arguments or stderr in command failures", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const secret = "secret-token-value"; + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.redactedFailure", + cwd, + args: ["status", `--unknown-option=${secret}`], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.redactedFailure", + command: "git", + argumentCount: 2, + cwd, + }); + assert.isNumber(error.exitCode); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, secret); + assert.notInclude(error.message, secret); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + }), + ); + + it.effect("keeps line callbacks flowing past the output cap when asked", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + // 4 KiB of multi-byte lines, well past a 512-byte cap; the last line + // is the one a failure surface would need. + const lines: Array = []; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.callbacksPastCap", + cwd, + args: [ + "-c", + 'alias.spew=!for i in $(seq 1 128); do printf "é%03d\\n" $i >&2; done; echo fatal: last line >&2', + "spew", + ], + maxOutputBytes: 512, + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, + progress: { onStderrLine: (line) => Effect.sync(() => void lines.push(line)) }, + }); + + assert.isTrue(result.stderrTruncated); + assert.isAtMost(result.stderr.length, 600); + assert.equal(lines.length, 129); + assert.equal(lines[0], "é001"); + assert.equal(lines[127], "é128"); + assert.equal(lines.at(-1), "fatal: last line"); + // No replacement characters: the cap landing inside "é" is invisible to callbacks. + assert.isFalse(lines.some((line) => line.includes("\uFFFD"))); + }), + ); + + it.effect("recovers a structurally identified missing cwd as a non-repository", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const [localStatus, remoteStatus, refs] = yield* Effect.all([ + driver.statusDetails(cwd), + driver.statusDetailsRemote(cwd, { refreshUpstream: false }), + driver.listRefs({ cwd }), + ]); + + assert.equal(localStatus.isRepo, false); + assert.equal(remoteStatus.isRepo, false); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const notAWorktree = pathService.join(cwd, "not-a-worktree"); + yield* fileSystem.makeDirectory(notAWorktree); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.removeWorktree", + command: "git", + argumentCount: 3, + cwd, + }); + assert.notProperty(error, "cause"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.detail, "Git command failed in"); + }), + ); + }); + + describe("review diff previews", () => { + it.effect("loads repository-relative files from a nested project directory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature/nested"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "nested file"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n"); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const nestedCwd = path.join(cwd, "nested"); + const preview = yield* driver.getReviewDiffPreview({ + cwd: nestedCwd, + baseRef: initialBranch, + }); + assert.equal( + preview.sources.find((source) => source.kind === "working-tree")!.files!.length, + 2, + ); + for (const source of preview.sources) { + for (const file of source.files ?? []) { + const scoped = yield* driver.getReviewDiffPreview({ + cwd: nestedCwd, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = scoped.sources.find((item) => item.kind === source.kind)!; + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + } + } + }), + ); + + it.effect("reads complete tracked and untracked manifests beyond 1 MB", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "untracked.txt", "untracked content\n"); + const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`); + const stats = paths.map((path) => `1\t0\t${path}\0`).join(""); + const untracked = [...paths, "untracked.txt"].join("\0") + "\0"; + assert.isAbove(stats.length, 1024 * 1024); + assert.isAbove(untracked.length, 1024 * 1024); + let readLargeUntracked = false; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + if ( + command.args.includes("--numstat") && + command.args.includes(`${initialBranch}...HEAD`) + ) { + return Effect.succeed(makeSuccessfulHandle(stats)); + } + if (command.args.includes("ls-files") && command.args.includes("--others")) { + return Effect.succeed(makeSuccessfulHandle(readLargeUntracked ? untracked : "")); + } + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const branch = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + }); + const files = branch.sources.find((source) => source.kind === "branch-range")!.files!; + assert.equal(files.length, paths.length); + assert.equal(files.at(-1)?.path, paths.at(-1)); + assert.equal( + files.reduce((total, file) => total + file.additions, 0), + paths.length, + ); + readLargeUntracked = true; + const dirty = yield* driver.getReviewDiffPreview({ + cwd, + file: { path: "untracked.txt", previousPath: null, sourceKind: "working-tree" }, + }); + const source = dirty.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(source.files, [ + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(source.diff, "+untracked content"); + }), + ); + + it.effect("propagates patch failures instead of reporting an empty complete diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", "changed\n"); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("--patch") + ? Effect.succeed(makeNonRepositoryHandle()) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const result = yield* driver.getReviewDiffPreview({ cwd }).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.operation, "GitVcsDriver.getReviewDiffPreview.patch"); + } + }), + ); + + it.effect("drops an unterminated path from truncated NUL-separated git output", () => + Effect.sync(() => { + const paths = splitNullSeparatedGitStdoutPaths({ + stdout: "complete.txt\0partial", + stdoutTruncated: true, + }); + + assert.deepStrictEqual(paths, ["complete.txt"]); + }), + ); + + it.effect("keeps the final path when NUL-separated git output is complete", () => + Effect.sync(() => { + const paths = splitNullSeparatedGitStdoutPaths({ + stdout: "complete.txt\0final.txt", + stdoutTruncated: false, + }); + + assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); + }), + ); + + it.effect("honors whitespace filtering for worktree and branch previews", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "change whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + + const included = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + const ignored = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: true, + }); + + assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff); + assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "working-tree")?.diff, + "", + ); + assert.deepStrictEqual( + ignored.sources.find((source) => source.kind === "working-tree")?.files, + [], + ); + assert.deepStrictEqual( + ignored.sources.find((source) => source.kind === "branch-range")?.files, + [], + ); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "branch-range")?.diff, + "", + ); + }), + ); + + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.noprefix", "true"]); + yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); + yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "committed change"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + + const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; + const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; + assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, "+++ b/untracked.txt"); + assert.include(branchRange, "diff --git a/README.md b/README.md"); + }), + ); + + it.effect("keeps untracked filenames with pathspec magic in the review", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, ":(exclude)after.ts", "literal pathspec contents\n"); + yield* writeTextFile(cwd, "ordinary.ts", "ordinary contents\n"); + const indexBefore = yield* git(cwd, ["ls-files", "--stage"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "+literal pathspec contents"); + assert.include(diff, "+ordinary contents"); + const scoped = yield* driver.getReviewDiffPreview({ + cwd, + file: { + path: ":(exclude)after.ts", + previousPath: null, + sourceKind: "working-tree", + }, + }); + const scopedSource = scoped.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(scopedSource.files, [ + { path: ":(exclude)after.ts", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(scopedSource.diff, "+literal pathspec contents"); + assert.notInclude(scopedSource.diff, "ordinary.ts"); + assert.strictEqual(yield* git(cwd, ["ls-files", "--stage"]), indexBefore); + }), + ); + + it.effect("detects an unstaged rename with edits without mutating a split index", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "before.ts", "one\ntwo\nthree\nfour\nfive\n"); + yield* git(cwd, ["add", "before.ts"]); + yield* git(cwd, ["commit", "-m", "add source file"]); + yield* git(cwd, ["config", "core.splitIndex", "true"]); + yield* git(cwd, ["config", "splitIndex.sharedIndexExpire", "now"]); + yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); + const indexHashBefore = yield* git(cwd, ["hash-object", indexPath]); + const gitDirValue = yield* git(cwd, ["rev-parse", "--git-dir"]); + const gitDir = pathService.isAbsolute(gitDirValue) + ? gitDirValue + : pathService.resolve(cwd, gitDirValue); + const sharedIndexesBefore = (yield* fileSystem.readDirectory(gitDir)) + .filter((entry) => entry.startsWith("sharedindex.")) + .sort(); + yield* fileSystem.rename( + pathService.join(cwd, "before.ts"), + pathService.join(cwd, "after.ts"), + ); + yield* writeTextFile(cwd, "after.ts", "one\ntwo\nTHREE\nfour\nfive\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + const indexHashAfter = yield* git(cwd, ["hash-object", indexPath]); + const sharedIndexesAfter = (yield* fileSystem.readDirectory(gitDir)) + .filter((entry) => entry.startsWith("sharedindex.")) + .sort(); + + const source = preview.sources.find((candidate) => candidate.kind === "working-tree")!; + assert.deepStrictEqual(source.files, [ + { path: "after.ts", previousPath: "before.ts", additions: 1, deletions: 1 }, + ]); + const scoped = yield* driver.getReviewDiffPreview({ + cwd, + file: { path: "after.ts", previousPath: "before.ts", sourceKind: "working-tree" }, + }); + const scopedSource = scoped.sources.find((candidate) => candidate.kind === "working-tree")!; + assert.deepStrictEqual(scopedSource.files, source.files); + assert.equal(scopedSource.diff, diff); + assert.include(diff, "rename from before.ts"); + assert.include(diff, "rename to after.ts"); + assert.include(diff, "-three"); + assert.include(diff, "+THREE"); + assert.strictEqual(diff.match(/^diff --git /gm)?.length, 1); + assert.strictEqual(indexHashAfter, indexHashBefore); + assert.deepStrictEqual(sharedIndexesAfter, sharedIndexesBefore); + }), + ); + + it.effect("keeps tracked changes visible when untracked discovery fails", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const failingLsFilesSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) { + return Effect.die("expected a standard Git command"); + } + return command.args[0] === "ls-files" && command.args[1] === "--others" + ? Effect.succeed(makeNonRepositoryHandle()) + : delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingLsFilesSpawner), + Effect.provide(ServerConfigLayer), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + yield* writeTextFile(cwd, "README.md", "# tracked change\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "-# test"); + assert.include(diff, "+# tracked change"); + }), + ); + + it.effect("preserves a staged deletion when the removed path still exists", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "removed.txt", "remove me\n"); + yield* git(cwd, ["add", "removed.txt"]); + yield* git(cwd, ["commit", "-m", "add removable file"]); + yield* git(cwd, ["rm", "--cached", "removed.txt"]); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "deleted file mode"); + assert.include(diff, "-remove me"); + assert.notInclude(diff, "new file mode"); + }), + ); + + it.effect("keeps untracked files visible before the first commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* writeTextFile(cwd, "untracked.txt", "visible before HEAD\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const source = preview.sources.find((candidate) => candidate.kind === "working-tree"); + + assert.include(source?.diff, "visible before HEAD"); + assert.equal(source?.truncated, false); + }), + ); + + for (const splitIndex of [false, true]) { + it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); + yield* writeTextFile(cwd, "stable.txt", "unchanged\n"); + yield* writeTextFile( + cwd, + ".git/filter.cjs", + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "cache stable file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, ".git/filter-runs", ""); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")!.files, + [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); + assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); + } + + for (const [timestamp, splitIndex] of [ + [1_700_000_000, false], + [1_700_000_000.9999, false], + [1_700_000_000, true], + [1_700_000_000.9999, true], + ] as const) { + it.effect( + `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + // Reproduce a same-timestamp edit without relying on filesystem clock resolution. + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "record racy file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }), + ); + } + + it.effect("keeps complete stats for files beyond the combined patch limit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const largeContents = "a long line of changed content for the diff preview\n".repeat(4000); + yield* git(cwd, ["checkout", "-b", "feature/large"]); + yield* writeTextFile(cwd, "a-large.txt", largeContents); + yield* writeTextFile(cwd, "z-last.txt", "last file\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "large change"]); + yield* writeTextFile(cwd, "a-large.txt", largeContents.replaceAll("changed", "updated")); + yield* writeTextFile(cwd, "z-last.txt", "last file updated\n"); + yield* writeTextFile(cwd, "untracked.txt", largeContents); + + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + const branch = preview.sources.find((source) => source.kind === "branch-range")!; + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.isTrue(branch.truncated); + assert.isTrue(dirty.truncated); + assert.notInclude(branch.diff, "z-last.txt"); + for (const source of [branch, dirty]) { + for (const file of source.files ?? []) { + const individual = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = individual.sources.find((candidate) => candidate.kind === source.kind)!; + assert.isFalse(patch.truncated); + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + assert.isEmpty( + individual.sources.find((candidate) => candidate.kind !== source.kind)!.diff, + ); + } + } + + assert.deepStrictEqual(branch.files, [ + { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 }, + { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.deepStrictEqual(dirty.files, [ + { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 4000 }, + { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 }, + { path: "z-last.txt", previousPath: null, additions: 1, deletions: 1 }, + ]); + }), + ); + + it.effect("preserves renames, unusual paths, modes, and binary statistics", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "mode-only.sh", "echo unchanged\n"); + yield* git(cwd, ["add", "mode-only.sh"]); + yield* git(cwd, ["commit", "-m", "add executable candidate"]); + yield* git(cwd, ["checkout", "-b", "feature/paths"]); + yield* git(cwd, ["mv", "README.md", "renamed.md"]); + yield* writeTextFile(cwd, "[literal].txt", "literal\n"); + yield* writeTextFile(cwd, " leading.txt", "whitespace path\n"); + yield* writeTextFile(cwd, "l.txt", "other\n"); + yield* writeTextFile(cwd, "binary.dat", "binary\0data"); + if ((yield* HostProcessPlatform) !== "win32") { + yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n"); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["update-index", "--chmod=+x", "mode-only.sh"]); + yield* git(cwd, ["commit", "-m", "rename and add files"]); + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + }); + const branch = preview.sources.find((source) => source.kind === "branch-range")!; + for (const path of ["renamed.md", "[literal].txt", " leading.txt", "mode-only.sh"]) { + const stat = branch.files!.find((file) => file.path === path)!; + const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({ + cwd, + baseRef: initialBranch, + file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" }, + }); + const result = yield* driver.getReviewDiffPreview(request); + const scoped = result.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(scoped.files, [stat]); + assert.notInclude(scoped.diff, "b/l.txt"); + if (path === "renamed.md") assert.include(scoped.diff, "rename from README.md"); + if (path === "mode-only.sh") { + assert.include(scoped.diff, "old mode 100644"); + assert.include(scoped.diff, "new mode 100755"); + } + } + assert.include(branch.diff, "rename from README.md"); + assert.include(branch.diff, "rename to renamed.md"); + assert.deepInclude(branch.files ?? [], { + path: "renamed.md", + previousPath: "README.md", + additions: 0, + deletions: 0, + }); + assert.deepInclude(branch.files ?? [], { + path: "binary.dat", + previousPath: null, + additions: 0, + deletions: 0, + }); + if ((yield* HostProcessPlatform) !== "win32") { + assert.deepInclude(branch.files ?? [], { + path: "tab\tand\nnewline.txt", + previousPath: null, + additions: 1, + deletions: 0, + }); + } + }), + ); + + it.effect("reports staged and untracked changes before the first commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* git(cwd, ["init"]); + yield* writeTextFile(cwd, "staged.txt", "staged\n"); + yield* git(cwd, ["add", "staged.txt"]); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "staged.txt", previousPath: null, additions: 1, deletions: 0 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "b/staged.txt"); + assert.include(dirty.diff, "b/untracked.txt"); + }), + ); + + it.effect("loads full file contents for working-tree diff expansion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "nested/.keep", ""); + yield* writeTextFile(cwd, "README.md", "# changed\nunchanged context\n"); + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(pathService.join(cwd, "nested")), + ); + + assert.strictEqual(contents.oldContents, "# test\n"); + assert.strictEqual(contents.newContents, "# changed\nunchanged context\n"); + }), + ); + + it.effect("attributes working-tree filesystem failures to the failing operation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + changeType: "new", + oldPath: "missing.ts", + newPath: "missing.ts", + }), + ) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath", + command: "fs.realPath", + cwd, + detail: "Could not resolve diff file 'missing.ts'.", + }); + }), + ); + + it.effect("loads new and deleted files without reading their missing side", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "added.ts", "export const added = true;\n"); + yield* fileSystem.remove(pathService.join(cwd, "README.md")); + + const [added, deleted] = yield* Effect.all([ + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + changeType: "new", + oldPath: "added.ts", + newPath: "added.ts", + }), + ), + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { changeType: "deleted" }), + ), + ]); + + assert.deepStrictEqual(added, { + oldContents: "", + newContents: "export const added = true;\n", + }); + assert.deepStrictEqual(deleted, { + oldContents: "# test\n", + newContents: "", + }); + }), + ); + + it.effect("loads merge-base and head contents for branch diff expansion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/context"]); + yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "change readme"]); + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "branch-range", + baseRef: initialBranch, + headRef: "feature/context", + }), + ); + + assert.strictEqual(contents.oldContents, "# test\n"); + assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n"); + }), + ); + }); + + describe("repository status", () => { + it.effect("reports non-repository directories without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("reports refName and dirty state for a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasWorkingTreeChanges, true); + assert.include( + status.workingTree.files.map((file) => file.path), + "feature.ts", + ); + }), + ); + + it.effect("reports changes to a file named HEAD", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "HEAD", "first line\n"); + yield* git(cwd, ["add", "HEAD"]); + yield* git(cwd, ["commit", "-m", "add HEAD file"]); + yield* writeTextFile(cwd, "HEAD", "first line\nsecond line\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + assert.deepInclude(status.workingTree.files, { + path: "HEAD", + insertions: 1, + deletions: 0, + }); + }), + ); + + it.effect("reports default-branch delta separately from upstream delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/synced"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, true); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); + + it.effect("reports remote divergence without reading working-tree details", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/remote-status"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]); + yield* writeTextFile(cwd, "untracked.txt", "local-only\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, "feature/remote-status"); + assert.equal(status.hasUpstream, true); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + assert.notProperty(status, "workingTree"); + assert.notProperty(status, "hasWorkingTreeChanges"); + }), + ); + + it.effect("reports remote status on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + + it.effect("can read cached remote divergence without fetching upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const updater = yield* makeTmpDir("git-vcs-driver-updater-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + + yield* git(updater, ["clone", remote, "."]); + yield* git(updater, ["config", "user.email", "test@test.com"]); + yield* git(updater, ["config", "user.name", "Test"]); + yield* writeTextFile(updater, "remote.txt", "remote\n"); + yield* git(updater, ["add", "remote.txt"]); + yield* git(updater, ["commit", "-m", "remote commit"]); + yield* git(updater, ["push", "origin", initialBranch]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const cachedStatus = yield* driver.statusDetailsRemote(cwd, { + refreshUpstream: false, + }); + const refreshedStatus = yield* driver.statusDetailsRemote(cwd); + + assert.equal(cachedStatus.behindCount, 0); + assert.equal(refreshedStatus.behindCount, 1); + }), + ); + + it.effect("does not start Git auto-maintenance from background upstream fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["repack", "-d"]); + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push"]); + yield* git(cwd, ["repack", "-d"]); + // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. + yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); + yield* git(cwd, ["config", "gc.autoDetach", "false"]); + yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); + const packCount = git(cwd, ["count-objects", "-v"]).pipe( + Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]), + ); + assert.equal(yield* packCount, "2"); + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(yield* packCount, "2"); + }), + ); + + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const upstream = yield* makeTmpDir("git-vcs-driver-upstream-"); + yield* initRepoWithCommit(cwd); + yield* git(origin, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["push", "upstream", "main"]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* writeTextFile(cwd, "release.txt", "release\n"); + yield* git(cwd, ["add", "release.txt"]); + yield* git(cwd, ["commit", "-m", "release commit"]); + yield* git(cwd, ["push", "-u", "upstream", "release"]); + yield* git(cwd, [ + "symbolic-ref", + "refs/remotes/upstream/HEAD", + "refs/remotes/upstream/release", + ]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(status.branch, "release"); + assert.equal(status.upstreamRef, "upstream/release"); + assert.equal(status.isDefaultBranch, false); + }), + ); + + it.effect("makes background upstream status fetches non-interactive", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); + const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); + const envKeys = [ + "GCM_INTERACTIVE", + "GIT_ASKPASS", + "GIT_SSH", + "GIT_TERMINAL_PROMPT", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "T3_TEST_SSH_ASKPASS_LOG", + ] as const; + const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); + + yield* fileSystem.writeFileString( + sshWrapperPath, + [ + "#!/bin/sh", + 'printf "GCM_INTERACTIVE=%s\\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_ASKPASS=%s\\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_TERMINAL_PROMPT=%s\\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS=%s\\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS_REQUIRE=%s\\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + "exit 1", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(sshWrapperPath, 0o755); + yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); + yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); + yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); + + yield* Effect.gen(function* () { + process.env.GIT_SSH = sshWrapperPath; + process.env.GCM_INTERACTIVE = "always"; + process.env.GIT_ASKPASS = "git-askpass"; + process.env.GIT_TERMINAL_PROMPT = "1"; + process.env.SSH_ASKPASS = "ssh-askpass"; + process.env.SSH_ASKPASS_REQUIRE = "force"; + process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [ + "GCM_INTERACTIVE=never", + "GIT_ASKPASS=", + "GIT_TERMINAL_PROMPT=0", + "SSH_ASKPASS=", + "SSH_ASKPASS_REQUIRE=never", + ]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + for (const key of envKeys) { + const previous = previousEnv.get(key); + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + }), + ), + ); + }), + ); + + it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 1); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); + + it.effect("reports combined staged and unstaged edits to the same file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "// line one\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* git(cwd, ["commit", "-m", "add feature"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n// line three\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + const file = status.workingTree.files.find((f) => f.path === "feature.ts"); + assert.ok(file); + // HEAD has 1 line. Staged has 2 lines (+1). Unstaged has 3 lines (+2 from HEAD). + // Combined net from HEAD: +2 insertions. + assert.equal(file.insertions, 2); + assert.equal(file.deletions, 0); + }), + ); + + it.effect("reports staged file counts on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "initial.ts", "// first file\n"); + yield* git(cwd, ["add", "initial.ts"]); + + const status = yield* driver.statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.workingTree.files.length, 1); + const file = status.workingTree.files[0]; + if (file) { + assert.equal(file.path, "initial.ts"); + assert.equal(file.insertions, 1); + } + }), + ); + }); + + describe("refName operations", () => { + it.effect("optionally includes remote refs that match local branches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const deduplicated = yield* driver.listRefs({ cwd }); + assert.equal( + deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`), + false, + ); + + const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + assert.equal( + complete.refs.some((ref) => ref.name === initialBranch), + true, + ); + assert.equal( + complete.refs.some((ref) => ref.name === `origin/${initialBranch}`), + true, + ); + + const remoteOnly = yield* driver.listRefs({ + cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }); + assert.equal(remoteOnly.refs.length, 1); + assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); + assert.equal(remoteOnly.refs[0]?.isRemote, true); + }), + ); + + it.effect("marks the origin default ref as default when no local copy exists", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/only-local"]); + yield* git(cwd, ["branch", "-D", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`); + assert.equal(remoteDefault?.isRemote, true); + assert.equal(remoteDefault?.isDefault, true); + }), + ); + + it.effect("creates, checks out, renames, and lists refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ cwd, refName: "feature/original" }); + const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); + assert.equal(switchRef.refName, "feature/original"); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "feature/original", + newBranch: "feature/renamed", + }); + assert.equal(renamed.branch, "feature/renamed"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); + + const refs = yield* driver.listRefs({ cwd }); + assert.equal( + refs.refs.find((refName) => refName.name === "feature/renamed")?.current, + true, + ); + }), + ); + + it.effect("returns the existing refName when rename source and target match", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const current = yield* git(cwd, ["branch", "--show-current"]); + const result = yield* driver.renameBranch({ + cwd, + oldBranch: current, + newBranch: current, + }); + + assert.equal(result.branch, current); + }), + ); + + it.effect("rejects a missing branch without restoring a matching dirty file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "obsolete-branch", "original\n"); + yield* git(cwd, ["add", "obsolete-branch"]); + yield* git(cwd, ["commit", "-m", "tracked file"]); + yield* git(cwd, ["branch", "obsolete-branch"]); + yield* git(cwd, ["branch", "-D", "obsolete-branch"]); + yield* writeTextFile(cwd, "obsolete-branch", "uncommitted work\n"); + + const result = yield* driver + .switchRef({ cwd, refName: "obsolete-branch" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.equal( + yield* fileSystem.readFileString(path.join(cwd, "obsolete-branch")), + "uncommitted work\n", + ); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + }), + ); + + it.effect("still creates and reuses remote tracking branches and allows detached refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); + assert.equal(result.refName, "remote-only"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/remote-only", + ); + yield* driver.switchRef({ cwd, refName: initialBranch }); + } + const commit = yield* git(cwd, ["rev-parse", "HEAD"]); + const detached = yield* driver.switchRef({ cwd, refName: commit }); + assert.equal(detached.refName, null); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit); + }), + ); + }); + + describe("worktree operations", () => { + it.effect("uses parallel checkout without skipping filters or hooks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n"); + yield* writeTextFile(cwd, "asset.txt", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "parallel"); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/parallel", + baseRefName: initialBranch, + }); + + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked"); + assert.equal(yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), "0\n"); + assert.equal(yield* fs.readFileString(path.join(worktreePath, "asset.txt")), "filtered\n"); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.equal( + yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + initialBranch, + ); + for (const [configured, expected] of [ + ["1", "1"], + ["", "0"], + ] as const) { + yield* git(cwd, ["config", "checkout.workers", configured]); + const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured"); + yield* driver.createWorktree({ + cwd, + path: configuredPath, + refName: initialBranch, + newRefName: `feature/configured-${expected}`, + }); + assert.equal( + yield* fs.readFileString(path.join(configuredPath, "checkout-workers")), + `${expected}\n`, + ); + } + }), + ); + it("parses checkout progress lines from git's stderr", () => { + assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { + percent: 78, + completed: 2104, + total: 2700, + }); + // Progress lines arrive carriage-return separated and end with a done marker. + assert.deepStrictEqual( + parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."), + { percent: 100, completed: 2700, total: 2700 }, + ); + assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null); + }); + + // NTFS rejects a newline in a file name, so there is nothing to preserve there. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "preserves newline characters in worktree paths when listing refs", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find( + (ref) => ref.name === "feature/newline-path", + )?.worktreePath; + + if (typeof listedPath !== "string") { + return assert.fail("expected the linked branch to include its worktree path"); + } + assert.equal( + NodeFS.realpathSync.native(listedPath), + NodeFS.realpathSync.native(worktreePath), + ); + }), + ); + + it.effect("checks out submodules in a new worktree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + // Git refuses `file:` submodule transports by default (CVE-2022-39253) + // and ignores repo-level config for it, so a local fixture needs the + // env allowance. Real submodules are https/ssh and need none of this. + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // A real submodule: `git worktree add` leaves these empty, which is + // what silently strips shared tooling out of every new worktree. + const submoduleRepo = yield* makeTmpDir("git-submodule-"); + yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); + yield* git(submoduleRepo, ["add", "."]); + yield* git(submoduleRepo, ["commit", "-m", "shared"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }); + + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), + true, + ); + }), + ); + + it.effect("still creates the worktree when submodule checkout fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + // Points at a repository that does not exist, so the checkout fails the + // way an unreachable private remote would. Creation must still succeed. + yield* writeTextFile( + cwd, + ".gitmodules", + '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n', + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add unreachable submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "broken-submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/broken-submodules", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(yield* fileSystem.exists(worktreePath), true); + }), + ); + + it.effect("resolves the submodule mode from the option, then t3.json", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: WorktreeSubmodules, + branch: string, + submodules: WorktreeSubmodules | null = null, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: "t3.json", + inner: false, + nested: false, + }); + }), + ); + + it.effect("reports checkout progress during parallel worktree creation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + for (let index = 0; index < 200; index += 1) { + yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "progress-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const seen = yield* Ref.make>( + [], + ); + const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); + + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, + { + progress: { + onWorktreeClaimed: (path) => + Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), + onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), + }, + }, + ); + // Claimed only once git has registered the directory. + assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); + + // Git separates live progress updates with `\r`, so the driver must + // surface every intermediate percentage, not just the final line. + const updates = yield* Ref.get(seen); + assert.isAbove(updates.length, 1); + assert.equal(updates.at(-1)?.percent, 100); + assert.equal(updates.at(-1)?.total, 201); + const completed = updates.map((update) => update.completed); + assert.deepEqual( + completed, + completed.toSorted((a, b) => a - b), + ); + }), + ); + + it.effect("creates and removes a worktree for a new refName", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "feature-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(created.worktree.refName, "feature/worktree"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + + it.effect("allows worktree removal to run longer than the default command timeout", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const removalStarted = yield* Deferred.make(); + const delayedRemovalSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if ( + ChildProcess.isStandardCommand(command) && + command.args[0] === "worktree" && + command.args[1] === "remove" + ) { + yield* Deferred.succeed(removalStarted, undefined); + yield* Effect.sleep("31 seconds"); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner), + Effect.provide(ServerConfigLayer), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal"); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/slow-removal", + }); + + const removal = yield* driver + .removeWorktree({ cwd, path: worktreePath, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(removalStarted); + yield* TestClock.adjust("31 seconds"); + yield* Fiber.join(removal); + + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + + it.effect("removes the same worktree path twice without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/shared", + }); + + // Two threads can record the same worktree path; the second delete + // must be a no-op instead of exit 128. + yield* driver.removeWorktree({ cwd, path: worktreePath }); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + }), + ); + + it.effect("prunes stale registrations when removing an already-gone worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreesRoot = yield* makeTmpDir("git-worktrees-"); + const stalePath = pathService.join(worktreesRoot, "stale"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: stalePath, + refName: initialBranch, + newRefName: "feature/stale", + }); + // Delete the directory behind git's back so the registration goes stale. + yield* fileSystem.remove(stalePath, { recursive: true }); + + yield* driver.removeWorktree({ + cwd, + path: pathService.join(worktreesRoot, "never-registered"), + }); + + const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]); + assert.notInclude(registered, "stale"); + }), + ); + }); + + describe("remote operations", () => { + it.effect("explains a real fetch failure for a missing local remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const missingRemote = `${cwd}/private-missing-remote`; + yield* git(cwd, ["remote", "add", "origin", missingRemote]); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, "could not access the remote repository"); + assert.equal(error.exitCode, 128); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, missingRemote); + assert.notProperty(error, "stderr"); + }), + ); + + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + + const reusedForSsh = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reusedForSsh, "origin"); + + const reusedForSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com/pingdotgg/t3code", + }); + assert.equal(reusedForSshScheme, "origin"); + + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + + const reusedForSshWithPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code.git", + }); + assert.equal(reusedForSshWithPort, "origin"); + + const addedForFork = yield* driver.ensureRemote({ + cwd, + preferredName: "octocat", + url: "git@github.com:octocat/t3code.git", + }); + assert.equal(addedForFork, "octocat"); + assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); + }), + ); + }); + + describe("commit context", () => { + it.effect("stages selected files and commits only those files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "b.txt", "b\n"); + + const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + + const commit = yield* driver.commit(cwd, "Add a", ""); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), + ); + + it.effect("treats selected file paths literally", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); + yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); + + yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? selected1.txt"); + }), + ); + }); + + describe("remote operations", () => { + for (const failure of ["offline", "auth", "timeout"] as const) { + it.effect(`does not retry a scoped fetch after ${failure}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const started = yield* Deferred.make(); + const attempts: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("unexpected command"); + if (command.args[0] !== "fetch") return yield* delegate.spawn(command); + attempts.push(command.args); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: + failure === "timeout" + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(128)), + stderr: Stream.encodeText( + Stream.make( + failure === "auth" + ? "fatal: Authentication failed" + : "fatal: Could not resolve host", + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const fetching = yield* driver + .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + if (failure === "timeout") { + yield* TestClock.adjust("31 seconds"); + yield* TestClock.adjust("31 seconds"); + } + const result = yield* Fiber.join(fetching); + assert.isTrue(Result.isFailure(result)); + assert.equal(attempts.length, 1); + if (Result.isFailure(result)) { + assert.equal( + result.failure.detail, + failure === "timeout" + ? "Git command timed out." + : failure === "offline" + ? "Git could not reach the remote. Check the server's network connection and remote host, then retry." + : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.", + ); + } + }), + ); + } + + it.effect("creates a worktree from the latest fetched remote commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const peer = yield* makeTmpDir("git-peer-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]); + const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]); + + yield* git(peer, ["clone", remote, "."]); + yield* git(peer, ["config", "user.email", "test@test.com"]); + yield* git(peer, ["config", "user.name", "Test"]); + yield* writeTextFile(peer, "remote-change.txt", "remote\n"); + yield* git(peer, ["add", "remote-change.txt"]); + yield* git(peer, ["commit", "-m", "remote change"]); + yield* git(peer, ["push", "origin", initialBranch]); + const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); + assert.notEqual(beforeFetch, remoteHead); + + yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.fetchRemote({ + cwd, + remoteName: "origin", + refName: `origin/${initialBranch}`, + }); + assert.isFalse( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); + + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: initialBranch, + }), + true, + ); + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: "local-only", + }), + false, + ); + + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: initialBranch, + fallbackRemoteName: "origin", + }); + const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: `origin/${initialBranch}`, + fallbackRemoteName: "origin", + }); + + assert.deepEqual(resolvedBase, { + commitSha: remoteHead, + remoteRefName: `origin/${initialBranch}`, + }); + assert.deepEqual(explicitlyResolvedBase, resolvedBase); + assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); + + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-fetched-worktrees-"), + "fetched-origin", + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: resolvedBase.commitSha, + newRefName: "t3code/fetched-origin", + baseRefName: resolvedBase.remoteRefName, + }); + + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"), + initialBranch, + ); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), + null, + ); + const status = yield* driver.statusDetails(worktreePath); + assert.equal(status.aheadCount, 0); + assert.equal(status.aheadOfDefaultCount, 0); + + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); + }), + ); + + it.effect("pushes with upstream setup and skips when already up to date", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/push", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/push", + ); + + const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(skipped, { + status: "skipped_up_to_date", + branch: "feature/push", + }); + }), + ); + + it.effect("allows pushes to run longer than the default command timeout", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const pushStarted = yield* Deferred.make(); + const delayedPushSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (ChildProcess.isStandardCommand(command) && command.args[0] === "push") { + yield* Deferred.succeed(pushStarted, undefined); + yield* Effect.sleep("31 seconds"); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedPushSpawner), + Effect.provide(ServerConfigLayer), + ); + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + + const pushing = yield* driver + .pushCurrentBranch(cwd, null) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(pushStarted); + yield* TestClock.adjust("31 seconds"); + const pushed = yield* Fiber.join(pushing); + + assert.deepInclude(pushed, { + status: "pushed", + setUpstream: true, + }); + }), + ); + + it.effect( + "pushes upstream branches to the remote branch name, not the upstream shorthand", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add upstream update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin/main", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), + "Add upstream update", + ); + const badBranch = yield* driver.execute({ + operation: "GitVcsDriver.test.showBadRemoteBranch", + cwd: remote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/origin/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(badBranch.exitCode, 0); + }), + ); + + it.effect("publishes a branch tracking its base under its own name, not the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "dev"]); + yield* git(cwd, ["push", "-u", "origin", "dev"]); + const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/x", + upstreamBranch: "origin/feature/x", + setUpstream: true, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature"); + assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/x", + ); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + }), + ); + + it.effect("keeps a recorded merge base when publishing a tracked branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); + yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/y", + upstreamBranch: "origin/feature/y", + setUpstream: true, + }); + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"), + "release/v2", + ); + }), + ); + + it.effect("still pushes a git-mangled tracking alias to its upstream head", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", remote]); + yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + // `checkout --track my-org/upstream/effect-atom` cannot name the local + // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its + // upstream is still its published head. + yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + "upstream/effect-atom", + ); + yield* writeTextFile(cwd, "alias.txt", "alias\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add alias update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "upstream/effect-atom", + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]), + "Add alias update", + ); + }), + ); + + it.effect("pushes to the requested remote instead of the primary remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const originRemote = yield* makeTmpDir("git-origin-remote-"); + const publishRemote = yield* makeTmpDir("git-publish-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(originRemote, ["init", "--bare"]); + yield* git(publishRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", originRemote]); + yield* git(cwd, ["remote", "add", "origin-1", publishRemote]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" }); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin-1/main", + setUpstream: true, + }); + assert.equal( + yield* git(publishRemote, ["log", "-1", "--pretty=%s", "main"]), + "initial commit", + ); + const originMain = yield* driver.execute({ + operation: "GitVcsDriver.test.originMainMissing", + cwd: originRemote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(originMain.exitCode, 0); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts new file mode 100644 index 0000000000000..c439cda1e8b83 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -0,0 +1,3756 @@ +import * as Cache from "effect/Cache"; +import * as Data from "effect/Data"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + // Coder: chunked review expansion permits up to 32 MiB with a typed size error. + MAX_REVIEW_DIFF_FILE_BYTES, + ReviewDiffFileTooLargeError, + type ReviewDiffFileContentsInput, + type ReviewDiffPreviewInput, + type ReviewDiffFileStat, + type ReviewDiffPreviewSource, + type VcsRef, +} from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../git/remoteRefs.ts"; +import { readProjectConfig } from "../project/configMetadata.ts"; +import { ServerConfig } from "../config.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const gitProcesses = Semaphore.makeUnsafe(8); +// `git worktree add` checks out the full tree, so on large repositories it can +// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle +// machine). Give it generous headroom while still bounding a genuinely hung git. +const WORKTREE_ADD_TIMEOUT_MS = 300_000; +const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; +const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; +const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +// Patches the clients render are parsed against git's default a/ and b/ path +// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would +// otherwise leak into the patch and leave every parsed file unnamed. +export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); +const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); + +const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); +const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); +const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10); +const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1); +const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; +const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); +const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); +const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); +const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); +const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + GCM_INTERACTIVE: "never", + GIT_ASKPASS: "", + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", +} satisfies NodeJS.ProcessEnv); +const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, +}); +const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze({ + isRepo: false, + defaultBranch: null, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, +}); + +type TraceTailState = { + processedChars: number; + remainder: string; +}; + +class StatusRemoteRefreshCacheKey extends Data.Class<{ + gitCommonDir: string; + remoteName: string; +}> {} + +function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const cooldownMs = + Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); + return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); +} + +class GitRefsSnapshotCacheKey extends Data.Class<{ + gitCommonDir: string; + epoch: number; +}> {} + +class GitRefsRefreshCacheKey extends Data.Class<{ + gitCommonDir: string; + generation: number; +}> {} + +interface GitRepositoryPaths { + readonly gitCommonDir: string; + readonly worktreeRoot: string | null; + readonly currentBranch: string | null; +} + +interface GitRefsSnapshot { + readonly localBranches: ReadonlyArray; + readonly remoteBranches: ReadonlyArray; + readonly hasPrimaryRemote: boolean; +} + +interface ExecuteGitOptions { + stdin?: string | undefined; + timeoutMs?: number | null | undefined; + allowNonZeroExit?: boolean | undefined; + fallbackErrorDetail?: string | undefined; + env?: NodeJS.ProcessEnv | undefined; + maxOutputBytes?: number | undefined; + appendTruncationMarker?: boolean | undefined; + progress?: GitVcsDriver.ExecuteGitProgress | undefined; +} + +function parseBranchAb(value: string): { ahead: number; behind: number } { + const match = value.match(/^\+(\d+)\s+-(\d+)$/); + if (!match) return { ahead: 0, behind: 0 }; + return { + ahead: Number(match[1] ?? "0"), + behind: Number(match[2] ?? "0"), + }; +} + +function parseNumstatEntries( + stdout: string, +): Array<{ path: string; insertions: number; deletions: number }> { + const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + for (const line of stdout.split(/\r?\n/g)) { + if (line.trim().length === 0) continue; + const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); + const rawPath = + pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); + if (rawPath.length === 0) continue; + const added = Number.parseInt(addedRaw ?? "0", 10); + const deleted = Number.parseInt(deletedRaw ?? "0", 10); + const renameArrowIndex = rawPath.indexOf(" => "); + const normalizedPath = + renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; + entries.push({ + path: normalizedPath.length > 0 ? normalizedPath : rawPath, + insertions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + }); + } + return entries; +} + +// -z preserves tabs/newlines in paths and gives renames two separate path fields. +function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { + const fields = stdout.split("\0"); + const files: ReviewDiffFileStat[] = []; + for (let index = 0; index < fields.length; index++) { + const field = fields[index]!; + const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field); + if (!match) continue; + const previousPath = match[3] === "" ? fields[++index]! : null; + const path = previousPath !== null ? fields[++index]! : match[3]!; + files.push({ + path, + previousPath, + additions: match[1] === "-" ? 0 : Number(match[1]), + deletions: match[2] === "-" ? 0 : Number(match[2]), + }); + } + return files; +} + +function parsePorcelainPath(line: string): string | null { + if (line.startsWith("? ") || line.startsWith("! ")) { + const simple = line.slice(2).trim(); + return simple.length > 0 ? simple : null; + } + + if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { + return null; + } + + const tabIndex = line.indexOf("\t"); + if (tabIndex >= 0) { + const fromTab = line.slice(tabIndex + 1); + const [filePath] = fromTab.split("\t"); + return filePath?.trim().length ? filePath.trim() : null; + } + + const parts = line.trim().split(/\s+/g); + const filePath = parts.at(-1) ?? ""; + return filePath.length > 0 ? filePath : null; +} + +function filterBranchesForListQuery( + refs: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return refs; + } + + const normalizedQuery = query.toLowerCase(); + return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + refs: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}): { + refs: ReadonlyArray; + nextCursor: number | null; + totalCount: number; +} { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.refs.length; + const refs = input.refs.slice(cursor, cursor + limit); + const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; + + return { + refs, + nextCursor, + totalCount, + }; +} + +function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null && currentBranch !== null && !currentPrunable) { + worktreePaths.set(currentBranch, currentPath); + } + currentPath = null; + currentBranch = null; + currentPrunable = false; + }; + + for (const field of stdout.split("\0")) { + if (field === "") { + flush(); + } else if (field.startsWith("worktree ")) { + currentPath = field.slice("worktree ".length); + } else if (field.startsWith("branch refs/heads/")) { + currentBranch = field.slice("branch refs/heads/".length); + } else if (field === "prunable" || field.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return worktreePaths; +} + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +export function splitNullSeparatedGitStdoutPaths( + result: Pick, +): string[] { + return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); +} + +function sanitizeRemoteName(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "fork"; +} + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function parseUpstreamRefWithRemoteNames( + upstreamRef: string, + remoteNames: ReadonlyArray, +): { upstreamRef: string; remoteName: string; branchName: string } | null { + const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); + if (!parsed) { + return null; + } + + return { + upstreamRef, + remoteName: parsed.remoteName, + branchName: parsed.branchName, + }; +} + +function parseUpstreamRefByFirstSeparator( + upstreamRef: string, +): { upstreamRef: string; remoteName: string; branchName: string } | null { + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { + return null; + } + + const remoteName = upstreamRef.slice(0, separatorIndex).trim(); + const branchName = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || branchName.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + branchName, + }; +} + +function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + continue; + } + const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); + const branchName = branchNameRaw?.trim() ?? ""; + const candidateUpstreamRef = upstreamBranchRaw.trim(); + if (branchName.length === 0 || candidateUpstreamRef.length === 0) { + continue; + } + if (candidateUpstreamRef === upstreamRef) { + return branchName; + } + } + + return null; +} + +function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { + const separatorIndex = branchName.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { + return null; + } + const localBranch = branchName.slice(separatorIndex + 1).trim(); + return localBranch.length > 0 ? localBranch : null; +} + +function gitCommandContext( + input: Pick, +) { + return { + operation: input.operation, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + } as const; +} + +function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { + const trimmed = value.trim(); + const prefix = `refs/remotes/${remoteName}/`; + if (!trimmed.startsWith(prefix)) { + return null; + } + const refName = trimmed.slice(prefix.length).trim(); + return refName.length > 0 ? refName : null; +} + +function isMissingGitCwdError(error: GitCommandError): boolean { + if (!(error.cause instanceof PlatformError.PlatformError)) { + return false; + } + + const reason = error.cause.reason; + if (reason._tag === "NotFound") { + return reason.pathOrDescriptor === error.cwd; + } + + return ( + reason._tag === "BadResource" && + reason.pathOrDescriptor === error.cwd && + typeof reason.cause === "object" && + reason.cause !== null && + "code" in reason.cause && + reason.cause.code === "ENOTDIR" + ); +} + +function isNonRepositoryGitStderr(stderr: string): boolean { + return stderr.toLowerCase().includes("not a git repository"); +} +function isUnbornHeadStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("bad revision 'head'") || + (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) + ); +} + +// Matches `git worktree remove` on a path git no longer tracks: "is not a +// working tree" when the registration is gone, "cannot remove working tree" +// when older gits fail validation on a registered-but-deleted directory. +function isMissingWorktreeStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("is not a working tree") || + normalized.includes("cannot remove working tree") + ); +} + +// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter +// persisted errors; unrecognized output keeps the generic failure message. +function fetchFailureDetail(stderr: string): string | undefined { + const lines = stderr.split(/\r?\n/).map((line) => line.trim()); + if ( + lines.some((line) => + /^(?:fatal: (?:Authentication failed|could not read (?:Username|Password))\b|\S+: Permission denied \(publickey)/i.test( + line, + ), + ) + ) { + return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; + } + if ( + lines.some((line) => + /^(?:(?:fatal: |ssh: )?Could not resolve host(?:name)?\b|fatal: unable to access .+: (?:Could not resolve host|Failed to connect)\b|ssh: connect to host \S+ port \d+: (?:Connection timed out|Connection refused|Network is unreachable)\b)/i.test( + line, + ), + ) + ) { + return "Git could not reach the remote. Check the server's network connection and remote host, then retry."; + } + if ( + lines.some((line) => + /^(?:remote: Repository not found\.?$|fatal: repository .+ not found$|fatal: .+ does not appear to be a git repository$)/i.test( + line, + ), + ) + ) { + return "Git could not access the remote repository. Check the remote URL and repository permissions on the server."; + } + if ( + lines.some((line) => + /^(?:(?:error|fatal): cannot lock ref\b|fatal: Unable to create ['"].+\.lock['"]:)/i.test( + line, + ), + ) + ) { + return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry."; + } + return undefined; +} + +interface Trace2Monitor { + readonly env: NodeJS.ProcessEnv; + readonly flush: Effect.Effect; +} + +function trace2ChildKey(record: Record): string | null { + const childId = record.child_id; + if (typeof childId === "number" || typeof childId === "string") { + return String(childId); + } + const hookName = record.hook_name; + return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; +} + +const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); + +// Untraced because it runs on every git spawn and returns at once without hook +// callbacks. Its errors fail the runGitCommand span. +// Coder: omit span-event export while retaining trace2 hook progress callbacks. +const createTrace2Monitor = Effect.fnUntraced(function* ( + input: Pick, + progress: GitVcsDriver.ExecuteGitProgress | undefined, +): Effect.fn.Return< + Trace2Monitor, + PlatformError.PlatformError, + Scope.Scope | FileSystem.FileSystem | Path.Path +> { + if (!progress?.onHookStarted && !progress?.onHookFinished) { + return { + env: {}, + flush: Effect.void, + }; + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const traceFilePath = yield* fs.makeTempFileScoped({ + prefix: `t3code-git-trace2-${process.pid}-`, + suffix: ".json", + }); + const hookStartByChildKey = new Map(); + const traceTailState = yield* Ref.make({ + processedChars: 0, + remainder: "", + }); + + const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + return; + } + + const traceRecord = decodeTrace2Record(trimmedLine); + if (Result.isFailure(traceRecord)) { + yield* Effect.logDebug( + `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, + traceRecord.failure, + ); + return; + } + + if (traceRecord.success.child_class !== "hook") { + return; + } + + const event = traceRecord.success.event; + const childKey = trace2ChildKey(traceRecord.success); + if (childKey === null) { + return; + } + const started = hookStartByChildKey.get(childKey); + const hookNameFromEvent = + typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; + const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); + if (hookName.length === 0) { + return; + } + + if (event === "child_start") { + const now = yield* DateTime.now; + hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); + if (progress.onHookStarted) { + yield* progress.onHookStarted(hookName); + } + return; + } + + if (event === "child_exit") { + hookStartByChildKey.delete(childKey); + const code = traceRecord.success.exitCode; + const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; + const now = yield* DateTime.now; + const durationMs = started + ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) + : null; + if (progress.onHookFinished) { + yield* progress.onHookFinished({ + hookName: started?.hookName ?? hookName, + exitCode, + durationMs, + }); + } + } + }); + + const deltaMutex = yield* Semaphore.make(1); + const readTraceDelta = deltaMutex.withPermit( + fs.readFileString(traceFilePath).pipe( + Effect.flatMap((contents) => + Effect.uninterruptible( + Ref.modify(traceTailState, ({ processedChars, remainder }) => { + if (contents.length <= processedChars) { + return [[], { processedChars, remainder }]; + } + + const appended = contents.slice(processedChars); + const combined = remainder + appended; + const lines = combined.split("\n"); + const nextRemainder = lines.pop() ?? ""; + + return [ + lines.map((line) => line.replace(/\r$/, "")), + { + processedChars: contents.length, + remainder: nextRemainder, + }, + ]; + }).pipe( + Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), + ), + ), + ), + Effect.ignore({ log: true }), + ), + ); + const traceFileName = path.basename(traceFilePath); + yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { + const eventPath = event.path; + const isTargetTraceEvent = + eventPath === traceFilePath || + eventPath === traceFileName || + path.basename(eventPath) === traceFileName; + if (!isTargetTraceEvent) return Effect.void; + return readTraceDelta; + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + + const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { + yield* readTraceDelta; + const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ + remainder.trim(), + { + processedChars, + remainder: "", + }, + ]); + if (finalLine.length > 0) { + yield* handleTraceLine(finalLine); + } + }); + + yield* Effect.addFinalizer(finalizeTrace2Monitor); + + return { + env: { + GIT_TRACE2_EVENT: traceFilePath, + }, + flush: readTraceDelta, + }; +}); + +const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; + +/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ +export function parseGitCheckoutProgressLine( + line: string, +): { percent: number; completed: number; total: number } | null { + const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); + if (!match) return null; + const percent = Number(match[1]); + const completed = Number(match[2]); + const total = Number(match[3]); + if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { + return null; + } + return { percent: Math.max(0, Math.min(100, percent)), completed, total }; +} + +const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; + +const collectOutput = Effect.fnUntraced(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, + appendTruncationMarker: boolean, + onLine: ((line: string) => Effect.Effect) | undefined, + keepLineCallbacksAfterTruncation = false, +): Effect.fn.Return< + { readonly text: string; readonly truncated: boolean; readonly invalidUtf8: boolean }, + GitCommandError +> { + const decoder = new TextDecoder(); + // Coder: preserve invalid UTF-8 detection for strict review blob expansion. + const utf8Validator = new TextDecoder("utf-8", { fatal: true }); + let invalidUtf8 = false; + const validateUtf8 = (chunk?: Uint8Array) => { + if (invalidUtf8) return; + try { + utf8Validator.decode(chunk, { stream: chunk !== undefined }); + } catch { + invalidUtf8 = true; + } + }; + // With callbacks continuing past the cap, lines are decoded by their own + // decoder from the first byte so no character is ever split at the cap. + const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; + let bytes = 0; + let text = ""; + let lineBuffer = ""; + let truncated = false; + // A separator-free stream past the cap must not grow the line buffer + // without bound; a line longer than this is not one the callbacks want. + const maxPendingLineBytes = 64 * 1024; + + // Git redraws progress with a bare `\r` between updates and only ends the + // line once the step is done, so `\r` has to count as a line break here. + const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { + let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); + while (separator) { + const line = lineBuffer.slice(0, separator.index); + lineBuffer = lineBuffer.slice(separator.index + separator[0].length); + if (line.length > 0 && onLine) { + yield* onLine(line); + } + separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); + } + + if (flush) { + const trailing = lineBuffer; + lineBuffer = ""; + if (trailing.length > 0 && onLine) { + yield* onLine(trailing); + } + } + }); + + const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + if (appendTruncationMarker && truncated) { + if (lineDecoder) { + lineBuffer += lineDecoder.decode(chunk, { stream: true }); + yield* emitCompleteLines(false); + if (lineBuffer.length > maxPendingLineBytes) lineBuffer = ""; + } + return; + } + const nextBytes = bytes + chunk.byteLength; + if (!appendTruncationMarker && nextBytes > maxOutputBytes) { + return yield* new GitCommandError({ + ...gitCommandContext(input), + detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`, + outputLength: nextBytes, + }); + } + + const chunkToDecode = + appendTruncationMarker && nextBytes > maxOutputBytes + ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) + : chunk; + bytes += chunkToDecode.byteLength; + truncated = appendTruncationMarker && nextBytes > maxOutputBytes; + + validateUtf8(chunkToDecode); + const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); + text += decoded; + lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; + yield* emitCompleteLines(false); + }); + + yield* Stream.runForEach(stream, processChunk).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + ...gitCommandContext(input), + detail: "Failed to read Git process output.", + cause, + }), + }), + ); + + if (!truncated) validateUtf8(); + const remainder = truncated ? "" : decoder.decode(); + text += remainder; + lineBuffer += remainder; + if (lineDecoder) lineBuffer += lineDecoder.decode(); + yield* emitCompleteLines(true); + return { + text, + truncated, + invalidUtf8, + }; +}); + +export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { worktreesDir } = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + + const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( + function* (input) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const appendTruncationMarker = input.appendTruncationMarker ?? false; + + const runGitCommand = Effect.fn("runGitCommand")(function* () { + const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, + }), + ), + ); + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + env: { + ...process.env, + ...input.env, + ...trace2Monitor.env, + }, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + commandInput, + child.stdout, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStdoutLine, + input.keepLineCallbacksAfterTruncation, + ), + collectOutput( + commandInput, + child.stderr, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStderrLine, + input.keepLineCallbacksAfterTruncation, + ), + child.exitCode.pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to read Git process exit code.", + cause, + }), + ), + ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + yield* trace2Monitor.flush; + + if (!input.allowNonZeroExit && exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", + exitCode, + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, + }); + } + + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + // Coder: full review expansion rejects malformed UTF-8. + stdoutInvalidUtf8: stdout.invalidUtf8, + } satisfies GitVcsDriver.ExecuteGitResult; + }); + + const execution = runGitCommand().pipe(Effect.scoped); + if (timeoutMs === null) { + return yield* execution; + } + + return yield* execution.pipe( + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Effect.fromOption( + result, + () => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command timed out.", + }), + ), + ), + ); + }, + ); + + // Coder: Git execution has no metrics collection or export. + const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => + executeRaw(input).pipe( + (execution) => + input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS + ? execution + : gitProcesses.withPermits(1)(execution), + Effect.withSpan(input.operation, { + kind: "client", + attributes: { + "git.operation": input.operation, + "git.cwd": input.cwd, + "git.args_count": input.args.length, + }, + }), + ); + + const executeGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + execute({ + operation, + cwd, + args, + ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + allowNonZeroExit: true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options.appendTruncationMarker !== undefined + ? { appendTruncationMarker: options.appendTruncationMarker } + : {}), + ...(options.progress ? { progress: options.progress } : {}), + }).pipe( + Effect.filterOrFail( + (result) => options.allowNonZeroExit || result.exitCode === 0, + (result) => + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); + + const executeGitWithStableDiagnostics = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, { + ...options, + env: { + ...options.env, + LC_ALL: "C", + }, + }); + + const runGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, options).pipe(Effect.asVoid); + + const runGitStdout = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( + Effect.map((result) => result.stdout), + ); + + const runGitStdoutWithOptions = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, options).pipe( + Effect.map((result) => + result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, + ), + ); + + const branchExists = (cwd: string, refName: string): Effect.Effect => + executeGit( + "GitVcsDriver.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + + const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( + cwd: string, + desiredBranch: string, + ) { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; + } + + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } + + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.renameBranch", + cwd, + args: ["branch", "-m", "--", desiredBranch], + }), + detail: `Could not find an available branch name for '${desiredBranch}'.`, + }); + }); + + const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { + const upstreamRef = yield* runGitStdout( + "GitVcsDriver.resolveCurrentUpstream", + cwd, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { + return null; + } + + const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + return ( + parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef) + ); + }); + + const fetchRemoteForStatus = ( + gitCommonDir: string, + remoteName: string, + ): Effect.Effect => { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps + // this poll from starting `git gc --auto`. When that gc fails, for example on a repository + // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` + // behind each time, so a background poll could fill the disk. + return executeGit( + "GitVcsDriver.fetchRemoteForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], + { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe(Effect.asVoid); + }; + + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( + cwd: string, + ) { + const commonDirResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ); + if (commonDirResult.exitCode !== 0) { + const stderr = commonDirResult.stderr.trim(); + if (isNonRepositoryGitStderr(stderr)) { + return null; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Failed to resolve the Git common directory.", + exitCode: commonDirResult.exitCode, + stdoutLength: commonDirResult.stdout.length, + stderrLength: commonDirResult.stderr.length, + }); + } + + const commonDirOutput = commonDirResult.stdout.trim(); + const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) + ? path.normalize(commonDirOutput) + : path.resolve(cwd, commonDirOutput); + const gitCommonDir = yield* fileSystem + .realPath(resolvedGitCommonDir) + .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + const [worktreeRootResult, currentBranchResult] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", + cwd, + ["rev-parse", "--show-toplevel"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.resolveRepositoryPaths.currentBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + ], + { concurrency: 2 }, + ); + const worktreeRootOutput = worktreeRootResult.stdout.trim(); + const worktreeRoot = + worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 + ? path.normalize( + path.isAbsolute(worktreeRootOutput) + ? worktreeRootOutput + : path.resolve(cwd, worktreeRootOutput), + ) + : null; + const currentBranchOutput = currentBranchResult.stdout.trim(); + const currentBranch = + currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 + ? currentBranchOutput + : null; + + return { + gitCommonDir, + worktreeRoot, + currentBranch, + } satisfies GitRepositoryPaths; + }); + + const repositoryPathsCache = yield* Cache.makeWith( + (cwd: string) => resolveRepositoryPathsUncached(cwd), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const repositoryPathsRefreshCache = yield* Cache.makeWith( + (cwd: string) => + Cache.invalidate(repositoryPathsCache, cwd).pipe( + Effect.andThen(Cache.get(repositoryPathsCache, cwd)), + ), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null + ? NON_REPOSITORY_PATHS_CACHE_TTL + : REPOSITORY_PATHS_REFRESH_COALESCE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd)); + const resolveRepositoryPaths = (cwd: string, refresh = false) => { + const cacheKey = normalizeRepositoryPathsCacheKey(cwd); + return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); + }; + + const defaultBranchCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.defaultBranch", + fetchCwd, + ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) return null; + return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); + }), + ); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const originExistsCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.originExists", + fetchCwd, + ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const invalidateStatusStaticCaches = (cwd: string) => + Effect.gen(function* () { + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + yield* Cache.invalidate(defaultBranchCache, cacheKey); + yield* Cache.invalidate(originExistsCache, cacheKey); + }); + + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { + const repositoryPaths = yield* resolveRepositoryPaths(cwd); + if (repositoryPaths !== null) { + return repositoryPaths.gitCommonDir; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Cannot resolve a Git common directory outside a repository.", + }); + }); + + const statusRemoteRefreshFailureCounts = new Map(); + const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => + `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; + const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { + const key = statusRemoteRefreshFailureKey(cacheKey); + const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; + statusRemoteRefreshFailureCounts.delete(key); + statusRemoteRefreshFailureCounts.set(key, nextCount); + if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; + if (oldestKey !== undefined) { + statusRemoteRefreshFailureCounts.delete(oldestKey); + } + } + }; + const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { + statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); + }; + const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( + cacheKey: StatusRemoteRefreshCacheKey, + ) { + return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( + Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), + Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), + Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), + Effect.as(true as const), + ); + }); + + const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { + capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + // A failed background fetch is intentionally cached and exponentially + // backed off. Status reads swallow this failure and use the last fetched + // refs, so repeated thread mounts cannot turn a slow or unavailable remote + // into a repository-wide Git subprocess storm. + timeToLive: (exit, cacheKey) => + Exit.isSuccess(exit) + ? STATUS_UPSTREAM_REFRESH_INTERVAL + : statusUpstreamRefreshFailureCooldown( + statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, + ), + }); + + const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( + cwd: string, + ) { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + const gitCommonDir = yield* resolveGitCommonDir(cwd); + // The cache loader logs failed attempts; cache hits keep using the last fetched refs. + yield* Cache.get( + statusRemoteRefreshCache, + new StatusRemoteRefreshCacheKey({ + gitCommonDir, + remoteName: upstream.remoteName, + }), + ).pipe(Effect.ignore); + }); + + const resolveDefaultBranchName = ( + cwd: string, + remoteName: string, + ): Effect.Effect => + executeGit( + "GitVcsDriver.resolveDefaultBranchName", + cwd, + ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); + }), + ); + + const remoteBranchExists: GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"] = (input) => + executeGit( + "GitVcsDriver.remoteBranchExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.remoteName}/${input.refName}`], + { + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.exitCode === 0)); + + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNamesInGitOrder), + ); + + const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* ( + cwd: string, + branchName: string, + ) { + const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames); + return parsedRemoteRef?.branchName ?? branchName; + }); + + const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + args: ["remote"], + }), + detail: "No git remote is configured for this repository.", + }); + }); + + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + cwd: string, + refName: string, + ) { + const branchPushRemote = yield* runGitStdout( + "GitVcsDriver.resolvePushRemoteName.branchPushRemote", + cwd, + ["config", "--get", `branch.${refName}.pushRemote`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (branchPushRemote.length > 0) { + return branchPushRemote; + } + + const pushDefaultRemote = yield* runGitStdout( + "GitVcsDriver.resolvePushRemoteName.remotePushDefault", + cwd, + ["config", "--get", "remote.pushDefault"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (pushDefaultRemote.length > 0) { + return pushDefaultRemote; + } + + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); + }); + + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( + "ensureRemote", + )(function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } + } + + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } + + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); + + const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( + cwd: string, + refName: string, + ) { + const configuredBaseBranch = yield* runGitStdout( + "GitVcsDriver.resolveBaseBranchForNoUpstream.config", + cwd, + ["config", "--get", `branch.${refName}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( + Effect.orElseSucceed(() => null), + ); + const defaultBranch = + primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); + const candidates = [ + configuredBaseBranch.length > 0 ? configuredBaseBranch : null, + defaultBranch, + ...DEFAULT_BASE_BRANCH_CANDIDATES, + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; + const normalizedCandidate = candidate.startsWith("origin/") + ? candidate.slice("origin/".length) + : remotePrefix && candidate.startsWith(remotePrefix) + ? candidate.slice(remotePrefix.length) + : candidate; + if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { + continue; + } + + if ( + primaryRemoteName && + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } + + if (yield* branchExists(cwd, normalizedCandidate)) { + return normalizedCandidate; + } + } + + return null; + }); + + const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( + cwd: string, + refName: string, + ) { + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); + if (!baseRef) { + return 0; + } + + const result = yield* executeGit( + "GitVcsDriver.computeAheadCountAgainstBase", + cwd, + ["rev-list", "--count", `${baseRef}..HEAD`], + { allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return 0; + } + + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }); + + const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { + const branchResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetailsRemote.branch", + cwd, + ["rev-parse", "--abbrev-ref", "HEAD"], + { allowNonZeroExit: true }, + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + + if (branchResult === null) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + let branch: string | null; + if (branchResult.exitCode !== 0) { + if (isNonRepositoryGitStderr(branchResult.stderr)) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + if (!isUnbornHeadStderr(branchResult.stderr)) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); + } + + const branchValue = yield* runGitStdout( + "GitVcsDriver.statusDetailsRemote.unbornBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ); + branch = branchValue.trim() || null; + } else { + const branchValue = branchResult.stdout.trim(); + branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + } + const upstream = yield* resolveCurrentUpstream(cwd); + const upstreamRef = upstream?.upstreamRef ?? null; + let aheadCount = 0; + let behindCount = 0; + + if (upstreamRef) { + const divergence = yield* executeGit( + "GitVcsDriver.statusDetailsRemote.divergence", + cwd, + ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`], + { allowNonZeroExit: true }, + ); + if (divergence.exitCode === 0) { + const [aheadRaw, behindRaw] = divergence.stdout.trim().split(/\s+/); + const parsedAhead = Number.parseInt(aheadRaw ?? "0", 10); + const parsedBehind = Number.parseInt(behindRaw ?? "0", 10); + aheadCount = Number.isFinite(parsedAhead) ? Math.max(0, parsedAhead) : 0; + behindCount = Number.isFinite(parsedBehind) ? Math.max(0, parsedBehind) : 0; + } + } else if (branch) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + Effect.orElseSucceed(() => 0), + ); + } + + const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin"); + const isDefaultBranch = + branch !== null && + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))); + const aheadOfDefaultCount = + branch && !isDefaultBranch + ? upstreamRef === null + ? aheadCount + : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) + : 0; + + return { + isRepo: true, + defaultBranch, + isDefaultBranch, + branch, + upstreamRef, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + aheadOfDefaultCount, + }; + }); + + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { + const indexResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.indexPath", + cwd, + ["rev-parse", "--git-path", "index"], + { allowNonZeroExit: true }, + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (indexResult === null) return NON_REPOSITORY_STATUS_DETAILS; + if (indexResult.exitCode === 0) { + const lockPath = `${path.resolve(cwd, indexResult.stdout.trim())}.lock`; + const lockError = new GitCommandError({ + operation: "GitVcsDriver.statusDetails.indexPath", + command: "git", + cwd, + detail: "Git index is locked. Status will resume when the index lock is removed.", + }); + // Status can succeed while locked, repeatedly running LFS clean filters without caching. + if ( + yield* fileSystem.exists(lockPath).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...lockError, + detail: "Failed to check the Git index lock.", + cause, + }), + ), + ) + ) { + return yield* lockError; + } + } + const statusResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + { + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } + + if (statusResult.exitCode !== 0) { + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: ["status", "--porcelain=2", "--branch"], + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); + } + + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.numstat", + cwd, + ["diff", "HEAD", "--numstat", "--"], + { allowNonZeroExit: true }, + ).pipe( + Effect.flatMap((result) => { + if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (isUnbornHeadStderr(result.stderr)) { + return Effect.map( + Effect.all([ + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ + "diff", + "--numstat", + ]), + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + ]), + ([unstagedStdout, stagedStdout]) => { + const staged = parseNumstatEntries(stagedStdout); + const unstaged = parseNumstatEntries(unstagedStdout); + const map = new Map(); + for (const entry of [...staged, ...unstaged]) { + const existing = map.get(entry.path) ?? { + insertions: 0, + deletions: 0, + }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + map.set(entry.path, existing); + } + return Array.from(map.entries()) + .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) + .join("\n"); + }, + ); + } + return Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.numstat", + cwd, + args: ["diff", "HEAD", "--numstat", "--"], + }), + detail: "git diff HEAD --numstat failed.", + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }), + ), + statusCacheKey + ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) + : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), + statusCacheKey + ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) + : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + ], + { concurrency: "unbounded" }, + ); + const statusStdout = statusResult.stdout; + + let refName: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let aheadOfDefaultCount = 0; + let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); + + for (const line of statusStdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + refName = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); + } + } + + const fallbackAheadCount = + !upstreamRef && refName + ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) + : null; + + if (fallbackAheadCount !== null) { + aheadCount = fallbackAheadCount; + behindCount = 0; + } + + const isDefaultBranch = + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))); + if (refName && !isDefaultBranch) { + aheadOfDefaultCount = + fallbackAheadCount !== null + ? fallbackAheadCount + : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); + } + + const numstatEntries = parseNumstatEntries(numstatStdout); + const fileStatMap = new Map(); + for (const entry of numstatEntries) { + fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); + } + + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); + + return { + isRepo: true, + hasOriginRemote: hasPrimaryRemote, + isDefaultBranch, + branch: refName, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + aheadOfDefaultCount, + }; + }); + + const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( + "statusDetailsLocal", + )(function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }); + + const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( + "statusDetails", + )(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), + Effect.ignoreCause({ log: true }), + ); + return yield* readStatusDetailsLocal(cwd); + }); + + const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] = + Effect.fn("statusDetailsRemote")(function* (cwd, options) { + if (options?.refreshUpstream !== false) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), + Effect.ignoreCause({ log: true }), + ); + } + return yield* readStatusDetailsRemote(cwd); + }); + + const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + isRepo: details.isRepo, + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, + pr: null, + })), + ); + + const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = + Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catchTags({ + GitCommandError: () => Effect.void, + }), + ); + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } + + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + + return { + stagedSummary, + stagedPatch, + }; + }); + + const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( + cwd, + subject, + body, + options?: GitVcsDriver.GitCommitOptions, + ) { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + const progress = + options?.progress?.onOutputLine === undefined + ? options?.progress + : { + ...options.progress, + onStdoutLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, + onStderrLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, + }; + yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(progress ? { progress } : {}), + }).pipe(Effect.asVoid); + const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ + "rev-parse", + "HEAD", + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( + "pushCurrentBranch", + )(function* (cwd, fallbackBranch, options) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push from detached HEAD.", + }); + } + + const requestedRemoteName = options?.remoteName?.trim() || null; + if (requestedRemoteName) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", + cwd, + ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } + + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (!publishRemoteName) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + + const hasRemoteBranch = yield* remoteBranchExists({ + cwd, + remoteName: publishRemoteName, + refName: branch, + }).pipe(Effect.orElseSucceed(() => false)); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + } + } + + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); + } + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushWithUpstream", + cwd, + ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } + + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.orElseSucceed(() => null), + ); + if (currentUpstream) { + // A branch tracking a differently named ref was cut from it, the way + // `git checkout -b feature origin/dev` and our own worktree flow leave + // it. That upstream is the branch's base, not its publish target, and + // pushing HEAD onto it would write feature commits to a shared branch + // (bare `git push` refuses this under push.default=simple). The one + // same-repo tracking setup that legitimately differs is a git-mangled + // alias such as local `upstream/effect-atom` for my-org/upstream's + // `effect-atom`: the branch name ends in the upstream head while the + // upstream ref ends in the branch name. + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const remoteName = publishRemoteName ?? currentUpstream.remoteName; + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + // `-u` retargets the upstream to the published branch, so keep the + // base recorded first; base resolution reads gh-merge-base before the + // upstream ref. + const configuredMergeBase = yield* runGitStdout( + "GitVcsDriver.pushCurrentBranch.readMergeBase", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (configuredMergeBase.length === 0) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ + "config", + `branch.${branch}.gh-merge-base`, + currentUpstream.branchName, + ]); + } + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushOwnBranch", + cwd, + ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: true, + }; + } + + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushUpstream", + cwd, + ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, + }; + } + + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], { timeoutMs: null }); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); + + const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn( + "pullCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Cannot pull from detached HEAD.", + }); + } + if (!details.hasUpstream) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Current branch has no upstream configured. Push with upstream first.", + }); + } + const beforeSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorDetail: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + refName, + upstreamRef: refreshed.upstreamRef, + }; + }); + + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( + "readRangeContext", + )(function* (cwd, baseRef) { + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.log", + cwd, + ["log", "--oneline", commitRange], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffStat", + cwd, + ["diff", "--stat", diffRange], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffPatch", + cwd, + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); + + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); + + // Use the same temporary index for patch and statistics so unstaged renames agree. + const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* ( + cwd: string, + untrackedPaths: ReadonlyArray, + ) { + const [stagedDeletionsStdout, indexValue] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", + cwd, + ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"], + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), + runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [ + "rev-parse", + "--git-path", + "index", + ]), + ], + { concurrency: 2 }, + ); + const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); + const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); + if (pathsToAdd.length === 0) return undefined; + + const indexPath = path.isAbsolute(indexValue.trim()) + ? indexValue.trim() + : path.resolve(cwd, indexValue.trim()); + const tempIndexPath = yield* fileSystem.makeTempFileScoped({ + prefix: `t3code-review-index-${process.pid}-`, + }); + const indexExists = yield* fileSystem.exists(indexPath); + if (indexExists) { + const { mtime } = yield* fileSystem.stat(indexPath); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. + // Flooring preserves the source second without making preceding-second files racy. + const indexTime = Option.isSome(mtime) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) + : 0; + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + } + const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; + const tempIndexConfig = [ + "-c", + "core.splitIndex=false", + "-c", + "splitIndex.sharedIndexExpire=never", + ]; + if (!indexExists) { + yield* executeGit("GitVcsDriver.review.emptyIndex", cwd, ["read-tree", "--empty"], { env }); + } + yield* executeGit( + "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.expandSplitIndex", + cwd, + [...tempIndexConfig, "update-index", "--no-split-index"], + { env }, + ); + yield* executeGit( + "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.addUntracked", + cwd, + [ + ...tempIndexConfig, + "--literal-pathspecs", + "add", + "--intent-to-add", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + { env, stdin: `${pathsToAdd.join("\0")}\0` }, + ); + return env; + }); + + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( + input: ReviewDiffPreviewInput, + ) { + const pathArgs = input.file + ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map( + (path) => `:(top,literal)${path}`, + ) + : []; + const patchLimit = input.file + ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES + : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; + const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (!repository?.worktreeRoot) { + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [], + }; + } + + const cwd = repository.worktreeRoot; + const branch = repository.currentBranch; + // Coder: thread switching can request only one review source. + const sourceKind = input.file?.sourceKind ?? input.sourceKind; + const baseRef = + input.baseRef ?? + (branch && sourceKind !== "working-tree" + ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null)) + : null); + + const diffArgs = [ + "diff", + "--find-renames", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( + ref: string, + env?: NodeJS.ProcessEnv, + ) { + const args = [...diffArgs, "--numstat", "-z"]; + const result = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.stat", + cwd, + [...args, ref, "--", ...pathArgs], + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env }, + ); + if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; + if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { + const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [ + "hash-object", + "-t", + "tree", + (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", + ])).trim(); + const stdout = yield* runGitStdoutWithOptions( + "GitVcsDriver.getReviewDiffPreview.unbornStat", + cwd, + [...args, emptyTree, "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env }, + ); + return { ref: emptyTree, files: parseReviewNumstat(stdout) }; + } + return yield* new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.stat", + cwd, + command: "git diff --numstat", + detail: "Could not read complete diff statistics.", + exitCode: result.exitCode, + }); + }); + const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( + ref: string | null, + env?: NodeJS.ProcessEnv, + ) { + if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] }; + const stat = yield* readStats(ref, env); + if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] }; + const patch = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.patch", + cwd, + [...diffArgs, "--patch", stat.ref, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true, env }, + ); + return { ...patch, files: stat.files }; + }); + const readDirty = Effect.gen(function* () { + if (sourceKind === "branch-range") return yield* readTrackedDiff(null); + const untracked = yield* executeGit( + "GitVcsDriver.review.listUntracked", + cwd, + ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ).pipe( + Effect.catchIf( + (error) => error.outputLength === undefined, + () => Effect.succeed(null), + ), + ); + if (untracked === null) { + const tracked = yield* readTrackedDiff("HEAD"); + return { ...tracked, files: undefined, stdoutTruncated: true }; + } + const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( + (candidate) => !input.file || candidate === input.file.path, + ); + if (paths.length === 0) return yield* readTrackedDiff("HEAD"); + const env = yield* prepareReviewIndex(cwd, paths).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.prepareReviewIndex", + cwd, + command: "git diff", + detail: "Could not prepare the review index.", + cause, + }), + ), + }), + ); + return yield* readTrackedDiff("HEAD", env); + }).pipe(Effect.scoped); + const [dirtyTrackedResult, baseResult] = yield* Effect.all( + [ + readDirty, + readTrackedDiff( + baseRef && branch && sourceKind !== "working-tree" ? `${baseRef}...HEAD` : null, + ), + ], + { concurrency: 2 }, + ); + const dirtyFiles = dirtyTrackedResult.files; + const baseFiles = baseResult.files; + const dirtyDiff = dirtyTrackedResult.stdout; + const baseDiff = baseResult.stdout; + const hashDiff = (diff: string, files: ReadonlyArray) => + crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", + cwd, + detail: "Failed to hash review diff.", + cause, + }), + ), + ); + const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ + hashDiff(dirtyDiff, dirtyFiles ?? []), + hashDiff(baseDiff, baseFiles), + ]); + + const sources: ReviewDiffPreviewSource[] = [ + { + id: "working-tree", + kind: "working-tree", + title: "Dirty worktree", + baseRef: "HEAD", + headRef: null, + diff: dirtyDiff, + ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }), + diffHash: dirtyDiffHash, + truncated: dirtyTrackedResult.stdoutTruncated, + }, + { + id: "branch-range", + kind: "branch-range", + title: baseRef ? `Against ${baseRef}` : "Against base branch", + baseRef, + headRef: branch ?? "HEAD", + diff: baseDiff, + files: baseFiles, + diffHash: baseDiffHash, + truncated: baseResult.stdoutTruncated, + }, + ]; + + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + // Coder: a top-level sourceKind (thread switching) returns only that source. + sources: input.sourceKind + ? sources.filter((source) => source.kind === input.sourceKind) + : sources, + }; + }); + + const reviewDiffFileError = ( + input: ReviewDiffFileContentsInput, + detail: string, + cause?: unknown, + ) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffFileContents", + command: "git", + cwd: input.cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const isPathWithinRoot = (root: string, candidate: string) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + + const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* ( + input: ReviewDiffFileContentsInput, + revision: string, + relativePath: string, + ) { + const result = yield* executeGit( + "GitVcsDriver.getReviewDiffFileContents.revision", + input.cwd, + ["show", `${revision}:${relativePath}`], + // Coder: detect truncation and return the chunked review API's typed size error. + { maxOutputBytes: MAX_REVIEW_DIFF_FILE_BYTES, appendTruncationMarker: true }, + ); + if (result.stdoutTruncated) { + return yield* new ReviewDiffFileTooLargeError({ + path: relativePath, + maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, + }); + } + if (result.stdoutInvalidUtf8 || result.stdout.includes("\0")) { + return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`); + } + return result.stdout; + }); + + const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* ( + input: ReviewDiffFileContentsInput, + repositoryRoot: string, + ) { + const fileError = (stage: string, detail: string, cause?: unknown) => + new GitCommandError({ + operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`, + command: stage, + cwd: input.cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const requestedPath = path.resolve(repositoryRoot, input.newPath); + if (!isPathWithinRoot(repositoryRoot, requestedPath)) { + return yield* fileError( + "path.resolve", + `Diff file '${input.newPath}' resolves outside the review workspace.`, + ); + } + + const [realRepositoryRoot, realTarget] = yield* Effect.all([ + fileSystem.realPath(repositoryRoot), + fileSystem.realPath(requestedPath), + ]).pipe( + Effect.mapError((cause) => + fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause), + ), + ); + if (!isPathWithinRoot(realRepositoryRoot, realTarget)) { + return yield* fileError( + "fs.realPath", + `Diff file '${input.newPath}' resolves outside the review workspace.`, + ); + } + + const info = yield* fileSystem + .stat(realTarget) + .pipe( + Effect.mapError((cause) => + fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause), + ), + ); + if (info.type !== "File") { + return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`); + } + // Coder: full review expansion uses the shared 32 MiB limit and typed error. + if (info.size > BigInt(MAX_REVIEW_DIFF_FILE_BYTES)) { + return yield* new ReviewDiffFileTooLargeError({ + path: input.newPath, + maxBytes: MAX_REVIEW_DIFF_FILE_BYTES, + }); + } + + const bytes = yield* fileSystem + .readFile(realTarget) + .pipe( + Effect.mapError((cause) => + fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause), + ), + ); + if (bytes.includes(0)) { + return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); + } + // Coder: reject invalid UTF-8 instead of replacing bytes in review contents. + return yield* Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: (cause) => + fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`, cause), + }); + }); + + const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( + input: ReviewDiffFileContentsInput, + ) { + if (input.sourceKind === "working-tree") { + const repositoryRoot = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", + input.cwd, + ["rev-parse", "--show-toplevel"], + ).pipe(Effect.map((value) => value.trim())); + if (repositoryRoot.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readWorkingTreeReviewFile(input, repositoryRoot), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } + + if (!input.baseRef || !input.headRef) { + return yield* reviewDiffFileError( + input, + "Branch diff file expansion requires both base and head refs.", + ); + } + const mergeBase = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.mergeBase", + input.cwd, + ["merge-base", input.baseRef, input.headRef], + ).pipe(Effect.map((value) => value.trim())); + if (mergeBase.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base."); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, mergeBase, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, input.headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + + const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => + runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), + ); + + const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + const gitDirArgs = ["--git-dir", gitCommonDir] as const; + const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.listRefs.snapshotRefs", + fetchCwd, + [ + ...gitDirArgs, + "for-each-ref", + "--format=%(refname)%09%(committerdate:unix)%09%(symref)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + fallbackErrorDetail: "Git ref snapshot enumeration failed.", + }, + ), + executeGit( + "GitVcsDriver.listRefs.defaultRef", + fetchCwd, + [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + fetchCwd, + [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], + { + timeoutMs: 30_000, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }, + ), + executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }), + ], + { concurrency: 2 }, + ); + + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + const parsedWorktreeEntries = + worktreeListResult.exitCode === 0 + ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( + ([branchName, worktreePath]) => + [branchName, path.normalize(path.resolve(worktreePath))] as const, + ) + : []; + const existingWorktreeEntries = yield* Effect.filter( + parsedWorktreeEntries, + ([, worktreePath]) => + fileSystem.stat(worktreePath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + { concurrency: 16 }, + ); + const worktreeMap = new Map(existingWorktreeEntries); + const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + + for (const line of refsResult.stdout.split("\n")) { + if (line.length === 0) continue; + const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); + if (!fullRefName || symbolicTarget) continue; + const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; + + if (fullRefName.startsWith("refs/heads/")) { + const name = fullRefName.slice("refs/heads/".length); + localBranches.push({ + ref: { + name, + current: false, + isRemote: false, + isDefault: name === defaultBranch, + worktreePath: worktreeMap.get(name) ?? null, + }, + lastCommit, + }); + continue; + } + if (!fullRefName.startsWith("refs/remotes/")) continue; + + const name = fullRefName.slice("refs/remotes/".length); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames); + const remoteBranch: VcsRef = { + name, + current: false, + isRemote: true, + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, + worktreePath: null, + ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}), + }; + remoteBranches.push({ ref: remoteBranch, lastCommit }); + } + + const byRecencyThenName = ( + left: { readonly ref: VcsRef; readonly lastCommit: number }, + right: { readonly ref: VcsRef; readonly lastCommit: number }, + ) => + left.lastCommit !== right.lastCommit + ? right.lastCommit - left.lastCommit + : left.ref.name.localeCompare(right.ref.name); + + return { + localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + hasPrimaryRemote: remoteNames.includes("origin"), + } satisfies GitRefsSnapshot; + }); + + const listRefsEpochByCommonDir = new Map(); + let listRefsEpochSequence = 0; + const bumpListRefsEpoch = (gitCommonDir: string): number => { + const nextEpoch = ++listRefsEpochSequence; + listRefsEpochByCommonDir.delete(gitCommonDir); + listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch); + if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsEpochByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsEpochByCommonDir.delete(oldestKey); + } + } + return nextEpoch; + }; + const listRefsGenerationByCommonDir = new Map(); + let listRefsGenerationSequence = 0; + const setListRefsGeneration = (gitCommonDir: string, generation: number): number => { + listRefsGenerationByCommonDir.delete(gitCommonDir); + listRefsGenerationByCommonDir.set(gitCommonDir, generation); + if (listRefsGenerationByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsGenerationByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsGenerationByCommonDir.delete(oldestKey); + } + } + return generation; + }; + const currentListRefsGeneration = (gitCommonDir: string): number => { + const current = listRefsGenerationByCommonDir.get(gitCommonDir); + return current === undefined + ? setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence) + : setListRefsGeneration(gitCommonDir, current); + }; + const bumpListRefsGeneration = (gitCommonDir: string): number => + setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence); + const listRefsSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero), + }, + ); + const listRefsRefreshSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsRefreshCacheKey) => + Effect.suspend(() => { + const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir); + return Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }), + ); + }), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN, + }, + ); + const resolveListRefsSnapshot = Effect.fn("resolveListRefsSnapshot")(function* ( + gitCommonDir: string, + refresh: boolean, + ) { + while (true) { + const generation = currentListRefsGeneration(gitCommonDir); + const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); + const snapshot = + refresh || currentEpoch === undefined + ? // The refresh cache owns the complete snapshot read, rather than only the + // epoch bump. Slow repositories therefore remain singleflight for the + // entire Git scan even when more refresh requests arrive after the + // coalescing TTL would otherwise have elapsed. + yield* Cache.get( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ gitCommonDir, generation }), + ) + : yield* Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }), + ); + if (currentListRefsGeneration(gitCommonDir) === generation) { + return snapshot; + } + } + }); + const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( + cwd: string, + ) { + const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); + const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); + if (repositoryPaths === null) return; + const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsEpoch(repositoryPaths.gitCommonDir); + yield* Cache.invalidate( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ + gitCommonDir: repositoryPaths.gitCommonDir, + generation: previousGeneration, + }), + ); + yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey); + yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey); + }); + + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + + const snapshot = yield* resolveListRefsSnapshot( + repositoryPaths.gitCommonDir, + input.refresh === true, + ); + const hasCurrentWorktreeBranch = + repositoryPaths.worktreeRoot !== null && + snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); + const localBranches = snapshot.localBranches.map((ref) => ({ + ...ref, + current: hasCurrentWorktreeBranch + ? ref.worktreePath === repositoryPaths.worktreeRoot + : ref.name === repositoryPaths.currentBranch, + })); + const combinedBranches = input.includeMatchingRemoteRefs + ? [...localBranches, ...snapshot.remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); + // Keep current/default refs on the first page even when the default + // only exists as origin/ (remote refs sort after all locals). + const allBranches = combinedBranches.toSorted((left, right) => { + const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; + const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; + return leftPriority - rightPriority; + }); + const branchesForKind = + input.refKind === "local" + ? allBranches.filter((ref) => !ref.isRemote) + : input.refKind === "remote" + ? allBranches.filter((ref) => ref.isRemote) + : allBranches; + const refs = paginateBranches({ + refs: filterBranchesForListQuery(branchesForKind, input.query), + cursor: input.cursor, + limit: input.limit, + }); + + return { + refs: [...refs.refs], + isRepo: true, + hasPrimaryRemote: snapshot.hasPrimaryRemote, + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, + }; + }, + ); + + // Coder: the checkout's t3.json uses the bounded, symlink-checked metadata reader. + const readWorktreeProjectFile = (worktreePath: string) => + Effect.sync(() => readProjectConfig(worktreePath)).pipe( + Effect.tap((result) => + result.status === "invalid" + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively") + : Effect.void, + ), + Effect.map((result) => result.file), + ); + + // Coder: ref-only polling never refreshes or rewrites the user's real index. + const refStatusLocal: GitVcsDriver.GitVcsDriver["Service"]["refStatusLocal"] = Effect.fn( + "GitVcsDriver.refStatusLocal", + )(function* (cwd) { + const inside = yield* executeGit( + "GitVcsDriver.refStatus.inside", + cwd, + ["rev-parse", "--is-inside-work-tree"], + { allowNonZeroExit: true }, + ); + if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") { + return { isRepo: false, refName: null }; + } + const branch = yield* executeGit( + "GitVcsDriver.currentBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + { allowNonZeroExit: true }, + ); + return { isRepo: true, refName: branch.exitCode === 0 ? branch.stdout.trim() || null : null }; + }); + + // Coder: thread branch renames also move the workspace worktree; identical paths are a no-op. + const moveWorktree: GitVcsDriver.GitVcsDriver["Service"]["moveWorktree"] = (input) => + input.oldPath === input.newPath + ? Effect.void + : runGit( + "GitVcsDriver.moveWorktree", + input.cwd, + ["worktree", "move", input.oldPath, input.newPath], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ).pipe(Effect.asVoid); + + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( + "createWorktree", + )(function* (input, options) { + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + const progress = options?.progress; + const onCheckoutProgress = progress?.onCheckoutProgress; + + const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + yield* executeGit( + "GitVcsDriver.createWorktree", + input.cwd, + ["-c", `checkout.workers=${checkoutWorkers}`, ...args], + { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, + }, + } + : {}), + }, + ); + + if (progress?.onWorktreeClaimed) { + yield* progress.onWorktreeClaimed(worktreePath); + } + + // `git worktree add` leaves submodules empty, so a repo that keeps agent + // skills, tooling or source in one gets a worktree that is quietly missing + // them. Best-effort: the objects are usually already in the parent's + // `.git/modules`, but a first-ever clone needs the network, and failing to + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level; the + // caller resolves that from settings, or the checkout's t3.json decides. + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : // Coder: read only bounded, symlink-checked checkout metadata. + yield* readWorktreeProjectFile(worktreePath), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); + } + if (submoduleMode.value !== "none") { + if (progress?.onSubmodulesStarted) { + yield* progress.onSubmodulesStarted(); + } + const onSubmoduleLine = progress?.onSubmoduleLine; + yield* runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }).pipe( + Effect.andThen( + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) + : Effect.void, + ), + ), + onSuccess: () => + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: true, detail: null }) + : Effect.void, + }), + ); + } + + if (input.newRefName && input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + baseBranch, + ]); + } + + return { + worktree: { + path: worktreePath, + refName: targetBranch, + }, + }; + }); + + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = + Effect.fn("fetchPullRequestBranch")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorDetail: "git fetch pull request branch failed", + }, + ); + }); + + const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( + "resolveCommit", + )(function* (input) { + const commitSha = yield* runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [ + "rev-parse", + "--verify", + `${input.revision}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = + Effect.fn("fetchPullRequestHeadCommit")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a + // branch, which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchPullRequestHeadCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`], + { + fallbackErrorDetail: "git fetch pull request head failed", + }, + ); + + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); + + const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = + Effect.fn("refreshCheckedOutBranch")(function* (input) { + const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" }); + if (headCommit === input.targetCommit) { + return { headCommit, moved: false, onTarget: true }; + } + + const worktreeChanges = yield* runGitStdout( + "GitVcsDriver.refreshCheckedOutBranch.status", + input.cwd, + ["status", "--porcelain"], + ); + if (worktreeChanges.trim().length > 0) { + return { headCommit, moved: false, onTarget: false }; + } + + const isAncestor = yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.isAncestor", + input.cwd, + ["merge-base", "--is-ancestor", headCommit, input.targetCommit], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can + // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD + // never left the commit the upstream held before the fetch. + if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { + return { headCommit, moved: false, onTarget: false }; + } + + if (!isAncestor) { + // The commit being reset away is about to be reachable from nothing. It is only ever a + // commit the remote already held, but "the remote held it" stops being a way back once + // the head it belonged to has been rewritten, so a ref keeps it findable. + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", + input.cwd, + ["update-ref", "refs/t3code/pre-refresh", headCommit], + { fallbackErrorDetail: "git failed to record the previous checkout commit" }, + ); + } + + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.move", + input.cwd, + // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another + // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` + // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the + // other branch — so a race loses nothing; the refresh fails and is reported instead. + isAncestor + ? ["merge", "--ff-only", input.targetCommit] + : ["reset", "--merge", input.targetCommit], + { + timeoutMs: 30_000, + fallbackErrorDetail: "git failed to move the checkout onto the pull request head", + }, + ); + + return { headCommit: input.targetCommit, moved: true, onTarget: true }; + }); + + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( + function* (input) { + const args = ["fetch", "--quiet", input.remoteName]; + const options = { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, + }; + const fetchAll = executeGitWithStableDiagnostics( + "GitVcsDriver.fetchRemote", + input.cwd, + args, + { + ...options, + allowNonZeroExit: true, + }, + ).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.void + : Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ), + ); + if (input.refName === undefined) { + return yield* fetchAll.pipe(Effect.asVoid); + } + const branch = + parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? + input.refName; + const scopedArgs = [ + ...args, + `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, + ]; + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.fetchRemote", + input.cwd, + scopedArgs, + { ...options, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) return; + if ( + result.stderr + .split(/\r?\n/) + .includes(`fatal: couldn't find remote ref refs/heads/${branch}`) + ) { + return yield* fetchAll.pipe(Effect.asVoid); + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: scopedArgs, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }, + ); + + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = + Effect.fn("resolveRemoteTrackingCommit")(function* (input) { + const remoteNames = yield* listRemoteNames(input.cwd); + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + input.refName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const remoteRefName = + parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; + const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ + "rev-parse", + "--verify", + `refs/remotes/${remoteRefName}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha, remoteRefName }; + }); + + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( + "fetchRemoteBranch", + )(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitVcsDriver.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }); + + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = + Effect.fn("fetchRemoteTrackingBranch")(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + }); + + const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => + runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, + ]); + + const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( + "removeWorktree", + )(function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + { + // Removing dependency-heavy worktrees is filesystem-bound and can take + // minutes, especially on Windows. Keep it bounded without interrupting + // git midway through cleanup. + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + allowNonZeroExit: true, + }, + ); + if (result.exitCode === 0) { + return; + } + // Threads can share a worktree path, and worktrees get removed or pruned + // outside the app, so a worktree that is already gone is a no-op rather + // than an error. Prune so no stale registration lingers to block a later + // `worktree add` at the same path. + const alreadyGone = + isMissingWorktreeStderr(result.stderr) && + !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + if (alreadyGone) { + yield* pruneWorktrees({ cwd: input.cwd }); + return; + } + // Raw stderr stays out of both the wire error and the log (it can carry + // secrets); log bounded diagnostics so a genuine failure is visible + // server-side. + yield* Effect.logWarning( + `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, + ); + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + detail: "git worktree remove failed", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }); + + const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( + "pruneWorktrees", + )(function* (input) { + yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { + timeoutMs: 15_000, + fallbackErrorDetail: "git worktree prune failed", + }); + }); + + const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( + "renameBranch", + )(function* (input) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorDetail: "git branch rename failed", + }, + ); + + return { branch: targetBranch }; + }); + + const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")( + function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); + + const localTrackingBranch = remoteExists + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackingBranch", + input.cwd, + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; + + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; + + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; + + // A stale ref must not turn into a path checkout that discards local edits. + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { + timeoutMs: 10_000, + fallbackErrorDetail: "git checkout failed", + }); + + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { refName }; + }, + ); + + const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")( + function* (input) { + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + timeoutMs: 10_000, + fallbackErrorDetail: "git branch create failed", + }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); + } + + return { refName: input.refName }; + }, + ); + + const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => + executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { + timeoutMs: 10_000, + fallbackErrorDetail: "git init failed", + }).pipe(Effect.asVoid); + + const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( + cwd, + ) => + runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ + "branch", + "--list", + "--no-column", + "--format=%(refname:short)", + ]).pipe( + Effect.map((stdout) => { + const branchNames: Array = []; + for (const line of stdout.split("\n")) { + const branchName = line.trim(); + if (branchName.length > 0) { + branchNames.push(branchName); + } + } + return branchNames; + }), + ); + + const withListRefsInvalidation = ( + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.ensuring( + Effect.all([ + invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), + invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), + ]), + ), + ); + const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( + input, + ) => + initRepo(input).pipe( + Effect.ensuring( + Effect.gen(function* () { + const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd); + yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); + yield* Cache.invalidate(repositoryPathsCache, cacheKey); + yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore); + }), + ), + ); + + return GitVcsDriver.GitVcsDriver.of({ + execute, + status, + statusDetails, + statusDetailsLocal, + statusDetailsRemote, + prepareCommitContext, + commit: (cwd, subject, body, options) => + withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), + pushCurrentBranch: (cwd, fallbackBranch, options) => + withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), + pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + readRangeContext, + // Coder: index-preserving ref-only polling. + refStatusLocal, + getReviewDiffPreview, + getReviewDiffFileContents, + readConfigValue, + listRefs, + createWorktree: (input, options) => + withListRefsInvalidation(input.cwd, createWorktree(input, options)), + fetchPullRequestBranch: (input) => + withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), + fetchPullRequestHeadCommit, + resolveCommit, + refreshCheckedOutBranch: (input) => + withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), + ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), + resolvePrimaryRemoteName, + resolveDefaultBranchName, + fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, + remoteBranchExists, + resolveRemoteTrackingCommit, + fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), + fetchRemoteTrackingBranch: (input) => + withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), + setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), + removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), + // Coder: worktree moves invalidate the same refs cache as other mutations. + moveWorktree: (input) => withListRefsInvalidation(input.cwd, moveWorktree(input)), + renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), + createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), + switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + initRepo: initRepoWithListRefsInvalidation, + listLocalBranchNames, + }); +}); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 190cb4d977d85..023f181d3ecd4 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -33,10 +34,8 @@ export interface VcsProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; readonly appendTruncationMarker?: boolean; - /** Classifies a non-zero result before stderr is discarded at the process boundary. */ + // Coder: the GitLab write probe classifies stderr before it is discarded. readonly classifyNonZeroExit?: (stderr: string) => VcsProcessExitFailureKind | undefined; - readonly onStdoutLine?: (line: string) => Effect.Effect; - readonly onStderrLine?: (line: string) => Effect.Effect; } export interface VcsProcessOutput { @@ -60,11 +59,15 @@ export class VcsProcess extends Context.Service< const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const VCS_PROCESS_CONCURRENCY = 8; +// Coder: GitLab-only source control does not allocate a GitHub CLI semaphore. export const CHECKPOINT_CAPTURE_OPERATION = "GitVcsDriver.checkpoints.captureCheckpoint"; +// Coder: hosted CLI authentication/not-found classification is GitLab-only. const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + if ( normalized.includes("authentication failed") || normalized.includes("not logged in") || @@ -74,14 +77,17 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai ) { return "authentication"; } + if ( normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || + normalized.includes("secondary rate limit") || normalized.includes("too many requests") || normalized.includes("http 429") ) { return "rate-limited"; } + if ( command === "glab" && (normalized.includes("merge request not found") || @@ -90,6 +96,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai ) { return "not-found"; } + return "command-failed"; }; @@ -100,8 +107,9 @@ const isTransientGitExit = (stderr: string) => export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; + const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); - const runOnce = Effect.fn("VcsProcess.runOnce")(function* (input: VcsProcessInput) { + const runUnbounded = Effect.fn("VcsProcess.runUnbounded")(function* (input: VcsProcessInput) { const baseError = { operation: input.operation, command: input.command, @@ -123,8 +131,6 @@ export const make = Effect.gen(function* () { outputMode: input.outputMode ?? "truncate", truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", timeoutBehavior: "error", - ...(input.onStdoutLine ? { onStdoutLine: input.onStdoutLine } : {}), - ...(input.onStderrLine ? { onStderrLine: input.onStderrLine } : {}), }) .pipe( Effect.mapError( @@ -161,6 +167,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { + // Coder: preserve the GitLab write probe's classification hook. const failureKind = input.classifyNonZeroExit?.(result.stderr) ?? classifyNonZeroExit(input.command, result.stderr); @@ -190,13 +197,20 @@ export const make = Effect.gen(function* () { }); const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); if ( input.command === "git" && input.operation === CHECKPOINT_CAPTURE_OPERATION && input.onStdoutChunk === undefined ) { // Retry the failed command, retaining the private index/tree and recovery's outer deadline. - return yield* runOnce(input).pipe( + return yield* bounded.pipe( + Effect.tapError((error) => + Effect.logDebug("checkpoint Git command failed", { + operation: input.operation, + errorTag: error._tag, + }), + ), Effect.retry({ times: 2, while: (error) => error._tag === "VcsProcessExitError" && error.retryable === true, @@ -204,7 +218,8 @@ export const make = Effect.gen(function* () { }), ); } - return yield* runOnce(input); + // Coder: all supported VCS processes use only the general semaphore. + return yield* bounded; }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts new file mode 100644 index 0000000000000..8d23605bad131 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -0,0 +1,1239 @@ +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import type { + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +// Coder: exercise the fork settings policy and lightweight ref-stream contract. +import { + DEFAULT_SERVER_SETTINGS, + GitManagerError, + ProjectId, + type OrchestrationProject, + type ServerSettings as ServerSettingsValue, + type VcsRefStatusStreamEvent, +} from "@t3tools/contracts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; + +// Coder: the demand-only policy exposes just the VCS scope used by this suite. +import type { BackgroundScope } from "../background/BackgroundPolicy.ts"; + +const baseLocalStatus: VcsStatusLocalResult = { + isRepo: true, + // Coder: source-control fixtures use the only supported hosted provider. + sourceControlProvider: { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: VcsStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const remoteStatusWithPr: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { + number: 2978, + title: "[codex] Rewrite client connection architecture", + // Coder: keep the merge-request fixture within the GitLab-only contract. + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/2978", + baseRef: "main", + headRef: "codex/connection-state-audit", + state: "open", + }, +}; + +const baseStatus: VcsStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + +function makeTestLayer(state: { + currentLocalStatus: VcsStatusLocalResult; + currentRemoteStatus: VcsStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; + remoteStatusRefreshUpstreamValues?: Array; + backgroundWorkEnabled?: boolean; +}) { + return VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => state.backgroundWorkEnabled !== false)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (_input, options) => + Effect.sync(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); +} + +function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) => boolean) { + return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + // Coder: omit client leases and host-power members absent from the fork policy. + shouldRunScopeWork: (scope) => Effect.sync(() => shouldRunScopeWork(scope)), + }); +} + +describe("VcsStatusBroadcaster", () => { + it.effect.skipIf(!symlinksSupported)( + "automatically pulls an enabled clean default branch when status detects it is behind", + () => { + let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; + let pullCalls = 0; + let configuredWorkspaceRoot = ""; + const localStatus: VcsStatusLocalResult = { + ...baseLocalStatus, + isDefaultRef: true, + refName: "main", + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(localStatus), + remoteStatus: () => Effect.succeed(remoteStatus), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + remoteStatus = { ...remoteStatus, behindCount: 0 }; + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + }), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-link-", + }); + configuredWorkspaceRoot = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, configuredWorkspaceRoot); + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const status = yield* broadcaster.refreshStatus(configuredWorkspaceRoot); + + assert.equal(pullCalls, 1); + assert.equal(status.behindCount, 0); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("reuses the cached VCS status across repeated reads", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + // Nobody loaded this cwd yet: no host request is spent. + assert.isNull(yield* broadcaster.refreshPullRequestStatus("/repo")); + assert.equal(state.remoteStatusCalls, 0); + + yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(state.remoteStatusCalls, 1); + + // Loaded and no PR known: ask GitManager to retry the missing PR. + state.currentRemoteStatus = remoteStatusWithPr; + const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 0); + + // The agent switches branches. The previous branch's PR must not block a read. + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; + state.currentRemoteStatus = baseRemoteStatus; + yield* broadcaster.refreshLocalStatus("/repo"); + const refreshedBranch = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshedBranch, baseRemoteStatus); + assert.equal(state.remoteStatusCalls, 3); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("a poll that started before the turn-end refresh cannot overwrite its PR", () => { + const releaseFirstPoll = Deferred.makeUnsafe(); + const firstPollStarted = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 2) { + // Hold an older empty response while the turn-end refresh queues. + yield* Deferred.succeed(firstPollStarted, undefined); + yield* Deferred.await(releaseFirstPoll); + return baseRemoteStatus; + } + return remoteReads === 1 ? baseRemoteStatus : remoteStatusWithPr; + }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + const poll = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(firstPollStarted); + const refresh = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseFirstPoll, undefined); + yield* Fiber.join(poll); + const refreshed = yield* Fiber.join(refresh); + + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + const final = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(final.pr, remoteStatusWithPr.pr); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("an initial status read cannot overwrite an explicit refresh", () => { + const firstReadStarted = Deferred.makeUnsafe(); + const releaseFirstRead = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 1) { + yield* Deferred.succeed(firstReadStarted, undefined); + yield* Deferred.await(releaseFirstRead); + return baseRemoteStatus; + } + return remoteStatusWithPr; + }), + invalidateStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstReadStarted); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + // Run ready fibers before releasing the delayed first read. + yield* TestClock.adjust(Duration.zero); + yield* Deferred.succeed(releaseFirstRead, undefined); + yield* Fiber.join(initial); + yield* Fiber.join(refresh); + assert.deepStrictEqual( + (yield* broadcaster.getStatus({ cwd: "/repo" })).pr, + remoteStatusWithPr.pr, + ); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("turn-end refresh skips a loaded cwd when background policy pauses it", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + backgroundWorkEnabled: false, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.suspend(() => { + state.remoteStatusCalls += 1; + return state.failRemoteStatus + ? Effect.fail( + new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + cwd: "/repo", + detail: "remote status failed", + }), + ) + : Effect.succeed(state.currentRemoteStatus); + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/partial-refresh", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 3, + }; + state.failRemoteStatus = true; + + const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.isTrue(Exit.isFailure(refreshExit)); + assert.deepStrictEqual(cached, baseStatus); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("refreshes only the cached local snapshot when requested", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/local-only-refresh", + hasWorkingTreeChanges: true, + }; + + const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...baseRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect.skipIf(!symlinksSupported)( + "normalizes symlinked CWDs before cache lookup and workflow calls", + () => { + const seenCwds: string[] = []; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-link-", + }); + const linkDir = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, linkDir); + const realPath = yield* fileSystem.realPath(realDir); + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: linkDir }); + yield* broadcaster.getStatus({ cwd: realDir }); + + assert.deepStrictEqual(seenCwds, [realPath, realPath]); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("loads remote status once when periodic refreshes are disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(scope)); + + const snapshot = yield* Deferred.await(snapshotDeferred); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: remoteStatusWithPr, + } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); + + yield* TestClock.adjust(Duration.minutes(2)); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("retries the initial remote load when periodic refreshes are disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, + }; + const privateCwd = "/private/user/workspace/repo"; + const nestedCause = new Error("private nested VCS failure"); + const messages: Array> = []; + const logger = Logger.make(({ message }) => { + messages.push(message as ReadonlyArray); + }); + let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (_input, options) => + Effect.suspend(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues.push(options?.refreshUpstream); + if (state.remoteStatusCalls === 1) { + return Effect.fail( + new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + cwd: privateCwd, + detail: "private initial remote status failure", + cause: nestedCause, + }), + ).pipe( + Effect.ensuring( + firstRemoteAttemptDeferred + ? Deferred.succeed(firstRemoteAttemptDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ); + } + return Effect.succeed(remoteStatusWithPr); + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const scope = yield* Scope.make(); + firstRemoteAttemptDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: privateCwd }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => + event._tag === "remoteUpdated" + ? Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(firstRemoteAttemptDeferred); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 1); + assert.deepStrictEqual( + messages.find((message) => message[0] === "VCS remote status refresh failed"), + [ + "VCS remote status refresh failed", + { + cwdLength: privateCwd.length, + reasonCount: 1, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.test"], + defectCount: 0, + defectTags: [], + interruptionCount: 0, + consecutiveFailures: 1, + nextDelayMs: 30_000, + }, + ], + ); + + yield* TestClock.adjust(Duration.seconds(30)); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: remoteStatusWithPr, + } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 0); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); + + yield* Scope.close(scope, Exit.void); + }).pipe( + Effect.provide( + Layer.mergeAll( + testLayer, + TestClock.layer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("delays automatic refresh when a cached remote snapshot is available", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + + yield* TestClock.adjust(Duration.seconds(59)); + assert.equal(state.remoteStatusCalls, 1); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 1); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), + 30_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(2, Duration.seconds(1))), + 60_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(3, Duration.seconds(1))), + 120_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.minutes(5))), + 300_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(20, Duration.seconds(1))), + 900_000, + ); + }); + + it("summarizes refresh causes without exposing nested failure details", () => { + const nestedCause = new Error("private nested failure detail"); + const failure = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/private/user/workspace/repo", + detail: "private Git failure detail", + cause: nestedCause, + }); + const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); + + assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { + reasonCount: 2, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.remoteStatus"], + defectCount: 1, + defectTags: ["TypeError"], + interruptionCount: 0, + }); + }); + + it.effect("does not start automatic remote refreshes without foreground client demand", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, + ), + ); + + assert.isTrue(Option.isSome(snapshot)); + assert.equal(state.remoteStatusCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); + }).pipe(Effect.provide(testLayer)); + }); +}); + +// Coder: preserve the lightweight ref stream without index refreshes or remote polling. +describe("Coder ref status stream", () => { + it.effect("reads only a non-mutating snapshot and never starts remote polling", () => { + let localReads = 0; + let remoteReads = 0; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(BackgroundPolicy.layer), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localRefStatus: () => Effect.succeed({ isRepo: true, refName: "main" }), + localStatus: () => + Effect.sync(() => { + localReads++; + return baseLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + remoteReads++; + return baseRemoteStatus; + }), + invalidateRemoteStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const ready = yield* Deferred.make(); + const observer = yield* broadcaster.streamRefStatus("/repo").pipe( + Stream.runForEach((event) => Deferred.succeed(ready, event)), + Effect.forkChild, + ); + assert.deepStrictEqual(yield* Deferred.await(ready), { + _tag: "snapshot", + local: { isRepo: true, refName: "main" }, + }); + yield* TestClock.adjust(Duration.minutes(2)); + assert.equal(localReads, 0); + assert.equal(remoteReads, 0); + yield* Fiber.interrupt(observer); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect( + "maps local updates and full snapshots, filters other repositories, and dedupes refs", + () => { + let local = baseLocalStatus; + let remote = baseRemoteStatus; + const events: Array = []; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(BackgroundPolicy.layer), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localRefStatus: () => Effect.succeed({ isRepo: local.isRepo, refName: local.refName }), + localStatus: () => Effect.sync(() => local), + remoteStatus: () => Effect.sync(() => remote), + invalidateLocalStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const ready = yield* Deferred.make(); + const observer = yield* broadcaster.streamRefStatus("/repo").pipe( + Stream.runForEach((event) => { + events.push(event); + return Deferred.succeed(ready, undefined); + }), + Effect.forkChild, + ); + yield* Deferred.await(ready); + yield* broadcaster.refreshLocalStatus("/other"); + yield* broadcaster.refreshLocalStatus("/repo"); + local = { ...local, hasWorkingTreeChanges: true }; + yield* broadcaster.refreshLocalStatus("/repo"); + yield* broadcaster.refreshStatus("/repo"); + // A remote-only change must not appear in the ref stream. + remote = { ...remote, aheadCount: 1 }; + yield* broadcaster.refreshPullRequestStatus("/repo"); + local = { ...local, refName: "feature/new" }; + yield* broadcaster.refreshStatus("/repo"); + local = { ...local, isRepo: false }; + yield* broadcaster.refreshLocalStatus("/repo"); + yield* TestClock.adjust(Duration.zero); + yield* Fiber.interrupt(observer); + assert.deepStrictEqual(events, [ + { _tag: "snapshot", local: { isRepo: true, refName: baseLocalStatus.refName } }, + { _tag: "localUpdated", local: { isRepo: true, refName: baseLocalStatus.refName } }, + { _tag: "localUpdated", local: { isRepo: true, refName: "feature/new" } }, + { _tag: "localUpdated", local: { isRepo: false, refName: "feature/new" } }, + ]); + }).pipe(Effect.provide(testLayer)); + }, + ); + + it.effect("subscribes before reading the snapshot so concurrent local updates are retained", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(BackgroundPolicy.layer), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localRefStatus: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ isRepo: true, refName: "main" }), + ), + localStatus: () => Effect.succeed({ ...baseLocalStatus, refName: "feature/new" }), + invalidateLocalStatus: () => Effect.void, + }), + ), + ); + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const observer = yield* broadcaster + .streamRefStatus("/repo") + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Deferred.await(started); + yield* broadcaster.refreshLocalStatus("/repo"); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(Array.from(yield* Fiber.join(observer)), [ + { _tag: "snapshot", local: { isRepo: true, refName: "main" } }, + { _tag: "localUpdated", local: { isRepo: true, refName: "feature/new" } }, + ]); + }).pipe(Effect.provide(testLayer)); + }), + ); +}); + +// Coder: retain legacy project opt-ins while honoring settings overrides, as startup does. +describe("Coder auto-pull policy", () => { + const projectId = ProjectId.make("coder-auto-pull"); + const cases: Array<{ + name: string; + autoPull: boolean | undefined; + settings: Partial; + enabled: boolean; + }> = [ + { name: "retains legacy project opt-ins", autoPull: true, settings: {}, enabled: true }, + { name: "defaults to disabled", autoPull: undefined, settings: {}, enabled: false }, + { + name: "inherits the global default", + autoPull: false, + settings: { defaultAutoPull: true }, + enabled: true, + }, + { + name: "honors a legacy explicit override", + autoPull: true, + settings: { projectAutoPullOverrides: { [projectId]: false } }, + enabled: false, + }, + { + name: "prefers project settings over legacy overrides", + autoPull: false, + settings: { + projectAutoPullOverrides: { [projectId]: false }, + projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } }, + }, + enabled: true, + }, + { + name: "stops inheriting legacy opt-ins once settings are folded", + autoPull: true, + settings: { projectSettingsFolded: true }, + enabled: false, + }, + ]; + for (const testCase of cases) { + it.effect(testCase.name, () => { + let behindCount = 2; + let pullCalls = 0; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(BackgroundPolicy.layer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe( + Layer.provide( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: projectId, + autoPull: testCase.autoPull, + } as OrchestrationProject), + ), + }), + ), + Layer.provide( + ServerSettings.layerTest({ ...DEFAULT_SERVER_SETTINGS, ...testCase.settings }), + ), + ), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.succeed({ ...baseLocalStatus, isDefaultRef: true, refName: "main" }), + remoteStatus: () => Effect.sync(() => ({ ...baseRemoteStatus, behindCount })), + invalidateStatus: () => Effect.void, + invalidateLocalStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls++; + behindCount = 0; + return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; + }), + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo"); + assert.equal(pullCalls, testCase.enabled ? 1 : 0); + }).pipe(Effect.provide(testLayer)); + }); + } +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts new file mode 100644 index 0000000000000..f927907525cf2 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -0,0 +1,782 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +// Coder: map local status changes onto the lightweight ref stream. +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import type { + GitManagerServiceError, + // Coder: lightweight sidebar ref status uses the existing Coder RPC contract. + VcsRefStatusStreamEvent, + VcsStatusInput, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; +// Coder: honor the fork settings model, including legacy project auto-pull opt-ins. +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); +const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); +const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; +const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; + +function boundedDiagnosticValue(value: string): string { + return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); +} + +function diagnosticValueTag(value: unknown): string { + try { + if ( + typeof value === "object" && + value !== null && + "_tag" in value && + typeof value._tag === "string" + ) { + return boundedDiagnosticValue(value._tag); + } + if (value instanceof Error) { + return boundedDiagnosticValue(value.name); + } + return typeof value; + } catch { + return "Uninspectable"; + } +} + +function diagnosticFailureOperation(value: unknown): string | undefined { + try { + if ( + typeof value === "object" && + value !== null && + "operation" in value && + typeof value.operation === "string" + ) { + return boundedDiagnosticValue(value.operation); + } + } catch { + return undefined; + } + return undefined; +} + +function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { + if ( + value !== undefined && + values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && + !values.includes(value) + ) { + values.push(value); + } +} + +export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { + const failureTags: Array = []; + const failureOperations: Array = []; + const defectTags: Array = []; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) { + failureCount += 1; + addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); + addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); + continue; + } + if (Cause.isDieReason(reason)) { + defectCount += 1; + addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); + continue; + } + interruptionCount += 1; + } + + return { + reasonCount: cause.reasons.length, + failureCount, + failureTags, + failureOperations, + defectCount, + defectTags, + interruptionCount, + }; +} + +interface VcsStatusChange { + readonly cwd: string; + readonly event: VcsStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedVcsStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; + readonly demandCwds: Ref.Ref>; +} + +interface StreamStatusOptions { + readonly automaticRemoteRefreshInterval?: Effect.Effect; +} + +export class VcsAutoPullPolicy extends Context.Reference<{ + readonly isEnabled: (cwd: string) => Effect.Effect; +}>("t3/vcs/VcsAutoPullPolicy", { + defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }), +}) {} + +export const autoPullPolicyLayer = Layer.effect( + VcsAutoPullPolicy, + Effect.gen(function* () { + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + return { + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + // Coder: resolve enablement exactly as startup auto-pull does. + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), + }; + }), +); + +export function remoteRefreshFailureDelay( + consecutiveFailures: number, + configuredInterval: Duration.Duration, +) { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = + Duration.toMillis(VCS_STATUS_REFRESH_FAILURE_BASE_DELAY) * Math.pow(2, exponent); + const cappedBackoff = Duration.min( + Duration.millis(backoffMs), + VCS_STATUS_REFRESH_FAILURE_MAX_DELAY, + ); + return Duration.max(configuredInterval, cappedBackoff); +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + /** + * Refresh a loaded cwd after a turn if background policy allows it. + * GitManager retries missing PRs for the current branch and keeps known + * PRs and failed lookup backoff cached. This does not fetch Git remotes. + */ + readonly refreshPullRequestStatus: ( + cwd: string, + ) => Effect.Effect; + // Coder: read refs without refreshing the real index or retaining a remote poller. + readonly streamRefStatus: ( + cwd: string, + ) => Stream.Stream; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; + } +>()("t3/vcs/VcsStatusBroadcaster") {} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +const normalizeCwd = (cwd: string) => + Effect.service(FileSystem.FileSystem).pipe( + Effect.flatMap((fs) => fs.realPath(cwd)), + Effect.orElseSucceed(() => cwd), + ); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const autoPullPolicy = yield* VcsAutoPullPolicy; + const workflow = yield* GitWorkflowService.GitWorkflowService; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const fs = yield* FileSystem.FileSystem; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + // One permit per cwd for remote reads that write the cache. Without it a + // periodic poll that started before `gh pr create` can finish after the + // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. + const remoteWriteLocks = new Map(); + const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { + let lock = remoteWriteLocks.get(cwd); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + remoteWriteLocks.set(cwd, lock); + } + return lock.withPermits(1)(effect); + }; + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }, + ); + + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }, + ); + + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( + cwd: string, + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + local: nextLocal, + remote: nextRemote, + }); + return [ + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + nextCache, + ] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } + + return mergeGitStatusParts(local, remote); + }); + + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const cached = yield* getCachedStatus(cwd); + if (cached?.local && cached.remote) { + return mergeGitStatusParts(cached.local.value, cached.remote.value); + } + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const latest = yield* getCachedStatus(cwd); + const [local, remote] = yield* Effect.all( + [ + latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), + latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); + }), + ); + }); + + const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( + function* (cwd: string) { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }, + ); + + const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* refreshLocalStatusCore(cwd); + }); + + const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + policyCwds: ReadonlyArray, + ) { + return yield* Effect.gen(function* () { + const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { + concurrency: "unbounded", + })).some(Boolean); + if ( + remote === null || + !remote.hasUpstream || + remote.aheadCount > 0 || + remote.behindCount <= 0 || + !autoPullEnabled + ) { + return null; + } + + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; + + yield* workflow.pullCurrentBranch(cwd); + yield* workflow.invalidateStatus(cwd); + const [refreshedLocal, refreshedRemote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], + { concurrency: "unbounded" }, + ); + yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); + return { local: refreshedLocal, remote: refreshedRemote }; + }).pipe( + Effect.catch(() => + Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)), + ), + ); + }); + + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + options?: { + readonly refreshUpstream?: boolean; + readonly policyCwds?: ReadonlyArray; + }, + ) { + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); + }); + + const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), + ); + }); + + const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = + Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote?.value == null) return null; + const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); + const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; + const shouldRefresh = (yield* Effect.forEach( + demandCwds, + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRefresh) return null; + // Resolve the checked-out branch again. A cached PR can belong to + // the previous branch after an agent checks out another branch. + const remote = yield* workflow.remoteStatus( + { cwd }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); + }); + + const makeRemoteRefreshLoop = ( + cwd: string, + demandCwdsRef: Ref.Ref>, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) => { + return Effect.gen(function* () { + const consecutiveFailuresRef = yield* Ref.make(0); + const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticRemoteRefreshInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval; + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + return activeInterval; + } + + const demandCwds = yield* Ref.get(demandCwdsRef); + const shouldRun = + needsInitialRefresh || + (yield* Effect.forEach( + [...demandCwds.keys()], + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ + type: "vcs-status", + cwd: demandCwd, + }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRun) { + return activeInterval; + } + + const exit = yield* refreshRemoteStatus(cwd, { + refreshUpstream: !Duration.isZero(configuredInterval), + policyCwds: [...demandCwds.keys()], + }).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } + + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } + + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, + ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), + }); + return nextDelay; + }); + + if (!refreshImmediately) { + const configuredInterval = yield* automaticRemoteRefreshInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval, + ); + } + + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), + ), + ), + Effect.asVoid, + ); + }); + }; + + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + demandCwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + return Ref.update(existing.demandCwds, (demandCwds) => { + const next = new Map(demandCwds); + next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1); + return next; + }).pipe( + Effect.map(() => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return [undefined, nextPollers] as const; + }), + ); + } + + return Ref.make>(new Map([[demandCwd, 1]])).pipe( + Effect.flatMap((demandCwds) => + makeRemoteRefreshLoop( + cwd, + demandCwds, + automaticRemoteRefreshInterval, + refreshImmediately, + ).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + demandCwds, + }); + return [undefined, nextPollers] as const; + }), + ), + ), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + demandCwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return Effect.succeed([null, activePollers] as const); + } + + if (existing.subscriberCount > 1) { + return Ref.update(existing.demandCwds, (demandCwds) => { + const nextDemandCwds = new Map(demandCwds); + const count = nextDemandCwds.get(demandCwd) ?? 0; + if (count <= 1) { + nextDemandCwds.delete(demandCwd); + } else { + nextDemandCwds.set(demandCwd, count - 1); + } + return nextDemandCwds; + }).pipe( + Effect.as([ + null, + new Map(activePollers).set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }), + ] as const), + ); + } + + return Effect.succeed([ + existing.fiber, + new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)), + ] as const); + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; + yield* retainRemotePoller( + cwd, + input.cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); + + const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + // Coder: subscribe before the non-mutating ref snapshot so concurrent updates are retained. + const streamRefStatus: VcsStatusBroadcaster["Service"]["streamRefStatus"] = (rawCwd) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const local = yield* workflow.localRefStatus({ cwd }); + return Stream.concat( + Stream.make({ _tag: "snapshot" as const, local }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((change) => change.cwd === cwd), + Stream.filterMap((change) => + "local" in change.event + ? Result.succeed({ + _tag: "localUpdated" as const, + local: { + isRepo: change.event.local.isRepo, + refName: change.event.local.refName, + }, + }) + : Result.failVoid, + ), + Stream.changesWith( + (left, right) => + left.local.isRepo === right.local.isRepo && + left.local.refName === right.local.refName, + ), + ), + ); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + refreshPullRequestStatus, + streamStatus, + // Coder: expose the lightweight ref subscription alongside upstream status streams. + streamRefStatus, + }); +}); + +export const layer = Layer.effect(VcsStatusBroadcaster, make); diff --git a/apps/server/src/vcs/projectAutoPull.test.ts b/apps/server/src/vcs/projectAutoPull.test.ts index af6b2b201166b..5b5358e8180af 100644 --- a/apps/server/src/vcs/projectAutoPull.test.ts +++ b/apps/server/src/vcs/projectAutoPull.test.ts @@ -1,35 +1,72 @@ -import type { VcsStatusLocalResult, VcsStatusRemoteResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { it, expect } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + type OrchestrationProjectShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import { autoPullProjects } from "./projectAutoPull.ts"; -import { automaticPullSkipReason } from "./projectAutoPull.ts"; - -const local = { +const project = { + id: ProjectId.make("project"), + workspaceRoot: "/repo", + autoPull: true, +} as OrchestrationProjectShell; +const eligible = { isRepo: true, - isDefaultRef: true, - hasWorkingTreeChanges: false, -} as VcsStatusLocalResult; - -const remote = { + isDefaultBranch: true, hasUpstream: true, + hasWorkingTreeChanges: false, aheadCount: 0, behindCount: 1, -} as VcsStatusRemoteResult; +} as GitVcsDriver.GitStatusDetails; + +it.effect.each([ + ["eligible", {}, true], + ["not a repository", { isRepo: false }, false], + ["feature branch", { isDefaultBranch: false }, false], + ["no upstream", { hasUpstream: false }, false], + ["dirty", { hasWorkingTreeChanges: true }, false], + ["local commits", { aheadCount: 1 }, false], + ["already current", { behindCount: 0 }, false], +] as const)("startup auto-pull: %s", ([_name, changes, expected]) => { + const pulls: string[] = []; + return autoPullProjects([project]).pipe( + Effect.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + statusDetails: () => Effect.succeed({ ...eligible, ...changes }), + pullCurrentBranch: (cwd) => + Effect.sync(() => { + pulls.push(cwd); + return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; + }), + }), + ), + Effect.tap(() => Effect.sync(() => expect(pulls).toEqual(expected ? ["/repo"] : []))), + ); +}); -describe("automaticPullSkipReason", () => { - it("allows only a clean, behind default branch with an upstream", () => { - expect(automaticPullSkipReason(local, remote)).toBeNull(); - expect(automaticPullSkipReason({ ...local, isRepo: false }, remote)).toBe( - "not-a-repository", - ); - expect(automaticPullSkipReason({ ...local, isDefaultRef: false }, remote)).toBe( - "not-on-default-branch", - ); - expect(automaticPullSkipReason(local, null)).toBe("no-upstream"); - expect(automaticPullSkipReason(local, { ...remote, hasUpstream: false })).toBe("no-upstream"); - expect(automaticPullSkipReason({ ...local, hasWorkingTreeChanges: true }, remote)).toBe( - "working-tree-changes", - ); - expect(automaticPullSkipReason(local, { ...remote, aheadCount: 1 })).toBe("local-commits"); - expect(automaticPullSkipReason(local, { ...remote, behindCount: 0 })).toBe("already-current"); - }); +it.effect("startup auto-pull respects per-project enablement and deduplicates roots", () => { + const reads: string[] = []; + return autoPullProjects( + [ + { ...project, autoPull: false, workspaceRoot: "/disabled" }, + project, + { ...project, id: ProjectId.make("duplicate") }, + ], + DEFAULT_SERVER_SETTINGS, + ).pipe( + Effect.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + statusDetails: (cwd) => + Effect.sync(() => { + reads.push(cwd); + return { ...eligible, behindCount: 0 }; + }), + }), + ), + Effect.tap(() => Effect.sync(() => expect(reads).toEqual(["/repo"]))), + ); }); diff --git a/apps/server/src/vcs/projectAutoPull.ts b/apps/server/src/vcs/projectAutoPull.ts index 0bf35b747a4de..f9db7cce27c93 100644 --- a/apps/server/src/vcs/projectAutoPull.ts +++ b/apps/server/src/vcs/projectAutoPull.ts @@ -1,66 +1,21 @@ import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import type { - OrchestrationProjectShell, - ServerSettings, - VcsStatusLocalResult, - VcsStatusRemoteResult, +import { + DEFAULT_SERVER_SETTINGS, + type OrchestrationProjectShell, + type ServerSettings, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; - -import * as GitWorkflowService from "../git/GitWorkflowService.ts"; - -export type AutomaticPullSkipReason = - | "not-a-repository" - | "not-on-default-branch" - | "no-upstream" - | "working-tree-changes" - | "local-commits" - | "already-current"; - -export function automaticPullSkipReason( - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, -): AutomaticPullSkipReason | null { - if (!local.isRepo) return "not-a-repository"; - if (!local.isDefaultRef) return "not-on-default-branch"; - if (remote === null || !remote.hasUpstream) return "no-upstream"; - if (local.hasWorkingTreeChanges) return "working-tree-changes"; - if (remote.aheadCount > 0) return "local-commits"; - if (remote.behindCount <= 0) return "already-current"; - return null; -} - -const pullProjectIfEligible = Effect.fn("pullProjectIfEligible")(function* (cwd: string) { - const workflow = yield* GitWorkflowService.GitWorkflowService; - yield* workflow.invalidateStatus(cwd); - const remote = yield* workflow.remoteStatus({ cwd }, { fetch: true }); - // Re-read local state after fetching so the final safety check is as fresh - // as possible immediately before the checkout can move. - const local = yield* workflow.localStatus({ cwd }); - const skipReason = automaticPullSkipReason(local, remote); - if (skipReason !== null) { - yield* Effect.logDebug("Skipped automatic project pull", { cwd, reason: skipReason }); - return false; - } - - const result = yield* workflow.pull({ cwd }, { automatic: true }); - yield* workflow.invalidateStatus(cwd); - yield* Effect.logDebug("Automatic project pull completed", { - cwd, - status: result.status, - refName: result.refName, - }); - return true; -}); +import * as GitVcsDriver from "./GitVcsDriver.ts"; export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, settings: ServerSettings = DEFAULT_SERVER_SETTINGS, ) { + const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects + // Coder: retain per-project enablement from the fork settings model. .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), @@ -69,8 +24,45 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( yield* Effect.forEach( workspaceRoots, (cwd) => - pullProjectIfEligible(cwd).pipe( - Effect.catch((cause) => Effect.logWarning("Automatic project pull failed", { cwd, cause })), + Effect.gen(function* () { + const status = yield* git.statusDetails(cwd); + if ( + !status.isRepo || + !status.isDefaultBranch || + !status.hasUpstream || + status.hasWorkingTreeChanges || + status.aheadCount > 0 + ) { + yield* Effect.logDebug("Skipped automatic project pull", { + cwd, + reason: !status.isRepo + ? "not-a-repository" + : !status.isDefaultBranch + ? "not-on-default-branch" + : !status.hasUpstream + ? "no-upstream" + : status.hasWorkingTreeChanges + ? "working-tree-changes" + : "local-commits", + }); + return; + } + + if (status.behindCount <= 0) return; + + const result = yield* git.pullCurrentBranch(cwd); + yield* Effect.logDebug("Automatic project pull completed", { + cwd, + status: result.status, + refName: result.refName, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Automatic project pull failed", { + cwd, + cause, + }), + ), ), { concurrency: 4, discard: true }, ); diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index 231cdbcb3e010..bc437dcebfc18 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -535,6 +535,32 @@ listed here is drift to remove rather than fork behavior to keep. Link/Unlink to thread through `usePullRequestLinking`). GitLab autolinks keep their `merge-request`, `issue`, and `commit` kinds, so upstream's GitHub reference confirmation is unused. +- **Git layer.** `vcs/GitVcsDriver.ts`, `vcs/GitVcsDriverCore.ts`, `vcs/VcsProcess.ts`, + `vcs/VcsStatusBroadcaster.ts`, `git/GitManager.ts`, `git/GitWorkflowService.ts`, and + `git/remoteRefs.ts` are upstream's. Each difference is marked `Coder:` in the file: + - The driver core has no Git metrics or span events. It adds `moveWorktree`, which + renames a thread's worktree folder together with its branch, and `refStatusLocal`, a + branch read that never refreshes the real index. Review file expansion allows up to + `MAX_REVIEW_DIFF_FILE_BYTES` and fails with `ReviewDiffFileTooLargeError` for the chunked + review RPCs, rejecting malformed UTF-8. A top-level review `sourceKind` returns only that + source. Worktree creation reads `t3.json` through the bounded `readProjectConfig`. + Checkpoint commits use the "T3 Coder" identity. + - `VcsProcess` has no GitHub CLI semaphore, classifies only `glab` failures, and keeps the + `classifyNonZeroExit` hook used by the GitLab write probe. + - `GitManager` probes the resolved provider's write access before any stacked action that + creates a merge request, so a blocked workspace, or a remote that resolves to no registered + provider, commits and pushes nothing. It also reads merge-request + templates for GitLab and fetches merge-request heads from `refs/merge-requests//head`. + Custom writing instructions remain one string. Upstream's GitHub and Forgejo branches stay + verbatim but are unreachable with the GitLab-only registry. + - `GitWorkflowService` adds `moveWorktree` and `localRefStatus` pass-throughs. + - `VcsStatusBroadcaster` adds `streamRefStatus` for `subscribeVcsRefStatus` and resolves + auto-pull with `resolveProjectAutoPull`. Its `BackgroundPolicy` dependency is a + Coder-owned stub that always allows work, because polling already stops when the last + status subscription ends. `coderWs.ts` reads the remote refresh interval from the flat + `automaticGitFetchInterval` setting. + - Startup auto-pull (`vcs/projectAutoPull.ts`) is upstream's `autoPullProjects` from + `serverRuntimeStartup.ts`, with the same per-project enablement. - **Settings.** Upstream's layout, navigation, and search, with Coder's Connections, Providers, GitLab, and background-activity panels. No Integrations, SnapShot, desktop, diagnostics, pairing, or `keybindings.json` editor. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 52e94612b24cc..6bc070f7fe038 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -243,7 +243,7 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ providers: ServerProviders, }); export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; -const isProviderAvailable = (snapshot: ServerProvider): boolean => +export const isProviderAvailable = (snapshot: ServerProvider): boolean => snapshot.availability !== "unavailable"; export const EnvironmentThemeColor = Schema.String.check( diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 71da8efc6bf7c..2873464e0613b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,6 +1,7 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, + isProviderAvailable, isProviderDriverKind, resolveProviderInstanceEnabled, type ModelSelection, @@ -91,9 +92,20 @@ export function resolveCoderTextGenerationModelSelection( return createModelSelection(provider.instanceId, model); } -export function resolveSourceControlWriterModelSelection(settings: ServerSettings): ModelSelection { +export function resolveSourceControlWriterModelSelection( + settings: ServerSettings, + providers?: ReadonlyArray, +): ModelSelection { const selection = settings.sourceControlWriterModelSelection; - return selection && isModelSelectionProviderEnabled(settings, selection) + if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { + return settings.textGenerationModelSelection; + } + if (providers === undefined) { + return selection; + } + + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + return provider?.enabled === true && isProviderAvailable(provider) ? selection : settings.textGenerationModelSelection; } diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 0688958c4de1b..baa4e9227b7ee 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -116,7 +116,7 @@ export function getChangeRequestTerminology( }; } -function getChangeRequestTerminologyForKind( +export function getChangeRequestTerminologyForKind( kind: SourceControlProviderKind, ): ChangeRequestTerminology { const presentation = resolveChangeRequestPresentationForKind(kind); @@ -128,7 +128,7 @@ function getChangeRequestTerminologyForKind( const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; -function isSshRemoteUrl(remoteUrl: string): boolean { +export function isSshRemoteUrl(remoteUrl: string): boolean { const trimmed = remoteUrl.trim(); return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } From 66b49998314b17d58fb0d749ae4fd0063d0e107c Mon Sep 17 00:00:00 2001 From: Henry Chu Date: Mon, 5 Oct 2026 21:34:14 -0400 Subject: [PATCH 2/3] Port upstream's Codex includeBranch commit-message test Co-Authored-By: Claude Opus 5.5 --- .../CodexTextGeneration.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 4a2e4937cb422..ed3451c408fc4 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -143,6 +143,32 @@ it.layer(NodeServices.layer)("CodexTextGeneration", (it) => { ), ); + it.effect("generates commit message with branch when includeBranch is true", () => + withFakeCodex( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + branch: "fix/important-system-change", + }), + }, + ({ textGeneration }) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + includeBranch: true, + modelSelection: DEFAULT_SELECTION, + }); + + expect(generated.subject).toBe("Add important change"); + expect(generated.branch).toBe("feature/fix/important-system-change"); + }), + ), + ); + it.effect("generates merge request content through workspace Codex", () => withFakeCodex( { From 1858f167c3531740a6d886ee38794e9614e256e9 Mon Sep 17 00:00:00 2001 From: Henry Chu Date: Mon, 5 Oct 2026 22:00:53 -0400 Subject: [PATCH 3/3] Verify GitLab write rejections with included response headers --- .../sourceControl/GitLabWriteProbe.test.ts | 27 ++++++++++++++++++- .../src/sourceControl/GitLabWriteProbe.ts | 4 ++- docs/internals/coder-only.md | 5 ++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabWriteProbe.test.ts b/apps/server/src/sourceControl/GitLabWriteProbe.test.ts index 687dbcc40eda3..0c8222f222d6c 100644 --- a/apps/server/src/sourceControl/GitLabWriteProbe.test.ts +++ b/apps/server/src/sourceControl/GitLabWriteProbe.test.ts @@ -39,7 +39,7 @@ it.effect("uses a state-free workspace-level mutation canary", () => command: "glab", cwd: "/repo", allowNonZeroExit: true, - args: ["api", "--method", "POST", "projects/0/merge_requests"], + args: ["api", "--include", "--method", "POST", "projects/0/merge_requests"], }), ); expect(mockedRun.mock.calls[0]?.[0]).not.toHaveProperty("stdin"); @@ -186,6 +186,31 @@ it.effect("recognizes an included HTTP/2 GitLab response", () => ), ); +it.effect("recognizes GitLab's missing-field rejection with included response headers", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed({ + ...output(1, "glab: HTTP 400"), + stdout: + 'HTTP/1.1 400 Bad Request\r\nX-Gitlab-Meta: {"version":"1"}\r\n\r\n' + + '{"error":"title is missing, source_branch is missing, target_branch is missing"}', + }), + ); + const probe = yield* GitLabWriteProbe.GitLabWriteProbe; + + expect(yield* probe.check({ cwd: "/workspace" })).toEqual({ + status: "writable", + writable: true, + }); + }).pipe( + Effect.provide( + GitLabWriteProbe.layer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockedRun })), + ), + ), + ), +); + it.effect("fails closed when an otherwise writable response is truncated", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitLabWriteProbe.ts b/apps/server/src/sourceControl/GitLabWriteProbe.ts index 5db5e6bd285e2..bf515dd2461e2 100644 --- a/apps/server/src/sourceControl/GitLabWriteProbe.ts +++ b/apps/server/src/sourceControl/GitLabWriteProbe.ts @@ -144,7 +144,9 @@ function processFailureDetail(error: VcsError): string { */ const workspacePolicyWriteProbe: GitLabWriteProbeBehavior = { request: () => ({ - args: ["api", "--method", "POST", "projects/0/merge_requests"], + // Coder: GitLab may validate missing MR fields before the project ID. Include headers + // so its state-free 400 rejection still carries a verifiable GitLab fingerprint. + args: ["api", "--include", "--method", "POST", "projects/0/merge_requests"], }), classifyProbe: (output) => { const response = `${output.stdout}\n${output.stderr}`; diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index 47fce6ff7fdbe..57f8ae5153854 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -431,8 +431,9 @@ inside the Linux workspace. The helper uses repository-scoped Git commands and t workspace-installed `glab` CLI to read MR summary, hover preview, activity, discussions, checks, reviewers, and diffs and to perform actions permitted for the signed-in viewer. At helper startup, a replaceable, state-free `glab` probe checks the workspace-wide GS write policy once for that helper lifetime. The -default sends an incomplete merge-request creation request to the impossible project ID `0`; a -normal GitLab validation or not-found response with a GitLab-specific response fingerprint proves +default sends an incomplete merge-request creation request to the impossible project ID `0` and +includes the response headers. A normal GitLab validation or not-found response with a +GitLab-specific response fingerprint proves the request reached GitLab, while neither a project nor an MR can be changed. A generic proxy 404, failed probe, or indeterminate response disables every GitLab mutation while leaving reads, Git operations, and MR checkout available. The Source Control settings surface