From d100711eb7036b58ece2df9a03ae10e70b7dde7b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 15:16:36 -0700 Subject: [PATCH] Flatten git service layer into GitCore - remove the GitService layer and wire callers to GitCore directly - update checkpointing and orchestration wiring to provide GitCore - adjust git layer tests and harnesses for the new structure --- .../OrchestrationEngineHarness.integration.ts | 4 +- .../checkpointing/Layers/CheckpointStore.ts | 9 +- apps/server/src/git/Layers/GitCore.test.ts | 446 +++++++----------- apps/server/src/git/Layers/GitCore.ts | 160 ++++++- apps/server/src/git/Layers/GitManager.test.ts | 19 +- apps/server/src/git/Layers/GitService.test.ts | 59 --- apps/server/src/git/Layers/GitService.ts | 144 ------ apps/server/src/git/Services/GitCore.ts | 21 + apps/server/src/git/Services/GitService.ts | 45 -- .../Layers/CheckpointReactor.test.ts | 3 +- apps/server/src/serverLayers.ts | 8 +- 11 files changed, 355 insertions(+), 563 deletions(-) delete mode 100644 apps/server/src/git/Layers/GitService.test.ts delete mode 100644 apps/server/src/git/Layers/GitService.ts delete mode 100644 apps/server/src/git/Services/GitService.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 19f34b49a0..f540685b79 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -24,6 +24,7 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; +import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(AnalyticsService.layerTest), ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, OrchestrationProjectionSnapshotQueryLive, ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, - CheckpointStoreLive, + checkpointStoreLayer, providerLayer, RuntimeReceiptBusLive, ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index fac183ff7a..b20204780c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { GitCommandError } from "../../git/Errors.ts"; -import { GitServiceLive } from "../../git/Layers/GitService.ts"; -import { GitService } from "../../git/Services/GitService.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const git = yield* GitService; + const git = yield* GitCore; const resolveHeadCommit = (cwd: string): Effect.Effect => git @@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () { } satisfies CheckpointStoreShape; }); -export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe( - Layer.provideMerge(GitServiceLive), -); +export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6d50cd2504..fb089aa8d4 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -6,9 +6,7 @@ import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { describe, expect, vi } from "vitest"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService, type GitServiceShape } from "../Services/GitService.ts"; -import { GitCoreLive } from "./GitCore.ts"; +import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "../Errors.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; @@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts"; // ── Helpers ── -const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(GitServiceTestLayer), Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); function makeTmpDir( prefix = "git-test-", @@ -49,10 +45,10 @@ function git( cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ + const gitCore = yield* GitCore; + const result = yield* gitCore.execute({ operation: "GitCore.test.git", cwd, args, @@ -88,102 +84,11 @@ function runShellCommand(input: { }); } -const makeIsolatedGitCore = (gitService: GitServiceShape) => - Effect.promise(async () => { - const gitServiceLayer = Layer.succeed(GitService, gitService); - const coreLayer = GitCoreLive.pipe( - Layer.provide(gitServiceLayer), - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), - ); - const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); - - return { - status: (input) => core.status(input), - statusDetails: (cwd) => core.statusDetails(cwd), - prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), - commit: (cwd, subject, body) => core.commit(cwd, subject, body), - pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), - pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), - readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), - readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), - listBranches: (input) => core.listBranches(input), - createWorktree: (input) => core.createWorktree(input), - fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), - ensureRemote: (input) => core.ensureRemote(input), - fetchRemoteBranch: (input) => core.fetchRemoteBranch(input), - setBranchUpstream: (input) => core.setBranchUpstream(input), - removeWorktree: (input) => core.removeWorktree(input), - renameBranch: (input) => core.renameBranch(input), - createBranch: (input) => core.createBranch(input), - checkoutBranch: (input) => core.checkoutBranch(input), - initRepo: (input) => core.initRepo(input), - listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), - } satisfies GitCoreShape; - }); - -function listGitBranches(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.listBranches(input); - }); -} - -function initGitRepo(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.initRepo(input); - }); -} - -function createGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.createBranch(input); - }); -} - -function checkoutGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.checkoutBranch(input); - }); -} - -function createGitWorktree(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.createWorktree(input); - }); -} - -function fetchGitPullRequestBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.fetchPullRequestBranch(input); - }); -} - -function removeGitWorktree(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.removeWorktree(input); - }); -} - -function renameGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.renameBranch(input); - }); -} - -function pullGitBranch({ cwd }: { cwd: string }) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.pullCurrentBranch(cwd); - }); -} +const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => + makeGitCore({ executeOverride }).pipe( + Effect.provide(ServerConfigLayer), + Effect.provide(NodeServices.layer), + ); /** Create a repo with an initial commit so branches work. */ function initRepoWithCommit( @@ -191,10 +96,11 @@ function initRepoWithCommit( ): Effect.Effect< { initialBranch: string }, GitCommandError | PlatformError.PlatformError, - GitCore | GitService | FileSystem.FileSystem + GitCore | FileSystem.FileSystem > { return Effect.gen(function* () { - yield* initGitRepo({ cwd }); + const core = yield* GitCore; + yield* core.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); @@ -214,7 +120,7 @@ function commitWithDate( ): Effect.Effect< void, GitCommandError | PlatformError.PlatformError, - GitService | FileSystem.FileSystem + GitCore | FileSystem.FileSystem > { return Effect.gen(function* () { yield* writeTextFile(path.join(cwd, fileName), fileContents); @@ -253,7 +159,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("creates a valid git repo", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - yield* initGitRepo({ cwd: tmp }); + yield* (yield* GitCore).initRepo({ cwd: tmp }); expect(existsSync(path.join(tmp, ".git"))).toBe(true); }), ); @@ -262,7 +168,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(true); expect(result.hasOriginRemote).toBe(false); expect(result.branches.length).toBeGreaterThanOrEqual(1); @@ -276,7 +182,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("returns isRepo: false for non-git directory", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(false); expect(result.hasOriginRemote).toBe(false); expect(result.branches).toEqual([]); @@ -287,7 +193,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current).toBeDefined(); expect(current!.current).toBe(true); @@ -300,7 +206,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); yield* git(tmp, ["checkout", "--detach", "HEAD"]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); expect(result.branches.some((branch) => branch.current)).toBe(false); }), @@ -310,12 +216,12 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; - yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); yield* commitWithDate( tmp, "older.txt", @@ -324,9 +230,9 @@ it.layer(TestLayer)("git integration", (it) => { "older branch change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); - yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); yield* commitWithDate( tmp, "newer.txt", @@ -336,9 +242,9 @@ it.layer(TestLayer)("git integration", (it) => { ); // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches[0]!.name).toBe("older-branch"); expect(result.branches[1]!.name).toBe("newer-branch"); }), @@ -349,7 +255,7 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; @@ -358,8 +264,8 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["push", "-u", "origin", defaultBranch]); yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); yield* commitWithDate( tmp, "current.txt", @@ -368,9 +274,9 @@ it.layer(TestLayer)("git integration", (it) => { "current change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); - yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); yield* commitWithDate( tmp, "newer.txt", @@ -379,9 +285,9 @@ it.layer(TestLayer)("git integration", (it) => { "newer change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches[0]!.name).toBe("current-branch"); expect(result.branches[1]!.name).toBe(defaultBranch); expect(result.branches[2]!.name).toBe("newer-branch"); @@ -392,10 +298,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); - yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const names = result.branches.map((b) => b.name); expect(names).toContain("feature-a"); expect(names).toContain("feature-b"); @@ -406,7 +312,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }), ); @@ -418,23 +324,23 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", remote]); yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); const remoteOnlyBranch = "feature/remote-only"; - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); yield* git(tmp, ["checkout", defaultBranch]); yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); expect(result.hasOriginRemote).toBe(true); @@ -466,7 +372,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; @@ -479,7 +385,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["checkout", defaultBranch]); yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const remoteBranch = result.branches.find( (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, ); @@ -498,11 +404,11 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature"); }), @@ -516,20 +422,20 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); const featureBranch = "feature-behind"; - yield* createGitBranch({ cwd: source, branch: featureBranch }); - yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); yield* git(source, ["add", "feature.txt"]); yield* git(source, ["commit", "-m", "feature base"]); yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); yield* git(clone, ["clone", remote, "."]); yield* git(clone, ["config", "user.email", "test@test.com"]); @@ -540,7 +446,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(clone, ["commit", "-m", "remote feature update"]); yield* git(clone, ["push", "origin", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); const core = yield* GitCore; yield* Effect.promise(() => vi.waitFor(async () => { @@ -560,7 +466,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -575,10 +481,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "fetch") { refreshFetchAttempts += 1; return Effect.fail( @@ -590,9 +495,8 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => vi.waitFor(() => { @@ -610,7 +514,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -624,17 +528,15 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "fetch") { fetchArgs = [...input.args]; return Effect.succeed({ code: 0, stdout: "", stderr: "" }); } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => vi.waitFor(() => { @@ -660,7 +562,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -674,23 +576,21 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let fetchStarted = false; let releaseFetch!: () => void; const waitForReleasePromise = new Promise((resolve) => { releaseFetch = resolve; }); - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "fetch") { fetchStarted = true; return Effect.promise(() => waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), ); } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => vi.waitFor(() => { @@ -706,7 +606,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); + const result = yield* Effect.result((yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" })); expect(result._tag).toBe("Failure"); }), ); @@ -718,16 +618,16 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); - yield* createGitBranch({ cwd: source, branch: "feature" }); + yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); const checkoutResult = yield* Effect.result( - checkoutGitBranch({ cwd: source, branch: "origin/feature" }), + (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), ); expect(checkoutResult._tag).toBe("Failure"); expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); @@ -743,7 +643,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", remoteName, remote]); @@ -757,7 +657,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` }); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); }), @@ -772,7 +672,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -782,7 +682,7 @@ it.layer(TestLayer)("git integration", (it) => { // would attempt to create an already-existing local branch. yield* git(source, ["branch", "--unset-upstream"]); - yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `origin/${defaultBranch}` }); const core = yield* GitCore; const status = yield* core.statusDetails(source); @@ -794,7 +694,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); // Create a conflicting change: modify README on current branch yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); @@ -802,22 +702,22 @@ it.layer(TestLayer)("git integration", (it) => { // First, checkout other branch cleanly yield* git(tmp, ["stash"]); - yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); yield* git(tmp, ["add", "."]); yield* git(tmp, ["commit", "-m", "other change"]); // Go back to default branch - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => !b.current, )!.name; - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); // Make uncommitted changes to the same file yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); // Checkout should fail due to uncommitted changes - const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); + const result = yield* Effect.result((yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" })); expect(result._tag).toBe("Failure"); }), ); @@ -830,9 +730,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); }), ); @@ -841,8 +741,8 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); + const result = yield* Effect.result((yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" })); expect(result._tag).toBe("Failure"); }), ); @@ -855,10 +755,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "feature/old-name", newBranch: "feature/new-name", @@ -866,7 +766,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(renamed.branch).toBe("feature/new-name"); - const branches = yield* listGitBranches({ cwd: tmp }); + const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); const current = branches.branches.find((branch) => branch.current); expect(current?.name).toBe("feature/new-name"); @@ -877,9 +777,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; + const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find((b) => b.current)!; - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: current.name, newBranch: current.name, @@ -893,18 +793,18 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "t3code/tmp-working", newBranch: "t3code/feat/session", }); expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* listGitBranches({ cwd: tmp }); + const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( true, ); @@ -920,12 +820,12 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "t3code/tmp-working", newBranch: "t3code/feat/session", @@ -939,19 +839,17 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "branch" && input.args[1] === "-m") { renameArgs = [...input.args]; } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); const renamed = yield* core.renameBranch({ cwd: tmp, @@ -974,11 +872,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "worktree-out"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-branch", @@ -991,7 +889,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); // Clean up worktree before tmp dir disposal - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1001,11 +899,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-check-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-check", @@ -1016,7 +914,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("wt-check"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1024,10 +922,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); const wtPath = path.join(tmp, "wt-existing"); - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: "feature/existing-worktree", path: wtPath, @@ -1038,7 +936,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("feature/existing-worktree"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1046,15 +944,15 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "existing" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; const result = yield* Effect.result( - createGitWorktree({ + (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "existing", @@ -1071,11 +969,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-list-dir"); - const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: mainBranch, newBranch: "wt-list", @@ -1083,17 +981,17 @@ it.layer(TestLayer)("git integration", (it) => { }); // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* listGitBranches({ cwd: wtPath }); + const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); expect(wtBranches.isRepo).toBe(true); const wtCurrent = wtBranches.branches.find((b) => b.current); expect(wtCurrent!.name).toBe("wt-list"); // Main repo should still show the original branch as current - const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(mainBranch); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1103,11 +1001,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-remove-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-remove", @@ -1115,7 +1013,7 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(existsSync(wtPath)).toBe(true); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1126,11 +1024,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-dirty-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-dirty", @@ -1140,11 +1038,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); - const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); + const failedRemove = yield* Effect.result((yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath })); expect(failedRemove._tag).toBe("Failure"); expect(existsSync(wtPath)).toBe(true); - yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1157,10 +1055,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature-login"); }), @@ -1175,12 +1073,12 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; const wtPath = path.join(tmp, "my-worktree"); - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "feature-wt", @@ -1191,7 +1089,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(result.worktree.path)).toBe(true); // Main repo still on original branch - const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(currentBranch); @@ -1199,7 +1097,7 @@ it.layer(TestLayer)("git integration", (it) => { const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); expect(wtBranch).toBe("feature-wt"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); }); @@ -1221,7 +1119,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); yield* git(tmp, ["checkout", initialBranch]); - yield* fetchGitPullRequestBranch({ + yield* (yield* GitCore).fetchPullRequestBranch({ cwd: tmp, prNumber: 55, branch: "feature/pr-fetch", @@ -1242,22 +1140,22 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); - yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); // Simulate switching to thread A's branch - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); // Simulate switching to thread B's branch - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); + branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); // Switch back to thread A - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); }), ); @@ -1270,30 +1168,30 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); // Make diverged branch have different file content - yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); yield* git(tmp, ["add", "."]); yield* git(tmp, ["commit", "-m", "diverge"]); // Actually, let's just get back to the initial branch explicitly - const allBranches = yield* listGitBranches({ cwd: tmp }); + const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); // Make local uncommitted changes to the same file yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); // Attempt checkout should fail const failedCheckout = yield* Effect.result( - checkoutGitBranch({ cwd: tmp, branch: "diverged" }), + (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), ); expect(failedCheckout._tag).toBe("Failure"); // Current branch should still be the initial one - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); }), ); @@ -1390,7 +1288,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1423,7 +1321,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", remoteName, remote]); @@ -1526,7 +1424,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", remote]); @@ -1562,7 +1460,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(fork, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", origin]); @@ -1668,7 +1566,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1761,8 +1659,8 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); yield* git(remote, ["init", "--bare"]); yield* git(tmp, ["remote", "add", "origin", remote]); - yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); const core = yield* GitCore; @@ -1790,7 +1688,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1818,7 +1716,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); + const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure.message.toLowerCase()).toContain("no upstream"); @@ -1830,10 +1728,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let didFailRecency = false; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (!didFailRecency && input.args[0] === "for-each-ref") { didFailRecency = true; return Effect.fail( @@ -1845,9 +1742,8 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); const result = yield* core.listBranches({ cwd: tmp }); @@ -1866,11 +1762,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(tmp, ["remote", "add", "origin", remote]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let didFailRemoteBranches = false; let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { + const core = yield* makeIsolatedGitCore((input) => { if (input.args.join(" ") === "branch --no-color --remotes") { didFailRemoteBranches = true; return Effect.fail( @@ -1893,9 +1788,8 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitService.execute(input); - }, - }); + return realGitCore.execute(input); + }); const result = yield* core.listBranches({ cwd: tmp }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 74c09da5f9..1984f4d6db 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1,10 +1,12 @@ -import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "../Errors.ts"; -import { GitService } from "../Services/GitService.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { GitCore, type GitCoreShape, type ExecuteGitInput, type ExecuteGitResult } from "../Services/GitCore.ts"; import { ServerConfig } from "../../config.ts"; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; @@ -218,11 +220,134 @@ function createGitCommandError( }); } -const makeGitCore = Effect.gen(function* () { - const git = yield* GitService; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfig; +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function toGitCommandError( + input: Pick, + detail: string, +) { + return (cause: unknown) => + Schema.is(GitCommandError)(cause) + ? cause + : new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const collectOutput = Effect.fn(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, +): Effect.fn.Return { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } + text += decoder.decode(chunk, { stream: true }); + }), + ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); + + text += decoder.decode(); + return text; +}); + +export const makeGitCore = (options?: { + executeOverride?: GitCoreShape["execute"]; +}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; + + let execute: GitCoreShape["execute"]; + + if (options?.executeOverride) { + execute = options.executeOverride; + } else { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + execute = Effect.fnUntraced(function* (input) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + const commandEffect = Effect.gen(function* () { + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + ...(input.env ? { env: input.env } : {}), + }), + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput(commandInput, child.stdout, maxOutputBytes), + collectOutput(commandInput, child.stderr, maxOutputBytes), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + ], + { concurrency: "unbounded" }, + ); + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } + + return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + }); + + return yield* commandEffect.pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); + } const executeGit = ( operation: string, @@ -230,15 +355,13 @@ const makeGitCore = Effect.gen(function* () { args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => - git - .execute({ - operation, - cwd, - args, - allowNonZeroExit: true, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - }) - .pipe( + execute({ + operation, + cwd, + args, + allowNonZeroExit: true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }).pipe( Effect.flatMap((result) => { if (options.allowNonZeroExit || result.code === 0) { return Effect.succeed(result); @@ -1402,6 +1525,7 @@ const makeGitCore = Effect.gen(function* () { ); return { + execute, status, statusDetails, prepareCommitContext, @@ -1425,4 +1549,4 @@ const makeGitCore = Effect.gen(function* () { } satisfies GitCoreShape; }); -export const GitCoreLive = Layer.effect(GitCore, makeGitCore); +export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e76994f853..57d8dfebd0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -15,9 +15,8 @@ import { GitHubCli, } from "../Services/GitHubCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; +import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; @@ -107,11 +106,11 @@ function runGit( ): Effect.Effect< { readonly code: number; readonly stdout: string; readonly stderr: string }, GitCommandError, - GitService + GitCore > { return Effect.gen(function* () { - const gitService = yield* GitService; - return yield* gitService.execute({ + const gitCore = yield* GitCore; + return yield* gitCore.execute({ operation: "GitManager.test.runGit", cwd, args, @@ -125,7 +124,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitService + FileSystem.FileSystem | Scope.Scope | GitCore > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -141,7 +140,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitService + FileSystem.FileSystem | Scope.Scope | GitCore > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -480,7 +479,6 @@ function makeManager(input?: { }); const gitCoreLayer = GitCoreLive.pipe( - Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); @@ -497,7 +495,10 @@ function makeManager(input?: { ); } -const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); +const GitManagerTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(NodeServices.layer), +); it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes PR metadata when branch already has an open PR", () => diff --git a/apps/server/src/git/Layers/GitService.test.ts b/apps/server/src/git/Layers/GitService.test.ts deleted file mode 100644 index 7db468c06c..0000000000 --- a/apps/server/src/git/Layers/GitService.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, assert } from "@effect/vitest"; -import { Effect, Layer, Schema } from "effect"; - -import { GitCommandError } from "../Errors.ts"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService } from "../Services/GitService.ts"; - -const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); - -layer("GitServiceLive", (it) => { - it.effect("runGit executes successful git commands", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.version", - cwd: process.cwd(), - args: ["--version"], - }); - - assert.equal(result.code, 0); - assert.ok(result.stdout.toLowerCase().includes("git version")); - }), - ); - - it.effect("runGit can return non-zero exit codes when allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.allowNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - allowNonZeroExit: true, - }); - - assert.notEqual(result.code, 0); - }), - ); - - it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* Effect.result( - gitService.execute({ - operation: "GitProcess.test.failOnNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - }), - ); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.ok(Schema.is(GitCommandError)(result.failure)); - assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); - assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); - } - }), - ); -}); diff --git a/apps/server/src/git/Layers/GitService.ts b/apps/server/src/git/Layers/GitService.ts deleted file mode 100644 index d3f07e3151..0000000000 --- a/apps/server/src/git/Layers/GitService.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Git process helpers - Effect-native git execution with typed errors. - * - * Centralizes child-process git invocation for server modules. This module - * only executes git commands and reports structured failures. - * - * @module GitServiceLive - */ -import { Effect, Layer, Option, Schema, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "../Errors.ts"; -import { - ExecuteGitInput, - ExecuteGitResult, - GitService, - GitServiceShape, -} from "../Services/GitService.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} - -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - Schema.is(GitCommandError)(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); -} - -const collectOutput = Effect.fn(function* ( - input: Pick, - stream: Stream.Stream, - maxOutputBytes: number, -): Effect.fn.Return { - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - - yield* Stream.runForEach(stream, (chunk) => - Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { - return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - }); - } - text += decoder.decode(chunk, { stream: true }); - }), - ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); - - text += decoder.decode(); - return text; -}); - -const makeGitService = Effect.gen(function* () { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - - const commandEffect = Effect.gen(function* () { - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - ...(input.env ? { env: input.env } : {}), - }), - ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput(commandInput, child.stdout, maxOutputBytes), - collectOutput(commandInput, child.stderr, maxOutputBytes), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), - ), - ], - { concurrency: "unbounded" }, - ); - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); - return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - }); - } - - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; - }); - - return yield* commandEffect.pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - }); - - return { - execute, - } satisfies GitServiceShape; -}); - -export const GitServiceLive = Layer.effect(GitService, makeGitService); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 879927934e..bcab916db4 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -24,6 +24,22 @@ import type { import type { GitCommandError } from "../Errors.ts"; +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +export interface ExecuteGitResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput { * GitCoreShape - Service API for low-level Git repository interactions. */ export interface GitCoreShape { + /** + * Execute a raw Git command. + */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + /** * Read Git status for a repository. */ diff --git a/apps/server/src/git/Services/GitService.ts b/apps/server/src/git/Services/GitService.ts deleted file mode 100644 index f43a4e6dcc..0000000000 --- a/apps/server/src/git/Services/GitService.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GitService - Service for Git command execution. - * - * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed - * domain errors for Git command execution. - * - * @module GitService - */ -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { GitCommandError } from "../Errors.ts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -} - -/** - * GitServiceShape - Service API for Git command execution. - */ -export interface GitServiceShape { - /** - * Execute a Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; -} - -/** - * GitService - Service for Git command execution. - */ -export class GitService extends ServiceMap.Service()( - "t3/git/Services/GitService", -) {} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 260c2e8671..702e826a5f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -260,7 +261,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 7250f8566c..ebf1e7f337 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -31,7 +31,6 @@ import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; -import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -70,8 +69,9 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { - const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); + const gitCoreLayer = GitCoreLive; const textGenerationLayer = CodexTextGenerationLive; + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(gitCoreLayer)); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -81,13 +81,13 @@ export function makeServerRuntimeServicesLayer() { const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), - Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(checkpointStoreLayer), ); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, OrchestrationProjectionSnapshotQueryLive, - CheckpointStoreLive, + checkpointStoreLayer, checkpointDiffQueryLayer, RuntimeReceiptBusLive, );