authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-23 17:02:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-23 17:02:09-07:00
logd415558915f3a06e92ce7e46acc07fce69374ea6
tree74784db432e0a7599872ab67f35e9c6e072b318c
parent843d6d8678c917a434c63834a98ceb99e5367530
signature Signed by PGP key B5690EEEBB952194

Flatten Git service layer and switch server paths to base dir (#1255)


17 files changed, 1481 insertions(+), 2196 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+3-1
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
2424
25import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";25import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
26import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";26import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
27import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
27import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";28import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
28import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";29import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
29import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";30import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
...@@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = (...@@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = (
284 Layer.provide(AnalyticsService.layerTest),285 Layer.provide(AnalyticsService.layerTest),
285 );286 );
286287
288 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
287 const runtimeServicesLayer = Layer.mergeAll(289 const runtimeServicesLayer = Layer.mergeAll(
288 orchestrationLayer,290 orchestrationLayer,
289 OrchestrationProjectionSnapshotQueryLive,291 OrchestrationProjectionSnapshotQueryLive,
290 ProjectionCheckpointRepositoryLive,292 ProjectionCheckpointRepositoryLive,
291 ProjectionPendingApprovalRepositoryLive,293 ProjectionPendingApprovalRepositoryLive,
292 CheckpointStoreLive,294 checkpointStoreLayer,
293 providerLayer,295 providerLayer,
294 RuntimeReceiptBusLive,296 RuntimeReceiptBusLive,
295 );297 );
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3-6
...@@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect";...@@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect";
1515
16import { CheckpointInvariantError } from "../Errors.ts";16import { CheckpointInvariantError } from "../Errors.ts";
17import { GitCommandError } from "../../git/Errors.ts";17import { GitCommandError } from "../../git/Errors.ts";
18import { GitServiceLive } from "../../git/Layers/GitService.ts";18import { GitCore } from "../../git/Services/GitCore.ts";
19import { GitService } from "../../git/Services/GitService.ts";
20import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";19import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
21import { CheckpointRef } from "@t3tools/contracts";20import { CheckpointRef } from "@t3tools/contracts";
2221
23const makeCheckpointStore = Effect.gen(function* () {22const makeCheckpointStore = Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;23 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;24 const path = yield* Path.Path;
26 const git = yield* GitService;25 const git = yield* GitCore;
2726
28 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>27 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
29 git28 git
...@@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () {
277 } satisfies CheckpointStoreShape;276 } satisfies CheckpointStoreShape;
278});277});
279278
280export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe(279export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore);
281 Layer.provideMerge(GitServiceLive),
282);
apps/server/src/git/Layers/GitCore.test.ts+244-335
...@@ -6,9 +6,7 @@ import { it } from "@effect/vitest";...@@ -6,9 +6,7 @@ import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { describe, expect, vi } from "vitest";7import { describe, expect, vi } from "vitest";
88
9import { GitServiceLive } from "./GitService.ts";9import { GitCoreLive, makeGitCore } from "./GitCore.ts";
10import { GitService, type GitServiceShape } from "../Services/GitService.ts";
11import { GitCoreLive } from "./GitCore.ts";
12import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";10import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
13import { GitCommandError } from "../Errors.ts";11import { GitCommandError } from "../Errors.ts";
14import { type ProcessRunResult, runProcess } from "../../processRunner.ts";12import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
...@@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts";...@@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts";
1614
17// ── Helpers ──15// ── Helpers ──
1816
19const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
20const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });17const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });
21const GitCoreTestLayer = GitCoreLive.pipe(18const GitCoreTestLayer = GitCoreLive.pipe(
22 Layer.provide(GitServiceTestLayer),
23 Layer.provide(ServerConfigLayer),19 Layer.provide(ServerConfigLayer),
24 Layer.provide(NodeServices.layer),20 Layer.provide(NodeServices.layer),
25);21);
26const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);22const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer);
2723
28function makeTmpDir(24function makeTmpDir(
29 prefix = "git-test-",25 prefix = "git-test-",
...@@ -49,10 +45,10 @@ function git(...@@ -49,10 +45,10 @@ function git(
49 cwd: string,45 cwd: string,
50 args: ReadonlyArray<string>,46 args: ReadonlyArray<string>,
51 env?: NodeJS.ProcessEnv,47 env?: NodeJS.ProcessEnv,
52): Effect.Effect<string, GitCommandError, GitService> {48): Effect.Effect<string, GitCommandError, GitCore> {
53 return Effect.gen(function* () {49 return Effect.gen(function* () {
54 const gitService = yield* GitService;50 const gitCore = yield* GitCore;
55 const result = yield* gitService.execute({51 const result = yield* gitCore.execute({
56 operation: "GitCore.test.git",52 operation: "GitCore.test.git",
57 cwd,53 cwd,
58 args,54 args,
...@@ -88,102 +84,10 @@ function runShellCommand(input: {...@@ -88,102 +84,10 @@ function runShellCommand(input: {
88 });84 });
89}85}
9086
91const makeIsolatedGitCore = (gitService: GitServiceShape) =>87const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) =>
92 Effect.promise(async () => {88 makeGitCore({ executeOverride }).pipe(
93 const gitServiceLayer = Layer.succeed(GitService, gitService);89 Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)),
94 const coreLayer = GitCoreLive.pipe(90 );
95 Layer.provide(gitServiceLayer),
96 Layer.provide(ServerConfigLayer),
97 Layer.provide(NodeServices.layer),
98 );
99 const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer)));
100
101 return {
102 status: (input) => core.status(input),
103 statusDetails: (cwd) => core.statusDetails(cwd),
104 prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths),
105 commit: (cwd, subject, body) => core.commit(cwd, subject, body),
106 pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch),
107 pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd),
108 readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch),
109 readConfigValue: (cwd, key) => core.readConfigValue(cwd, key),
110 listBranches: (input) => core.listBranches(input),
111 createWorktree: (input) => core.createWorktree(input),
112 fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),
113 ensureRemote: (input) => core.ensureRemote(input),
114 fetchRemoteBranch: (input) => core.fetchRemoteBranch(input),
115 setBranchUpstream: (input) => core.setBranchUpstream(input),
116 removeWorktree: (input) => core.removeWorktree(input),
117 renameBranch: (input) => core.renameBranch(input),
118 createBranch: (input) => core.createBranch(input),
119 checkoutBranch: (input) => core.checkoutBranch(input),
120 initRepo: (input) => core.initRepo(input),
121 listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd),
122 } satisfies GitCoreShape;
123 });
124
125function listGitBranches(input: Parameters<GitCoreShape["listBranches"]>[0]) {
126 return Effect.gen(function* () {
127 const core = yield* GitCore;
128 return yield* core.listBranches(input);
129 });
130}
131
132function initGitRepo(input: Parameters<GitCoreShape["initRepo"]>[0]) {
133 return Effect.gen(function* () {
134 const core = yield* GitCore;
135 return yield* core.initRepo(input);
136 });
137}
138
139function createGitBranch(input: Parameters<GitCoreShape["createBranch"]>[0]) {
140 return Effect.gen(function* () {
141 const core = yield* GitCore;
142 return yield* core.createBranch(input);
143 });
144}
145
146function checkoutGitBranch(input: Parameters<GitCoreShape["checkoutBranch"]>[0]) {
147 return Effect.gen(function* () {
148 const core = yield* GitCore;
149 return yield* core.checkoutBranch(input);
150 });
151}
152
153function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) {
154 return Effect.gen(function* () {
155 const core = yield* GitCore;
156 return yield* core.createWorktree(input);
157 });
158}
159
160function fetchGitPullRequestBranch(input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0]) {
161 return Effect.gen(function* () {
162 const core = yield* GitCore;
163 return yield* core.fetchPullRequestBranch(input);
164 });
165}
166
167function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) {
168 return Effect.gen(function* () {
169 const core = yield* GitCore;
170 return yield* core.removeWorktree(input);
171 });
172}
173
174function renameGitBranch(input: Parameters<GitCoreShape["renameBranch"]>[0]) {
175 return Effect.gen(function* () {
176 const core = yield* GitCore;
177 return yield* core.renameBranch(input);
178 });
179}
180
181function pullGitBranch({ cwd }: { cwd: string }) {
182 return Effect.gen(function* () {
183 const core = yield* GitCore;
184 return yield* core.pullCurrentBranch(cwd);
185 });
186}
18791
188/** Create a repo with an initial commit so branches work. */92/** Create a repo with an initial commit so branches work. */
189function initRepoWithCommit(93function initRepoWithCommit(
...@@ -191,10 +95,11 @@ function initRepoWithCommit(...@@ -191,10 +95,11 @@ function initRepoWithCommit(
191): Effect.Effect<95): Effect.Effect<
192 { initialBranch: string },96 { initialBranch: string },
193 GitCommandError | PlatformError.PlatformError,97 GitCommandError | PlatformError.PlatformError,
194 GitCore | GitService | FileSystem.FileSystem98 GitCore | FileSystem.FileSystem
195> {99> {
196 return Effect.gen(function* () {100 return Effect.gen(function* () {
197 yield* initGitRepo({ cwd });101 const core = yield* GitCore;
102 yield* core.initRepo({ cwd });
198 yield* git(cwd, ["config", "user.email", "test@test.com"]);103 yield* git(cwd, ["config", "user.email", "test@test.com"]);
199 yield* git(cwd, ["config", "user.name", "Test"]);104 yield* git(cwd, ["config", "user.name", "Test"]);
200 yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");105 yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
...@@ -214,7 +119,7 @@ function commitWithDate(...@@ -214,7 +119,7 @@ function commitWithDate(
214): Effect.Effect<119): Effect.Effect<
215 void,120 void,
216 GitCommandError | PlatformError.PlatformError,121 GitCommandError | PlatformError.PlatformError,
217 GitService | FileSystem.FileSystem122 GitCore | FileSystem.FileSystem
218> {123> {
219 return Effect.gen(function* () {124 return Effect.gen(function* () {
220 yield* writeTextFile(path.join(cwd, fileName), fileContents);125 yield* writeTextFile(path.join(cwd, fileName), fileContents);
...@@ -253,7 +158,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -253,7 +158,7 @@ it.layer(TestLayer)("git integration", (it) => {
253 it.effect("creates a valid git repo", () =>158 it.effect("creates a valid git repo", () =>
254 Effect.gen(function* () {159 Effect.gen(function* () {
255 const tmp = yield* makeTmpDir();160 const tmp = yield* makeTmpDir();
256 yield* initGitRepo({ cwd: tmp });161 yield* (yield* GitCore).initRepo({ cwd: tmp });
257 expect(existsSync(path.join(tmp, ".git"))).toBe(true);162 expect(existsSync(path.join(tmp, ".git"))).toBe(true);
258 }),163 }),
259 );164 );
...@@ -262,7 +167,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -262,7 +167,7 @@ it.layer(TestLayer)("git integration", (it) => {
262 Effect.gen(function* () {167 Effect.gen(function* () {
263 const tmp = yield* makeTmpDir();168 const tmp = yield* makeTmpDir();
264 yield* initRepoWithCommit(tmp);169 yield* initRepoWithCommit(tmp);
265 const result = yield* listGitBranches({ cwd: tmp });170 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
266 expect(result.isRepo).toBe(true);171 expect(result.isRepo).toBe(true);
267 expect(result.hasOriginRemote).toBe(false);172 expect(result.hasOriginRemote).toBe(false);
268 expect(result.branches.length).toBeGreaterThanOrEqual(1);173 expect(result.branches.length).toBeGreaterThanOrEqual(1);
...@@ -276,7 +181,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -276,7 +181,7 @@ it.layer(TestLayer)("git integration", (it) => {
276 it.effect("returns isRepo: false for non-git directory", () =>181 it.effect("returns isRepo: false for non-git directory", () =>
277 Effect.gen(function* () {182 Effect.gen(function* () {
278 const tmp = yield* makeTmpDir();183 const tmp = yield* makeTmpDir();
279 const result = yield* listGitBranches({ cwd: tmp });184 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
280 expect(result.isRepo).toBe(false);185 expect(result.isRepo).toBe(false);
281 expect(result.hasOriginRemote).toBe(false);186 expect(result.hasOriginRemote).toBe(false);
282 expect(result.branches).toEqual([]);187 expect(result.branches).toEqual([]);
...@@ -287,7 +192,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -287,7 +192,7 @@ it.layer(TestLayer)("git integration", (it) => {
287 Effect.gen(function* () {192 Effect.gen(function* () {
288 const tmp = yield* makeTmpDir();193 const tmp = yield* makeTmpDir();
289 yield* initRepoWithCommit(tmp);194 yield* initRepoWithCommit(tmp);
290 const result = yield* listGitBranches({ cwd: tmp });195 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
291 const current = result.branches.find((b) => b.current);196 const current = result.branches.find((b) => b.current);
292 expect(current).toBeDefined();197 expect(current).toBeDefined();
293 expect(current!.current).toBe(true);198 expect(current!.current).toBe(true);
...@@ -300,7 +205,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -300,7 +205,7 @@ it.layer(TestLayer)("git integration", (it) => {
300 yield* initRepoWithCommit(tmp);205 yield* initRepoWithCommit(tmp);
301 yield* git(tmp, ["checkout", "--detach", "HEAD"]);206 yield* git(tmp, ["checkout", "--detach", "HEAD"]);
302207
303 const result = yield* listGitBranches({ cwd: tmp });208 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
304 expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false);209 expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false);
305 expect(result.branches.some((branch) => branch.current)).toBe(false);210 expect(result.branches.some((branch) => branch.current)).toBe(false);
306 }),211 }),
...@@ -310,12 +215,12 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -310,12 +215,12 @@ it.layer(TestLayer)("git integration", (it) => {
310 Effect.gen(function* () {215 Effect.gen(function* () {
311 const tmp = yield* makeTmpDir();216 const tmp = yield* makeTmpDir();
312 yield* initRepoWithCommit(tmp);217 yield* initRepoWithCommit(tmp);
313 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(218 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
314 (branch) => branch.current,219 (branch) => branch.current,
315 )!.name;220 )!.name;
316221
317 yield* createGitBranch({ cwd: tmp, branch: "older-branch" });222 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" });
318 yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" });223 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });
319 yield* commitWithDate(224 yield* commitWithDate(
320 tmp,225 tmp,
321 "older.txt",226 "older.txt",
...@@ -324,9 +229,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -324,9 +229,9 @@ it.layer(TestLayer)("git integration", (it) => {
324 "older branch change",229 "older branch change",
325 );230 );
326231
327 yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch });232 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });
328 yield* createGitBranch({ cwd: tmp, branch: "newer-branch" });233 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
329 yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" });234 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
330 yield* commitWithDate(235 yield* commitWithDate(
331 tmp,236 tmp,
332 "newer.txt",237 "newer.txt",
...@@ -336,9 +241,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -336,9 +241,9 @@ it.layer(TestLayer)("git integration", (it) => {
336 );241 );
337242
338 // Switch away to show current branch is pinned, then remaining branches are recency-sorted.243 // Switch away to show current branch is pinned, then remaining branches are recency-sorted.
339 yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" });244 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });
340245
341 const result = yield* listGitBranches({ cwd: tmp });246 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
342 expect(result.branches[0]!.name).toBe("older-branch");247 expect(result.branches[0]!.name).toBe("older-branch");
343 expect(result.branches[1]!.name).toBe("newer-branch");248 expect(result.branches[1]!.name).toBe("newer-branch");
344 }),249 }),
...@@ -349,7 +254,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -349,7 +254,7 @@ it.layer(TestLayer)("git integration", (it) => {
349 const tmp = yield* makeTmpDir();254 const tmp = yield* makeTmpDir();
350 const remote = yield* makeTmpDir();255 const remote = yield* makeTmpDir();
351 yield* initRepoWithCommit(tmp);256 yield* initRepoWithCommit(tmp);
352 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(257 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
353 (branch) => branch.current,258 (branch) => branch.current,
354 )!.name;259 )!.name;
355260
...@@ -358,8 +263,8 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -358,8 +263,8 @@ it.layer(TestLayer)("git integration", (it) => {
358 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);263 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
359 yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]);264 yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]);
360265
361 yield* createGitBranch({ cwd: tmp, branch: "current-branch" });266 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" });
362 yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" });267 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });
363 yield* commitWithDate(268 yield* commitWithDate(
364 tmp,269 tmp,
365 "current.txt",270 "current.txt",
...@@ -368,9 +273,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -368,9 +273,9 @@ it.layer(TestLayer)("git integration", (it) => {
368 "current change",273 "current change",
369 );274 );
370275
371 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });276 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
372 yield* createGitBranch({ cwd: tmp, branch: "newer-branch" });277 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
373 yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" });278 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
374 yield* commitWithDate(279 yield* commitWithDate(
375 tmp,280 tmp,
376 "newer.txt",281 "newer.txt",
...@@ -379,9 +284,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -379,9 +284,9 @@ it.layer(TestLayer)("git integration", (it) => {
379 "newer change",284 "newer change",
380 );285 );
381286
382 yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" });287 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });
383288
384 const result = yield* listGitBranches({ cwd: tmp });289 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
385 expect(result.branches[0]!.name).toBe("current-branch");290 expect(result.branches[0]!.name).toBe("current-branch");
386 expect(result.branches[1]!.name).toBe(defaultBranch);291 expect(result.branches[1]!.name).toBe(defaultBranch);
387 expect(result.branches[2]!.name).toBe("newer-branch");292 expect(result.branches[2]!.name).toBe("newer-branch");
...@@ -392,10 +297,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -392,10 +297,10 @@ it.layer(TestLayer)("git integration", (it) => {
392 Effect.gen(function* () {297 Effect.gen(function* () {
393 const tmp = yield* makeTmpDir();298 const tmp = yield* makeTmpDir();
394 yield* initRepoWithCommit(tmp);299 yield* initRepoWithCommit(tmp);
395 yield* createGitBranch({ cwd: tmp, branch: "feature-a" });300 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
396 yield* createGitBranch({ cwd: tmp, branch: "feature-b" });301 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
397302
398 const result = yield* listGitBranches({ cwd: tmp });303 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
399 const names = result.branches.map((b) => b.name);304 const names = result.branches.map((b) => b.name);
400 expect(names).toContain("feature-a");305 expect(names).toContain("feature-a");
401 expect(names).toContain("feature-b");306 expect(names).toContain("feature-b");
...@@ -406,7 +311,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -406,7 +311,7 @@ it.layer(TestLayer)("git integration", (it) => {
406 Effect.gen(function* () {311 Effect.gen(function* () {
407 const tmp = yield* makeTmpDir();312 const tmp = yield* makeTmpDir();
408 yield* initRepoWithCommit(tmp);313 yield* initRepoWithCommit(tmp);
409 const result = yield* listGitBranches({ cwd: tmp });314 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
410 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);315 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
411 }),316 }),
412 );317 );
...@@ -418,23 +323,23 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -418,23 +323,23 @@ it.layer(TestLayer)("git integration", (it) => {
418323
419 yield* git(remote, ["init", "--bare"]);324 yield* git(remote, ["init", "--bare"]);
420 yield* initRepoWithCommit(tmp);325 yield* initRepoWithCommit(tmp);
421 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(326 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
422 (branch) => branch.current,327 (branch) => branch.current,
423 )!.name;328 )!.name;
424329
425 yield* git(tmp, ["remote", "add", "origin", remote]);330 yield* git(tmp, ["remote", "add", "origin", remote]);
426 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);331 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
427332
428 yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" });333 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" });
429334
430 const remoteOnlyBranch = "feature/remote-only";335 const remoteOnlyBranch = "feature/remote-only";
431 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });336 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
432 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);337 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);
433 yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);338 yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);
434 yield* git(tmp, ["checkout", defaultBranch]);339 yield* git(tmp, ["checkout", defaultBranch]);
435 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);340 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
436341
437 const result = yield* listGitBranches({ cwd: tmp });342 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
438 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);343 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
439344
440 expect(result.hasOriginRemote).toBe(true);345 expect(result.hasOriginRemote).toBe(true);
...@@ -466,7 +371,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -466,7 +371,7 @@ it.layer(TestLayer)("git integration", (it) => {
466371
467 yield* git(remote, ["init", "--bare"]);372 yield* git(remote, ["init", "--bare"]);
468 yield* initRepoWithCommit(tmp);373 yield* initRepoWithCommit(tmp);
469 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(374 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
470 (branch) => branch.current,375 (branch) => branch.current,
471 )!.name;376 )!.name;
472377
...@@ -479,7 +384,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -479,7 +384,7 @@ it.layer(TestLayer)("git integration", (it) => {
479 yield* git(tmp, ["checkout", defaultBranch]);384 yield* git(tmp, ["checkout", defaultBranch]);
480 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);385 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
481386
482 const result = yield* listGitBranches({ cwd: tmp });387 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
483 const remoteBranch = result.branches.find(388 const remoteBranch = result.branches.find(
484 (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`,389 (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`,
485 );390 );
...@@ -498,11 +403,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -498,11 +403,11 @@ it.layer(TestLayer)("git integration", (it) => {
498 Effect.gen(function* () {403 Effect.gen(function* () {
499 const tmp = yield* makeTmpDir();404 const tmp = yield* makeTmpDir();
500 yield* initRepoWithCommit(tmp);405 yield* initRepoWithCommit(tmp);
501 yield* createGitBranch({ cwd: tmp, branch: "feature" });406 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" });
502407
503 yield* checkoutGitBranch({ cwd: tmp, branch: "feature" });408 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" });
504409
505 const result = yield* listGitBranches({ cwd: tmp });410 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
506 const current = result.branches.find((b) => b.current);411 const current = result.branches.find((b) => b.current);
507 expect(current!.name).toBe("feature");412 expect(current!.name).toBe("feature");
508 }),413 }),
...@@ -516,20 +421,20 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -516,20 +421,20 @@ it.layer(TestLayer)("git integration", (it) => {
516 yield* git(remote, ["init", "--bare"]);421 yield* git(remote, ["init", "--bare"]);
517422
518 yield* initRepoWithCommit(source);423 yield* initRepoWithCommit(source);
519 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(424 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
520 (branch) => branch.current,425 (branch) => branch.current,
521 )!.name;426 )!.name;
522 yield* git(source, ["remote", "add", "origin", remote]);427 yield* git(source, ["remote", "add", "origin", remote]);
523 yield* git(source, ["push", "-u", "origin", defaultBranch]);428 yield* git(source, ["push", "-u", "origin", defaultBranch]);
524429
525 const featureBranch = "feature-behind";430 const featureBranch = "feature-behind";
526 yield* createGitBranch({ cwd: source, branch: featureBranch });431 yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch });
527 yield* checkoutGitBranch({ cwd: source, branch: featureBranch });432 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
528 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");433 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
529 yield* git(source, ["add", "feature.txt"]);434 yield* git(source, ["add", "feature.txt"]);
530 yield* git(source, ["commit", "-m", "feature base"]);435 yield* git(source, ["commit", "-m", "feature base"]);
531 yield* git(source, ["push", "-u", "origin", featureBranch]);436 yield* git(source, ["push", "-u", "origin", featureBranch]);
532 yield* checkoutGitBranch({ cwd: source, branch: defaultBranch });437 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch });
533438
534 yield* git(clone, ["clone", remote, "."]);439 yield* git(clone, ["clone", remote, "."]);
535 yield* git(clone, ["config", "user.email", "test@test.com"]);440 yield* git(clone, ["config", "user.email", "test@test.com"]);
...@@ -540,7 +445,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -540,7 +445,7 @@ it.layer(TestLayer)("git integration", (it) => {
540 yield* git(clone, ["commit", "-m", "remote feature update"]);445 yield* git(clone, ["commit", "-m", "remote feature update"]);
541 yield* git(clone, ["push", "origin", featureBranch]);446 yield* git(clone, ["push", "origin", featureBranch]);
542447
543 yield* checkoutGitBranch({ cwd: source, branch: featureBranch });448 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
544 const core = yield* GitCore;449 const core = yield* GitCore;
545 yield* Effect.promise(() =>450 yield* Effect.promise(() =>
546 vi.waitFor(async () => {451 vi.waitFor(async () => {
...@@ -560,7 +465,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -560,7 +465,7 @@ it.layer(TestLayer)("git integration", (it) => {
560 yield* git(remote, ["init", "--bare"]);465 yield* git(remote, ["init", "--bare"]);
561466
562 yield* initRepoWithCommit(source);467 yield* initRepoWithCommit(source);
563 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(468 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
564 (branch) => branch.current,469 (branch) => branch.current,
565 )!.name;470 )!.name;
566 yield* git(source, ["remote", "add", "origin", remote]);471 yield* git(source, ["remote", "add", "origin", remote]);
...@@ -575,23 +480,21 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -575,23 +480,21 @@ it.layer(TestLayer)("git integration", (it) => {
575 yield* git(source, ["push", "-u", "origin", featureBranch]);480 yield* git(source, ["push", "-u", "origin", featureBranch]);
576 yield* git(source, ["checkout", defaultBranch]);481 yield* git(source, ["checkout", defaultBranch]);
577482
578 const realGitService = yield* GitService;483 const realGitCore = yield* GitCore;
579 let refreshFetchAttempts = 0;484 let refreshFetchAttempts = 0;
580 const core = yield* makeIsolatedGitCore({485 const core = yield* makeIsolatedGitCore((input) => {
581 execute: (input) => {486 if (input.args[0] === "fetch") {
582 if (input.args[0] === "fetch") {487 refreshFetchAttempts += 1;
583 refreshFetchAttempts += 1;488 return Effect.fail(
584 return Effect.fail(489 new GitCommandError({
585 new GitCommandError({490 operation: "git.test.refreshFailure",
586 operation: "git.test.refreshFailure",491 command: `git ${input.args.join(" ")}`,
587 command: `git ${input.args.join(" ")}`,492 cwd: input.cwd,
588 cwd: input.cwd,493 detail: "simulated fetch timeout",
589 detail: "simulated fetch timeout",494 }),
590 }),495 );
591 );496 }
592 }497 return realGitCore.execute(input);
593 return realGitService.execute(input);
594 },
595 });498 });
596 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });499 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
597 yield* Effect.promise(() =>500 yield* Effect.promise(() =>
...@@ -610,7 +513,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -610,7 +513,7 @@ it.layer(TestLayer)("git integration", (it) => {
610 yield* git(remote, ["init", "--bare"]);513 yield* git(remote, ["init", "--bare"]);
611514
612 yield* initRepoWithCommit(source);515 yield* initRepoWithCommit(source);
613 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(516 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
614 (branch) => branch.current,517 (branch) => branch.current,
615 )!.name;518 )!.name;
616 yield* git(source, ["remote", "add", "origin", remote]);519 yield* git(source, ["remote", "add", "origin", remote]);
...@@ -624,16 +527,14 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -624,16 +527,14 @@ it.layer(TestLayer)("git integration", (it) => {
624 yield* git(source, ["push", "-u", "origin", featureBranch]);527 yield* git(source, ["push", "-u", "origin", featureBranch]);
625 yield* git(source, ["checkout", defaultBranch]);528 yield* git(source, ["checkout", defaultBranch]);
626529
627 const realGitService = yield* GitService;530 const realGitCore = yield* GitCore;
628 let fetchArgs: readonly string[] | null = null;531 let fetchArgs: readonly string[] | null = null;
629 const core = yield* makeIsolatedGitCore({532 const core = yield* makeIsolatedGitCore((input) => {
630 execute: (input) => {533 if (input.args[0] === "fetch") {
631 if (input.args[0] === "fetch") {534 fetchArgs = [...input.args];
632 fetchArgs = [...input.args];535 return Effect.succeed({ code: 0, stdout: "", stderr: "" });
633 return Effect.succeed({ code: 0, stdout: "", stderr: "" });536 }
634 }537 return realGitCore.execute(input);
635 return realGitService.execute(input);
636 },
637 });538 });
638 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });539 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
639 yield* Effect.promise(() =>540 yield* Effect.promise(() =>
...@@ -660,7 +561,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -660,7 +561,7 @@ it.layer(TestLayer)("git integration", (it) => {
660 yield* git(remote, ["init", "--bare"]);561 yield* git(remote, ["init", "--bare"]);
661562
662 yield* initRepoWithCommit(source);563 yield* initRepoWithCommit(source);
663 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(564 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
664 (branch) => branch.current,565 (branch) => branch.current,
665 )!.name;566 )!.name;
666 yield* git(source, ["remote", "add", "origin", remote]);567 yield* git(source, ["remote", "add", "origin", remote]);
...@@ -674,22 +575,20 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -674,22 +575,20 @@ it.layer(TestLayer)("git integration", (it) => {
674 yield* git(source, ["push", "-u", "origin", featureBranch]);575 yield* git(source, ["push", "-u", "origin", featureBranch]);
675 yield* git(source, ["checkout", defaultBranch]);576 yield* git(source, ["checkout", defaultBranch]);
676577
677 const realGitService = yield* GitService;578 const realGitCore = yield* GitCore;
678 let fetchStarted = false;579 let fetchStarted = false;
679 let releaseFetch!: () => void;580 let releaseFetch!: () => void;
680 const waitForReleasePromise = new Promise<void>((resolve) => {581 const waitForReleasePromise = new Promise<void>((resolve) => {
681 releaseFetch = resolve;582 releaseFetch = resolve;
682 });583 });
683 const core = yield* makeIsolatedGitCore({584 const core = yield* makeIsolatedGitCore((input) => {
684 execute: (input) => {585 if (input.args[0] === "fetch") {
685 if (input.args[0] === "fetch") {586 fetchStarted = true;
686 fetchStarted = true;587 return Effect.promise(() =>
687 return Effect.promise(() =>588 waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })),
688 waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })),589 );
689 );590 }
690 }591 return realGitCore.execute(input);
691 return realGitService.execute(input);
692 },
693 });592 });
694 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });593 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
695 yield* Effect.promise(() =>594 yield* Effect.promise(() =>
...@@ -706,7 +605,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -706,7 +605,9 @@ it.layer(TestLayer)("git integration", (it) => {
706 Effect.gen(function* () {605 Effect.gen(function* () {
707 const tmp = yield* makeTmpDir();606 const tmp = yield* makeTmpDir();
708 yield* initRepoWithCommit(tmp);607 yield* initRepoWithCommit(tmp);
709 const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" }));608 const result = yield* Effect.result(
609 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }),
610 );
710 expect(result._tag).toBe("Failure");611 expect(result._tag).toBe("Failure");
711 }),612 }),
712 );613 );
...@@ -718,16 +619,16 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -718,16 +619,16 @@ it.layer(TestLayer)("git integration", (it) => {
718 yield* git(remote, ["init", "--bare"]);619 yield* git(remote, ["init", "--bare"]);
719620
720 yield* initRepoWithCommit(source);621 yield* initRepoWithCommit(source);
721 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(622 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
722 (branch) => branch.current,623 (branch) => branch.current,
723 )!.name;624 )!.name;
724 yield* git(source, ["remote", "add", "origin", remote]);625 yield* git(source, ["remote", "add", "origin", remote]);
725 yield* git(source, ["push", "-u", "origin", defaultBranch]);626 yield* git(source, ["push", "-u", "origin", defaultBranch]);
726627
727 yield* createGitBranch({ cwd: source, branch: "feature" });628 yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" });
728629
729 const checkoutResult = yield* Effect.result(630 const checkoutResult = yield* Effect.result(
730 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),631 (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }),
731 );632 );
732 expect(checkoutResult._tag).toBe("Failure");633 expect(checkoutResult._tag).toBe("Failure");
733 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);634 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
...@@ -743,7 +644,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -743,7 +644,7 @@ it.layer(TestLayer)("git integration", (it) => {
743 yield* git(remote, ["init", "--bare"]);644 yield* git(remote, ["init", "--bare"]);
744645
745 yield* initRepoWithCommit(source);646 yield* initRepoWithCommit(source);
746 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(647 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
747 (branch) => branch.current,648 (branch) => branch.current,
748 )!.name;649 )!.name;
749 yield* git(source, ["remote", "add", remoteName, remote]);650 yield* git(source, ["remote", "add", remoteName, remote]);
...@@ -757,7 +658,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -757,7 +658,10 @@ it.layer(TestLayer)("git integration", (it) => {
757 yield* git(source, ["checkout", defaultBranch]);658 yield* git(source, ["checkout", defaultBranch]);
758 yield* git(source, ["branch", "-D", featureBranch]);659 yield* git(source, ["branch", "-D", featureBranch]);
759660
760 yield* checkoutGitBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` });661 yield* (yield* GitCore).checkoutBranch({
662 cwd: source,
663 branch: `${remoteName}/${featureBranch}`,
664 });
761665
762 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");666 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
763 }),667 }),
...@@ -772,9 +676,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -772,9 +676,9 @@ it.layer(TestLayer)("git integration", (it) => {
772 yield* git(remote, ["init", "--bare"]);676 yield* git(remote, ["init", "--bare"]);
773677
774 yield* initRepoWithCommit(source);678 yield* initRepoWithCommit(source);
775 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(679 const defaultBranch = (yield* (yield* GitCore).listBranches({
776 (branch) => branch.current,680 cwd: source,
777 )!.name;681 })).branches.find((branch) => branch.current)!.name;
778 yield* git(source, ["remote", "add", "origin", remote]);682 yield* git(source, ["remote", "add", "origin", remote]);
779 yield* git(source, ["push", "-u", "origin", defaultBranch]);683 yield* git(source, ["push", "-u", "origin", defaultBranch]);
780684
...@@ -782,7 +686,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -782,7 +686,10 @@ it.layer(TestLayer)("git integration", (it) => {
782 // would attempt to create an already-existing local branch.686 // would attempt to create an already-existing local branch.
783 yield* git(source, ["branch", "--unset-upstream"]);687 yield* git(source, ["branch", "--unset-upstream"]);
784688
785 yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` });689 yield* (yield* GitCore).checkoutBranch({
690 cwd: source,
691 branch: `origin/${defaultBranch}`,
692 });
786693
787 const core = yield* GitCore;694 const core = yield* GitCore;
788 const status = yield* core.statusDetails(source);695 const status = yield* core.statusDetails(source);
...@@ -794,7 +701,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -794,7 +701,7 @@ it.layer(TestLayer)("git integration", (it) => {
794 Effect.gen(function* () {701 Effect.gen(function* () {
795 const tmp = yield* makeTmpDir();702 const tmp = yield* makeTmpDir();
796 yield* initRepoWithCommit(tmp);703 yield* initRepoWithCommit(tmp);
797 yield* createGitBranch({ cwd: tmp, branch: "other" });704 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" });
798705
799 // Create a conflicting change: modify README on current branch706 // Create a conflicting change: modify README on current branch
800 yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");707 yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");
...@@ -802,22 +709,24 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -802,22 +709,24 @@ it.layer(TestLayer)("git integration", (it) => {
802709
803 // First, checkout other branch cleanly710 // First, checkout other branch cleanly
804 yield* git(tmp, ["stash"]);711 yield* git(tmp, ["stash"]);
805 yield* checkoutGitBranch({ cwd: tmp, branch: "other" });712 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" });
806 yield* writeTextFile(path.join(tmp, "README.md"), "other content\n");713 yield* writeTextFile(path.join(tmp, "README.md"), "other content\n");
807 yield* git(tmp, ["add", "."]);714 yield* git(tmp, ["add", "."]);
808 yield* git(tmp, ["commit", "-m", "other change"]);715 yield* git(tmp, ["commit", "-m", "other change"]);
809716
810 // Go back to default branch717 // Go back to default branch
811 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(718 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
812 (b) => !b.current,719 (b) => !b.current,
813 )!.name;720 )!.name;
814 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });721 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
815722
816 // Make uncommitted changes to the same file723 // Make uncommitted changes to the same file
817 yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n");724 yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n");
818725
819 // Checkout should fail due to uncommitted changes726 // Checkout should fail due to uncommitted changes
820 const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" }));727 const result = yield* Effect.result(
728 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }),
729 );
821 expect(result._tag).toBe("Failure");730 expect(result._tag).toBe("Failure");
822 }),731 }),
823 );732 );
...@@ -830,9 +739,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -830,9 +739,9 @@ it.layer(TestLayer)("git integration", (it) => {
830 Effect.gen(function* () {739 Effect.gen(function* () {
831 const tmp = yield* makeTmpDir();740 const tmp = yield* makeTmpDir();
832 yield* initRepoWithCommit(tmp);741 yield* initRepoWithCommit(tmp);
833 yield* createGitBranch({ cwd: tmp, branch: "new-feature" });742 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" });
834743
835 const result = yield* listGitBranches({ cwd: tmp });744 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
836 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);745 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
837 }),746 }),
838 );747 );
...@@ -841,8 +750,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -841,8 +750,10 @@ it.layer(TestLayer)("git integration", (it) => {
841 Effect.gen(function* () {750 Effect.gen(function* () {
842 const tmp = yield* makeTmpDir();751 const tmp = yield* makeTmpDir();
843 yield* initRepoWithCommit(tmp);752 yield* initRepoWithCommit(tmp);
844 yield* createGitBranch({ cwd: tmp, branch: "dupe" });753 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" });
845 const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" }));754 const result = yield* Effect.result(
755 (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }),
756 );
846 expect(result._tag).toBe("Failure");757 expect(result._tag).toBe("Failure");
847 }),758 }),
848 );759 );
...@@ -855,10 +766,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -855,10 +766,10 @@ it.layer(TestLayer)("git integration", (it) => {
855 Effect.gen(function* () {766 Effect.gen(function* () {
856 const tmp = yield* makeTmpDir();767 const tmp = yield* makeTmpDir();
857 yield* initRepoWithCommit(tmp);768 yield* initRepoWithCommit(tmp);
858 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });769 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" });
859 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });770 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" });
860771
861 const renamed = yield* renameGitBranch({772 const renamed = yield* (yield* GitCore).renameBranch({
862 cwd: tmp,773 cwd: tmp,
863 oldBranch: "feature/old-name",774 oldBranch: "feature/old-name",
864 newBranch: "feature/new-name",775 newBranch: "feature/new-name",
...@@ -866,7 +777,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -866,7 +777,7 @@ it.layer(TestLayer)("git integration", (it) => {
866777
867 expect(renamed.branch).toBe("feature/new-name");778 expect(renamed.branch).toBe("feature/new-name");
868779
869 const branches = yield* listGitBranches({ cwd: tmp });780 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
870 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);781 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
871 const current = branches.branches.find((branch) => branch.current);782 const current = branches.branches.find((branch) => branch.current);
872 expect(current?.name).toBe("feature/new-name");783 expect(current?.name).toBe("feature/new-name");
...@@ -877,9 +788,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -877,9 +788,11 @@ it.layer(TestLayer)("git integration", (it) => {
877 Effect.gen(function* () {788 Effect.gen(function* () {
878 const tmp = yield* makeTmpDir();789 const tmp = yield* makeTmpDir();
879 yield* initRepoWithCommit(tmp);790 yield* initRepoWithCommit(tmp);
880 const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!;791 const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
792 (b) => b.current,
793 )!;
881794
882 const renamed = yield* renameGitBranch({795 const renamed = yield* (yield* GitCore).renameBranch({
883 cwd: tmp,796 cwd: tmp,
884 oldBranch: current.name,797 oldBranch: current.name,
885 newBranch: current.name,798 newBranch: current.name,
...@@ -893,18 +806,18 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -893,18 +806,18 @@ it.layer(TestLayer)("git integration", (it) => {
893 Effect.gen(function* () {806 Effect.gen(function* () {
894 const tmp = yield* makeTmpDir();807 const tmp = yield* makeTmpDir();
895 yield* initRepoWithCommit(tmp);808 yield* initRepoWithCommit(tmp);
896 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });809 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
897 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });810 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" });
898 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });811 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" });
899812
900 const renamed = yield* renameGitBranch({813 const renamed = yield* (yield* GitCore).renameBranch({
901 cwd: tmp,814 cwd: tmp,
902 oldBranch: "t3code/tmp-working",815 oldBranch: "t3code/tmp-working",
903 newBranch: "t3code/feat/session",816 newBranch: "t3code/feat/session",
904 });817 });
905818
906 expect(renamed.branch).toBe("t3code/feat/session-1");819 expect(renamed.branch).toBe("t3code/feat/session-1");
907 const branches = yield* listGitBranches({ cwd: tmp });820 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
908 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(821 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
909 true,822 true,
910 );823 );
...@@ -920,12 +833,12 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -920,12 +833,12 @@ it.layer(TestLayer)("git integration", (it) => {
920 Effect.gen(function* () {833 Effect.gen(function* () {
921 const tmp = yield* makeTmpDir();834 const tmp = yield* makeTmpDir();
922 yield* initRepoWithCommit(tmp);835 yield* initRepoWithCommit(tmp);
923 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });836 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
924 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });837 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
925 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });838 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" });
926 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });839 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" });
927840
928 const renamed = yield* renameGitBranch({841 const renamed = yield* (yield* GitCore).renameBranch({
929 cwd: tmp,842 cwd: tmp,
930 oldBranch: "t3code/tmp-working",843 oldBranch: "t3code/tmp-working",
931 newBranch: "t3code/feat/session",844 newBranch: "t3code/feat/session",
...@@ -939,18 +852,16 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -939,18 +852,16 @@ it.layer(TestLayer)("git integration", (it) => {
939 Effect.gen(function* () {852 Effect.gen(function* () {
940 const tmp = yield* makeTmpDir();853 const tmp = yield* makeTmpDir();
941 yield* initRepoWithCommit(tmp);854 yield* initRepoWithCommit(tmp);
942 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });855 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" });
943 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });856 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" });
944857
945 const realGitService = yield* GitService;858 const realGitCore = yield* GitCore;
946 let renameArgs: ReadonlyArray<string> | null = null;859 let renameArgs: ReadonlyArray<string> | null = null;
947 const core = yield* makeIsolatedGitCore({860 const core = yield* makeIsolatedGitCore((input) => {
948 execute: (input) => {861 if (input.args[0] === "branch" && input.args[1] === "-m") {
949 if (input.args[0] === "branch" && input.args[1] === "-m") {862 renameArgs = [...input.args];
950 renameArgs = [...input.args];863 }
951 }864 return realGitCore.execute(input);
952 return realGitService.execute(input);
953 },
954 });865 });
955866
956 const renamed = yield* core.renameBranch({867 const renamed = yield* core.renameBranch({
...@@ -974,11 +885,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -974,11 +885,11 @@ it.layer(TestLayer)("git integration", (it) => {
974 yield* initRepoWithCommit(tmp);885 yield* initRepoWithCommit(tmp);
975886
976 const wtPath = path.join(tmp, "worktree-out");887 const wtPath = path.join(tmp, "worktree-out");
977 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(888 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
978 (b) => b.current,889 (b) => b.current,
979 )!.name;890 )!.name;
980891
981 const result = yield* createGitWorktree({892 const result = yield* (yield* GitCore).createWorktree({
982 cwd: tmp,893 cwd: tmp,
983 branch: currentBranch,894 branch: currentBranch,
984 newBranch: "wt-branch",895 newBranch: "wt-branch",
...@@ -991,7 +902,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -991,7 +902,7 @@ it.layer(TestLayer)("git integration", (it) => {
991 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);902 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
992903
993 // Clean up worktree before tmp dir disposal904 // Clean up worktree before tmp dir disposal
994 yield* removeGitWorktree({ cwd: tmp, path: wtPath });905 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
995 }),906 }),
996 );907 );
997908
...@@ -1001,11 +912,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1001,11 +912,11 @@ it.layer(TestLayer)("git integration", (it) => {
1001 yield* initRepoWithCommit(tmp);912 yield* initRepoWithCommit(tmp);
1002913
1003 const wtPath = path.join(tmp, "wt-check-dir");914 const wtPath = path.join(tmp, "wt-check-dir");
1004 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(915 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1005 (b) => b.current,916 (b) => b.current,
1006 )!.name;917 )!.name;
1007918
1008 yield* createGitWorktree({919 yield* (yield* GitCore).createWorktree({
1009 cwd: tmp,920 cwd: tmp,
1010 branch: currentBranch,921 branch: currentBranch,
1011 newBranch: "wt-check",922 newBranch: "wt-check",
...@@ -1016,7 +927,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1016,7 +927,7 @@ it.layer(TestLayer)("git integration", (it) => {
1016 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);927 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1017 expect(branchOutput).toBe("wt-check");928 expect(branchOutput).toBe("wt-check");
1018929
1019 yield* removeGitWorktree({ cwd: tmp, path: wtPath });930 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1020 }),931 }),
1021 );932 );
1022933
...@@ -1024,10 +935,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1024,10 +935,10 @@ it.layer(TestLayer)("git integration", (it) => {
1024 Effect.gen(function* () {935 Effect.gen(function* () {
1025 const tmp = yield* makeTmpDir();936 const tmp = yield* makeTmpDir();
1026 yield* initRepoWithCommit(tmp);937 yield* initRepoWithCommit(tmp);
1027 yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" });938 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" });
1028939
1029 const wtPath = path.join(tmp, "wt-existing");940 const wtPath = path.join(tmp, "wt-existing");
1030 const result = yield* createGitWorktree({941 const result = yield* (yield* GitCore).createWorktree({
1031 cwd: tmp,942 cwd: tmp,
1032 branch: "feature/existing-worktree",943 branch: "feature/existing-worktree",
1033 path: wtPath,944 path: wtPath,
...@@ -1038,7 +949,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1038,7 +949,7 @@ it.layer(TestLayer)("git integration", (it) => {
1038 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);949 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1039 expect(branchOutput).toBe("feature/existing-worktree");950 expect(branchOutput).toBe("feature/existing-worktree");
1040951
1041 yield* removeGitWorktree({ cwd: tmp, path: wtPath });952 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1042 }),953 }),
1043 );954 );
1044955
...@@ -1046,15 +957,15 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1046,15 +957,15 @@ it.layer(TestLayer)("git integration", (it) => {
1046 Effect.gen(function* () {957 Effect.gen(function* () {
1047 const tmp = yield* makeTmpDir();958 const tmp = yield* makeTmpDir();
1048 yield* initRepoWithCommit(tmp);959 yield* initRepoWithCommit(tmp);
1049 yield* createGitBranch({ cwd: tmp, branch: "existing" });960 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" });
1050961
1051 const wtPath = path.join(tmp, "wt-conflict");962 const wtPath = path.join(tmp, "wt-conflict");
1052 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(963 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1053 (b) => b.current,964 (b) => b.current,
1054 )!.name;965 )!.name;
1055966
1056 const result = yield* Effect.result(967 const result = yield* Effect.result(
1057 createGitWorktree({968 (yield* GitCore).createWorktree({
1058 cwd: tmp,969 cwd: tmp,
1059 branch: currentBranch,970 branch: currentBranch,
1060 newBranch: "existing",971 newBranch: "existing",
...@@ -1071,11 +982,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1071,11 +982,11 @@ it.layer(TestLayer)("git integration", (it) => {
1071 yield* initRepoWithCommit(tmp);982 yield* initRepoWithCommit(tmp);
1072983
1073 const wtPath = path.join(tmp, "wt-list-dir");984 const wtPath = path.join(tmp, "wt-list-dir");
1074 const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(985 const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1075 (b) => b.current,986 (b) => b.current,
1076 )!.name;987 )!.name;
1077988
1078 yield* createGitWorktree({989 yield* (yield* GitCore).createWorktree({
1079 cwd: tmp,990 cwd: tmp,
1080 branch: mainBranch,991 branch: mainBranch,
1081 newBranch: "wt-list",992 newBranch: "wt-list",
...@@ -1083,17 +994,17 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1083,17 +994,17 @@ it.layer(TestLayer)("git integration", (it) => {
1083 });994 });
1084995
1085 // listGitBranches from the worktree should show wt-list as current996 // listGitBranches from the worktree should show wt-list as current
1086 const wtBranches = yield* listGitBranches({ cwd: wtPath });997 const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath });
1087 expect(wtBranches.isRepo).toBe(true);998 expect(wtBranches.isRepo).toBe(true);
1088 const wtCurrent = wtBranches.branches.find((b) => b.current);999 const wtCurrent = wtBranches.branches.find((b) => b.current);
1089 expect(wtCurrent!.name).toBe("wt-list");1000 expect(wtCurrent!.name).toBe("wt-list");
10901001
1091 // Main repo should still show the original branch as current1002 // Main repo should still show the original branch as current
1092 const mainBranches = yield* listGitBranches({ cwd: tmp });1003 const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1093 const mainCurrent = mainBranches.branches.find((b) => b.current);1004 const mainCurrent = mainBranches.branches.find((b) => b.current);
1094 expect(mainCurrent!.name).toBe(mainBranch);1005 expect(mainCurrent!.name).toBe(mainBranch);
10951006
1096 yield* removeGitWorktree({ cwd: tmp, path: wtPath });1007 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1097 }),1008 }),
1098 );1009 );
10991010
...@@ -1103,11 +1014,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1103,11 +1014,11 @@ it.layer(TestLayer)("git integration", (it) => {
1103 yield* initRepoWithCommit(tmp);1014 yield* initRepoWithCommit(tmp);
11041015
1105 const wtPath = path.join(tmp, "wt-remove-dir");1016 const wtPath = path.join(tmp, "wt-remove-dir");
1106 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(1017 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1107 (b) => b.current,1018 (b) => b.current,
1108 )!.name;1019 )!.name;
11091020
1110 yield* createGitWorktree({1021 yield* (yield* GitCore).createWorktree({
1111 cwd: tmp,1022 cwd: tmp,
1112 branch: currentBranch,1023 branch: currentBranch,
1113 newBranch: "wt-remove",1024 newBranch: "wt-remove",
...@@ -1115,7 +1026,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1115,7 +1026,7 @@ it.layer(TestLayer)("git integration", (it) => {
1115 });1026 });
1116 expect(existsSync(wtPath)).toBe(true);1027 expect(existsSync(wtPath)).toBe(true);
11171028
1118 yield* removeGitWorktree({ cwd: tmp, path: wtPath });1029 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1119 expect(existsSync(wtPath)).toBe(false);1030 expect(existsSync(wtPath)).toBe(false);
1120 }),1031 }),
1121 );1032 );
...@@ -1126,11 +1037,11 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1126,11 +1037,11 @@ it.layer(TestLayer)("git integration", (it) => {
1126 yield* initRepoWithCommit(tmp);1037 yield* initRepoWithCommit(tmp);
11271038
1128 const wtPath = path.join(tmp, "wt-dirty-dir");1039 const wtPath = path.join(tmp, "wt-dirty-dir");
1129 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(1040 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1130 (b) => b.current,1041 (b) => b.current,
1131 )!.name;1042 )!.name;
11321043
1133 yield* createGitWorktree({1044 yield* (yield* GitCore).createWorktree({
1134 cwd: tmp,1045 cwd: tmp,
1135 branch: currentBranch,1046 branch: currentBranch,
1136 newBranch: "wt-dirty",1047 newBranch: "wt-dirty",
...@@ -1140,11 +1051,13 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1140,11 +1051,13 @@ it.layer(TestLayer)("git integration", (it) => {
11401051
1141 yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n");1052 yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n");
11421053
1143 const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath }));1054 const failedRemove = yield* Effect.result(
1055 (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }),
1056 );
1144 expect(failedRemove._tag).toBe("Failure");1057 expect(failedRemove._tag).toBe("Failure");
1145 expect(existsSync(wtPath)).toBe(true);1058 expect(existsSync(wtPath)).toBe(true);
11461059
1147 yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true });1060 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true });
1148 expect(existsSync(wtPath)).toBe(false);1061 expect(existsSync(wtPath)).toBe(false);
1149 }),1062 }),
1150 );1063 );
...@@ -1157,10 +1070,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1157,10 +1070,10 @@ it.layer(TestLayer)("git integration", (it) => {
1157 Effect.gen(function* () {1070 Effect.gen(function* () {
1158 const tmp = yield* makeTmpDir();1071 const tmp = yield* makeTmpDir();
1159 yield* initRepoWithCommit(tmp);1072 yield* initRepoWithCommit(tmp);
1160 yield* createGitBranch({ cwd: tmp, branch: "feature-login" });1073 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" });
1161 yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" });1074 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" });
11621075
1163 const result = yield* listGitBranches({ cwd: tmp });1076 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
1164 const current = result.branches.find((b) => b.current);1077 const current = result.branches.find((b) => b.current);
1165 expect(current!.name).toBe("feature-login");1078 expect(current!.name).toBe("feature-login");
1166 }),1079 }),
...@@ -1175,12 +1088,12 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1175,12 +1088,12 @@ it.layer(TestLayer)("git integration", (it) => {
1175 const tmp = yield* makeTmpDir();1088 const tmp = yield* makeTmpDir();
1176 yield* initRepoWithCommit(tmp);1089 yield* initRepoWithCommit(tmp);
11771090
1178 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(1091 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1179 (b) => b.current,1092 (b) => b.current,
1180 )!.name;1093 )!.name;
11811094
1182 const wtPath = path.join(tmp, "my-worktree");1095 const wtPath = path.join(tmp, "my-worktree");
1183 const result = yield* createGitWorktree({1096 const result = yield* (yield* GitCore).createWorktree({
1184 cwd: tmp,1097 cwd: tmp,
1185 branch: currentBranch,1098 branch: currentBranch,
1186 newBranch: "feature-wt",1099 newBranch: "feature-wt",
...@@ -1191,7 +1104,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1191,7 +1104,7 @@ it.layer(TestLayer)("git integration", (it) => {
1191 expect(existsSync(result.worktree.path)).toBe(true);1104 expect(existsSync(result.worktree.path)).toBe(true);
11921105
1193 // Main repo still on original branch1106 // Main repo still on original branch
1194 const mainBranches = yield* listGitBranches({ cwd: tmp });1107 const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1195 const mainCurrent = mainBranches.branches.find((b) => b.current);1108 const mainCurrent = mainBranches.branches.find((b) => b.current);
1196 expect(mainCurrent!.name).toBe(currentBranch);1109 expect(mainCurrent!.name).toBe(currentBranch);
11971110
...@@ -1199,7 +1112,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1199,7 +1112,7 @@ it.layer(TestLayer)("git integration", (it) => {
1199 const wtBranch = yield* git(wtPath, ["branch", "--show-current"]);1112 const wtBranch = yield* git(wtPath, ["branch", "--show-current"]);
1200 expect(wtBranch).toBe("feature-wt");1113 expect(wtBranch).toBe("feature-wt");
12011114
1202 yield* removeGitWorktree({ cwd: tmp, path: wtPath });1115 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1203 }),1116 }),
1204 );1117 );
1205 });1118 });
...@@ -1221,7 +1134,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1221,7 +1134,7 @@ it.layer(TestLayer)("git integration", (it) => {
1221 yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]);1134 yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]);
1222 yield* git(tmp, ["checkout", initialBranch]);1135 yield* git(tmp, ["checkout", initialBranch]);
12231136
1224 yield* fetchGitPullRequestBranch({1137 yield* (yield* GitCore).fetchPullRequestBranch({
1225 cwd: tmp,1138 cwd: tmp,
1226 prNumber: 55,1139 prNumber: 55,
1227 branch: "feature/pr-fetch",1140 branch: "feature/pr-fetch",
...@@ -1242,22 +1155,22 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1242,22 +1155,22 @@ it.layer(TestLayer)("git integration", (it) => {
1242 Effect.gen(function* () {1155 Effect.gen(function* () {
1243 const tmp = yield* makeTmpDir();1156 const tmp = yield* makeTmpDir();
1244 yield* initRepoWithCommit(tmp);1157 yield* initRepoWithCommit(tmp);
1245 yield* createGitBranch({ cwd: tmp, branch: "branch-a" });1158 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" });
1246 yield* createGitBranch({ cwd: tmp, branch: "branch-b" });1159 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" });
12471160
1248 // Simulate switching to thread A's branch1161 // Simulate switching to thread A's branch
1249 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" });1162 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1250 let branches = yield* listGitBranches({ cwd: tmp });1163 let branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1251 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");1164 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
12521165
1253 // Simulate switching to thread B's branch1166 // Simulate switching to thread B's branch
1254 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" });1167 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" });
1255 branches = yield* listGitBranches({ cwd: tmp });1168 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1256 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");1169 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
12571170
1258 // Switch back to thread A1171 // Switch back to thread A
1259 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" });1172 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1260 branches = yield* listGitBranches({ cwd: tmp });1173 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1261 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");1174 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
1262 }),1175 }),
1263 );1176 );
...@@ -1270,30 +1183,30 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1270,30 +1183,30 @@ it.layer(TestLayer)("git integration", (it) => {
1270 Effect.gen(function* () {1183 Effect.gen(function* () {
1271 const tmp = yield* makeTmpDir();1184 const tmp = yield* makeTmpDir();
1272 yield* initRepoWithCommit(tmp);1185 yield* initRepoWithCommit(tmp);
1273 yield* createGitBranch({ cwd: tmp, branch: "diverged" });1186 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" });
12741187
1275 // Make diverged branch have different file content1188 // Make diverged branch have different file content
1276 yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" });1189 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" });
1277 yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n");1190 yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n");
1278 yield* git(tmp, ["add", "."]);1191 yield* git(tmp, ["add", "."]);
1279 yield* git(tmp, ["commit", "-m", "diverge"]);1192 yield* git(tmp, ["commit", "-m", "diverge"]);
12801193
1281 // Actually, let's just get back to the initial branch explicitly1194 // Actually, let's just get back to the initial branch explicitly
1282 const allBranches = yield* listGitBranches({ cwd: tmp });1195 const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1283 const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;1196 const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;
1284 yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch });1197 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });
12851198
1286 // Make local uncommitted changes to the same file1199 // Make local uncommitted changes to the same file
1287 yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n");1200 yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n");
12881201
1289 // Attempt checkout should fail1202 // Attempt checkout should fail
1290 const failedCheckout = yield* Effect.result(1203 const failedCheckout = yield* Effect.result(
1291 checkoutGitBranch({ cwd: tmp, branch: "diverged" }),1204 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }),
1292 );1205 );
1293 expect(failedCheckout._tag).toBe("Failure");1206 expect(failedCheckout._tag).toBe("Failure");
12941207
1295 // Current branch should still be the initial one1208 // Current branch should still be the initial one
1296 const result = yield* listGitBranches({ cwd: tmp });1209 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
1297 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);1210 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
1298 }),1211 }),
1299 );1212 );
...@@ -1390,9 +1303,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1390,9 +1303,9 @@ it.layer(TestLayer)("git integration", (it) => {
1390 yield* git(remote, ["init", "--bare"]);1303 yield* git(remote, ["init", "--bare"]);
13911304
1392 yield* initRepoWithCommit(source);1305 yield* initRepoWithCommit(source);
1393 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(1306 const initialBranch = (yield* (yield* GitCore).listBranches({
1394 (branch) => branch.current,1307 cwd: source,
1395 )!.name;1308 })).branches.find((branch) => branch.current)!.name;
1396 yield* git(source, ["remote", "add", "origin", remote]);1309 yield* git(source, ["remote", "add", "origin", remote]);
1397 yield* git(source, ["push", "-u", "origin", initialBranch]);1310 yield* git(source, ["push", "-u", "origin", initialBranch]);
1398 yield* git(source, ["checkout", "-b", "feature/remote-base-only"]);1311 yield* git(source, ["checkout", "-b", "feature/remote-base-only"]);
...@@ -1423,9 +1336,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1423,9 +1336,9 @@ it.layer(TestLayer)("git integration", (it) => {
1423 yield* git(remote, ["init", "--bare"]);1336 yield* git(remote, ["init", "--bare"]);
14241337
1425 yield* initRepoWithCommit(source);1338 yield* initRepoWithCommit(source);
1426 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(1339 const initialBranch = (yield* (yield* GitCore).listBranches({
1427 (branch) => branch.current,1340 cwd: source,
1428 )!.name;1341 })).branches.find((branch) => branch.current)!.name;
1429 yield* git(source, ["remote", "add", remoteName, remote]);1342 yield* git(source, ["remote", "add", remoteName, remote]);
1430 yield* git(source, ["push", "-u", remoteName, initialBranch]);1343 yield* git(source, ["push", "-u", remoteName, initialBranch]);
1431 yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]);1344 yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]);
...@@ -1526,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1526,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => {
1526 yield* git(remote, ["init", "--bare"]);1439 yield* git(remote, ["init", "--bare"]);
15271440
1528 yield* initRepoWithCommit(tmp);1441 yield* initRepoWithCommit(tmp);
1529 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(1442 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1530 (branch) => branch.current,1443 (branch) => branch.current,
1531 )!.name;1444 )!.name;
1532 yield* git(tmp, ["remote", "add", "origin", remote]);1445 yield* git(tmp, ["remote", "add", "origin", remote]);
...@@ -1562,7 +1475,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1562,7 +1475,7 @@ it.layer(TestLayer)("git integration", (it) => {
1562 yield* git(fork, ["init", "--bare"]);1475 yield* git(fork, ["init", "--bare"]);
15631476
1564 yield* initRepoWithCommit(tmp);1477 yield* initRepoWithCommit(tmp);
1565 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(1478 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1566 (branch) => branch.current,1479 (branch) => branch.current,
1567 )!.name;1480 )!.name;
1568 yield* git(tmp, ["remote", "add", "origin", origin]);1481 yield* git(tmp, ["remote", "add", "origin", origin]);
...@@ -1668,9 +1581,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1668,9 +1581,9 @@ it.layer(TestLayer)("git integration", (it) => {
1668 yield* git(remote, ["init", "--bare"]);1581 yield* git(remote, ["init", "--bare"]);
16691582
1670 yield* initRepoWithCommit(source);1583 yield* initRepoWithCommit(source);
1671 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(1584 const initialBranch = (yield* (yield* GitCore).listBranches({
1672 (branch) => branch.current,1585 cwd: source,
1673 )!.name;1586 })).branches.find((branch) => branch.current)!.name;
1674 yield* git(source, ["remote", "add", "origin", remote]);1587 yield* git(source, ["remote", "add", "origin", remote]);
1675 yield* git(source, ["push", "-u", "origin", initialBranch]);1588 yield* git(source, ["push", "-u", "origin", initialBranch]);
16761589
...@@ -1761,8 +1674,8 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1761,8 +1674,8 @@ it.layer(TestLayer)("git integration", (it) => {
1761 yield* initRepoWithCommit(tmp);1674 yield* initRepoWithCommit(tmp);
1762 yield* git(remote, ["init", "--bare"]);1675 yield* git(remote, ["init", "--bare"]);
1763 yield* git(tmp, ["remote", "add", "origin", remote]);1676 yield* git(tmp, ["remote", "add", "origin", remote]);
1764 yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" });1677 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" });
1765 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" });1678 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" });
17661679
1767 yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n");1680 yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n");
1768 const core = yield* GitCore;1681 const core = yield* GitCore;
...@@ -1790,7 +1703,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1790,7 +1703,7 @@ it.layer(TestLayer)("git integration", (it) => {
1790 yield* git(remote, ["init", "--bare"]);1703 yield* git(remote, ["init", "--bare"]);
17911704
1792 yield* initRepoWithCommit(source);1705 yield* initRepoWithCommit(source);
1793 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(1706 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
1794 (branch) => branch.current,1707 (branch) => branch.current,
1795 )!.name;1708 )!.name;
1796 yield* git(source, ["remote", "add", "origin", remote]);1709 yield* git(source, ["remote", "add", "origin", remote]);
...@@ -1818,7 +1731,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1818,7 +1731,7 @@ it.layer(TestLayer)("git integration", (it) => {
1818 Effect.gen(function* () {1731 Effect.gen(function* () {
1819 const tmp = yield* makeTmpDir();1732 const tmp = yield* makeTmpDir();
1820 yield* initRepoWithCommit(tmp);1733 yield* initRepoWithCommit(tmp);
1821 const result = yield* Effect.result(pullGitBranch({ cwd: tmp }));1734 const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp));
1822 expect(result._tag).toBe("Failure");1735 expect(result._tag).toBe("Failure");
1823 if (result._tag === "Failure") {1736 if (result._tag === "Failure") {
1824 expect(result.failure.message.toLowerCase()).toContain("no upstream");1737 expect(result.failure.message.toLowerCase()).toContain("no upstream");
...@@ -1830,23 +1743,21 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1830,23 +1743,21 @@ it.layer(TestLayer)("git integration", (it) => {
1830 Effect.gen(function* () {1743 Effect.gen(function* () {
1831 const tmp = yield* makeTmpDir();1744 const tmp = yield* makeTmpDir();
1832 yield* initRepoWithCommit(tmp);1745 yield* initRepoWithCommit(tmp);
1833 const realGitService = yield* GitService;1746 const realGitCore = yield* GitCore;
1834 let didFailRecency = false;1747 let didFailRecency = false;
1835 const core = yield* makeIsolatedGitCore({1748 const core = yield* makeIsolatedGitCore((input) => {
1836 execute: (input) => {1749 if (!didFailRecency && input.args[0] === "for-each-ref") {
1837 if (!didFailRecency && input.args[0] === "for-each-ref") {1750 didFailRecency = true;
1838 didFailRecency = true;1751 return Effect.fail(
1839 return Effect.fail(1752 new GitCommandError({
1840 new GitCommandError({1753 operation: "git.test.listBranchesRecency",
1841 operation: "git.test.listBranchesRecency",1754 command: `git ${input.args.join(" ")}`,
1842 command: `git ${input.args.join(" ")}`,1755 cwd: input.cwd,
1843 cwd: input.cwd,1756 detail: "timeout",
1844 detail: "timeout",1757 }),
1845 }),1758 );
1846 );1759 }
1847 }1760 return realGitCore.execute(input);
1848 return realGitService.execute(input);
1849 },
1850 });1761 });
18511762
1852 const result = yield* core.listBranches({ cwd: tmp });1763 const result = yield* core.listBranches({ cwd: tmp });
...@@ -1866,35 +1777,33 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1866,35 +1777,33 @@ it.layer(TestLayer)("git integration", (it) => {
1866 yield* git(remote, ["init", "--bare"]);1777 yield* git(remote, ["init", "--bare"]);
1867 yield* git(tmp, ["remote", "add", "origin", remote]);1778 yield* git(tmp, ["remote", "add", "origin", remote]);
18681779
1869 const realGitService = yield* GitService;1780 const realGitCore = yield* GitCore;
1870 let didFailRemoteBranches = false;1781 let didFailRemoteBranches = false;
1871 let didFailRemoteNames = false;1782 let didFailRemoteNames = false;
1872 const core = yield* makeIsolatedGitCore({1783 const core = yield* makeIsolatedGitCore((input) => {
1873 execute: (input) => {1784 if (input.args.join(" ") === "branch --no-color --remotes") {
1874 if (input.args.join(" ") === "branch --no-color --remotes") {1785 didFailRemoteBranches = true;
1875 didFailRemoteBranches = true;1786 return Effect.fail(
1876 return Effect.fail(1787 new GitCommandError({
1877 new GitCommandError({1788 operation: "git.test.listBranchesRemoteBranches",
1878 operation: "git.test.listBranchesRemoteBranches",1789 command: `git ${input.args.join(" ")}`,
1879 command: `git ${input.args.join(" ")}`,1790 cwd: input.cwd,
1880 cwd: input.cwd,1791 detail: "remote unavailable",
1881 detail: "remote unavailable",1792 }),
1882 }),1793 );
1883 );1794 }
1884 }1795 if (input.args.join(" ") === "remote") {
1885 if (input.args.join(" ") === "remote") {1796 didFailRemoteNames = true;
1886 didFailRemoteNames = true;1797 return Effect.fail(
1887 return Effect.fail(1798 new GitCommandError({
1888 new GitCommandError({1799 operation: "git.test.listBranchesRemoteNames",
1889 operation: "git.test.listBranchesRemoteNames",1800 command: `git ${input.args.join(" ")}`,
1890 command: `git ${input.args.join(" ")}`,1801 cwd: input.cwd,
1891 cwd: input.cwd,1802 detail: "remote unavailable",
1892 detail: "remote unavailable",1803 }),
1893 }),1804 );
1894 );1805 }
1895 }1806 return realGitCore.execute(input);
1896 return realGitService.execute(input);
1897 },
1898 });1807 });
18991808
1900 const result = yield* core.listBranches({ cwd: tmp });1809 const result = yield* core.listBranches({ cwd: tmp });
apps/server/src/git/Layers/GitCore.ts+1189-1047
...@@ -1,10 +1,29 @@...@@ -1,10 +1,29 @@
1import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect";1import {
2 Cache,
3 Data,
4 Duration,
5 Effect,
6 Exit,
7 FileSystem,
8 Layer,
9 Option,
10 Path,
11 Schema,
12 Stream,
13} from "effect";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
215
3import { GitCommandError } from "../Errors.ts";16import { GitCommandError } from "../Errors.ts";
4import { GitService } from "../Services/GitService.ts";17import {
5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";18 GitCore,
19 type GitCoreShape,
20 type ExecuteGitInput,
21 type ExecuteGitResult,
22} from "../Services/GitCore.ts";
6import { ServerConfig } from "../../config.ts";23import { ServerConfig } from "../../config.ts";
724
25const DEFAULT_TIMEOUT_MS = 30_000;
26const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
8const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);27const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
9const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);28const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
10const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;29const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
...@@ -218,27 +237,146 @@ function createGitCommandError(...@@ -218,27 +237,146 @@ function createGitCommandError(
218 });237 });
219}238}
220239
221const makeGitCore = Effect.gen(function* () {240function quoteGitCommand(args: ReadonlyArray<string>): string {
222 const git = yield* GitService;241 return `git ${args.join(" ")}`;
223 const fileSystem = yield* FileSystem.FileSystem;242}
224 const path = yield* Path.Path;243
225 const { worktreesDir } = yield* ServerConfig;244function toGitCommandError(
226245 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
227 const executeGit = (246 detail: string,
228 operation: string,247) {
229 cwd: string,248 return (cause: unknown) =>
230 args: readonly string[],249 Schema.is(GitCommandError)(cause)
231 options: ExecuteGitOptions = {},250 ? cause
232 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>251 : new GitCommandError({
233 git252 operation: input.operation,
234 .execute({253 command: quoteGitCommand(input.args),
254 cwd: input.cwd,
255 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
256 ...(cause !== undefined ? { cause } : {}),
257 });
258}
259
260const collectOutput = Effect.fn(function* <E>(
261 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
262 stream: Stream.Stream<Uint8Array, E>,
263 maxOutputBytes: number,
264): Effect.fn.Return<string, GitCommandError> {
265 const decoder = new TextDecoder();
266 let bytes = 0;
267 let text = "";
268
269 yield* Stream.runForEach(stream, (chunk) =>
270 Effect.gen(function* () {
271 bytes += chunk.byteLength;
272 if (bytes > maxOutputBytes) {
273 return yield* new GitCommandError({
274 operation: input.operation,
275 command: quoteGitCommand(input.args),
276 cwd: input.cwd,
277 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
278 });
279 }
280 text += decoder.decode(chunk, { stream: true });
281 }),
282 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
283
284 text += decoder.decode();
285 return text;
286});
287
288export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) =>
289 Effect.gen(function* () {
290 const fileSystem = yield* FileSystem.FileSystem;
291 const path = yield* Path.Path;
292 const { worktreesDir } = yield* ServerConfig;
293
294 let execute: GitCoreShape["execute"];
295
296 if (options?.executeOverride) {
297 execute = options.executeOverride;
298 } else {
299 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
300 execute = Effect.fnUntraced(function* (input) {
301 const commandInput = {
302 ...input,
303 args: [...input.args],
304 } as const;
305 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
306 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
307
308 const commandEffect = Effect.gen(function* () {
309 const child = yield* commandSpawner
310 .spawn(
311 ChildProcess.make("git", commandInput.args, {
312 cwd: commandInput.cwd,
313 ...(input.env ? { env: input.env } : {}),
314 }),
315 )
316 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
317
318 const [stdout, stderr, exitCode] = yield* Effect.all(
319 [
320 collectOutput(commandInput, child.stdout, maxOutputBytes),
321 collectOutput(commandInput, child.stderr, maxOutputBytes),
322 child.exitCode.pipe(
323 Effect.map((value) => Number(value)),
324 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
325 ),
326 ],
327 { concurrency: "unbounded" },
328 );
329
330 if (!input.allowNonZeroExit && exitCode !== 0) {
331 const trimmedStderr = stderr.trim();
332 return yield* new GitCommandError({
333 operation: commandInput.operation,
334 command: quoteGitCommand(commandInput.args),
335 cwd: commandInput.cwd,
336 detail:
337 trimmedStderr.length > 0
338 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
339 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
340 });
341 }
342
343 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
344 });
345
346 return yield* commandEffect.pipe(
347 Effect.scoped,
348 Effect.timeoutOption(timeoutMs),
349 Effect.flatMap((result) =>
350 Option.match(result, {
351 onNone: () =>
352 Effect.fail(
353 new GitCommandError({
354 operation: commandInput.operation,
355 command: quoteGitCommand(commandInput.args),
356 cwd: commandInput.cwd,
357 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
358 }),
359 ),
360 onSome: Effect.succeed,
361 }),
362 ),
363 );
364 });
365 }
366
367 const executeGit = (
368 operation: string,
369 cwd: string,
370 args: readonly string[],
371 options: ExecuteGitOptions = {},
372 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>
373 execute({
235 operation,374 operation,
236 cwd,375 cwd,
237 args,376 args,
238 allowNonZeroExit: true,377 allowNonZeroExit: true,
239 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),378 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
240 })379 }).pipe(
241 .pipe(
242 Effect.flatMap((result) => {380 Effect.flatMap((result) => {
243 if (options.allowNonZeroExit || result.code === 0) {381 if (options.allowNonZeroExit || result.code === 0) {
244 return Effect.succeed(result);382 return Effect.succeed(result);
...@@ -263,1166 +401,1170 @@ const makeGitCore = Effect.gen(function* () {...@@ -263,1166 +401,1170 @@ const makeGitCore = Effect.gen(function* () {
263 }),401 }),
264 );402 );
265403
266 const runGit = (404 const runGit = (
267 operation: string,405 operation: string,
268 cwd: string,406 cwd: string,
269 args: readonly string[],407 args: readonly string[],
270 allowNonZeroExit = false,408 allowNonZeroExit = false,
271 ): Effect.Effect<void, GitCommandError> =>409 ): Effect.Effect<void, GitCommandError> =>
272 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);410 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
273411
274 const runGitStdout = (412 const runGitStdout = (
275 operation: string,413 operation: string,
276 cwd: string,414 cwd: string,
277 args: readonly string[],415 args: readonly string[],
278 allowNonZeroExit = false,416 allowNonZeroExit = false,
279 ): Effect.Effect<string, GitCommandError> =>417 ): Effect.Effect<string, GitCommandError> =>
280 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(418 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
281 Effect.map((result) => result.stdout),419 Effect.map((result) => result.stdout),
282 );
283
284 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
285 executeGit(
286 "GitCore.branchExists",
287 cwd,
288 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
289 {
290 allowNonZeroExit: true,
291 timeoutMs: 5_000,
292 },
293 ).pipe(Effect.map((result) => result.code === 0));
294
295 const resolveAvailableBranchName = (
296 cwd: string,
297 desiredBranch: string,
298 ): Effect.Effect<string, GitCommandError> =>
299 Effect.gen(function* () {
300 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
301 if (!isDesiredTaken) {
302 return desiredBranch;
303 }
304
305 for (let suffix = 1; suffix <= 100; suffix += 1) {
306 const candidate = `${desiredBranch}-${suffix}`;
307 const isCandidateTaken = yield* branchExists(cwd, candidate);
308 if (!isCandidateTaken) {
309 return candidate;
310 }
311 }
312
313 return yield* createGitCommandError(
314 "GitCore.renameBranch",
315 cwd,
316 ["branch", "-m", "--", desiredBranch],
317 `Could not find an available branch name for '${desiredBranch}'.`,
318 );420 );
319 });
320421
321 const resolveCurrentUpstream = (422 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
322 cwd: string,423 executeGit(
323 ): Effect.Effect<424 "GitCore.branchExists",
324 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,
325 GitCommandError
326 > =>
327 Effect.gen(function* () {
328 const upstreamRef = yield* runGitStdout(
329 "GitCore.resolveCurrentUpstream",
330 cwd,425 cwd,
331 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],426 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
332 true,427 {
333 ).pipe(Effect.map((stdout) => stdout.trim()));428 allowNonZeroExit: true,
334429 timeoutMs: 5_000,
335 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {430 },
336 return null;431 ).pipe(Effect.map((result) => result.code === 0));
337 }
338
339 const separatorIndex = upstreamRef.indexOf("/");
340 if (separatorIndex <= 0) {
341 return null;
342 }
343 const remoteName = upstreamRef.slice(0, separatorIndex);
344 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
345 if (remoteName.length === 0 || upstreamBranch.length === 0) {
346 return null;
347 }
348432
349 return {433 const resolveAvailableBranchName = (
350 upstreamRef,434 cwd: string,
351 remoteName,435 desiredBranch: string,
352 upstreamBranch,436 ): Effect.Effect<string, GitCommandError> =>
353 };437 Effect.gen(function* () {
354 });438 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
439 if (!isDesiredTaken) {
440 return desiredBranch;
441 }
355442
356 const fetchUpstreamRef = (443 for (let suffix = 1; suffix <= 100; suffix += 1) {
357 cwd: string,444 const candidate = `${desiredBranch}-${suffix}`;
358 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },445 const isCandidateTaken = yield* branchExists(cwd, candidate);
359 ): Effect.Effect<void, GitCommandError> => {446 if (!isCandidateTaken) {
360 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;447 return candidate;
361 return runGit(448 }
362 "GitCore.fetchUpstreamRef",449 }
363 cwd,
364 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
365 true,
366 );
367 };
368450
369 const fetchUpstreamRefForStatus = (451 return yield* createGitCommandError(
370 cwd: string,452 "GitCore.renameBranch",
371 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },453 cwd,
372 ): Effect.Effect<void, GitCommandError> => {454 ["branch", "-m", "--", desiredBranch],
373 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;455 `Could not find an available branch name for '${desiredBranch}'.`,
374 return executeGit(456 );
375 "GitCore.fetchUpstreamRefForStatus",457 });
376 cwd,
377 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
378 {
379 allowNonZeroExit: true,
380 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
381 },
382 ).pipe(Effect.asVoid);
383 };
384458
385 const statusUpstreamRefreshCache = yield* Cache.makeWith({459 const resolveCurrentUpstream = (
386 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,460 cwd: string,
387 lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>461 ): Effect.Effect<
462 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,
463 GitCommandError
464 > =>
388 Effect.gen(function* () {465 Effect.gen(function* () {
389 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {466 const upstreamRef = yield* runGitStdout(
390 upstreamRef: cacheKey.upstreamRef,467 "GitCore.resolveCurrentUpstream",
391 remoteName: cacheKey.remoteName,
392 upstreamBranch: cacheKey.upstreamBranch,
393 });
394 return true as const;
395 }),
396 // Keep successful refreshes warm; drop failures immediately so next request can retry.
397 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),
398 });
399
400 const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>
401 Effect.gen(function* () {
402 const upstream = yield* resolveCurrentUpstream(cwd);
403 if (!upstream) return;
404 yield* Cache.get(
405 statusUpstreamRefreshCache,
406 new StatusUpstreamRefreshCacheKey({
407 cwd,468 cwd,
408 upstreamRef: upstream.upstreamRef,469 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
409 remoteName: upstream.remoteName,470 true,
410 upstreamBranch: upstream.upstreamBranch,471 ).pipe(Effect.map((stdout) => stdout.trim()));
411 }),
412 );
413 });
414472
415 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>473 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
416 Effect.gen(function* () {
417 const upstream = yield* resolveCurrentUpstream(cwd);
418 if (!upstream) return;
419 yield* fetchUpstreamRef(cwd, upstream);
420 });
421
422 const resolveDefaultBranchName = (
423 cwd: string,
424 remoteName: string,
425 ): Effect.Effect<string | null, GitCommandError> =>
426 executeGit(
427 "GitCore.resolveDefaultBranchName",
428 cwd,
429 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
430 { allowNonZeroExit: true },
431 ).pipe(
432 Effect.map((result) => {
433 if (result.code !== 0) {
434 return null;474 return null;
435 }475 }
436 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
437 }),
438 );
439
440 const remoteBranchExists = (
441 cwd: string,
442 remoteName: string,
443 branch: string,
444 ): Effect.Effect<boolean, GitCommandError> =>
445 executeGit(
446 "GitCore.remoteBranchExists",
447 cwd,
448 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
449 {
450 allowNonZeroExit: true,
451 },
452 ).pipe(Effect.map((result) => result.code === 0));
453
454 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
455 executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
456 allowNonZeroExit: true,
457 }).pipe(Effect.map((result) => result.code === 0));
458476
459 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>477 const separatorIndex = upstreamRef.indexOf("/");
460 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(478 if (separatorIndex <= 0) {
461 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),479 return null;
462 );480 }
481 const remoteName = upstreamRef.slice(0, separatorIndex);
482 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
483 if (remoteName.length === 0 || upstreamBranch.length === 0) {
484 return null;
485 }
463486
464 const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> =>487 return {
465 Effect.gen(function* () {488 upstreamRef,
466 if (yield* originRemoteExists(cwd)) {489 remoteName,
467 return "origin";490 upstreamBranch,
468 }491 };
469 const remotes = yield* listRemoteNames(cwd);492 });
470 const [firstRemote] = remotes;
471 if (firstRemote) {
472 return firstRemote;
473 }
474 return yield* createGitCommandError(
475 "GitCore.resolvePrimaryRemoteName",
476 cwd,
477 ["remote"],
478 "No git remote is configured for this repository.",
479 );
480 });
481493
482 const resolvePushRemoteName = (494 const fetchUpstreamRef = (
483 cwd: string,495 cwd: string,
484 branch: string,496 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
485 ): Effect.Effect<string | null, GitCommandError> =>497 ): Effect.Effect<void, GitCommandError> => {
486 Effect.gen(function* () {498 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
487 const branchPushRemote = yield* runGitStdout(499 return runGit(
488 "GitCore.resolvePushRemoteName.branchPushRemote",500 "GitCore.fetchUpstreamRef",
489 cwd,501 cwd,
490 ["config", "--get", `branch.${branch}.pushRemote`],502 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
491 true,503 true,
492 ).pipe(Effect.map((stdout) => stdout.trim()));504 );
493 if (branchPushRemote.length > 0) {505 };
494 return branchPushRemote;
495 }
496506
497 const pushDefaultRemote = yield* runGitStdout(507 const fetchUpstreamRefForStatus = (
498 "GitCore.resolvePushRemoteName.remotePushDefault",508 cwd: string,
509 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
510 ): Effect.Effect<void, GitCommandError> => {
511 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
512 return executeGit(
513 "GitCore.fetchUpstreamRefForStatus",
499 cwd,514 cwd,
500 ["config", "--get", "remote.pushDefault"],515 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
501 true,516 {
502 ).pipe(Effect.map((stdout) => stdout.trim()));517 allowNonZeroExit: true,
503 if (pushDefaultRemote.length > 0) {518 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
504 return pushDefaultRemote;519 },
505 }520 ).pipe(Effect.asVoid);
521 };
506522
507 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));523 const statusUpstreamRefreshCache = yield* Cache.makeWith({
524 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
525 lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>
526 Effect.gen(function* () {
527 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
528 upstreamRef: cacheKey.upstreamRef,
529 remoteName: cacheKey.remoteName,
530 upstreamBranch: cacheKey.upstreamBranch,
531 });
532 return true as const;
533 }),
534 // Keep successful refreshes warm; drop failures immediately so next request can retry.
535 timeToLive: (exit) =>
536 Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero,
508 });537 });
509538
510 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>539 const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>
511 Effect.gen(function* () {540 Effect.gen(function* () {
512 const preferredName = sanitizeRemoteName(input.preferredName);541 const upstream = yield* resolveCurrentUpstream(cwd);
513 const normalizedTargetUrl = normalizeRemoteUrl(input.url);542 if (!upstream) return;
514 const remoteFetchUrls = yield* runGitStdout(543 yield* Cache.get(
515 "GitCore.ensureRemote.listRemoteUrls",544 statusUpstreamRefreshCache,
516 input.cwd,545 new StatusUpstreamRefreshCacheKey({
517 ["remote", "-v"],546 cwd,
518 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));547 upstreamRef: upstream.upstreamRef,
519548 remoteName: upstream.remoteName,
520 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {549 upstreamBranch: upstream.upstreamBranch,
521 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {550 }),
522 return remoteName;551 );
523 }552 });
524 }
525553
526 let remoteName = preferredName;554 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>
527 let suffix = 1;555 Effect.gen(function* () {
528 while (remoteFetchUrls.has(remoteName)) {556 const upstream = yield* resolveCurrentUpstream(cwd);
529 remoteName = `${preferredName}-${suffix}`;557 if (!upstream) return;
530 suffix += 1;558 yield* fetchUpstreamRef(cwd, upstream);
531 }559 });
532560
533 yield* runGit("GitCore.ensureRemote.add", input.cwd, [561 const resolveDefaultBranchName = (
534 "remote",562 cwd: string,
535 "add",563 remoteName: string,
536 remoteName,564 ): Effect.Effect<string | null, GitCommandError> =>
537 input.url,565 executeGit(
538 ]);566 "GitCore.resolveDefaultBranchName",
539 return remoteName;567 cwd,
540 });568 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
569 { allowNonZeroExit: true },
570 ).pipe(
571 Effect.map((result) => {
572 if (result.code !== 0) {
573 return null;
574 }
575 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
576 }),
577 );
541578
542 const resolveBaseBranchForNoUpstream = (579 const remoteBranchExists = (
543 cwd: string,580 cwd: string,
544 branch: string,581 remoteName: string,
545 ): Effect.Effect<string | null, GitCommandError> =>582 branch: string,
546 Effect.gen(function* () {583 ): Effect.Effect<boolean, GitCommandError> =>
547 const configuredBaseBranch = yield* runGitStdout(584 executeGit(
548 "GitCore.resolveBaseBranchForNoUpstream.config",585 "GitCore.remoteBranchExists",
549 cwd,586 cwd,
550 ["config", "--get", `branch.${branch}.gh-merge-base`],587 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
551 true,588 {
552 ).pipe(Effect.map((stdout) => stdout.trim()));589 allowNonZeroExit: true,
590 },
591 ).pipe(Effect.map((result) => result.code === 0));
592
593 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
594 executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
595 allowNonZeroExit: true,
596 }).pipe(Effect.map((result) => result.code === 0));
553597
554 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(598 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
555 Effect.catch(() => Effect.succeed(null)),599 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
600 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
556 );601 );
557 const defaultBranch =
558 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
559 const candidates = [
560 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
561 defaultBranch,
562 ...DEFAULT_BASE_BRANCH_CANDIDATES,
563 ];
564
565 for (const candidate of candidates) {
566 if (!candidate) {
567 continue;
568 }
569602
570 const remotePrefix =603 const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> =>
571 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;604 Effect.gen(function* () {
572 const normalizedCandidate = candidate.startsWith("origin/")605 if (yield* originRemoteExists(cwd)) {
573 ? candidate.slice("origin/".length)606 return "origin";
574 : remotePrefix && candidate.startsWith(remotePrefix)
575 ? candidate.slice(remotePrefix.length)
576 : candidate;
577 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
578 continue;
579 }607 }
608 const remotes = yield* listRemoteNames(cwd);
609 const [firstRemote] = remotes;
610 if (firstRemote) {
611 return firstRemote;
612 }
613 return yield* createGitCommandError(
614 "GitCore.resolvePrimaryRemoteName",
615 cwd,
616 ["remote"],
617 "No git remote is configured for this repository.",
618 );
619 });
580620
581 if (yield* branchExists(cwd, normalizedCandidate)) {621 const resolvePushRemoteName = (
582 return normalizedCandidate;622 cwd: string,
623 branch: string,
624 ): Effect.Effect<string | null, GitCommandError> =>
625 Effect.gen(function* () {
626 const branchPushRemote = yield* runGitStdout(
627 "GitCore.resolvePushRemoteName.branchPushRemote",
628 cwd,
629 ["config", "--get", `branch.${branch}.pushRemote`],
630 true,
631 ).pipe(Effect.map((stdout) => stdout.trim()));
632 if (branchPushRemote.length > 0) {
633 return branchPushRemote;
583 }634 }
584635
585 if (636 const pushDefaultRemote = yield* runGitStdout(
586 primaryRemoteName &&637 "GitCore.resolvePushRemoteName.remotePushDefault",
587 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))638 cwd,
588 ) {639 ["config", "--get", "remote.pushDefault"],
589 return `${primaryRemoteName}/${normalizedCandidate}`;640 true,
641 ).pipe(Effect.map((stdout) => stdout.trim()));
642 if (pushDefaultRemote.length > 0) {
643 return pushDefaultRemote;
590 }644 }
591 }
592645
593 return null;646 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
594 });647 });
595648
596 const computeAheadCountAgainstBase = (649 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
597 cwd: string,650 Effect.gen(function* () {
598 branch: string,651 const preferredName = sanitizeRemoteName(input.preferredName);
599 ): Effect.Effect<number, GitCommandError> =>652 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
600 Effect.gen(function* () {653 const remoteFetchUrls = yield* runGitStdout(
601 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);654 "GitCore.ensureRemote.listRemoteUrls",
602 if (!baseBranch) {655 input.cwd,
603 return 0;656 ["remote", "-v"],
604 }657 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
605658
606 const result = yield* executeGit(659 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
607 "GitCore.computeAheadCountAgainstBase",660 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
608 cwd,661 return remoteName;
609 ["rev-list", "--count", `${baseBranch}..HEAD`],662 }
610 { allowNonZeroExit: true },663 }
611 );
612 if (result.code !== 0) {
613 return 0;
614 }
615664
616 const parsed = Number.parseInt(result.stdout.trim(), 10);665 let remoteName = preferredName;
617 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;666 let suffix = 1;
618 });667 while (remoteFetchUrls.has(remoteName)) {
668 remoteName = `${preferredName}-${suffix}`;
669 suffix += 1;
670 }
619671
620 const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> =>672 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
621 Effect.gen(function* () {673 "remote",
622 const branchRecency = yield* executeGit(674 "add",
623 "GitCore.readBranchRecency",675 remoteName,
624 cwd,676 input.url,
625 [677 ]);
626 "for-each-ref",678 return remoteName;
627 "--format=%(refname:short)%09%(committerdate:unix)",679 });
628 "refs/heads",
629 "refs/remotes",
630 ],
631 {
632 timeoutMs: 15_000,
633 allowNonZeroExit: true,
634 },
635 );
636680
637 const branchLastCommit = new Map<string, number>();681 const resolveBaseBranchForNoUpstream = (
638 if (branchRecency.code !== 0) {682 cwd: string,
639 return branchLastCommit;683 branch: string,
640 }684 ): Effect.Effect<string | null, GitCommandError> =>
685 Effect.gen(function* () {
686 const configuredBaseBranch = yield* runGitStdout(
687 "GitCore.resolveBaseBranchForNoUpstream.config",
688 cwd,
689 ["config", "--get", `branch.${branch}.gh-merge-base`],
690 true,
691 ).pipe(Effect.map((stdout) => stdout.trim()));
641692
642 for (const line of branchRecency.stdout.split("\n")) {693 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
643 if (line.length === 0) {694 Effect.catch(() => Effect.succeed(null)),
644 continue;695 );
645 }696 const defaultBranch =
646 const [name, lastCommitRaw] = line.split("\t");697 primaryRemoteName === null
647 if (!name) {698 ? null
648 continue;699 : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
649 }700 const candidates = [
650 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);701 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
651 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);702 defaultBranch,
652 }703 ...DEFAULT_BASE_BRANCH_CANDIDATES,
704 ];
705
706 for (const candidate of candidates) {
707 if (!candidate) {
708 continue;
709 }
653710
654 return branchLastCommit;711 const remotePrefix =
655 });712 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
713 const normalizedCandidate = candidate.startsWith("origin/")
714 ? candidate.slice("origin/".length)
715 : remotePrefix && candidate.startsWith(remotePrefix)
716 ? candidate.slice(remotePrefix.length)
717 : candidate;
718 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
719 continue;
720 }
656721
657 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>722 if (yield* branchExists(cwd, normalizedCandidate)) {
658 Effect.gen(function* () {723 return normalizedCandidate;
659 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));724 }
660
661 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
662 [
663 runGitStdout("GitCore.statusDetails.status", cwd, [
664 "status",
665 "--porcelain=2",
666 "--branch",
667 ]),
668 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
669 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
670 "diff",
671 "--cached",
672 "--numstat",
673 ]),
674 ],
675 { concurrency: "unbounded" },
676 );
677725
678 let branch: string | null = null;726 if (
679 let upstreamRef: string | null = null;727 primaryRemoteName &&
680 let aheadCount = 0;728 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
681 let behindCount = 0;729 ) {
682 let hasWorkingTreeChanges = false;730 return `${primaryRemoteName}/${normalizedCandidate}`;
683 const changedFilesWithoutNumstat = new Set<string>();731 }
684
685 for (const line of statusStdout.split(/\r?\n/g)) {
686 if (line.startsWith("# branch.head ")) {
687 const value = line.slice("# branch.head ".length).trim();
688 branch = value.startsWith("(") ? null : value;
689 continue;
690 }
691 if (line.startsWith("# branch.upstream ")) {
692 const value = line.slice("# branch.upstream ".length).trim();
693 upstreamRef = value.length > 0 ? value : null;
694 continue;
695 }732 }
696 if (line.startsWith("# branch.ab ")) {733
697 const value = line.slice("# branch.ab ".length).trim();734 return null;
698 const parsed = parseBranchAb(value);735 });
699 aheadCount = parsed.ahead;736
700 behindCount = parsed.behind;737 const computeAheadCountAgainstBase = (
701 continue;738 cwd: string,
739 branch: string,
740 ): Effect.Effect<number, GitCommandError> =>
741 Effect.gen(function* () {
742 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);
743 if (!baseBranch) {
744 return 0;
702 }745 }
703 if (line.trim().length > 0 && !line.startsWith("#")) {746
704 hasWorkingTreeChanges = true;747 const result = yield* executeGit(
705 const pathValue = parsePorcelainPath(line);748 "GitCore.computeAheadCountAgainstBase",
706 if (pathValue) changedFilesWithoutNumstat.add(pathValue);749 cwd,
750 ["rev-list", "--count", `${baseBranch}..HEAD`],
751 { allowNonZeroExit: true },
752 );
753 if (result.code !== 0) {
754 return 0;
707 }755 }
708 }
709756
710 if (!upstreamRef && branch) {757 const parsed = Number.parseInt(result.stdout.trim(), 10);
711 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(758 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
712 Effect.catch(() => Effect.succeed(0)),759 });
760
761 const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> =>
762 Effect.gen(function* () {
763 const branchRecency = yield* executeGit(
764 "GitCore.readBranchRecency",
765 cwd,
766 [
767 "for-each-ref",
768 "--format=%(refname:short)%09%(committerdate:unix)",
769 "refs/heads",
770 "refs/remotes",
771 ],
772 {
773 timeoutMs: 15_000,
774 allowNonZeroExit: true,
775 },
713 );776 );
714 behindCount = 0;
715 }
716777
717 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);778 const branchLastCommit = new Map<string, number>();
718 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);779 if (branchRecency.code !== 0) {
719 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();780 return branchLastCommit;
720 for (const entry of [...stagedEntries, ...unstagedEntries]) {781 }
721 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
722 existing.insertions += entry.insertions;
723 existing.deletions += entry.deletions;
724 fileStatMap.set(entry.path, existing);
725 }
726782
727 let insertions = 0;783 for (const line of branchRecency.stdout.split("\n")) {
728 let deletions = 0;784 if (line.length === 0) {
729 const files = Array.from(fileStatMap.entries())785 continue;
730 .map(([filePath, stat]) => {786 }
731 insertions += stat.insertions;787 const [name, lastCommitRaw] = line.split("\t");
732 deletions += stat.deletions;788 if (!name) {
733 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };789 continue;
734 })790 }
735 .toSorted((a, b) => a.path.localeCompare(b.path));791 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
736792 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
737 for (const filePath of changedFilesWithoutNumstat) {793 }
738 if (fileStatMap.has(filePath)) continue;
739 files.push({ path: filePath, insertions: 0, deletions: 0 });
740 }
741 files.sort((a, b) => a.path.localeCompare(b.path));
742
743 return {
744 branch,
745 upstreamRef,
746 hasWorkingTreeChanges,
747 workingTree: {
748 files,
749 insertions,
750 deletions,
751 },
752 hasUpstream: upstreamRef !== null,
753 aheadCount,
754 behindCount,
755 };
756 });
757794
758 const status: GitCoreShape["status"] = (input) =>795 return branchLastCommit;
759 statusDetails(input.cwd).pipe(796 });
760 Effect.map((details) => ({797
761 branch: details.branch,798 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>
762 hasWorkingTreeChanges: details.hasWorkingTreeChanges,799 Effect.gen(function* () {
763 workingTree: details.workingTree,800 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
764 hasUpstream: details.hasUpstream,801
765 aheadCount: details.aheadCount,802 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
766 behindCount: details.behindCount,803 [
767 pr: null,804 runGitStdout("GitCore.statusDetails.status", cwd, [
768 })),805 "status",
769 );806 "--porcelain=2",
770807 "--branch",
771 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>808 ]),
772 Effect.gen(function* () {809 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
773 if (filePaths && filePaths.length > 0) {810 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
774 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(811 "diff",
775 Effect.catch(() => Effect.void),812 "--cached",
813 "--numstat",
814 ]),
815 ],
816 { concurrency: "unbounded" },
776 );817 );
777 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
778 "add",
779 "-A",
780 "--",
781 ...filePaths,
782 ]);
783 } else {
784 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
785 }
786818
787 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [819 let branch: string | null = null;
788 "diff",820 let upstreamRef: string | null = null;
789 "--cached",821 let aheadCount = 0;
790 "--name-status",822 let behindCount = 0;
791 ]).pipe(Effect.map((stdout) => stdout.trim()));823 let hasWorkingTreeChanges = false;
792 if (stagedSummary.length === 0) {824 const changedFilesWithoutNumstat = new Set<string>();
793 return null;825
794 }826 for (const line of statusStdout.split(/\r?\n/g)) {
827 if (line.startsWith("# branch.head ")) {
828 const value = line.slice("# branch.head ".length).trim();
829 branch = value.startsWith("(") ? null : value;
830 continue;
831 }
832 if (line.startsWith("# branch.upstream ")) {
833 const value = line.slice("# branch.upstream ".length).trim();
834 upstreamRef = value.length > 0 ? value : null;
835 continue;
836 }
837 if (line.startsWith("# branch.ab ")) {
838 const value = line.slice("# branch.ab ".length).trim();
839 const parsed = parseBranchAb(value);
840 aheadCount = parsed.ahead;
841 behindCount = parsed.behind;
842 continue;
843 }
844 if (line.trim().length > 0 && !line.startsWith("#")) {
845 hasWorkingTreeChanges = true;
846 const pathValue = parsePorcelainPath(line);
847 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
848 }
849 }
795850
796 const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [851 if (!upstreamRef && branch) {
797 "diff",852 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
798 "--cached",853 Effect.catch(() => Effect.succeed(0)),
799 "--patch",854 );
800 "--minimal",855 behindCount = 0;
801 ]);856 }
802857
803 return {858 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
804 stagedSummary,859 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
805 stagedPatch,860 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
806 };861 for (const entry of [...stagedEntries, ...unstagedEntries]) {
807 });862 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
863 existing.insertions += entry.insertions;
864 existing.deletions += entry.deletions;
865 fileStatMap.set(entry.path, existing);
866 }
808867
809 const commit: GitCoreShape["commit"] = (cwd, subject, body) =>868 let insertions = 0;
810 Effect.gen(function* () {869 let deletions = 0;
811 const args = ["commit", "-m", subject];870 const files = Array.from(fileStatMap.entries())
812 const trimmedBody = body.trim();871 .map(([filePath, stat]) => {
813 if (trimmedBody.length > 0) {872 insertions += stat.insertions;
814 args.push("-m", trimmedBody);873 deletions += stat.deletions;
815 }874 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
816 yield* runGit("GitCore.commit.commit", cwd, args);875 })
817 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [876 .toSorted((a, b) => a.path.localeCompare(b.path));
818 "rev-parse",877
819 "HEAD",878 for (const filePath of changedFilesWithoutNumstat) {
820 ]).pipe(Effect.map((stdout) => stdout.trim()));879 if (fileStatMap.has(filePath)) continue;
880 files.push({ path: filePath, insertions: 0, deletions: 0 });
881 }
882 files.sort((a, b) => a.path.localeCompare(b.path));
821883
822 return { commitSha };884 return {
823 });885 branch,
886 upstreamRef,
887 hasWorkingTreeChanges,
888 workingTree: {
889 files,
890 insertions,
891 deletions,
892 },
893 hasUpstream: upstreamRef !== null,
894 aheadCount,
895 behindCount,
896 };
897 });
824898
825 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) =>899 const status: GitCoreShape["status"] = (input) =>
826 Effect.gen(function* () {900 statusDetails(input.cwd).pipe(
827 const details = yield* statusDetails(cwd);901 Effect.map((details) => ({
828 const branch = details.branch ?? fallbackBranch;902 branch: details.branch,
829 if (!branch) {903 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
830 return yield* createGitCommandError(904 workingTree: details.workingTree,
831 "GitCore.pushCurrentBranch",905 hasUpstream: details.hasUpstream,
906 aheadCount: details.aheadCount,
907 behindCount: details.behindCount,
908 pr: null,
909 })),
910 );
911
912 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>
913 Effect.gen(function* () {
914 if (filePaths && filePaths.length > 0) {
915 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
916 Effect.catch(() => Effect.void),
917 );
918 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
919 "add",
920 "-A",
921 "--",
922 ...filePaths,
923 ]);
924 } else {
925 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
926 }
927
928 const stagedSummary = yield* runGitStdout(
929 "GitCore.prepareCommitContext.stagedSummary",
832 cwd,930 cwd,
833 ["push"],931 ["diff", "--cached", "--name-status"],
834 "Cannot push from detached HEAD.",932 ).pipe(Effect.map((stdout) => stdout.trim()));
835 );933 if (stagedSummary.length === 0) {
836 }934 return null;
935 }
837936
838 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;937 const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [
839 if (hasNoLocalDelta) {938 "diff",
840 if (details.hasUpstream) {939 "--cached",
841 return {940 "--patch",
842 status: "skipped_up_to_date" as const,941 "--minimal",
843 branch,942 ]);
844 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),943
845 };944 return {
945 stagedSummary,
946 stagedPatch,
947 };
948 });
949
950 const commit: GitCoreShape["commit"] = (cwd, subject, body) =>
951 Effect.gen(function* () {
952 const args = ["commit", "-m", subject];
953 const trimmedBody = body.trim();
954 if (trimmedBody.length > 0) {
955 args.push("-m", trimmedBody);
846 }956 }
957 yield* runGit("GitCore.commit.commit", cwd, args);
958 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
959 "rev-parse",
960 "HEAD",
961 ]).pipe(Effect.map((stdout) => stdout.trim()));
847962
848 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(963 return { commitSha };
849 Effect.catch(() => Effect.succeed(null)),964 });
850 );965
851 if (comparableBaseBranch) {966 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) =>
852 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(967 Effect.gen(function* () {
853 Effect.catch(() => Effect.succeed(null)),968 const details = yield* statusDetails(cwd);
969 const branch = details.branch ?? fallbackBranch;
970 if (!branch) {
971 return yield* createGitCommandError(
972 "GitCore.pushCurrentBranch",
973 cwd,
974 ["push"],
975 "Cannot push from detached HEAD.",
854 );976 );
855 if (!publishRemoteName) {977 }
978
979 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
980 if (hasNoLocalDelta) {
981 if (details.hasUpstream) {
856 return {982 return {
857 status: "skipped_up_to_date" as const,983 status: "skipped_up_to_date" as const,
858 branch,984 branch,
985 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
859 };986 };
860 }987 }
861988
862 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(989 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
863 Effect.catch(() => Effect.succeed(false)),990 Effect.catch(() => Effect.succeed(null)),
864 );991 );
865 if (hasRemoteBranch) {992 if (comparableBaseBranch) {
866 return {993 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
867 status: "skipped_up_to_date" as const,994 Effect.catch(() => Effect.succeed(null)),
868 branch,995 );
869 };996 if (!publishRemoteName) {
997 return {
998 status: "skipped_up_to_date" as const,
999 branch,
1000 };
1001 }
1002
1003 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1004 Effect.catch(() => Effect.succeed(false)),
1005 );
1006 if (hasRemoteBranch) {
1007 return {
1008 status: "skipped_up_to_date" as const,
1009 branch,
1010 };
1011 }
870 }1012 }
871 }1013 }
872 }
8731014
874 if (!details.hasUpstream) {1015 if (!details.hasUpstream) {
875 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);1016 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
876 if (!publishRemoteName) {1017 if (!publishRemoteName) {
877 return yield* createGitCommandError(1018 return yield* createGitCommandError(
878 "GitCore.pushCurrentBranch",1019 "GitCore.pushCurrentBranch",
879 cwd,1020 cwd,
880 ["push"],1021 ["push"],
881 "Cannot push because no git remote is configured for this repository.",1022 "Cannot push because no git remote is configured for this repository.",
882 );1023 );
1024 }
1025 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
1026 "push",
1027 "-u",
1028 publishRemoteName,
1029 branch,
1030 ]);
1031 return {
1032 status: "pushed" as const,
1033 branch,
1034 upstreamBranch: `${publishRemoteName}/${branch}`,
1035 setUpstream: true,
1036 };
883 }1037 }
884 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
885 "push",
886 "-u",
887 publishRemoteName,
888 branch,
889 ]);
890 return {
891 status: "pushed" as const,
892 branch,
893 upstreamBranch: `${publishRemoteName}/${branch}`,
894 setUpstream: true,
895 };
896 }
8971038
898 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(1039 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
899 Effect.catch(() => Effect.succeed(null)),1040 Effect.catch(() => Effect.succeed(null)),
900 );1041 );
901 if (currentUpstream) {1042 if (currentUpstream) {
902 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [1043 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
903 "push",1044 "push",
904 currentUpstream.remoteName,1045 currentUpstream.remoteName,
905 `HEAD:${currentUpstream.upstreamBranch}`,1046 `HEAD:${currentUpstream.upstreamBranch}`,
906 ]);1047 ]);
1048 return {
1049 status: "pushed" as const,
1050 branch,
1051 upstreamBranch: currentUpstream.upstreamRef,
1052 setUpstream: false,
1053 };
1054 }
1055
1056 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
907 return {1057 return {
908 status: "pushed" as const,1058 status: "pushed" as const,
909 branch,1059 branch,
910 upstreamBranch: currentUpstream.upstreamRef,1060 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
911 setUpstream: false,1061 setUpstream: false,
912 };1062 };
913 }1063 });
914
915 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
916 return {
917 status: "pushed" as const,
918 branch,
919 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
920 setUpstream: false,
921 };
922 });
9231064
924 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>1065 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>
925 Effect.gen(function* () {1066 Effect.gen(function* () {
926 const details = yield* statusDetails(cwd);1067 const details = yield* statusDetails(cwd);
927 const branch = details.branch;1068 const branch = details.branch;
928 if (!branch) {1069 if (!branch) {
929 return yield* createGitCommandError(1070 return yield* createGitCommandError(
930 "GitCore.pullCurrentBranch",1071 "GitCore.pullCurrentBranch",
1072 cwd,
1073 ["pull", "--ff-only"],
1074 "Cannot pull from detached HEAD.",
1075 );
1076 }
1077 if (!details.hasUpstream) {
1078 return yield* createGitCommandError(
1079 "GitCore.pullCurrentBranch",
1080 cwd,
1081 ["pull", "--ff-only"],
1082 "Current branch has no upstream configured. Push with upstream first.",
1083 );
1084 }
1085 const beforeSha = yield* runGitStdout(
1086 "GitCore.pullCurrentBranch.beforeSha",
931 cwd,1087 cwd,
932 ["pull", "--ff-only"],1088 ["rev-parse", "HEAD"],
933 "Cannot pull from detached HEAD.",1089 true,
934 );1090 ).pipe(Effect.map((stdout) => stdout.trim()));
935 }1091 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
936 if (!details.hasUpstream) {1092 timeoutMs: 30_000,
937 return yield* createGitCommandError(1093 fallbackErrorMessage: "git pull failed",
938 "GitCore.pullCurrentBranch",1094 });
1095 const afterSha = yield* runGitStdout(
1096 "GitCore.pullCurrentBranch.afterSha",
939 cwd,1097 cwd,
940 ["pull", "--ff-only"],1098 ["rev-parse", "HEAD"],
941 "Current branch has no upstream configured. Push with upstream first.",1099 true,
942 );1100 ).pipe(Effect.map((stdout) => stdout.trim()));
943 }
944 const beforeSha = yield* runGitStdout(
945 "GitCore.pullCurrentBranch.beforeSha",
946 cwd,
947 ["rev-parse", "HEAD"],
948 true,
949 ).pipe(Effect.map((stdout) => stdout.trim()));
950 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
951 timeoutMs: 30_000,
952 fallbackErrorMessage: "git pull failed",
953 });
954 const afterSha = yield* runGitStdout(
955 "GitCore.pullCurrentBranch.afterSha",
956 cwd,
957 ["rev-parse", "HEAD"],
958 true,
959 ).pipe(Effect.map((stdout) => stdout.trim()));
960
961 const refreshed = yield* statusDetails(cwd);
962 return {
963 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
964 branch,
965 upstreamBranch: refreshed.upstreamRef,
966 };
967 });
9681101
969 const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) =>1102 const refreshed = yield* statusDetails(cwd);
970 Effect.gen(function* () {1103 return {
971 const range = `${baseBranch}..HEAD`;1104 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
972 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(1105 branch,
973 [1106 upstreamBranch: refreshed.upstreamRef,
974 runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]),1107 };
975 runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]),1108 });
976 runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [
977 "diff",
978 "--patch",
979 "--minimal",
980 range,
981 ]),
982 ],
983 { concurrency: "unbounded" },
984 );
9851109
986 return {1110 const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) =>
987 commitSummary,1111 Effect.gen(function* () {
988 diffSummary,1112 const range = `${baseBranch}..HEAD`;
989 diffPatch,1113 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
990 };1114 [
991 });1115 runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]),
1116 runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]),
1117 runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [
1118 "diff",
1119 "--patch",
1120 "--minimal",
1121 range,
1122 ]),
1123 ],
1124 { concurrency: "unbounded" },
1125 );
9921126
993 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>1127 return {
994 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(1128 commitSummary,
995 Effect.map((stdout) => stdout.trim()),1129 diffSummary,
996 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),1130 diffPatch,
997 );1131 };
1132 });
9981133
999 const listBranches: GitCoreShape["listBranches"] = (input) =>1134 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>
1000 Effect.gen(function* () {1135 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1001 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(1136 Effect.map((stdout) => stdout.trim()),
1002 Effect.catch(() => Effect.succeed(new Map<string, number>())),1137 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1003 );
1004 const localBranchResult = yield* executeGit(
1005 "GitCore.listBranches.branchNoColor",
1006 input.cwd,
1007 ["branch", "--no-color"],
1008 {
1009 timeoutMs: 10_000,
1010 allowNonZeroExit: true,
1011 },
1012 );1138 );
10131139
1014 if (localBranchResult.code !== 0) {1140 const listBranches: GitCoreShape["listBranches"] = (input) =>
1015 const stderr = localBranchResult.stderr.trim();1141 Effect.gen(function* () {
1016 if (stderr.toLowerCase().includes("not a git repository")) {1142 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1017 return { branches: [], isRepo: false, hasOriginRemote: false };1143 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1018 }1144 );
1019 return yield* createGitCommandError(1145 const localBranchResult = yield* executeGit(
1020 "GitCore.listBranches",1146 "GitCore.listBranches.branchNoColor",
1021 input.cwd,1147 input.cwd,
1022 ["branch", "--no-color"],1148 ["branch", "--no-color"],
1023 stderr || "git branch failed",1149 {
1150 timeoutMs: 10_000,
1151 allowNonZeroExit: true,
1152 },
1024 );1153 );
1025 }
1026
1027 const remoteBranchResultEffect = executeGit(
1028 "GitCore.listBranches.remoteBranches",
1029 input.cwd,
1030 ["branch", "--no-color", "--remotes"],
1031 {
1032 timeoutMs: 10_000,
1033 allowNonZeroExit: true,
1034 },
1035 ).pipe(
1036 Effect.catch((error) =>
1037 Effect.logWarning(
1038 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1039 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1040 ),
1041 );
10421154
1043 const remoteNamesResultEffect = executeGit(1155 if (localBranchResult.code !== 0) {
1044 "GitCore.listBranches.remoteNames",1156 const stderr = localBranchResult.stderr.trim();
1045 input.cwd,1157 if (stderr.toLowerCase().includes("not a git repository")) {
1046 ["remote"],1158 return { branches: [], isRepo: false, hasOriginRemote: false };
1047 {1159 }
1048 timeoutMs: 5_000,1160 return yield* createGitCommandError(
1049 allowNonZeroExit: true,1161 "GitCore.listBranches",
1050 },1162 input.cwd,
1051 ).pipe(1163 ["branch", "--no-color"],
1052 Effect.catch((error) =>1164 stderr || "git branch failed",
1053 Effect.logWarning(1165 );
1054 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,1166 }
1055 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1056 ),
1057 );
10581167
1059 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =1168 const remoteBranchResultEffect = executeGit(
1060 yield* Effect.all(1169 "GitCore.listBranches.remoteBranches",
1061 [1170 input.cwd,
1062 executeGit(1171 ["branch", "--no-color", "--remotes"],
1063 "GitCore.listBranches.defaultRef",1172 {
1064 input.cwd,1173 timeoutMs: 10_000,
1065 ["symbolic-ref", "refs/remotes/origin/HEAD"],1174 allowNonZeroExit: true,
1066 {1175 },
1067 timeoutMs: 5_000,1176 ).pipe(
1068 allowNonZeroExit: true,1177 Effect.catch((error) =>
1069 },1178 Effect.logWarning(
1070 ),1179 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1071 executeGit(1180 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1072 "GitCore.listBranches.worktreeList",1181 ),
1073 input.cwd,
1074 ["worktree", "list", "--porcelain"],
1075 {
1076 timeoutMs: 5_000,
1077 allowNonZeroExit: true,
1078 },
1079 ),
1080 remoteBranchResultEffect,
1081 remoteNamesResultEffect,
1082 branchRecencyPromise,
1083 ],
1084 { concurrency: "unbounded" },
1085 );1182 );
10861183
1087 const remoteNames =1184 const remoteNamesResultEffect = executeGit(
1088 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];1185 "GitCore.listBranches.remoteNames",
1089 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {1186 input.cwd,
1090 yield* Effect.logWarning(1187 ["remote"],
1091 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,1188 {
1092 );1189 timeoutMs: 5_000,
1093 }1190 allowNonZeroExit: true,
1094 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {1191 },
1095 yield* Effect.logWarning(1192 ).pipe(
1096 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,1193 Effect.catch((error) =>
1194 Effect.logWarning(
1195 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1196 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1197 ),
1097 );1198 );
1098 }
10991199
1100 const defaultBranch =1200 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1101 defaultRef.code === 01201 yield* Effect.all(
1102 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")1202 [
1103 : null;1203 executeGit(
1204 "GitCore.listBranches.defaultRef",
1205 input.cwd,
1206 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1207 {
1208 timeoutMs: 5_000,
1209 allowNonZeroExit: true,
1210 },
1211 ),
1212 executeGit(
1213 "GitCore.listBranches.worktreeList",
1214 input.cwd,
1215 ["worktree", "list", "--porcelain"],
1216 {
1217 timeoutMs: 5_000,
1218 allowNonZeroExit: true,
1219 },
1220 ),
1221 remoteBranchResultEffect,
1222 remoteNamesResultEffect,
1223 branchRecencyPromise,
1224 ],
1225 { concurrency: "unbounded" },
1226 );
11041227
1105 const worktreeMap = new Map<string, string>();1228 const remoteNames =
1106 if (worktreeList.code === 0) {1229 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1107 let currentPath: string | null = null;1230 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1108 for (const line of worktreeList.stdout.split("\n")) {1231 yield* Effect.logWarning(
1109 if (line.startsWith("worktree ")) {1232 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1110 const candidatePath = line.slice("worktree ".length);1233 );
1111 const exists = yield* fileSystem.stat(candidatePath).pipe(1234 }
1112 Effect.map(() => true),1235 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1113 Effect.catch(() => Effect.succeed(false)),1236 yield* Effect.logWarning(
1114 );1237 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1115 currentPath = exists ? candidatePath : null;1238 );
1116 } else if (line.startsWith("branch refs/heads/") && currentPath) {1239 }
1117 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);1240
1118 } else if (line === "") {1241 const defaultBranch =
1119 currentPath = null;1242 defaultRef.code === 0
1243 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1244 : null;
1245
1246 const worktreeMap = new Map<string, string>();
1247 if (worktreeList.code === 0) {
1248 let currentPath: string | null = null;
1249 for (const line of worktreeList.stdout.split("\n")) {
1250 if (line.startsWith("worktree ")) {
1251 const candidatePath = line.slice("worktree ".length);
1252 const exists = yield* fileSystem.stat(candidatePath).pipe(
1253 Effect.map(() => true),
1254 Effect.catch(() => Effect.succeed(false)),
1255 );
1256 currentPath = exists ? candidatePath : null;
1257 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1258 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1259 } else if (line === "") {
1260 currentPath = null;
1261 }
1120 }1262 }
1121 }1263 }
1122 }
11231264
1124 const localBranches = localBranchResult.stdout1265 const localBranches = localBranchResult.stdout
1125 .split("\n")1266 .split("\n")
1126 .map(parseBranchLine)1267 .map(parseBranchLine)
1127 .filter((branch): branch is { name: string; current: boolean } => branch !== null)1268 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1128 .map((branch) => ({1269 .map((branch) => ({
1129 name: branch.name,1270 name: branch.name,
1130 current: branch.current,1271 current: branch.current,
1131 isRemote: false,1272 isRemote: false,
1132 isDefault: branch.name === defaultBranch,1273 isDefault: branch.name === defaultBranch,
1133 worktreePath: worktreeMap.get(branch.name) ?? null,1274 worktreePath: worktreeMap.get(branch.name) ?? null,
1134 }))1275 }))
1135 .toSorted((a, b) => {1276 .toSorted((a, b) => {
1136 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;1277 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1137 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;1278 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1138 if (aPriority !== bPriority) return aPriority - bPriority;1279 if (aPriority !== bPriority) return aPriority - bPriority;
11391280
1140 const aLastCommit = branchLastCommit.get(a.name) ?? 0;1281 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1141 const bLastCommit = branchLastCommit.get(b.name) ?? 0;1282 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1142 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;1283 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1143 return a.name.localeCompare(b.name);1284 return a.name.localeCompare(b.name);
1144 });1285 });
1286
1287 const remoteBranches =
1288 remoteBranchResult.code === 0
1289 ? remoteBranchResult.stdout
1290 .split("\n")
1291 .map(parseBranchLine)
1292 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1293 .map((branch) => {
1294 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1295 const remoteBranch: {
1296 name: string;
1297 current: boolean;
1298 isRemote: boolean;
1299 remoteName?: string;
1300 isDefault: boolean;
1301 worktreePath: string | null;
1302 } = {
1303 name: branch.name,
1304 current: false,
1305 isRemote: true,
1306 isDefault: false,
1307 worktreePath: null,
1308 };
1309 if (parsedRemoteRef) {
1310 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1311 }
1312 return remoteBranch;
1313 })
1314 .toSorted((a, b) => {
1315 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1316 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1317 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1318 return a.name.localeCompare(b.name);
1319 })
1320 : [];
1321
1322 const branches = [...localBranches, ...remoteBranches];
1323
1324 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1325 });
11451326
1146 const remoteBranches =1327 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
1147 remoteBranchResult.code === 01328 Effect.gen(function* () {
1148 ? remoteBranchResult.stdout1329 const targetBranch = input.newBranch ?? input.branch;
1149 .split("\n")1330 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1150 .map(parseBranchLine)1331 const repoName = path.basename(input.cwd);
1151 .filter((branch): branch is { name: string; current: boolean } => branch !== null)1332 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1152 .map((branch) => {1333 const args = input.newBranch
1153 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);1334 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1154 const remoteBranch: {1335 : ["worktree", "add", worktreePath, input.branch];
1155 name: string;1336
1156 current: boolean;1337 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1157 isRemote: boolean;1338 fallbackErrorMessage: "git worktree add failed",
1158 remoteName?: string;1339 });
1159 isDefault: boolean;
1160 worktreePath: string | null;
1161 } = {
1162 name: branch.name,
1163 current: false,
1164 isRemote: true,
1165 isDefault: false,
1166 worktreePath: null,
1167 };
1168 if (parsedRemoteRef) {
1169 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1170 }
1171 return remoteBranch;
1172 })
1173 .toSorted((a, b) => {
1174 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1175 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1176 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1177 return a.name.localeCompare(b.name);
1178 })
1179 : [];
1180
1181 const branches = [...localBranches, ...remoteBranches];
1182
1183 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1184 });
11851340
1186 const createWorktree: GitCoreShape["createWorktree"] = (input) =>1341 return {
1187 Effect.gen(function* () {1342 worktree: {
1188 const targetBranch = input.newBranch ?? input.branch;1343 path: worktreePath,
1189 const sanitizedBranch = targetBranch.replace(/\//g, "-");1344 branch: targetBranch,
1190 const repoName = path.basename(input.cwd);1345 },
1191 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);1346 };
1192 const args = input.newBranch
1193 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1194 : ["worktree", "add", worktreePath, input.branch];
1195
1196 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1197 fallbackErrorMessage: "git worktree add failed",
1198 });1347 });
11991348
1200 return {1349 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) =>
1201 worktree: {1350 Effect.gen(function* () {
1202 path: worktreePath,1351 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1203 branch: targetBranch,1352 yield* executeGit(
1204 },1353 "GitCore.fetchPullRequestBranch",
1205 };1354 input.cwd,
1206 });1355 [
1356 "fetch",
1357 "--quiet",
1358 "--no-tags",
1359 remoteName,
1360 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1361 ],
1362 {
1363 fallbackErrorMessage: "git fetch pull request branch failed",
1364 },
1365 );
1366 }).pipe(Effect.asVoid);
12071367
1208 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) =>1368 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>
1209 Effect.gen(function* () {1369 Effect.gen(function* () {
1210 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);1370 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1211 yield* executeGit(
1212 "GitCore.fetchPullRequestBranch",
1213 input.cwd,
1214 [
1215 "fetch",1371 "fetch",
1216 "--quiet",1372 "--quiet",
1217 "--no-tags",1373 "--no-tags",
1218 remoteName,1374 input.remoteName,
1219 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,1375 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1220 ],1376 ]);
1221 {
1222 fallbackErrorMessage: "git fetch pull request branch failed",
1223 },
1224 );
1225 }).pipe(Effect.asVoid);
12261377
1227 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>1378 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1228 Effect.gen(function* () {1379 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1229 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [1380 yield* runGit(
1230 "fetch",1381 "GitCore.fetchRemoteBranch.materialize",
1231 "--quiet",1382 input.cwd,
1232 "--no-tags",1383 localBranchAlreadyExists
1233 input.remoteName,1384 ? ["branch", "--force", input.localBranch, targetRef]
1234 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,1385 : ["branch", input.localBranch, targetRef],
1386 );
1387 }).pipe(Effect.asVoid);
1388
1389 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1390 runGit("GitCore.setBranchUpstream", input.cwd, [
1391 "branch",
1392 "--set-upstream-to",
1393 `${input.remoteName}/${input.remoteBranch}`,
1394 input.branch,
1235 ]);1395 ]);
12361396
1237 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);1397 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
1238 const targetRef = `${input.remoteName}/${input.remoteBranch}`;1398 Effect.gen(function* () {
1239 yield* runGit(1399 const args = ["worktree", "remove"];
1240 "GitCore.fetchRemoteBranch.materialize",1400 if (input.force) {
1241 input.cwd,1401 args.push("--force");
1242 localBranchAlreadyExists1402 }
1243 ? ["branch", "--force", input.localBranch, targetRef]1403 args.push(input.path);
1244 : ["branch", input.localBranch, targetRef],1404 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
1245 );1405 timeoutMs: 15_000,
1246 }).pipe(Effect.asVoid);1406 fallbackErrorMessage: "git worktree remove failed",
12471407 }).pipe(
1248 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>1408 Effect.mapError((error) =>
1249 runGit("GitCore.setBranchUpstream", input.cwd, [1409 createGitCommandError(
1250 "branch",1410 "GitCore.removeWorktree",
1251 "--set-upstream-to",1411 input.cwd,
1252 `${input.remoteName}/${input.remoteBranch}`,1412 args,
1253 input.branch,1413 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
1254 ]);1414 error,
12551415 ),
1256 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
1257 Effect.gen(function* () {
1258 const args = ["worktree", "remove"];
1259 if (input.force) {
1260 args.push("--force");
1261 }
1262 args.push(input.path);
1263 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
1264 timeoutMs: 15_000,
1265 fallbackErrorMessage: "git worktree remove failed",
1266 }).pipe(
1267 Effect.mapError((error) =>
1268 createGitCommandError(
1269 "GitCore.removeWorktree",
1270 input.cwd,
1271 args,
1272 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
1273 error,
1274 ),1416 ),
1275 ),1417 );
1276 );1418 });
1277 });
1278
1279 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
1280 Effect.gen(function* () {
1281 if (input.oldBranch === input.newBranch) {
1282 return { branch: input.newBranch };
1283 }
1284 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
12851419
1286 yield* executeGit(1420 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
1287 "GitCore.renameBranch",1421 Effect.gen(function* () {
1288 input.cwd,1422 if (input.oldBranch === input.newBranch) {
1289 ["branch", "-m", "--", input.oldBranch, targetBranch],1423 return { branch: input.newBranch };
1290 {1424 }
1291 timeoutMs: 10_000,1425 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
1292 fallbackErrorMessage: "git branch rename failed",
1293 },
1294 );
12951426
1296 return { branch: targetBranch };1427 yield* executeGit(
1297 });1428 "GitCore.renameBranch",
1429 input.cwd,
1430 ["branch", "-m", "--", input.oldBranch, targetBranch],
1431 {
1432 timeoutMs: 10_000,
1433 fallbackErrorMessage: "git branch rename failed",
1434 },
1435 );
12981436
1299 const createBranch: GitCoreShape["createBranch"] = (input) =>1437 return { branch: targetBranch };
1300 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {1438 });
1301 timeoutMs: 10_000,
1302 fallbackErrorMessage: "git branch create failed",
1303 }).pipe(Effect.asVoid);
13041439
1305 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>1440 const createBranch: GitCoreShape["createBranch"] = (input) =>
1306 Effect.gen(function* () {1441 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1307 const [localInputExists, remoteExists] = yield* Effect.all(1442 timeoutMs: 10_000,
1308 [1443 fallbackErrorMessage: "git branch create failed",
1309 executeGit(1444 }).pipe(Effect.asVoid);
1310 "GitCore.checkoutBranch.localInputExists",
1311 input.cwd,
1312 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1313 {
1314 timeoutMs: 5_000,
1315 allowNonZeroExit: true,
1316 },
1317 ).pipe(Effect.map((result) => result.code === 0)),
1318 executeGit(
1319 "GitCore.checkoutBranch.remoteExists",
1320 input.cwd,
1321 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],
1322 {
1323 timeoutMs: 5_000,
1324 allowNonZeroExit: true,
1325 },
1326 ).pipe(Effect.map((result) => result.code === 0)),
1327 ],
1328 { concurrency: "unbounded" },
1329 );
13301445
1331 const localTrackingBranch = remoteExists1446 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
1332 ? yield* executeGit(1447 Effect.gen(function* () {
1333 "GitCore.checkoutBranch.localTrackingBranch",1448 const [localInputExists, remoteExists] = yield* Effect.all(
1334 input.cwd,1449 [
1335 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],1450 executeGit(
1336 {1451 "GitCore.checkoutBranch.localInputExists",
1337 timeoutMs: 5_000,1452 input.cwd,
1338 allowNonZeroExit: true,1453 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1339 },1454 {
1340 ).pipe(1455 timeoutMs: 5_000,
1341 Effect.map((result) =>1456 allowNonZeroExit: true,
1342 result.code === 01457 },
1343 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)1458 ).pipe(Effect.map((result) => result.code === 0)),
1344 : null,1459 executeGit(
1345 ),1460 "GitCore.checkoutBranch.remoteExists",
1346 )1461 input.cwd,
1347 : null;1462 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],
1463 {
1464 timeoutMs: 5_000,
1465 allowNonZeroExit: true,
1466 },
1467 ).pipe(Effect.map((result) => result.code === 0)),
1468 ],
1469 { concurrency: "unbounded" },
1470 );
13481471
1349 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);1472 const localTrackingBranch = remoteExists
1350 const localTrackedBranchTargetExists =
1351 remoteExists && localTrackedBranchCandidate
1352 ? yield* executeGit(1473 ? yield* executeGit(
1353 "GitCore.checkoutBranch.localTrackedBranchTargetExists",1474 "GitCore.checkoutBranch.localTrackingBranch",
1354 input.cwd,1475 input.cwd,
1355 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],1476 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
1356 {1477 {
1357 timeoutMs: 5_000,1478 timeoutMs: 5_000,
1358 allowNonZeroExit: true,1479 allowNonZeroExit: true,
1359 },1480 },
1360 ).pipe(Effect.map((result) => result.code === 0))1481 ).pipe(
1361 : false;1482 Effect.map((result) =>
1483 result.code === 0
1484 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
1485 : null,
1486 ),
1487 )
1488 : null;
13621489
1363 const checkoutArgs = localInputExists1490 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
1364 ? ["checkout", input.branch]1491 const localTrackedBranchTargetExists =
1365 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists1492 remoteExists && localTrackedBranchCandidate
1493 ? yield* executeGit(
1494 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
1495 input.cwd,
1496 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
1497 {
1498 timeoutMs: 5_000,
1499 allowNonZeroExit: true,
1500 },
1501 ).pipe(Effect.map((result) => result.code === 0))
1502 : false;
1503
1504 const checkoutArgs = localInputExists
1366 ? ["checkout", input.branch]1505 ? ["checkout", input.branch]
1367 : remoteExists && !localTrackingBranch1506 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
1368 ? ["checkout", "--track", input.branch]1507 ? ["checkout", input.branch]
1369 : remoteExists && localTrackingBranch1508 : remoteExists && !localTrackingBranch
1370 ? ["checkout", localTrackingBranch]1509 ? ["checkout", "--track", input.branch]
1371 : ["checkout", input.branch];1510 : remoteExists && localTrackingBranch
1511 ? ["checkout", localTrackingBranch]
1512 : ["checkout", input.branch];
1513
1514 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
1515 timeoutMs: 10_000,
1516 fallbackErrorMessage: "git checkout failed",
1517 });
13721518
1373 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {1519 // Refresh upstream refs in the background so checkout remains responsive.
1374 timeoutMs: 10_000,1520 yield* Effect.forkScoped(
1375 fallbackErrorMessage: "git checkout failed",1521 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1522 );
1376 });1523 });
13771524
1378 // Refresh upstream refs in the background so checkout remains responsive.1525 const initRepo: GitCoreShape["initRepo"] = (input) =>
1379 yield* Effect.forkScoped(1526 executeGit("GitCore.initRepo", input.cwd, ["init"], {
1380 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),1527 timeoutMs: 10_000,
1528 fallbackErrorMessage: "git init failed",
1529 }).pipe(Effect.asVoid);
1530
1531 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1532 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1533 "branch",
1534 "--list",
1535 "--format=%(refname:short)",
1536 ]).pipe(
1537 Effect.map((stdout) =>
1538 stdout
1539 .split("\n")
1540 .map((line) => line.trim())
1541 .filter((line) => line.length > 0),
1542 ),
1381 );1543 );
1382 });
1383
1384 const initRepo: GitCoreShape["initRepo"] = (input) =>
1385 executeGit("GitCore.initRepo", input.cwd, ["init"], {
1386 timeoutMs: 10_000,
1387 fallbackErrorMessage: "git init failed",
1388 }).pipe(Effect.asVoid);
1389
1390 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1391 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1392 "branch",
1393 "--list",
1394 "--format=%(refname:short)",
1395 ]).pipe(
1396 Effect.map((stdout) =>
1397 stdout
1398 .split("\n")
1399 .map((line) => line.trim())
1400 .filter((line) => line.length > 0),
1401 ),
1402 );
14031544
1404 return {1545 return {
1405 status,1546 execute,
1406 statusDetails,1547 status,
1407 prepareCommitContext,1548 statusDetails,
1408 commit,1549 prepareCommitContext,
1409 pushCurrentBranch,1550 commit,
1410 pullCurrentBranch,1551 pushCurrentBranch,
1411 readRangeContext,1552 pullCurrentBranch,
1412 readConfigValue,1553 readRangeContext,
1413 listBranches,1554 readConfigValue,
1414 createWorktree,1555 listBranches,
1415 fetchPullRequestBranch,1556 createWorktree,
1416 ensureRemote,1557 fetchPullRequestBranch,
1417 fetchRemoteBranch,1558 ensureRemote,
1418 setBranchUpstream,1559 fetchRemoteBranch,
1419 removeWorktree,1560 setBranchUpstream,
1420 renameBranch,1561 removeWorktree,
1421 createBranch,1562 renameBranch,
1422 checkoutBranch,1563 createBranch,
1423 initRepo,1564 checkoutBranch,
1424 listLocalBranchNames,1565 initRepo,
1425 } satisfies GitCoreShape;1566 listLocalBranchNames,
1426});1567 } satisfies GitCoreShape;
1568 });
14271569
1428export const GitCoreLive = Layer.effect(GitCore, makeGitCore);1570export const GitCoreLive = Layer.effect(GitCore, makeGitCore());
apps/server/src/git/Layers/GitManager.test.ts+10-9
...@@ -15,9 +15,8 @@ import {...@@ -15,9 +15,8 @@ import {
15 GitHubCli,15 GitHubCli,
16} from "../Services/GitHubCli.ts";16} from "../Services/GitHubCli.ts";
17import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";17import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
18import { GitServiceLive } from "./GitService.ts";
19import { GitService } from "../Services/GitService.ts";
20import { GitCoreLive } from "./GitCore.ts";18import { GitCoreLive } from "./GitCore.ts";
19import { GitCore } from "../Services/GitCore.ts";
21import { makeGitManager } from "./GitManager.ts";20import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";21import { ServerConfig } from "../../config.ts";
2322
...@@ -107,11 +106,11 @@ function runGit(...@@ -107,11 +106,11 @@ function runGit(
107): Effect.Effect<106): Effect.Effect<
108 { readonly code: number; readonly stdout: string; readonly stderr: string },107 { readonly code: number; readonly stdout: string; readonly stderr: string },
109 GitCommandError,108 GitCommandError,
110 GitService109 GitCore
111> {110> {
112 return Effect.gen(function* () {111 return Effect.gen(function* () {
113 const gitService = yield* GitService;112 const gitCore = yield* GitCore;
114 return yield* gitService.execute({113 return yield* gitCore.execute({
115 operation: "GitManager.test.runGit",114 operation: "GitManager.test.runGit",
116 cwd,115 cwd,
117 args,116 args,
...@@ -125,7 +124,7 @@ function initRepo(...@@ -125,7 +124,7 @@ function initRepo(
125): Effect.Effect<124): Effect.Effect<
126 void,125 void,
127 PlatformError.PlatformError | GitCommandError,126 PlatformError.PlatformError | GitCommandError,
128 FileSystem.FileSystem | Scope.Scope | GitService127 FileSystem.FileSystem | Scope.Scope | GitCore
129> {128> {
130 return Effect.gen(function* () {129 return Effect.gen(function* () {
131 const fs = yield* FileSystem.FileSystem;130 const fs = yield* FileSystem.FileSystem;
...@@ -141,7 +140,7 @@ function initRepo(...@@ -141,7 +140,7 @@ function initRepo(
141function createBareRemote(): Effect.Effect<140function createBareRemote(): Effect.Effect<
142 string,141 string,
143 PlatformError.PlatformError | GitCommandError,142 PlatformError.PlatformError | GitCommandError,
144 FileSystem.FileSystem | Scope.Scope | GitService143 FileSystem.FileSystem | Scope.Scope | GitCore
145> {144> {
146 return Effect.gen(function* () {145 return Effect.gen(function* () {
147 const remoteDir = yield* makeTempDir("t3code-git-remote-");146 const remoteDir = yield* makeTempDir("t3code-git-remote-");
...@@ -480,7 +479,6 @@ function makeManager(input?: {...@@ -480,7 +479,6 @@ function makeManager(input?: {
480 });479 });
481480
482 const gitCoreLayer = GitCoreLive.pipe(481 const gitCoreLayer = GitCoreLive.pipe(
483 Layer.provideMerge(GitServiceLive),
484 Layer.provideMerge(NodeServices.layer),482 Layer.provideMerge(NodeServices.layer),
485 Layer.provideMerge(ServerConfigLayer),483 Layer.provideMerge(ServerConfigLayer),
486 );484 );
...@@ -497,7 +495,10 @@ function makeManager(input?: {...@@ -497,7 +495,10 @@ function makeManager(input?: {
497 );495 );
498}496}
499497
500const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);498const GitManagerTestLayer = GitCoreLive.pipe(
499 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
500 Layer.provideMerge(NodeServices.layer),
501);
501502
502it.layer(GitManagerTestLayer)("GitManager", (it) => {503it.layer(GitManagerTestLayer)("GitManager", (it) => {
503 it.effect("status includes PR metadata when branch already has an open PR", () =>504 it.effect("status includes PR metadata when branch already has an open PR", () =>
apps/server/src/git/Layers/GitService.test.ts deleted-59
...@@ -1,59 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, assert } from "@effect/vitest";
3import { Effect, Layer, Schema } from "effect";
4
5import { GitCommandError } from "../Errors.ts";
6import { GitServiceLive } from "./GitService.ts";
7import { GitService } from "../Services/GitService.ts";
8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
10
11layer("GitServiceLive", (it) => {
12 it.effect("runGit executes successful git commands", () =>
13 Effect.gen(function* () {
14 const gitService = yield* GitService;
15 const result = yield* gitService.execute({
16 operation: "GitProcess.test.version",
17 cwd: process.cwd(),
18 args: ["--version"],
19 });
20
21 assert.equal(result.code, 0);
22 assert.ok(result.stdout.toLowerCase().includes("git version"));
23 }),
24 );
25
26 it.effect("runGit can return non-zero exit codes when allowed", () =>
27 Effect.gen(function* () {
28 const gitService = yield* GitService;
29 const result = yield* gitService.execute({
30 operation: "GitProcess.test.allowNonZero",
31 cwd: process.cwd(),
32 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
33 allowNonZeroExit: true,
34 });
35
36 assert.notEqual(result.code, 0);
37 }),
38 );
39
40 it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () =>
41 Effect.gen(function* () {
42 const gitService = yield* GitService;
43 const result = yield* Effect.result(
44 gitService.execute({
45 operation: "GitProcess.test.failOnNonZero",
46 cwd: process.cwd(),
47 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
48 }),
49 );
50
51 assert.equal(result._tag, "Failure");
52 if (result._tag === "Failure") {
53 assert.ok(Schema.is(GitCommandError)(result.failure));
54 assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero");
55 assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__");
56 }
57 }),
58 );
59});
apps/server/src/git/Layers/GitService.ts deleted-144
...@@ -1,144 +0,0 @@
1/**
2 * Git process helpers - Effect-native git execution with typed errors.
3 *
4 * Centralizes child-process git invocation for server modules. This module
5 * only executes git commands and reports structured failures.
6 *
7 * @module GitServiceLive
8 */
9import { Effect, Layer, Option, Schema, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "../Errors.ts";
12import {
13 ExecuteGitInput,
14 ExecuteGitResult,
15 GitService,
16 GitServiceShape,
17} from "../Services/GitService.ts";
18
19const DEFAULT_TIMEOUT_MS = 30_000;
20const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
21
22function quoteGitCommand(args: ReadonlyArray<string>): string {
23 return `git ${args.join(" ")}`;
24}
25
26function toGitCommandError(
27 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
28 detail: string,
29) {
30 return (cause: unknown) =>
31 Schema.is(GitCommandError)(cause)
32 ? cause
33 : new GitCommandError({
34 operation: input.operation,
35 command: quoteGitCommand(input.args),
36 cwd: input.cwd,
37 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
38 ...(cause !== undefined ? { cause } : {}),
39 });
40}
41
42const collectOutput = Effect.fn(function* <E>(
43 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
44 stream: Stream.Stream<Uint8Array, E>,
45 maxOutputBytes: number,
46): Effect.fn.Return<string, GitCommandError> {
47 const decoder = new TextDecoder();
48 let bytes = 0;
49 let text = "";
50
51 yield* Stream.runForEach(stream, (chunk) =>
52 Effect.gen(function* () {
53 bytes += chunk.byteLength;
54 if (bytes > maxOutputBytes) {
55 return yield* new GitCommandError({
56 operation: input.operation,
57 command: quoteGitCommand(input.args),
58 cwd: input.cwd,
59 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
60 });
61 }
62 text += decoder.decode(chunk, { stream: true });
63 }),
64 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
65
66 text += decoder.decode();
67 return text;
68});
69
70const makeGitService = Effect.gen(function* () {
71 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
72
73 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
74 const commandInput = {
75 ...input,
76 args: [...input.args],
77 } as const;
78 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
79 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
80
81 const commandEffect = Effect.gen(function* () {
82 const child = yield* commandSpawner
83 .spawn(
84 ChildProcess.make("git", commandInput.args, {
85 cwd: commandInput.cwd,
86 ...(input.env ? { env: input.env } : {}),
87 }),
88 )
89 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
90
91 const [stdout, stderr, exitCode] = yield* Effect.all(
92 [
93 collectOutput(commandInput, child.stdout, maxOutputBytes),
94 collectOutput(commandInput, child.stderr, maxOutputBytes),
95 child.exitCode.pipe(
96 Effect.map((value) => Number(value)),
97 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
98 ),
99 ],
100 { concurrency: "unbounded" },
101 );
102
103 if (!input.allowNonZeroExit && exitCode !== 0) {
104 const trimmedStderr = stderr.trim();
105 return yield* new GitCommandError({
106 operation: commandInput.operation,
107 command: quoteGitCommand(commandInput.args),
108 cwd: commandInput.cwd,
109 detail:
110 trimmedStderr.length > 0
111 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
112 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
113 });
114 }
115
116 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
117 });
118
119 return yield* commandEffect.pipe(
120 Effect.scoped,
121 Effect.timeoutOption(timeoutMs),
122 Effect.flatMap((result) =>
123 Option.match(result, {
124 onNone: () =>
125 Effect.fail(
126 new GitCommandError({
127 operation: commandInput.operation,
128 command: quoteGitCommand(commandInput.args),
129 cwd: commandInput.cwd,
130 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
131 }),
132 ),
133 onSome: Effect.succeed,
134 }),
135 ),
136 );
137 });
138
139 return {
140 execute,
141 } satisfies GitServiceShape;
142});
143
144export const GitServiceLive = Layer.effect(GitService, makeGitService);
apps/server/src/git/Services/GitCore.ts+21
...@@ -24,6 +24,22 @@ import type {...@@ -24,6 +24,22 @@ import type {
2424
25import type { GitCommandError } from "../Errors.ts";25import type { GitCommandError } from "../Errors.ts";
2626
27export interface ExecuteGitInput {
28 readonly operation: string;
29 readonly cwd: string;
30 readonly args: ReadonlyArray<string>;
31 readonly env?: NodeJS.ProcessEnv;
32 readonly allowNonZeroExit?: boolean;
33 readonly timeoutMs?: number;
34 readonly maxOutputBytes?: number;
35}
36
37export interface ExecuteGitResult {
38 readonly code: number;
39 readonly stdout: string;
40 readonly stderr: string;
41}
42
27export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {43export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
28 upstreamRef: string | null;44 upstreamRef: string | null;
29}45}
...@@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput {...@@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput {
86 * GitCoreShape - Service API for low-level Git repository interactions.102 * GitCoreShape - Service API for low-level Git repository interactions.
87 */103 */
88export interface GitCoreShape {104export interface GitCoreShape {
105 /**
106 * Execute a raw Git command.
107 */
108 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
109
89 /**110 /**
90 * Read Git status for a repository.111 * Read Git status for a repository.
91 */112 */
apps/server/src/git/Services/GitService.ts deleted-45
...@@ -1,45 +0,0 @@
1/**
2 * GitService - Service for Git command execution.
3 *
4 * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed
5 * domain errors for Git command execution.
6 *
7 * @module GitService
8 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { GitCommandError } from "../Errors.ts";
13
14export interface ExecuteGitInput {
15 readonly operation: string;
16 readonly cwd: string;
17 readonly args: ReadonlyArray<string>;
18 readonly env?: NodeJS.ProcessEnv;
19 readonly allowNonZeroExit?: boolean;
20 readonly timeoutMs?: number;
21 readonly maxOutputBytes?: number;
22}
23
24export interface ExecuteGitResult {
25 readonly code: number;
26 readonly stdout: string;
27 readonly stderr: string;
28}
29
30/**
31 * GitServiceShape - Service API for Git command execution.
32 */
33export interface GitServiceShape {
34 /**
35 * Execute a Git command.
36 */
37 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
38}
39
40/**
41 * GitService - Service for Git command execution.
42 */
43export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()(
44 "t3/git/Services/GitService",
45) {}
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2-1
...@@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";...@@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
1919
20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
22import { GitCoreLive } from "../../git/Layers/GitCore.ts";
22import { CheckpointReactorLive } from "./CheckpointReactor.ts";23import { CheckpointReactorLive } from "./CheckpointReactor.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";24import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";25import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
...@@ -260,7 +261,7 @@ describe("CheckpointReactor", () => {...@@ -260,7 +261,7 @@ describe("CheckpointReactor", () => {
260 Layer.provideMerge(orchestrationLayer),261 Layer.provideMerge(orchestrationLayer),
261 Layer.provideMerge(RuntimeReceiptBusLive),262 Layer.provideMerge(RuntimeReceiptBusLive),
262 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),263 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
263 Layer.provideMerge(CheckpointStoreLive),264 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))),
264 Layer.provideMerge(ServerConfigLayer),265 Layer.provideMerge(ServerConfigLayer),
265 Layer.provideMerge(NodeServices.layer),266 Layer.provideMerge(NodeServices.layer),
266 );267 );
apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts deleted-149
...@@ -1,149 +0,0 @@
1import { EventEmitter } from "node:events";
2import { PassThrough } from "node:stream";
3import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
4import { afterEach, describe, expect, it } from "vitest";
5
6async function* emptyPrompt(): AsyncGenerator<never, void, void> {}
7
8class FakeClaudeCodeProcess implements SpawnedProcess {
9 readonly stdin = new PassThrough();
10 readonly stdout = new PassThrough();
11 killed = false;
12 exitCode: number | null = null;
13
14 private readonly events = new EventEmitter();
15 private bufferedInput = "";
16
17 constructor(
18 private readonly onMessage: (
19 message: Record<string, unknown>,
20 process: FakeClaudeCodeProcess,
21 ) => void,
22 ) {
23 this.stdin.setEncoding("utf8");
24 this.stdin.on("data", (chunk: string) => {
25 this.bufferedInput += chunk;
26 this.drainInput();
27 });
28 }
29
30 emitJson(message: unknown): void {
31 this.stdout.write(`${JSON.stringify(message)}\n`);
32 }
33
34 kill(_signal: NodeJS.Signals): boolean {
35 this.killed = true;
36 this.exitCode = 0;
37 this.stdout.end();
38 this.events.emit("exit", 0, null);
39 return true;
40 }
41
42 on(
43 event: "exit" | "error",
44 listener:
45 | ((code: number | null, signal: NodeJS.Signals | null) => void)
46 | ((error: Error) => void),
47 ): void {
48 this.events.on(event, listener);
49 }
50
51 once(
52 event: "exit" | "error",
53 listener:
54 | ((code: number | null, signal: NodeJS.Signals | null) => void)
55 | ((error: Error) => void),
56 ): void {
57 this.events.once(event, listener);
58 }
59
60 off(
61 event: "exit" | "error",
62 listener:
63 | ((code: number | null, signal: NodeJS.Signals | null) => void)
64 | ((error: Error) => void),
65 ): void {
66 this.events.off(event, listener);
67 }
68
69 private drainInput(): void {
70 while (true) {
71 const newlineIndex = this.bufferedInput.indexOf("\n");
72 if (newlineIndex === -1) {
73 return;
74 }
75 const line = this.bufferedInput.slice(0, newlineIndex).trim();
76 this.bufferedInput = this.bufferedInput.slice(newlineIndex + 1);
77 if (line.length === 0) {
78 continue;
79 }
80 this.onMessage(JSON.parse(line) as Record<string, unknown>, this);
81 }
82 }
83}
84
85describe("Claude SDK fast mode probe", () => {
86 let activeQuery: ReturnType<typeof query> | null = null;
87
88 afterEach(() => {
89 activeQuery?.close();
90 activeQuery = null;
91 });
92
93 it("passes fast mode through the SDK settings flag", async () => {
94 let spawnOptions: SpawnOptions | undefined;
95
96 activeQuery = query({
97 prompt: emptyPrompt(),
98 options: {
99 persistSession: false,
100 settings: {
101 fastMode: true,
102 },
103 spawnClaudeCodeProcess: (options): SpawnedProcess => {
104 spawnOptions = options;
105 return new FakeClaudeCodeProcess((message, process) => {
106 if (
107 message.type !== "control_request" ||
108 typeof message.request_id !== "string" ||
109 !message.request ||
110 typeof message.request !== "object" ||
111 (message.request as { subtype?: unknown }).subtype !== "initialize"
112 ) {
113 return;
114 }
115
116 process.emitJson({
117 type: "control_response",
118 response: {
119 subtype: "success",
120 request_id: message.request_id,
121 response: {
122 commands: [],
123 agents: [],
124 output_style: "default",
125 available_output_styles: ["default"],
126 models: [],
127 account: {
128 subscriptionType: "max",
129 },
130 fast_mode_state: "on",
131 },
132 },
133 });
134 });
135 },
136 },
137 });
138
139 const initialization = await activeQuery.initializationResult();
140 expect(initialization.fast_mode_state).toBe("on");
141
142 expect(spawnOptions).toBeDefined();
143 const settingsFlagIndex = spawnOptions?.args.indexOf("--settings") ?? -1;
144 expect(settingsFlagIndex).toBeGreaterThan(-1);
145 expect(JSON.parse(spawnOptions?.args[settingsFlagIndex + 1] ?? "")).toEqual({
146 fastMode: true,
147 });
148 });
149});
apps/server/src/serverLayers.ts+6-7
...@@ -31,7 +31,6 @@ import { GitManagerLive } from "./git/Layers/GitManager";...@@ -31,7 +31,6 @@ import { GitManagerLive } from "./git/Layers/GitManager";
31import { GitCoreLive } from "./git/Layers/GitCore";31import { GitCoreLive } from "./git/Layers/GitCore";
32import { GitHubCliLive } from "./git/Layers/GitHubCli";32import { GitHubCliLive } from "./git/Layers/GitHubCli";
33import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";33import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
34import { GitServiceLive } from "./git/Layers/GitService";
35import { PtyAdapter } from "./terminal/Services/PTY";34import { PtyAdapter } from "./terminal/Services/PTY";
36import { AnalyticsService } from "./telemetry/Services/AnalyticsService";35import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3736
...@@ -86,8 +85,8 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -86,8 +85,8 @@ export function makeServerProviderLayer(): Layer.Layer<
86}85}
8786
88export function makeServerRuntimeServicesLayer() {87export function makeServerRuntimeServicesLayer() {
89 const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
90 const textGenerationLayer = CodexTextGenerationLive;88 const textGenerationLayer = CodexTextGenerationLive;
89 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
9190
92 const orchestrationLayer = OrchestrationEngineLive.pipe(91 const orchestrationLayer = OrchestrationEngineLive.pipe(
93 Layer.provide(OrchestrationProjectionPipelineLive),92 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -97,13 +96,13 @@ export function makeServerRuntimeServicesLayer() {...@@ -97,13 +96,13 @@ export function makeServerRuntimeServicesLayer() {
9796
98 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(97 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
99 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),98 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
100 Layer.provideMerge(CheckpointStoreLive),99 Layer.provideMerge(checkpointStoreLayer),
101 );100 );
102101
103 const runtimeServicesLayer = Layer.mergeAll(102 const runtimeServicesLayer = Layer.mergeAll(
104 orchestrationLayer,103 orchestrationLayer,
105 OrchestrationProjectionSnapshotQueryLive,104 OrchestrationProjectionSnapshotQueryLive,
106 CheckpointStoreLive,105 checkpointStoreLayer,
107 checkpointDiffQueryLayer,106 checkpointDiffQueryLayer,
108 RuntimeReceiptBusLive,107 RuntimeReceiptBusLive,
109 );108 );
...@@ -112,7 +111,7 @@ export function makeServerRuntimeServicesLayer() {...@@ -112,7 +111,7 @@ export function makeServerRuntimeServicesLayer() {
112 );111 );
113 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(112 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
114 Layer.provideMerge(runtimeServicesLayer),113 Layer.provideMerge(runtimeServicesLayer),
115 Layer.provideMerge(gitCoreLayer),114 Layer.provideMerge(GitCoreLive),
116 Layer.provideMerge(textGenerationLayer),115 Layer.provideMerge(textGenerationLayer),
117 );116 );
118 const checkpointReactorLayer = CheckpointReactorLive.pipe(117 const checkpointReactorLayer = CheckpointReactorLive.pipe(
...@@ -127,14 +126,14 @@ export function makeServerRuntimeServicesLayer() {...@@ -127,14 +126,14 @@ export function makeServerRuntimeServicesLayer() {
127 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));126 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
128127
129 const gitManagerLayer = GitManagerLive.pipe(128 const gitManagerLayer = GitManagerLive.pipe(
130 Layer.provideMerge(gitCoreLayer),129 Layer.provideMerge(GitCoreLive),
131 Layer.provideMerge(GitHubCliLive),130 Layer.provideMerge(GitHubCliLive),
132 Layer.provideMerge(textGenerationLayer),131 Layer.provideMerge(textGenerationLayer),
133 );132 );
134133
135 return Layer.mergeAll(134 return Layer.mergeAll(
136 orchestrationReactorLayer,135 orchestrationReactorLayer,
137 gitCoreLayer,136 GitCoreLive,
138 gitManagerLayer,137 gitManagerLayer,
139 terminalLayer,138 terminalLayer,
140 KeybindingsLive,139 KeybindingsLive,
apps/web/package.json+1-1
...@@ -51,7 +51,7 @@...@@ -51,7 +51,7 @@
51 "@vitejs/plugin-react": "^6.0.0",51 "@vitejs/plugin-react": "^6.0.0",
52 "@vitest/browser-playwright": "^4.0.18",52 "@vitest/browser-playwright": "^4.0.18",
53 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",53 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
54 "msw": "^2.12.10",54 "msw": "2.12.11",
55 "playwright": "^1.58.2",55 "playwright": "^1.58.2",
56 "tailwindcss": "^4.0.0",56 "tailwindcss": "^4.0.0",
57 "typescript": "catalog:",57 "typescript": "catalog:",
apps/web/public/mockServiceWorker.js+1-1
...@@ -7,7 +7,7 @@...@@ -7,7 +7,7 @@
7 * - Please do NOT modify this file.7 * - Please do NOT modify this file.
8 */8 */
99
10const PACKAGE_VERSION = '2.12.10'10const PACKAGE_VERSION = '2.12.11'
11const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'11const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
12const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')12const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13const activeClientIds = new Set()13const activeClientIds = new Set()
bun.lock+1-1
...@@ -112,7 +112,7 @@...@@ -112,7 +112,7 @@
112 "@vitejs/plugin-react": "^6.0.0",112 "@vitejs/plugin-react": "^6.0.0",
113 "@vitest/browser-playwright": "^4.0.18",113 "@vitest/browser-playwright": "^4.0.18",
114 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",114 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
115 "msw": "^2.12.10",115 "msw": "2.12.11",
116 "playwright": "^1.58.2",116 "playwright": "^1.58.2",
117 "tailwindcss": "^4.0.0",117 "tailwindcss": "^4.0.0",
118 "typescript": "catalog:",118 "typescript": "catalog:",
scripts/claude-fast-mode-probe.ts deleted-129
...@@ -1,129 +0,0 @@
1import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
2
3type ProbeMode = "off" | "on";
4
5async function* emptyPrompt(): AsyncGenerator<never, void, void> {}
6
7function parseArgs(argv: ReadonlyArray<string>): {
8 readonly mode: ProbeMode | "both";
9 readonly model?: string;
10 readonly cwd?: string;
11 readonly prompt?: string;
12} {
13 let mode: ProbeMode | "both" = "both";
14 let model: string | undefined;
15 let cwd: string | undefined;
16 let prompt: string | undefined;
17
18 for (let index = 0; index < argv.length; index += 1) {
19 const value = argv[index];
20 if (!value) {
21 continue;
22 }
23 if (value === "--mode") {
24 const next = argv[index + 1];
25 if (next === "off" || next === "on" || next === "both") {
26 mode = next;
27 index += 1;
28 }
29 continue;
30 }
31 if (value === "--model") {
32 model = argv[index + 1];
33 index += 1;
34 continue;
35 }
36 if (value === "--cwd") {
37 cwd = argv[index + 1];
38 index += 1;
39 continue;
40 }
41 if (value === "--prompt") {
42 prompt = argv[index + 1] ?? prompt;
43 index += 1;
44 }
45 }
46
47 return {
48 mode,
49 ...(model ? { model } : {}),
50 ...(cwd ? { cwd } : {}),
51 ...(prompt ? { prompt } : {}),
52 };
53}
54
55async function runProbe(input: {
56 readonly mode: ProbeMode;
57 readonly model?: string;
58 readonly cwd?: string;
59 readonly prompt?: string;
60}): Promise<void> {
61 const messages = query({
62 prompt: input.prompt ?? emptyPrompt(),
63 options: {
64 ...(input.model ? { model: input.model } : {}),
65 ...(input.cwd ? { cwd: input.cwd } : {}),
66 persistSession: false,
67 tools: [],
68 permissionMode: "plan",
69 includePartialMessages: true,
70 ...(input.mode === "on" ? { settings: { fastMode: true } } : {}),
71 },
72 });
73
74 const summary = {
75 mode: input.mode,
76 initFastModeState: null as string | null,
77 resultFastModeState: null as string | null,
78 resultSubtype: null as string | null,
79 resultText: null as string | null,
80 };
81
82 const initialization = await messages.initializationResult();
83 summary.initFastModeState = initialization.fast_mode_state ?? null;
84
85 if (!input.prompt) {
86 messages.close();
87 console.log(JSON.stringify(summary, null, 2));
88 return;
89 }
90
91 for await (const message of messages) {
92 handleProbeMessage(message, summary);
93 }
94 console.log(JSON.stringify(summary, null, 2));
95}
96
97function handleProbeMessage(
98 message: SDKMessage,
99 summary: {
100 mode: ProbeMode;
101 initFastModeState: string | null;
102 resultFastModeState: string | null;
103 resultSubtype: string | null;
104 resultText: string | null;
105 },
106): void {
107 if (message.type !== "result") {
108 return;
109 }
110 summary.resultSubtype = message.subtype;
111 summary.resultFastModeState = message.fast_mode_state ?? null;
112 summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n");
113}
114
115async function main(): Promise<void> {
116 const args = parseArgs(process.argv.slice(2));
117 const modes = args.mode === "both" ? (["off", "on"] as const) : [args.mode];
118
119 for (const mode of modes) {
120 await runProbe({
121 mode,
122 ...(args.model ? { model: args.model } : {}),
123 ...(args.cwd ? { cwd: args.cwd } : {}),
124 ...(args.prompt ? { prompt: args.prompt } : {}),
125 });
126 }
127}
128
129await main();
scripts/claude-haiku-thinking-probe.ts deleted-261
...@@ -1,261 +0,0 @@
1import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
2
3type ProbeCase =
4 | "default"
5 | "effort-low"
6 | "effort-high"
7 | "settings-off"
8 | "settings-on"
9 | "thinking-disabled"
10 | "thinking-enabled";
11
12function parseArgs(argv: ReadonlyArray<string>): {
13 readonly model: string;
14 readonly cwd?: string;
15 readonly prompt: string;
16 readonly cases: ReadonlyArray<ProbeCase>;
17} {
18 let model = "claude-haiku-4-5";
19 let cwd: string | undefined;
20 let prompt =
21 "Think step by step about this before answering: what is 27 multiplied by 43? Return the final number only.";
22 let cases: ReadonlyArray<ProbeCase> = [
23 "default",
24 "effort-low",
25 "effort-high",
26 "settings-off",
27 "settings-on",
28 "thinking-disabled",
29 "thinking-enabled",
30 ];
31
32 for (let index = 0; index < argv.length; index += 1) {
33 const value = argv[index];
34 if (!value) {
35 continue;
36 }
37 if (value === "--model") {
38 model = argv[index + 1] ?? model;
39 index += 1;
40 continue;
41 }
42 if (value === "--cwd") {
43 cwd = argv[index + 1] ?? cwd;
44 index += 1;
45 continue;
46 }
47 if (value === "--prompt") {
48 prompt = argv[index + 1] ?? prompt;
49 index += 1;
50 continue;
51 }
52 if (value === "--cases") {
53 const next = argv[index + 1];
54 if (next) {
55 const requested = next
56 .split(",")
57 .map((entry) => entry.trim())
58 .filter(
59 (entry): entry is ProbeCase =>
60 entry === "default" ||
61 entry === "effort-low" ||
62 entry === "effort-high" ||
63 entry === "settings-off" ||
64 entry === "settings-on" ||
65 entry === "thinking-disabled" ||
66 entry === "thinking-enabled",
67 );
68 if (requested.length > 0) {
69 cases = requested;
70 }
71 }
72 index += 1;
73 }
74 }
75
76 return {
77 model,
78 prompt,
79 ...(cwd ? { cwd } : {}),
80 cases,
81 };
82}
83
84function caseOptions(caseName: ProbeCase): {
85 readonly label: ProbeCase;
86 readonly effort?: "low" | "high";
87 readonly settings?: { alwaysThinkingEnabled: boolean };
88 readonly thinking?: ThinkingConfig;
89} {
90 switch (caseName) {
91 case "effort-low":
92 return {
93 label: caseName,
94 effort: "low",
95 };
96 case "effort-high":
97 return {
98 label: caseName,
99 effort: "high",
100 };
101 case "settings-off":
102 return {
103 label: caseName,
104 settings: {
105 alwaysThinkingEnabled: false,
106 },
107 };
108 case "settings-on":
109 return {
110 label: caseName,
111 settings: {
112 alwaysThinkingEnabled: true,
113 },
114 };
115 case "thinking-disabled":
116 return {
117 label: caseName,
118 thinking: {
119 type: "disabled",
120 },
121 };
122 case "thinking-enabled":
123 return {
124 label: caseName,
125 thinking: {
126 type: "enabled",
127 budgetTokens: 1024,
128 },
129 };
130 default:
131 return {
132 label: caseName,
133 };
134 }
135}
136
137type ProbeSummary = {
138 readonly case: ProbeCase;
139 readonly model: string;
140 initModelInfo: {
141 value: string | null;
142 supportsEffort: boolean | null;
143 supportsAdaptiveThinking: boolean | null;
144 supportedEffortLevels: ReadonlyArray<string> | null;
145 } | null;
146 resultSubtype: string | null;
147 resultText: string | null;
148 assistantThinkingBlockCount: number;
149 streamThinkingEventTypes: Record<string, number>;
150 error: string | null;
151};
152
153async function runCase(input: {
154 readonly caseName: ProbeCase;
155 readonly model: string;
156 readonly cwd?: string;
157 readonly prompt: string;
158}): Promise<ProbeSummary> {
159 const caseConfig = caseOptions(input.caseName);
160 const summary: ProbeSummary = {
161 case: input.caseName,
162 model: input.model,
163 initModelInfo: null,
164 resultSubtype: null,
165 resultText: null,
166 assistantThinkingBlockCount: 0,
167 streamThinkingEventTypes: {},
168 error: null,
169 };
170
171 try {
172 const messages = query({
173 prompt: input.prompt,
174 options: {
175 model: input.model,
176 ...(input.cwd ? { cwd: input.cwd } : {}),
177 persistSession: false,
178 tools: [],
179 permissionMode: "bypassPermissions",
180 includePartialMessages: true,
181 maxTurns: 1,
182 ...(caseConfig.effort ? { effort: caseConfig.effort } : {}),
183 ...(caseConfig.settings ? { settings: caseConfig.settings } : {}),
184 ...(caseConfig.thinking ? { thinking: caseConfig.thinking } : {}),
185 },
186 });
187
188 const initialization = await messages.initializationResult();
189 const initModel =
190 initialization.models.find((candidate) => candidate.value === input.model) ??
191 initialization.models.find((candidate) => candidate.value.includes("haiku")) ??
192 null;
193 summary.initModelInfo = initModel
194 ? {
195 value: initModel.value,
196 supportsEffort: initModel.supportsEffort ?? null,
197 supportsAdaptiveThinking: initModel.supportsAdaptiveThinking ?? null,
198 supportedEffortLevels: initModel.supportedEffortLevels ?? null,
199 }
200 : null;
201
202 for await (const message of messages) {
203 handleMessage(summary, message);
204 }
205 } catch (error) {
206 summary.error = error instanceof Error ? error.message : String(error);
207 }
208
209 return summary;
210}
211
212function handleMessage(summary: ProbeSummary, message: SDKMessage): void {
213 if (message.type === "stream_event") {
214 const event = message.event as {
215 type?: string;
216 delta?: {
217 type?: string;
218 };
219 };
220 const eventType = event.delta?.type ?? event.type;
221 if (typeof eventType === "string" && eventType.includes("thinking")) {
222 summary.streamThinkingEventTypes[eventType] =
223 (summary.streamThinkingEventTypes[eventType] ?? 0) + 1;
224 }
225 return;
226 }
227
228 if (message.type === "assistant") {
229 for (const block of message.message.content) {
230 if (typeof block?.type === "string" && block.type.includes("thinking")) {
231 summary.assistantThinkingBlockCount += 1;
232 }
233 }
234 return;
235 }
236
237 if (message.type === "result") {
238 summary.resultSubtype = message.subtype;
239 summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n");
240 }
241}
242
243async function main(): Promise<void> {
244 const args = parseArgs(process.argv.slice(2));
245 const results: ProbeSummary[] = [];
246
247 for (const caseName of args.cases) {
248 results.push(
249 await runCase({
250 caseName,
251 model: args.model,
252 ...(args.cwd ? { cwd: args.cwd } : {}),
253 prompt: args.prompt,
254 }),
255 );
256 }
257
258 console.log(JSON.stringify(results, null, 2));
259}
260
261await main();