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 {
2424
2525import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
2626import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
27import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
2728import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
2829import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
2930import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
......@@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = (
284285 Layer.provide(AnalyticsService.layerTest),
285286 );
286287
288 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
287289 const runtimeServicesLayer = Layer.mergeAll(
288290 orchestrationLayer,
289291 OrchestrationProjectionSnapshotQueryLive,
290292 ProjectionCheckpointRepositoryLive,
291293 ProjectionPendingApprovalRepositoryLive,
292 CheckpointStoreLive,
294 checkpointStoreLayer,
293295 providerLayer,
294296 RuntimeReceiptBusLive,
295297 );
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3-6
......@@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect";
1515
1616import { CheckpointInvariantError } from "../Errors.ts";
1717import { GitCommandError } from "../../git/Errors.ts";
18import { GitServiceLive } from "../../git/Layers/GitService.ts";
19import { GitService } from "../../git/Services/GitService.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";
2019import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2120import { CheckpointRef } from "@t3tools/contracts";
2221
2322const makeCheckpointStore = Effect.gen(function* () {
2423 const fs = yield* FileSystem.FileSystem;
2524 const path = yield* Path.Path;
26 const git = yield* GitService;
25 const git = yield* GitCore;
2726
2827 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
2928 git
......@@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () {
277276 } satisfies CheckpointStoreShape;
278277});
279278
280export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe(
281 Layer.provideMerge(GitServiceLive),
282);
279export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore);
apps/server/src/git/Layers/GitCore.test.ts+244-335
......@@ -6,9 +6,7 @@ import { it } from "@effect/vitest";
66import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
77import { describe, expect, vi } from "vitest";
88
9import { GitServiceLive } from "./GitService.ts";
10import { GitService, type GitServiceShape } from "../Services/GitService.ts";
11import { GitCoreLive } from "./GitCore.ts";
9import { GitCoreLive, makeGitCore } from "./GitCore.ts";
1210import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
1311import { GitCommandError } from "../Errors.ts";
1412import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
......@@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts";
1614
1715// ── Helpers ──
1816
19const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
2017const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });
2118const GitCoreTestLayer = GitCoreLive.pipe(
22 Layer.provide(GitServiceTestLayer),
2319 Layer.provide(ServerConfigLayer),
2420 Layer.provide(NodeServices.layer),
2521);
26const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);
22const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer);
2723
2824function makeTmpDir(
2925 prefix = "git-test-",
......@@ -49,10 +45,10 @@ function git(
4945 cwd: string,
5046 args: ReadonlyArray<string>,
5147 env?: NodeJS.ProcessEnv,
52): Effect.Effect<string, GitCommandError, GitService> {
48): Effect.Effect<string, GitCommandError, GitCore> {
5349 return Effect.gen(function* () {
54 const gitService = yield* GitService;
55 const result = yield* gitService.execute({
50 const gitCore = yield* GitCore;
51 const result = yield* gitCore.execute({
5652 operation: "GitCore.test.git",
5753 cwd,
5854 args,
......@@ -88,102 +84,10 @@ function runShellCommand(input: {
8884 });
8985}
9086
91const makeIsolatedGitCore = (gitService: GitServiceShape) =>
92 Effect.promise(async () => {
93 const gitServiceLayer = Layer.succeed(GitService, gitService);
94 const coreLayer = GitCoreLive.pipe(
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}
87const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) =>
88 makeGitCore({ executeOverride }).pipe(
89 Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)),
90 );
18791
18892/** Create a repo with an initial commit so branches work. */
18993function initRepoWithCommit(
......@@ -191,10 +95,11 @@ function initRepoWithCommit(
19195): Effect.Effect<
19296 { initialBranch: string },
19397 GitCommandError | PlatformError.PlatformError,
194 GitCore | GitService | FileSystem.FileSystem
98 GitCore | FileSystem.FileSystem
19599> {
196100 return Effect.gen(function* () {
197 yield* initGitRepo({ cwd });
101 const core = yield* GitCore;
102 yield* core.initRepo({ cwd });
198103 yield* git(cwd, ["config", "user.email", "test@test.com"]);
199104 yield* git(cwd, ["config", "user.name", "Test"]);
200105 yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
......@@ -214,7 +119,7 @@ function commitWithDate(
214119): Effect.Effect<
215120 void,
216121 GitCommandError | PlatformError.PlatformError,
217 GitService | FileSystem.FileSystem
122 GitCore | FileSystem.FileSystem
218123> {
219124 return Effect.gen(function* () {
220125 yield* writeTextFile(path.join(cwd, fileName), fileContents);
......@@ -253,7 +158,7 @@ it.layer(TestLayer)("git integration", (it) => {
253158 it.effect("creates a valid git repo", () =>
254159 Effect.gen(function* () {
255160 const tmp = yield* makeTmpDir();
256 yield* initGitRepo({ cwd: tmp });
161 yield* (yield* GitCore).initRepo({ cwd: tmp });
257162 expect(existsSync(path.join(tmp, ".git"))).toBe(true);
258163 }),
259164 );
......@@ -262,7 +167,7 @@ it.layer(TestLayer)("git integration", (it) => {
262167 Effect.gen(function* () {
263168 const tmp = yield* makeTmpDir();
264169 yield* initRepoWithCommit(tmp);
265 const result = yield* listGitBranches({ cwd: tmp });
170 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
266171 expect(result.isRepo).toBe(true);
267172 expect(result.hasOriginRemote).toBe(false);
268173 expect(result.branches.length).toBeGreaterThanOrEqual(1);
......@@ -276,7 +181,7 @@ it.layer(TestLayer)("git integration", (it) => {
276181 it.effect("returns isRepo: false for non-git directory", () =>
277182 Effect.gen(function* () {
278183 const tmp = yield* makeTmpDir();
279 const result = yield* listGitBranches({ cwd: tmp });
184 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
280185 expect(result.isRepo).toBe(false);
281186 expect(result.hasOriginRemote).toBe(false);
282187 expect(result.branches).toEqual([]);
......@@ -287,7 +192,7 @@ it.layer(TestLayer)("git integration", (it) => {
287192 Effect.gen(function* () {
288193 const tmp = yield* makeTmpDir();
289194 yield* initRepoWithCommit(tmp);
290 const result = yield* listGitBranches({ cwd: tmp });
195 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
291196 const current = result.branches.find((b) => b.current);
292197 expect(current).toBeDefined();
293198 expect(current!.current).toBe(true);
......@@ -300,7 +205,7 @@ it.layer(TestLayer)("git integration", (it) => {
300205 yield* initRepoWithCommit(tmp);
301206 yield* git(tmp, ["checkout", "--detach", "HEAD"]);
302207
303 const result = yield* listGitBranches({ cwd: tmp });
208 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
304209 expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false);
305210 expect(result.branches.some((branch) => branch.current)).toBe(false);
306211 }),
......@@ -310,12 +215,12 @@ it.layer(TestLayer)("git integration", (it) => {
310215 Effect.gen(function* () {
311216 const tmp = yield* makeTmpDir();
312217 yield* initRepoWithCommit(tmp);
313 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
218 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
314219 (branch) => branch.current,
315220 )!.name;
316221
317 yield* createGitBranch({ cwd: tmp, branch: "older-branch" });
318 yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" });
222 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" });
223 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });
319224 yield* commitWithDate(
320225 tmp,
321226 "older.txt",
......@@ -324,9 +229,9 @@ it.layer(TestLayer)("git integration", (it) => {
324229 "older branch change",
325230 );
326231
327 yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch });
328 yield* createGitBranch({ cwd: tmp, branch: "newer-branch" });
329 yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" });
232 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });
233 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
234 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
330235 yield* commitWithDate(
331236 tmp,
332237 "newer.txt",
......@@ -336,9 +241,9 @@ it.layer(TestLayer)("git integration", (it) => {
336241 );
337242
338243 // 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 });
342247 expect(result.branches[0]!.name).toBe("older-branch");
343248 expect(result.branches[1]!.name).toBe("newer-branch");
344249 }),
......@@ -349,7 +254,7 @@ it.layer(TestLayer)("git integration", (it) => {
349254 const tmp = yield* makeTmpDir();
350255 const remote = yield* makeTmpDir();
351256 yield* initRepoWithCommit(tmp);
352 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
257 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
353258 (branch) => branch.current,
354259 )!.name;
355260
......@@ -358,8 +263,8 @@ it.layer(TestLayer)("git integration", (it) => {
358263 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
359264 yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]);
360265
361 yield* createGitBranch({ cwd: tmp, branch: "current-branch" });
362 yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" });
266 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" });
267 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });
363268 yield* commitWithDate(
364269 tmp,
365270 "current.txt",
......@@ -368,9 +273,9 @@ it.layer(TestLayer)("git integration", (it) => {
368273 "current change",
369274 );
370275
371 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });
372 yield* createGitBranch({ cwd: tmp, branch: "newer-branch" });
373 yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" });
276 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
277 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
278 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
374279 yield* commitWithDate(
375280 tmp,
376281 "newer.txt",
......@@ -379,9 +284,9 @@ it.layer(TestLayer)("git integration", (it) => {
379284 "newer change",
380285 );
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 });
385290 expect(result.branches[0]!.name).toBe("current-branch");
386291 expect(result.branches[1]!.name).toBe(defaultBranch);
387292 expect(result.branches[2]!.name).toBe("newer-branch");
......@@ -392,10 +297,10 @@ it.layer(TestLayer)("git integration", (it) => {
392297 Effect.gen(function* () {
393298 const tmp = yield* makeTmpDir();
394299 yield* initRepoWithCommit(tmp);
395 yield* createGitBranch({ cwd: tmp, branch: "feature-a" });
396 yield* createGitBranch({ cwd: tmp, branch: "feature-b" });
300 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
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 });
399304 const names = result.branches.map((b) => b.name);
400305 expect(names).toContain("feature-a");
401306 expect(names).toContain("feature-b");
......@@ -406,7 +311,7 @@ it.layer(TestLayer)("git integration", (it) => {
406311 Effect.gen(function* () {
407312 const tmp = yield* makeTmpDir();
408313 yield* initRepoWithCommit(tmp);
409 const result = yield* listGitBranches({ cwd: tmp });
314 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
410315 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
411316 }),
412317 );
......@@ -418,23 +323,23 @@ it.layer(TestLayer)("git integration", (it) => {
418323
419324 yield* git(remote, ["init", "--bare"]);
420325 yield* initRepoWithCommit(tmp);
421 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
326 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
422327 (branch) => branch.current,
423328 )!.name;
424329
425330 yield* git(tmp, ["remote", "add", "origin", remote]);
426331 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
430335 const remoteOnlyBranch = "feature/remote-only";
431 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });
336 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
432337 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);
433338 yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);
434339 yield* git(tmp, ["checkout", defaultBranch]);
435340 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
436341
437 const result = yield* listGitBranches({ cwd: tmp });
342 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
438343 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
439344
440345 expect(result.hasOriginRemote).toBe(true);
......@@ -466,7 +371,7 @@ it.layer(TestLayer)("git integration", (it) => {
466371
467372 yield* git(remote, ["init", "--bare"]);
468373 yield* initRepoWithCommit(tmp);
469 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
374 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
470375 (branch) => branch.current,
471376 )!.name;
472377
......@@ -479,7 +384,7 @@ it.layer(TestLayer)("git integration", (it) => {
479384 yield* git(tmp, ["checkout", defaultBranch]);
480385 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
481386
482 const result = yield* listGitBranches({ cwd: tmp });
387 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
483388 const remoteBranch = result.branches.find(
484389 (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`,
485390 );
......@@ -498,11 +403,11 @@ it.layer(TestLayer)("git integration", (it) => {
498403 Effect.gen(function* () {
499404 const tmp = yield* makeTmpDir();
500405 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 });
506411 const current = result.branches.find((b) => b.current);
507412 expect(current!.name).toBe("feature");
508413 }),
......@@ -516,20 +421,20 @@ it.layer(TestLayer)("git integration", (it) => {
516421 yield* git(remote, ["init", "--bare"]);
517422
518423 yield* initRepoWithCommit(source);
519 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
424 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
520425 (branch) => branch.current,
521426 )!.name;
522427 yield* git(source, ["remote", "add", "origin", remote]);
523428 yield* git(source, ["push", "-u", "origin", defaultBranch]);
524429
525430 const featureBranch = "feature-behind";
526 yield* createGitBranch({ cwd: source, branch: featureBranch });
527 yield* checkoutGitBranch({ cwd: source, branch: featureBranch });
431 yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch });
432 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
528433 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
529434 yield* git(source, ["add", "feature.txt"]);
530435 yield* git(source, ["commit", "-m", "feature base"]);
531436 yield* git(source, ["push", "-u", "origin", featureBranch]);
532 yield* checkoutGitBranch({ cwd: source, branch: defaultBranch });
437 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch });
533438
534439 yield* git(clone, ["clone", remote, "."]);
535440 yield* git(clone, ["config", "user.email", "test@test.com"]);
......@@ -540,7 +445,7 @@ it.layer(TestLayer)("git integration", (it) => {
540445 yield* git(clone, ["commit", "-m", "remote feature update"]);
541446 yield* git(clone, ["push", "origin", featureBranch]);
542447
543 yield* checkoutGitBranch({ cwd: source, branch: featureBranch });
448 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
544449 const core = yield* GitCore;
545450 yield* Effect.promise(() =>
546451 vi.waitFor(async () => {
......@@ -560,7 +465,7 @@ it.layer(TestLayer)("git integration", (it) => {
560465 yield* git(remote, ["init", "--bare"]);
561466
562467 yield* initRepoWithCommit(source);
563 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
468 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
564469 (branch) => branch.current,
565470 )!.name;
566471 yield* git(source, ["remote", "add", "origin", remote]);
......@@ -575,23 +480,21 @@ it.layer(TestLayer)("git integration", (it) => {
575480 yield* git(source, ["push", "-u", "origin", featureBranch]);
576481 yield* git(source, ["checkout", defaultBranch]);
577482
578 const realGitService = yield* GitService;
483 const realGitCore = yield* GitCore;
579484 let refreshFetchAttempts = 0;
580 const core = yield* makeIsolatedGitCore({
581 execute: (input) => {
582 if (input.args[0] === "fetch") {
583 refreshFetchAttempts += 1;
584 return Effect.fail(
585 new GitCommandError({
586 operation: "git.test.refreshFailure",
587 command: `git ${input.args.join(" ")}`,
588 cwd: input.cwd,
589 detail: "simulated fetch timeout",
590 }),
591 );
592 }
593 return realGitService.execute(input);
594 },
485 const core = yield* makeIsolatedGitCore((input) => {
486 if (input.args[0] === "fetch") {
487 refreshFetchAttempts += 1;
488 return Effect.fail(
489 new GitCommandError({
490 operation: "git.test.refreshFailure",
491 command: `git ${input.args.join(" ")}`,
492 cwd: input.cwd,
493 detail: "simulated fetch timeout",
494 }),
495 );
496 }
497 return realGitCore.execute(input);
595498 });
596499 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
597500 yield* Effect.promise(() =>
......@@ -610,7 +513,7 @@ it.layer(TestLayer)("git integration", (it) => {
610513 yield* git(remote, ["init", "--bare"]);
611514
612515 yield* initRepoWithCommit(source);
613 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
516 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
614517 (branch) => branch.current,
615518 )!.name;
616519 yield* git(source, ["remote", "add", "origin", remote]);
......@@ -624,16 +527,14 @@ it.layer(TestLayer)("git integration", (it) => {
624527 yield* git(source, ["push", "-u", "origin", featureBranch]);
625528 yield* git(source, ["checkout", defaultBranch]);
626529
627 const realGitService = yield* GitService;
530 const realGitCore = yield* GitCore;
628531 let fetchArgs: readonly string[] | null = null;
629 const core = yield* makeIsolatedGitCore({
630 execute: (input) => {
631 if (input.args[0] === "fetch") {
632 fetchArgs = [...input.args];
633 return Effect.succeed({ code: 0, stdout: "", stderr: "" });
634 }
635 return realGitService.execute(input);
636 },
532 const core = yield* makeIsolatedGitCore((input) => {
533 if (input.args[0] === "fetch") {
534 fetchArgs = [...input.args];
535 return Effect.succeed({ code: 0, stdout: "", stderr: "" });
536 }
537 return realGitCore.execute(input);
637538 });
638539 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
639540 yield* Effect.promise(() =>
......@@ -660,7 +561,7 @@ it.layer(TestLayer)("git integration", (it) => {
660561 yield* git(remote, ["init", "--bare"]);
661562
662563 yield* initRepoWithCommit(source);
663 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
564 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
664565 (branch) => branch.current,
665566 )!.name;
666567 yield* git(source, ["remote", "add", "origin", remote]);
......@@ -674,22 +575,20 @@ it.layer(TestLayer)("git integration", (it) => {
674575 yield* git(source, ["push", "-u", "origin", featureBranch]);
675576 yield* git(source, ["checkout", defaultBranch]);
676577
677 const realGitService = yield* GitService;
578 const realGitCore = yield* GitCore;
678579 let fetchStarted = false;
679580 let releaseFetch!: () => void;
680581 const waitForReleasePromise = new Promise<void>((resolve) => {
681582 releaseFetch = resolve;
682583 });
683 const core = yield* makeIsolatedGitCore({
684 execute: (input) => {
685 if (input.args[0] === "fetch") {
686 fetchStarted = true;
687 return Effect.promise(() =>
688 waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })),
689 );
690 }
691 return realGitService.execute(input);
692 },
584 const core = yield* makeIsolatedGitCore((input) => {
585 if (input.args[0] === "fetch") {
586 fetchStarted = true;
587 return Effect.promise(() =>
588 waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })),
589 );
590 }
591 return realGitCore.execute(input);
693592 });
694593 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
695594 yield* Effect.promise(() =>
......@@ -706,7 +605,9 @@ it.layer(TestLayer)("git integration", (it) => {
706605 Effect.gen(function* () {
707606 const tmp = yield* makeTmpDir();
708607 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 );
710611 expect(result._tag).toBe("Failure");
711612 }),
712613 );
......@@ -718,16 +619,16 @@ it.layer(TestLayer)("git integration", (it) => {
718619 yield* git(remote, ["init", "--bare"]);
719620
720621 yield* initRepoWithCommit(source);
721 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
622 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
722623 (branch) => branch.current,
723624 )!.name;
724625 yield* git(source, ["remote", "add", "origin", remote]);
725626 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
729630 const checkoutResult = yield* Effect.result(
730 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),
631 (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }),
731632 );
732633 expect(checkoutResult._tag).toBe("Failure");
733634 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
......@@ -743,7 +644,7 @@ it.layer(TestLayer)("git integration", (it) => {
743644 yield* git(remote, ["init", "--bare"]);
744645
745646 yield* initRepoWithCommit(source);
746 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
647 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
747648 (branch) => branch.current,
748649 )!.name;
749650 yield* git(source, ["remote", "add", remoteName, remote]);
......@@ -757,7 +658,10 @@ it.layer(TestLayer)("git integration", (it) => {
757658 yield* git(source, ["checkout", defaultBranch]);
758659 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
762666 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
763667 }),
......@@ -772,9 +676,9 @@ it.layer(TestLayer)("git integration", (it) => {
772676 yield* git(remote, ["init", "--bare"]);
773677
774678 yield* initRepoWithCommit(source);
775 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
776 (branch) => branch.current,
777 )!.name;
679 const defaultBranch = (yield* (yield* GitCore).listBranches({
680 cwd: source,
681 })).branches.find((branch) => branch.current)!.name;
778682 yield* git(source, ["remote", "add", "origin", remote]);
779683 yield* git(source, ["push", "-u", "origin", defaultBranch]);
780684
......@@ -782,7 +686,10 @@ it.layer(TestLayer)("git integration", (it) => {
782686 // would attempt to create an already-existing local branch.
783687 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
787694 const core = yield* GitCore;
788695 const status = yield* core.statusDetails(source);
......@@ -794,7 +701,7 @@ it.layer(TestLayer)("git integration", (it) => {
794701 Effect.gen(function* () {
795702 const tmp = yield* makeTmpDir();
796703 yield* initRepoWithCommit(tmp);
797 yield* createGitBranch({ cwd: tmp, branch: "other" });
704 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" });
798705
799706 // Create a conflicting change: modify README on current branch
800707 yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");
......@@ -802,22 +709,24 @@ it.layer(TestLayer)("git integration", (it) => {
802709
803710 // First, checkout other branch cleanly
804711 yield* git(tmp, ["stash"]);
805 yield* checkoutGitBranch({ cwd: tmp, branch: "other" });
712 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" });
806713 yield* writeTextFile(path.join(tmp, "README.md"), "other content\n");
807714 yield* git(tmp, ["add", "."]);
808715 yield* git(tmp, ["commit", "-m", "other change"]);
809716
810717 // 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(
812719 (b) => !b.current,
813720 )!.name;
814 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });
721 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
815722
816723 // Make uncommitted changes to the same file
817724 yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n");
818725
819726 // 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 );
821730 expect(result._tag).toBe("Failure");
822731 }),
823732 );
......@@ -830,9 +739,9 @@ it.layer(TestLayer)("git integration", (it) => {
830739 Effect.gen(function* () {
831740 const tmp = yield* makeTmpDir();
832741 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 });
836745 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
837746 }),
838747 );
......@@ -841,8 +750,10 @@ it.layer(TestLayer)("git integration", (it) => {
841750 Effect.gen(function* () {
842751 const tmp = yield* makeTmpDir();
843752 yield* initRepoWithCommit(tmp);
844 yield* createGitBranch({ cwd: tmp, branch: "dupe" });
845 const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" }));
753 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" });
754 const result = yield* Effect.result(
755 (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }),
756 );
846757 expect(result._tag).toBe("Failure");
847758 }),
848759 );
......@@ -855,10 +766,10 @@ it.layer(TestLayer)("git integration", (it) => {
855766 Effect.gen(function* () {
856767 const tmp = yield* makeTmpDir();
857768 yield* initRepoWithCommit(tmp);
858 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
859 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
769 yield* (yield* GitCore).createBranch({ 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({
862773 cwd: tmp,
863774 oldBranch: "feature/old-name",
864775 newBranch: "feature/new-name",
......@@ -866,7 +777,7 @@ it.layer(TestLayer)("git integration", (it) => {
866777
867778 expect(renamed.branch).toBe("feature/new-name");
868779
869 const branches = yield* listGitBranches({ cwd: tmp });
780 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
870781 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
871782 const current = branches.branches.find((branch) => branch.current);
872783 expect(current?.name).toBe("feature/new-name");
......@@ -877,9 +788,11 @@ it.layer(TestLayer)("git integration", (it) => {
877788 Effect.gen(function* () {
878789 const tmp = yield* makeTmpDir();
879790 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({
883796 cwd: tmp,
884797 oldBranch: current.name,
885798 newBranch: current.name,
......@@ -893,18 +806,18 @@ it.layer(TestLayer)("git integration", (it) => {
893806 Effect.gen(function* () {
894807 const tmp = yield* makeTmpDir();
895808 yield* initRepoWithCommit(tmp);
896 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
897 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
898 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
809 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
810 yield* (yield* GitCore).createBranch({ 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({
901814 cwd: tmp,
902815 oldBranch: "t3code/tmp-working",
903816 newBranch: "t3code/feat/session",
904817 });
905818
906819 expect(renamed.branch).toBe("t3code/feat/session-1");
907 const branches = yield* listGitBranches({ cwd: tmp });
820 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
908821 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
909822 true,
910823 );
......@@ -920,12 +833,12 @@ it.layer(TestLayer)("git integration", (it) => {
920833 Effect.gen(function* () {
921834 const tmp = yield* makeTmpDir();
922835 yield* initRepoWithCommit(tmp);
923 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
924 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
925 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
926 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
836 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
837 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
838 yield* (yield* GitCore).createBranch({ 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({
929842 cwd: tmp,
930843 oldBranch: "t3code/tmp-working",
931844 newBranch: "t3code/feat/session",
......@@ -939,18 +852,16 @@ it.layer(TestLayer)("git integration", (it) => {
939852 Effect.gen(function* () {
940853 const tmp = yield* makeTmpDir();
941854 yield* initRepoWithCommit(tmp);
942 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
943 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
855 yield* (yield* GitCore).createBranch({ 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;
946859 let renameArgs: ReadonlyArray<string> | null = null;
947 const core = yield* makeIsolatedGitCore({
948 execute: (input) => {
949 if (input.args[0] === "branch" && input.args[1] === "-m") {
950 renameArgs = [...input.args];
951 }
952 return realGitService.execute(input);
953 },
860 const core = yield* makeIsolatedGitCore((input) => {
861 if (input.args[0] === "branch" && input.args[1] === "-m") {
862 renameArgs = [...input.args];
863 }
864 return realGitCore.execute(input);
954865 });
955866
956867 const renamed = yield* core.renameBranch({
......@@ -974,11 +885,11 @@ it.layer(TestLayer)("git integration", (it) => {
974885 yield* initRepoWithCommit(tmp);
975886
976887 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(
978889 (b) => b.current,
979890 )!.name;
980891
981 const result = yield* createGitWorktree({
892 const result = yield* (yield* GitCore).createWorktree({
982893 cwd: tmp,
983894 branch: currentBranch,
984895 newBranch: "wt-branch",
......@@ -991,7 +902,7 @@ it.layer(TestLayer)("git integration", (it) => {
991902 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
992903
993904 // Clean up worktree before tmp dir disposal
994 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
905 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
995906 }),
996907 );
997908
......@@ -1001,11 +912,11 @@ it.layer(TestLayer)("git integration", (it) => {
1001912 yield* initRepoWithCommit(tmp);
1002913
1003914 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(
1005916 (b) => b.current,
1006917 )!.name;
1007918
1008 yield* createGitWorktree({
919 yield* (yield* GitCore).createWorktree({
1009920 cwd: tmp,
1010921 branch: currentBranch,
1011922 newBranch: "wt-check",
......@@ -1016,7 +927,7 @@ it.layer(TestLayer)("git integration", (it) => {
1016927 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1017928 expect(branchOutput).toBe("wt-check");
1018929
1019 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
930 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1020931 }),
1021932 );
1022933
......@@ -1024,10 +935,10 @@ it.layer(TestLayer)("git integration", (it) => {
1024935 Effect.gen(function* () {
1025936 const tmp = yield* makeTmpDir();
1026937 yield* initRepoWithCommit(tmp);
1027 yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" });
938 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" });
1028939
1029940 const wtPath = path.join(tmp, "wt-existing");
1030 const result = yield* createGitWorktree({
941 const result = yield* (yield* GitCore).createWorktree({
1031942 cwd: tmp,
1032943 branch: "feature/existing-worktree",
1033944 path: wtPath,
......@@ -1038,7 +949,7 @@ it.layer(TestLayer)("git integration", (it) => {
1038949 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1039950 expect(branchOutput).toBe("feature/existing-worktree");
1040951
1041 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
952 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1042953 }),
1043954 );
1044955
......@@ -1046,15 +957,15 @@ it.layer(TestLayer)("git integration", (it) => {
1046957 Effect.gen(function* () {
1047958 const tmp = yield* makeTmpDir();
1048959 yield* initRepoWithCommit(tmp);
1049 yield* createGitBranch({ cwd: tmp, branch: "existing" });
960 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" });
1050961
1051962 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(
1053964 (b) => b.current,
1054965 )!.name;
1055966
1056967 const result = yield* Effect.result(
1057 createGitWorktree({
968 (yield* GitCore).createWorktree({
1058969 cwd: tmp,
1059970 branch: currentBranch,
1060971 newBranch: "existing",
......@@ -1071,11 +982,11 @@ it.layer(TestLayer)("git integration", (it) => {
1071982 yield* initRepoWithCommit(tmp);
1072983
1073984 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(
1075986 (b) => b.current,
1076987 )!.name;
1077988
1078 yield* createGitWorktree({
989 yield* (yield* GitCore).createWorktree({
1079990 cwd: tmp,
1080991 branch: mainBranch,
1081992 newBranch: "wt-list",
......@@ -1083,17 +994,17 @@ it.layer(TestLayer)("git integration", (it) => {
1083994 });
1084995
1085996 // 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 });
1087998 expect(wtBranches.isRepo).toBe(true);
1088999 const wtCurrent = wtBranches.branches.find((b) => b.current);
10891000 expect(wtCurrent!.name).toBe("wt-list");
10901001
10911002 // 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 });
10931004 const mainCurrent = mainBranches.branches.find((b) => b.current);
10941005 expect(mainCurrent!.name).toBe(mainBranch);
10951006
1096 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
1007 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
10971008 }),
10981009 );
10991010
......@@ -1103,11 +1014,11 @@ it.layer(TestLayer)("git integration", (it) => {
11031014 yield* initRepoWithCommit(tmp);
11041015
11051016 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(
11071018 (b) => b.current,
11081019 )!.name;
11091020
1110 yield* createGitWorktree({
1021 yield* (yield* GitCore).createWorktree({
11111022 cwd: tmp,
11121023 branch: currentBranch,
11131024 newBranch: "wt-remove",
......@@ -1115,7 +1026,7 @@ it.layer(TestLayer)("git integration", (it) => {
11151026 });
11161027 expect(existsSync(wtPath)).toBe(true);
11171028
1118 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
1029 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
11191030 expect(existsSync(wtPath)).toBe(false);
11201031 }),
11211032 );
......@@ -1126,11 +1037,11 @@ it.layer(TestLayer)("git integration", (it) => {
11261037 yield* initRepoWithCommit(tmp);
11271038
11281039 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(
11301041 (b) => b.current,
11311042 )!.name;
11321043
1133 yield* createGitWorktree({
1044 yield* (yield* GitCore).createWorktree({
11341045 cwd: tmp,
11351046 branch: currentBranch,
11361047 newBranch: "wt-dirty",
......@@ -1140,11 +1051,13 @@ it.layer(TestLayer)("git integration", (it) => {
11401051
11411052 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 );
11441057 expect(failedRemove._tag).toBe("Failure");
11451058 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 });
11481061 expect(existsSync(wtPath)).toBe(false);
11491062 }),
11501063 );
......@@ -1157,10 +1070,10 @@ it.layer(TestLayer)("git integration", (it) => {
11571070 Effect.gen(function* () {
11581071 const tmp = yield* makeTmpDir();
11591072 yield* initRepoWithCommit(tmp);
1160 yield* createGitBranch({ cwd: tmp, branch: "feature-login" });
1161 yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" });
1073 yield* (yield* GitCore).createBranch({ 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 });
11641077 const current = result.branches.find((b) => b.current);
11651078 expect(current!.name).toBe("feature-login");
11661079 }),
......@@ -1175,12 +1088,12 @@ it.layer(TestLayer)("git integration", (it) => {
11751088 const tmp = yield* makeTmpDir();
11761089 yield* initRepoWithCommit(tmp);
11771090
1178 const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
1091 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
11791092 (b) => b.current,
11801093 )!.name;
11811094
11821095 const wtPath = path.join(tmp, "my-worktree");
1183 const result = yield* createGitWorktree({
1096 const result = yield* (yield* GitCore).createWorktree({
11841097 cwd: tmp,
11851098 branch: currentBranch,
11861099 newBranch: "feature-wt",
......@@ -1191,7 +1104,7 @@ it.layer(TestLayer)("git integration", (it) => {
11911104 expect(existsSync(result.worktree.path)).toBe(true);
11921105
11931106 // Main repo still on original branch
1194 const mainBranches = yield* listGitBranches({ cwd: tmp });
1107 const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
11951108 const mainCurrent = mainBranches.branches.find((b) => b.current);
11961109 expect(mainCurrent!.name).toBe(currentBranch);
11971110
......@@ -1199,7 +1112,7 @@ it.layer(TestLayer)("git integration", (it) => {
11991112 const wtBranch = yield* git(wtPath, ["branch", "--show-current"]);
12001113 expect(wtBranch).toBe("feature-wt");
12011114
1202 yield* removeGitWorktree({ cwd: tmp, path: wtPath });
1115 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
12031116 }),
12041117 );
12051118 });
......@@ -1221,7 +1134,7 @@ it.layer(TestLayer)("git integration", (it) => {
12211134 yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]);
12221135 yield* git(tmp, ["checkout", initialBranch]);
12231136
1224 yield* fetchGitPullRequestBranch({
1137 yield* (yield* GitCore).fetchPullRequestBranch({
12251138 cwd: tmp,
12261139 prNumber: 55,
12271140 branch: "feature/pr-fetch",
......@@ -1242,22 +1155,22 @@ it.layer(TestLayer)("git integration", (it) => {
12421155 Effect.gen(function* () {
12431156 const tmp = yield* makeTmpDir();
12441157 yield* initRepoWithCommit(tmp);
1245 yield* createGitBranch({ cwd: tmp, branch: "branch-a" });
1246 yield* createGitBranch({ cwd: tmp, branch: "branch-b" });
1158 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" });
1159 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" });
12471160
12481161 // Simulate switching to thread A's branch
1249 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" });
1250 let branches = yield* listGitBranches({ cwd: tmp });
1162 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1163 let branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
12511164 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
12521165
12531166 // Simulate switching to thread B's branch
1254 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" });
1255 branches = yield* listGitBranches({ cwd: tmp });
1167 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" });
1168 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
12561169 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
12571170
12581171 // Switch back to thread A
1259 yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" });
1260 branches = yield* listGitBranches({ cwd: tmp });
1172 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1173 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
12611174 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
12621175 }),
12631176 );
......@@ -1270,30 +1183,30 @@ it.layer(TestLayer)("git integration", (it) => {
12701183 Effect.gen(function* () {
12711184 const tmp = yield* makeTmpDir();
12721185 yield* initRepoWithCommit(tmp);
1273 yield* createGitBranch({ cwd: tmp, branch: "diverged" });
1186 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" });
12741187
12751188 // Make diverged branch have different file content
1276 yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" });
1189 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" });
12771190 yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n");
12781191 yield* git(tmp, ["add", "."]);
12791192 yield* git(tmp, ["commit", "-m", "diverge"]);
12801193
12811194 // 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 });
12831196 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
12861199 // Make local uncommitted changes to the same file
12871200 yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n");
12881201
12891202 // Attempt checkout should fail
12901203 const failedCheckout = yield* Effect.result(
1291 checkoutGitBranch({ cwd: tmp, branch: "diverged" }),
1204 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }),
12921205 );
12931206 expect(failedCheckout._tag).toBe("Failure");
12941207
12951208 // Current branch should still be the initial one
1296 const result = yield* listGitBranches({ cwd: tmp });
1209 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
12971210 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
12981211 }),
12991212 );
......@@ -1390,9 +1303,9 @@ it.layer(TestLayer)("git integration", (it) => {
13901303 yield* git(remote, ["init", "--bare"]);
13911304
13921305 yield* initRepoWithCommit(source);
1393 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(
1394 (branch) => branch.current,
1395 )!.name;
1306 const initialBranch = (yield* (yield* GitCore).listBranches({
1307 cwd: source,
1308 })).branches.find((branch) => branch.current)!.name;
13961309 yield* git(source, ["remote", "add", "origin", remote]);
13971310 yield* git(source, ["push", "-u", "origin", initialBranch]);
13981311 yield* git(source, ["checkout", "-b", "feature/remote-base-only"]);
......@@ -1423,9 +1336,9 @@ it.layer(TestLayer)("git integration", (it) => {
14231336 yield* git(remote, ["init", "--bare"]);
14241337
14251338 yield* initRepoWithCommit(source);
1426 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(
1427 (branch) => branch.current,
1428 )!.name;
1339 const initialBranch = (yield* (yield* GitCore).listBranches({
1340 cwd: source,
1341 })).branches.find((branch) => branch.current)!.name;
14291342 yield* git(source, ["remote", "add", remoteName, remote]);
14301343 yield* git(source, ["push", "-u", remoteName, initialBranch]);
14311344 yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]);
......@@ -1526,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => {
15261439 yield* git(remote, ["init", "--bare"]);
15271440
15281441 yield* initRepoWithCommit(tmp);
1529 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
1442 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
15301443 (branch) => branch.current,
15311444 )!.name;
15321445 yield* git(tmp, ["remote", "add", "origin", remote]);
......@@ -1562,7 +1475,7 @@ it.layer(TestLayer)("git integration", (it) => {
15621475 yield* git(fork, ["init", "--bare"]);
15631476
15641477 yield* initRepoWithCommit(tmp);
1565 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
1478 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
15661479 (branch) => branch.current,
15671480 )!.name;
15681481 yield* git(tmp, ["remote", "add", "origin", origin]);
......@@ -1668,9 +1581,9 @@ it.layer(TestLayer)("git integration", (it) => {
16681581 yield* git(remote, ["init", "--bare"]);
16691582
16701583 yield* initRepoWithCommit(source);
1671 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(
1672 (branch) => branch.current,
1673 )!.name;
1584 const initialBranch = (yield* (yield* GitCore).listBranches({
1585 cwd: source,
1586 })).branches.find((branch) => branch.current)!.name;
16741587 yield* git(source, ["remote", "add", "origin", remote]);
16751588 yield* git(source, ["push", "-u", "origin", initialBranch]);
16761589
......@@ -1761,8 +1674,8 @@ it.layer(TestLayer)("git integration", (it) => {
17611674 yield* initRepoWithCommit(tmp);
17621675 yield* git(remote, ["init", "--bare"]);
17631676 yield* git(tmp, ["remote", "add", "origin", remote]);
1764 yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" });
1765 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" });
1677 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" });
1678 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" });
17661679
17671680 yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n");
17681681 const core = yield* GitCore;
......@@ -1790,7 +1703,7 @@ it.layer(TestLayer)("git integration", (it) => {
17901703 yield* git(remote, ["init", "--bare"]);
17911704
17921705 yield* initRepoWithCommit(source);
1793 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(
1706 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
17941707 (branch) => branch.current,
17951708 )!.name;
17961709 yield* git(source, ["remote", "add", "origin", remote]);
......@@ -1818,7 +1731,7 @@ it.layer(TestLayer)("git integration", (it) => {
18181731 Effect.gen(function* () {
18191732 const tmp = yield* makeTmpDir();
18201733 yield* initRepoWithCommit(tmp);
1821 const result = yield* Effect.result(pullGitBranch({ cwd: tmp }));
1734 const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp));
18221735 expect(result._tag).toBe("Failure");
18231736 if (result._tag === "Failure") {
18241737 expect(result.failure.message.toLowerCase()).toContain("no upstream");
......@@ -1830,23 +1743,21 @@ it.layer(TestLayer)("git integration", (it) => {
18301743 Effect.gen(function* () {
18311744 const tmp = yield* makeTmpDir();
18321745 yield* initRepoWithCommit(tmp);
1833 const realGitService = yield* GitService;
1746 const realGitCore = yield* GitCore;
18341747 let didFailRecency = false;
1835 const core = yield* makeIsolatedGitCore({
1836 execute: (input) => {
1837 if (!didFailRecency && input.args[0] === "for-each-ref") {
1838 didFailRecency = true;
1839 return Effect.fail(
1840 new GitCommandError({
1841 operation: "git.test.listBranchesRecency",
1842 command: `git ${input.args.join(" ")}`,
1843 cwd: input.cwd,
1844 detail: "timeout",
1845 }),
1846 );
1847 }
1848 return realGitService.execute(input);
1849 },
1748 const core = yield* makeIsolatedGitCore((input) => {
1749 if (!didFailRecency && input.args[0] === "for-each-ref") {
1750 didFailRecency = true;
1751 return Effect.fail(
1752 new GitCommandError({
1753 operation: "git.test.listBranchesRecency",
1754 command: `git ${input.args.join(" ")}`,
1755 cwd: input.cwd,
1756 detail: "timeout",
1757 }),
1758 );
1759 }
1760 return realGitCore.execute(input);
18501761 });
18511762
18521763 const result = yield* core.listBranches({ cwd: tmp });
......@@ -1866,35 +1777,33 @@ it.layer(TestLayer)("git integration", (it) => {
18661777 yield* git(remote, ["init", "--bare"]);
18671778 yield* git(tmp, ["remote", "add", "origin", remote]);
18681779
1869 const realGitService = yield* GitService;
1780 const realGitCore = yield* GitCore;
18701781 let didFailRemoteBranches = false;
18711782 let didFailRemoteNames = false;
1872 const core = yield* makeIsolatedGitCore({
1873 execute: (input) => {
1874 if (input.args.join(" ") === "branch --no-color --remotes") {
1875 didFailRemoteBranches = true;
1876 return Effect.fail(
1877 new GitCommandError({
1878 operation: "git.test.listBranchesRemoteBranches",
1879 command: `git ${input.args.join(" ")}`,
1880 cwd: input.cwd,
1881 detail: "remote unavailable",
1882 }),
1883 );
1884 }
1885 if (input.args.join(" ") === "remote") {
1886 didFailRemoteNames = true;
1887 return Effect.fail(
1888 new GitCommandError({
1889 operation: "git.test.listBranchesRemoteNames",
1890 command: `git ${input.args.join(" ")}`,
1891 cwd: input.cwd,
1892 detail: "remote unavailable",
1893 }),
1894 );
1895 }
1896 return realGitService.execute(input);
1897 },
1783 const core = yield* makeIsolatedGitCore((input) => {
1784 if (input.args.join(" ") === "branch --no-color --remotes") {
1785 didFailRemoteBranches = true;
1786 return Effect.fail(
1787 new GitCommandError({
1788 operation: "git.test.listBranchesRemoteBranches",
1789 command: `git ${input.args.join(" ")}`,
1790 cwd: input.cwd,
1791 detail: "remote unavailable",
1792 }),
1793 );
1794 }
1795 if (input.args.join(" ") === "remote") {
1796 didFailRemoteNames = true;
1797 return Effect.fail(
1798 new GitCommandError({
1799 operation: "git.test.listBranchesRemoteNames",
1800 command: `git ${input.args.join(" ")}`,
1801 cwd: input.cwd,
1802 detail: "remote unavailable",
1803 }),
1804 );
1805 }
1806 return realGitCore.execute(input);
18981807 });
18991808
19001809 const result = yield* core.listBranches({ cwd: tmp });
apps/server/src/git/Layers/GitCore.ts+1189-1047
......@@ -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
316import { GitCommandError } from "../Errors.ts";
4import { GitService } from "../Services/GitService.ts";
5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
17import {
18 GitCore,
19 type GitCoreShape,
20 type ExecuteGitInput,
21 type ExecuteGitResult,
22} from "../Services/GitCore.ts";
623import { ServerConfig } from "../../config.ts";
724
25const DEFAULT_TIMEOUT_MS = 30_000;
26const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
827const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
928const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
1029const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
......@@ -218,27 +237,146 @@ function createGitCommandError(
218237 });
219238}
220239
221const makeGitCore = Effect.gen(function* () {
222 const git = yield* GitService;
223 const fileSystem = yield* FileSystem.FileSystem;
224 const path = yield* Path.Path;
225 const { worktreesDir } = yield* ServerConfig;
226
227 const executeGit = (
228 operation: string,
229 cwd: string,
230 args: readonly string[],
231 options: ExecuteGitOptions = {},
232 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>
233 git
234 .execute({
240function quoteGitCommand(args: ReadonlyArray<string>): string {
241 return `git ${args.join(" ")}`;
242}
243
244function toGitCommandError(
245 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
246 detail: string,
247) {
248 return (cause: unknown) =>
249 Schema.is(GitCommandError)(cause)
250 ? cause
251 : new GitCommandError({
252 operation: input.operation,
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({
235374 operation,
236375 cwd,
237376 args,
238377 allowNonZeroExit: true,
239378 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
240 })
241 .pipe(
379 }).pipe(
242380 Effect.flatMap((result) => {
243381 if (options.allowNonZeroExit || result.code === 0) {
244382 return Effect.succeed(result);
......@@ -263,1166 +401,1170 @@ const makeGitCore = Effect.gen(function* () {
263401 }),
264402 );
265403
266 const runGit = (
267 operation: string,
268 cwd: string,
269 args: readonly string[],
270 allowNonZeroExit = false,
271 ): Effect.Effect<void, GitCommandError> =>
272 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
273
274 const runGitStdout = (
275 operation: string,
276 cwd: string,
277 args: readonly string[],
278 allowNonZeroExit = false,
279 ): Effect.Effect<string, GitCommandError> =>
280 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
281 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}'.`,
404 const runGit = (
405 operation: string,
406 cwd: string,
407 args: readonly string[],
408 allowNonZeroExit = false,
409 ): Effect.Effect<void, GitCommandError> =>
410 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
411
412 const runGitStdout = (
413 operation: string,
414 cwd: string,
415 args: readonly string[],
416 allowNonZeroExit = false,
417 ): Effect.Effect<string, GitCommandError> =>
418 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
419 Effect.map((result) => result.stdout),
318420 );
319 });
320421
321 const resolveCurrentUpstream = (
322 cwd: string,
323 ): Effect.Effect<
324 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,
325 GitCommandError
326 > =>
327 Effect.gen(function* () {
328 const upstreamRef = yield* runGitStdout(
329 "GitCore.resolveCurrentUpstream",
422 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
423 executeGit(
424 "GitCore.branchExists",
330425 cwd,
331 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
332 true,
333 ).pipe(Effect.map((stdout) => stdout.trim()));
334
335 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
336 return null;
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 }
426 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
427 {
428 allowNonZeroExit: true,
429 timeoutMs: 5_000,
430 },
431 ).pipe(Effect.map((result) => result.code === 0));
348432
349 return {
350 upstreamRef,
351 remoteName,
352 upstreamBranch,
353 };
354 });
433 const resolveAvailableBranchName = (
434 cwd: string,
435 desiredBranch: string,
436 ): Effect.Effect<string, GitCommandError> =>
437 Effect.gen(function* () {
438 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
439 if (!isDesiredTaken) {
440 return desiredBranch;
441 }
355442
356 const fetchUpstreamRef = (
357 cwd: string,
358 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
359 ): Effect.Effect<void, GitCommandError> => {
360 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
361 return runGit(
362 "GitCore.fetchUpstreamRef",
363 cwd,
364 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
365 true,
366 );
367 };
443 for (let suffix = 1; suffix <= 100; suffix += 1) {
444 const candidate = `${desiredBranch}-${suffix}`;
445 const isCandidateTaken = yield* branchExists(cwd, candidate);
446 if (!isCandidateTaken) {
447 return candidate;
448 }
449 }
368450
369 const fetchUpstreamRefForStatus = (
370 cwd: string,
371 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
372 ): Effect.Effect<void, GitCommandError> => {
373 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
374 return executeGit(
375 "GitCore.fetchUpstreamRefForStatus",
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 };
451 return yield* createGitCommandError(
452 "GitCore.renameBranch",
453 cwd,
454 ["branch", "-m", "--", desiredBranch],
455 `Could not find an available branch name for '${desiredBranch}'.`,
456 );
457 });
384458
385 const statusUpstreamRefreshCache = yield* Cache.makeWith({
386 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
387 lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>
459 const resolveCurrentUpstream = (
460 cwd: string,
461 ): Effect.Effect<
462 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,
463 GitCommandError
464 > =>
388465 Effect.gen(function* () {
389 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
390 upstreamRef: cacheKey.upstreamRef,
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({
466 const upstreamRef = yield* runGitStdout(
467 "GitCore.resolveCurrentUpstream",
407468 cwd,
408 upstreamRef: upstream.upstreamRef,
409 remoteName: upstream.remoteName,
410 upstreamBranch: upstream.upstreamBranch,
411 }),
412 );
413 });
469 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
470 true,
471 ).pipe(Effect.map((stdout) => stdout.trim()));
414472
415 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>
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) {
473 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
434474 return null;
435475 }
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> =>
460 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
461 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
462 );
477 const separatorIndex = upstreamRef.indexOf("/");
478 if (separatorIndex <= 0) {
479 return null;
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> =>
465 Effect.gen(function* () {
466 if (yield* originRemoteExists(cwd)) {
467 return "origin";
468 }
469 const remotes = yield* listRemoteNames(cwd);
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 });
487 return {
488 upstreamRef,
489 remoteName,
490 upstreamBranch,
491 };
492 });
481493
482 const resolvePushRemoteName = (
483 cwd: string,
484 branch: string,
485 ): Effect.Effect<string | null, GitCommandError> =>
486 Effect.gen(function* () {
487 const branchPushRemote = yield* runGitStdout(
488 "GitCore.resolvePushRemoteName.branchPushRemote",
494 const fetchUpstreamRef = (
495 cwd: string,
496 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
497 ): Effect.Effect<void, GitCommandError> => {
498 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
499 return runGit(
500 "GitCore.fetchUpstreamRef",
489501 cwd,
490 ["config", "--get", `branch.${branch}.pushRemote`],
502 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
491503 true,
492 ).pipe(Effect.map((stdout) => stdout.trim()));
493 if (branchPushRemote.length > 0) {
494 return branchPushRemote;
495 }
504 );
505 };
496506
497 const pushDefaultRemote = yield* runGitStdout(
498 "GitCore.resolvePushRemoteName.remotePushDefault",
507 const fetchUpstreamRefForStatus = (
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",
499514 cwd,
500 ["config", "--get", "remote.pushDefault"],
501 true,
502 ).pipe(Effect.map((stdout) => stdout.trim()));
503 if (pushDefaultRemote.length > 0) {
504 return pushDefaultRemote;
505 }
515 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
516 {
517 allowNonZeroExit: true,
518 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
519 },
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,
508537 });
509538
510 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
511 Effect.gen(function* () {
512 const preferredName = sanitizeRemoteName(input.preferredName);
513 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
514 const remoteFetchUrls = yield* runGitStdout(
515 "GitCore.ensureRemote.listRemoteUrls",
516 input.cwd,
517 ["remote", "-v"],
518 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
519
520 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
521 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
522 return remoteName;
523 }
524 }
539 const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>
540 Effect.gen(function* () {
541 const upstream = yield* resolveCurrentUpstream(cwd);
542 if (!upstream) return;
543 yield* Cache.get(
544 statusUpstreamRefreshCache,
545 new StatusUpstreamRefreshCacheKey({
546 cwd,
547 upstreamRef: upstream.upstreamRef,
548 remoteName: upstream.remoteName,
549 upstreamBranch: upstream.upstreamBranch,
550 }),
551 );
552 });
525553
526 let remoteName = preferredName;
527 let suffix = 1;
528 while (remoteFetchUrls.has(remoteName)) {
529 remoteName = `${preferredName}-${suffix}`;
530 suffix += 1;
531 }
554 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>
555 Effect.gen(function* () {
556 const upstream = yield* resolveCurrentUpstream(cwd);
557 if (!upstream) return;
558 yield* fetchUpstreamRef(cwd, upstream);
559 });
532560
533 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
534 "remote",
535 "add",
536 remoteName,
537 input.url,
538 ]);
539 return remoteName;
540 });
561 const resolveDefaultBranchName = (
562 cwd: string,
563 remoteName: string,
564 ): Effect.Effect<string | null, GitCommandError> =>
565 executeGit(
566 "GitCore.resolveDefaultBranchName",
567 cwd,
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 = (
543 cwd: string,
544 branch: string,
545 ): Effect.Effect<string | null, GitCommandError> =>
546 Effect.gen(function* () {
547 const configuredBaseBranch = yield* runGitStdout(
548 "GitCore.resolveBaseBranchForNoUpstream.config",
579 const remoteBranchExists = (
580 cwd: string,
581 remoteName: string,
582 branch: string,
583 ): Effect.Effect<boolean, GitCommandError> =>
584 executeGit(
585 "GitCore.remoteBranchExists",
549586 cwd,
550 ["config", "--get", `branch.${branch}.gh-merge-base`],
551 true,
552 ).pipe(Effect.map((stdout) => stdout.trim()));
587 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
588 {
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(
555 Effect.catch(() => Effect.succeed(null)),
598 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
599 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
600 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
556601 );
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 =
571 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
572 const normalizedCandidate = candidate.startsWith("origin/")
573 ? candidate.slice("origin/".length)
574 : remotePrefix && candidate.startsWith(remotePrefix)
575 ? candidate.slice(remotePrefix.length)
576 : candidate;
577 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
578 continue;
603 const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> =>
604 Effect.gen(function* () {
605 if (yield* originRemoteExists(cwd)) {
606 return "origin";
579607 }
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)) {
582 return normalizedCandidate;
621 const resolvePushRemoteName = (
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;
583634 }
584635
585 if (
586 primaryRemoteName &&
587 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
588 ) {
589 return `${primaryRemoteName}/${normalizedCandidate}`;
636 const pushDefaultRemote = yield* runGitStdout(
637 "GitCore.resolvePushRemoteName.remotePushDefault",
638 cwd,
639 ["config", "--get", "remote.pushDefault"],
640 true,
641 ).pipe(Effect.map((stdout) => stdout.trim()));
642 if (pushDefaultRemote.length > 0) {
643 return pushDefaultRemote;
590644 }
591 }
592645
593 return null;
594 });
646 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
647 });
595648
596 const computeAheadCountAgainstBase = (
597 cwd: string,
598 branch: string,
599 ): Effect.Effect<number, GitCommandError> =>
600 Effect.gen(function* () {
601 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);
602 if (!baseBranch) {
603 return 0;
604 }
649 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
650 Effect.gen(function* () {
651 const preferredName = sanitizeRemoteName(input.preferredName);
652 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
653 const remoteFetchUrls = yield* runGitStdout(
654 "GitCore.ensureRemote.listRemoteUrls",
655 input.cwd,
656 ["remote", "-v"],
657 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
605658
606 const result = yield* executeGit(
607 "GitCore.computeAheadCountAgainstBase",
608 cwd,
609 ["rev-list", "--count", `${baseBranch}..HEAD`],
610 { allowNonZeroExit: true },
611 );
612 if (result.code !== 0) {
613 return 0;
614 }
659 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
660 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
661 return remoteName;
662 }
663 }
615664
616 const parsed = Number.parseInt(result.stdout.trim(), 10);
617 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
618 });
665 let remoteName = preferredName;
666 let suffix = 1;
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> =>
621 Effect.gen(function* () {
622 const branchRecency = yield* executeGit(
623 "GitCore.readBranchRecency",
624 cwd,
625 [
626 "for-each-ref",
627 "--format=%(refname:short)%09%(committerdate:unix)",
628 "refs/heads",
629 "refs/remotes",
630 ],
631 {
632 timeoutMs: 15_000,
633 allowNonZeroExit: true,
634 },
635 );
672 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
673 "remote",
674 "add",
675 remoteName,
676 input.url,
677 ]);
678 return remoteName;
679 });
636680
637 const branchLastCommit = new Map<string, number>();
638 if (branchRecency.code !== 0) {
639 return branchLastCommit;
640 }
681 const resolveBaseBranchForNoUpstream = (
682 cwd: string,
683 branch: string,
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")) {
643 if (line.length === 0) {
644 continue;
645 }
646 const [name, lastCommitRaw] = line.split("\t");
647 if (!name) {
648 continue;
649 }
650 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
651 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
652 }
693 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
694 Effect.catch(() => Effect.succeed(null)),
695 );
696 const defaultBranch =
697 primaryRemoteName === null
698 ? null
699 : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
700 const candidates = [
701 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
702 defaultBranch,
703 ...DEFAULT_BASE_BRANCH_CANDIDATES,
704 ];
705
706 for (const candidate of candidates) {
707 if (!candidate) {
708 continue;
709 }
653710
654 return branchLastCommit;
655 });
711 const remotePrefix =
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) =>
658 Effect.gen(function* () {
659 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
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 );
722 if (yield* branchExists(cwd, normalizedCandidate)) {
723 return normalizedCandidate;
724 }
677725
678 let branch: string | null = null;
679 let upstreamRef: string | null = null;
680 let aheadCount = 0;
681 let behindCount = 0;
682 let hasWorkingTreeChanges = false;
683 const changedFilesWithoutNumstat = new Set<string>();
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;
726 if (
727 primaryRemoteName &&
728 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
729 ) {
730 return `${primaryRemoteName}/${normalizedCandidate}`;
731 }
695732 }
696 if (line.startsWith("# branch.ab ")) {
697 const value = line.slice("# branch.ab ".length).trim();
698 const parsed = parseBranchAb(value);
699 aheadCount = parsed.ahead;
700 behindCount = parsed.behind;
701 continue;
733
734 return null;
735 });
736
737 const computeAheadCountAgainstBase = (
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;
702745 }
703 if (line.trim().length > 0 && !line.startsWith("#")) {
704 hasWorkingTreeChanges = true;
705 const pathValue = parsePorcelainPath(line);
706 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
746
747 const result = yield* executeGit(
748 "GitCore.computeAheadCountAgainstBase",
749 cwd,
750 ["rev-list", "--count", `${baseBranch}..HEAD`],
751 { allowNonZeroExit: true },
752 );
753 if (result.code !== 0) {
754 return 0;
707755 }
708 }
709756
710 if (!upstreamRef && branch) {
711 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
712 Effect.catch(() => Effect.succeed(0)),
757 const parsed = Number.parseInt(result.stdout.trim(), 10);
758 return Number.isFinite(parsed) ? Math.max(0, parsed) : 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 },
713776 );
714 behindCount = 0;
715 }
716777
717 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
718 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
719 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
720 for (const entry of [...stagedEntries, ...unstagedEntries]) {
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 }
778 const branchLastCommit = new Map<string, number>();
779 if (branchRecency.code !== 0) {
780 return branchLastCommit;
781 }
726782
727 let insertions = 0;
728 let deletions = 0;
729 const files = Array.from(fileStatMap.entries())
730 .map(([filePath, stat]) => {
731 insertions += stat.insertions;
732 deletions += stat.deletions;
733 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
734 })
735 .toSorted((a, b) => a.path.localeCompare(b.path));
736
737 for (const filePath of changedFilesWithoutNumstat) {
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 });
783 for (const line of branchRecency.stdout.split("\n")) {
784 if (line.length === 0) {
785 continue;
786 }
787 const [name, lastCommitRaw] = line.split("\t");
788 if (!name) {
789 continue;
790 }
791 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
792 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
793 }
757794
758 const status: GitCoreShape["status"] = (input) =>
759 statusDetails(input.cwd).pipe(
760 Effect.map((details) => ({
761 branch: details.branch,
762 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
763 workingTree: details.workingTree,
764 hasUpstream: details.hasUpstream,
765 aheadCount: details.aheadCount,
766 behindCount: details.behindCount,
767 pr: null,
768 })),
769 );
770
771 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>
772 Effect.gen(function* () {
773 if (filePaths && filePaths.length > 0) {
774 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
775 Effect.catch(() => Effect.void),
795 return branchLastCommit;
796 });
797
798 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>
799 Effect.gen(function* () {
800 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
801
802 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
803 [
804 runGitStdout("GitCore.statusDetails.status", cwd, [
805 "status",
806 "--porcelain=2",
807 "--branch",
808 ]),
809 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
810 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
811 "diff",
812 "--cached",
813 "--numstat",
814 ]),
815 ],
816 { concurrency: "unbounded" },
776817 );
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, [
788 "diff",
789 "--cached",
790 "--name-status",
791 ]).pipe(Effect.map((stdout) => stdout.trim()));
792 if (stagedSummary.length === 0) {
793 return null;
794 }
819 let branch: string | null = null;
820 let upstreamRef: string | null = null;
821 let aheadCount = 0;
822 let behindCount = 0;
823 let hasWorkingTreeChanges = false;
824 const changedFilesWithoutNumstat = new Set<string>();
825
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, [
797 "diff",
798 "--cached",
799 "--patch",
800 "--minimal",
801 ]);
851 if (!upstreamRef && branch) {
852 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
853 Effect.catch(() => Effect.succeed(0)),
854 );
855 behindCount = 0;
856 }
802857
803 return {
804 stagedSummary,
805 stagedPatch,
806 };
807 });
858 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
859 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
860 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
861 for (const entry of [...stagedEntries, ...unstagedEntries]) {
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) =>
810 Effect.gen(function* () {
811 const args = ["commit", "-m", subject];
812 const trimmedBody = body.trim();
813 if (trimmedBody.length > 0) {
814 args.push("-m", trimmedBody);
815 }
816 yield* runGit("GitCore.commit.commit", cwd, args);
817 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
818 "rev-parse",
819 "HEAD",
820 ]).pipe(Effect.map((stdout) => stdout.trim()));
868 let insertions = 0;
869 let deletions = 0;
870 const files = Array.from(fileStatMap.entries())
871 .map(([filePath, stat]) => {
872 insertions += stat.insertions;
873 deletions += stat.deletions;
874 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
875 })
876 .toSorted((a, b) => a.path.localeCompare(b.path));
877
878 for (const filePath of changedFilesWithoutNumstat) {
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 };
823 });
884 return {
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) =>
826 Effect.gen(function* () {
827 const details = yield* statusDetails(cwd);
828 const branch = details.branch ?? fallbackBranch;
829 if (!branch) {
830 return yield* createGitCommandError(
831 "GitCore.pushCurrentBranch",
899 const status: GitCoreShape["status"] = (input) =>
900 statusDetails(input.cwd).pipe(
901 Effect.map((details) => ({
902 branch: details.branch,
903 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
904 workingTree: details.workingTree,
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",
832930 cwd,
833 ["push"],
834 "Cannot push from detached HEAD.",
835 );
836 }
931 ["diff", "--cached", "--name-status"],
932 ).pipe(Effect.map((stdout) => stdout.trim()));
933 if (stagedSummary.length === 0) {
934 return null;
935 }
837936
838 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
839 if (hasNoLocalDelta) {
840 if (details.hasUpstream) {
841 return {
842 status: "skipped_up_to_date" as const,
843 branch,
844 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
845 };
937 const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [
938 "diff",
939 "--cached",
940 "--patch",
941 "--minimal",
942 ]);
943
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);
846956 }
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(
849 Effect.catch(() => Effect.succeed(null)),
850 );
851 if (comparableBaseBranch) {
852 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
853 Effect.catch(() => Effect.succeed(null)),
963 return { commitSha };
964 });
965
966 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) =>
967 Effect.gen(function* () {
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.",
854976 );
855 if (!publishRemoteName) {
977 }
978
979 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
980 if (hasNoLocalDelta) {
981 if (details.hasUpstream) {
856982 return {
857983 status: "skipped_up_to_date" as const,
858984 branch,
985 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
859986 };
860987 }
861988
862 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
863 Effect.catch(() => Effect.succeed(false)),
989 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
990 Effect.catch(() => Effect.succeed(null)),
864991 );
865 if (hasRemoteBranch) {
866 return {
867 status: "skipped_up_to_date" as const,
868 branch,
869 };
992 if (comparableBaseBranch) {
993 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
994 Effect.catch(() => Effect.succeed(null)),
995 );
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 }
8701012 }
8711013 }
872 }
8731014
874 if (!details.hasUpstream) {
875 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
876 if (!publishRemoteName) {
877 return yield* createGitCommandError(
878 "GitCore.pushCurrentBranch",
879 cwd,
880 ["push"],
881 "Cannot push because no git remote is configured for this repository.",
882 );
1015 if (!details.hasUpstream) {
1016 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1017 if (!publishRemoteName) {
1018 return yield* createGitCommandError(
1019 "GitCore.pushCurrentBranch",
1020 cwd,
1021 ["push"],
1022 "Cannot push because no git remote is configured for this repository.",
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 };
8831037 }
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(
899 Effect.catch(() => Effect.succeed(null)),
900 );
901 if (currentUpstream) {
902 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
903 "push",
904 currentUpstream.remoteName,
905 `HEAD:${currentUpstream.upstreamBranch}`,
906 ]);
1039 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
1040 Effect.catch(() => Effect.succeed(null)),
1041 );
1042 if (currentUpstream) {
1043 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
1044 "push",
1045 currentUpstream.remoteName,
1046 `HEAD:${currentUpstream.upstreamBranch}`,
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"]);
9071057 return {
9081058 status: "pushed" as const,
9091059 branch,
910 upstreamBranch: currentUpstream.upstreamRef,
1060 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
9111061 setUpstream: false,
9121062 };
913 }
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 });
1063 });
9231064
924 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>
925 Effect.gen(function* () {
926 const details = yield* statusDetails(cwd);
927 const branch = details.branch;
928 if (!branch) {
929 return yield* createGitCommandError(
930 "GitCore.pullCurrentBranch",
1065 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>
1066 Effect.gen(function* () {
1067 const details = yield* statusDetails(cwd);
1068 const branch = details.branch;
1069 if (!branch) {
1070 return yield* createGitCommandError(
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",
9311087 cwd,
932 ["pull", "--ff-only"],
933 "Cannot pull from detached HEAD.",
934 );
935 }
936 if (!details.hasUpstream) {
937 return yield* createGitCommandError(
938 "GitCore.pullCurrentBranch",
1088 ["rev-parse", "HEAD"],
1089 true,
1090 ).pipe(Effect.map((stdout) => stdout.trim()));
1091 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
1092 timeoutMs: 30_000,
1093 fallbackErrorMessage: "git pull failed",
1094 });
1095 const afterSha = yield* runGitStdout(
1096 "GitCore.pullCurrentBranch.afterSha",
9391097 cwd,
940 ["pull", "--ff-only"],
941 "Current branch has no upstream configured. Push with upstream first.",
942 );
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 });
1098 ["rev-parse", "HEAD"],
1099 true,
1100 ).pipe(Effect.map((stdout) => stdout.trim()));
9681101
969 const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) =>
970 Effect.gen(function* () {
971 const range = `${baseBranch}..HEAD`;
972 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
973 [
974 runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]),
975 runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]),
976 runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [
977 "diff",
978 "--patch",
979 "--minimal",
980 range,
981 ]),
982 ],
983 { concurrency: "unbounded" },
984 );
1102 const refreshed = yield* statusDetails(cwd);
1103 return {
1104 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
1105 branch,
1106 upstreamBranch: refreshed.upstreamRef,
1107 };
1108 });
9851109
986 return {
987 commitSummary,
988 diffSummary,
989 diffPatch,
990 };
991 });
1110 const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) =>
1111 Effect.gen(function* () {
1112 const range = `${baseBranch}..HEAD`;
1113 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1114 [
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) =>
994 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
995 Effect.map((stdout) => stdout.trim()),
996 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
997 );
1127 return {
1128 commitSummary,
1129 diffSummary,
1130 diffPatch,
1131 };
1132 });
9981133
999 const listBranches: GitCoreShape["listBranches"] = (input) =>
1000 Effect.gen(function* () {
1001 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1002 Effect.catch(() => Effect.succeed(new Map<string, number>())),
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 },
1134 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>
1135 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1136 Effect.map((stdout) => stdout.trim()),
1137 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
10121138 );
10131139
1014 if (localBranchResult.code !== 0) {
1015 const stderr = localBranchResult.stderr.trim();
1016 if (stderr.toLowerCase().includes("not a git repository")) {
1017 return { branches: [], isRepo: false, hasOriginRemote: false };
1018 }
1019 return yield* createGitCommandError(
1020 "GitCore.listBranches",
1140 const listBranches: GitCoreShape["listBranches"] = (input) =>
1141 Effect.gen(function* () {
1142 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1143 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1144 );
1145 const localBranchResult = yield* executeGit(
1146 "GitCore.listBranches.branchNoColor",
10211147 input.cwd,
10221148 ["branch", "--no-color"],
1023 stderr || "git branch failed",
1149 {
1150 timeoutMs: 10_000,
1151 allowNonZeroExit: true,
1152 },
10241153 );
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(
1044 "GitCore.listBranches.remoteNames",
1045 input.cwd,
1046 ["remote"],
1047 {
1048 timeoutMs: 5_000,
1049 allowNonZeroExit: true,
1050 },
1051 ).pipe(
1052 Effect.catch((error) =>
1053 Effect.logWarning(
1054 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1055 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1056 ),
1057 );
1155 if (localBranchResult.code !== 0) {
1156 const stderr = localBranchResult.stderr.trim();
1157 if (stderr.toLowerCase().includes("not a git repository")) {
1158 return { branches: [], isRepo: false, hasOriginRemote: false };
1159 }
1160 return yield* createGitCommandError(
1161 "GitCore.listBranches",
1162 input.cwd,
1163 ["branch", "--no-color"],
1164 stderr || "git branch failed",
1165 );
1166 }
10581167
1059 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1060 yield* Effect.all(
1061 [
1062 executeGit(
1063 "GitCore.listBranches.defaultRef",
1064 input.cwd,
1065 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1066 {
1067 timeoutMs: 5_000,
1068 allowNonZeroExit: true,
1069 },
1070 ),
1071 executeGit(
1072 "GitCore.listBranches.worktreeList",
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" },
1168 const remoteBranchResultEffect = executeGit(
1169 "GitCore.listBranches.remoteBranches",
1170 input.cwd,
1171 ["branch", "--no-color", "--remotes"],
1172 {
1173 timeoutMs: 10_000,
1174 allowNonZeroExit: true,
1175 },
1176 ).pipe(
1177 Effect.catch((error) =>
1178 Effect.logWarning(
1179 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1180 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1181 ),
10851182 );
10861183
1087 const remoteNames =
1088 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1089 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1090 yield* Effect.logWarning(
1091 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1092 );
1093 }
1094 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1095 yield* Effect.logWarning(
1096 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1184 const remoteNamesResultEffect = executeGit(
1185 "GitCore.listBranches.remoteNames",
1186 input.cwd,
1187 ["remote"],
1188 {
1189 timeoutMs: 5_000,
1190 allowNonZeroExit: true,
1191 },
1192 ).pipe(
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 ),
10971198 );
1098 }
10991199
1100 const defaultBranch =
1101 defaultRef.code === 0
1102 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1103 : null;
1200 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1201 yield* Effect.all(
1202 [
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>();
1106 if (worktreeList.code === 0) {
1107 let currentPath: string | null = null;
1108 for (const line of worktreeList.stdout.split("\n")) {
1109 if (line.startsWith("worktree ")) {
1110 const candidatePath = line.slice("worktree ".length);
1111 const exists = yield* fileSystem.stat(candidatePath).pipe(
1112 Effect.map(() => true),
1113 Effect.catch(() => Effect.succeed(false)),
1114 );
1115 currentPath = exists ? candidatePath : null;
1116 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1117 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1118 } else if (line === "") {
1119 currentPath = null;
1228 const remoteNames =
1229 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1230 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1231 yield* Effect.logWarning(
1232 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1233 );
1234 }
1235 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1236 yield* Effect.logWarning(
1237 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1238 );
1239 }
1240
1241 const defaultBranch =
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 }
11201262 }
11211263 }
1122 }
11231264
1124 const localBranches = localBranchResult.stdout
1125 .split("\n")
1126 .map(parseBranchLine)
1127 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1128 .map((branch) => ({
1129 name: branch.name,
1130 current: branch.current,
1131 isRemote: false,
1132 isDefault: branch.name === defaultBranch,
1133 worktreePath: worktreeMap.get(branch.name) ?? null,
1134 }))
1135 .toSorted((a, b) => {
1136 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1137 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1138 if (aPriority !== bPriority) return aPriority - bPriority;
1139
1140 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1141 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1142 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1143 return a.name.localeCompare(b.name);
1144 });
1265 const localBranches = localBranchResult.stdout
1266 .split("\n")
1267 .map(parseBranchLine)
1268 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1269 .map((branch) => ({
1270 name: branch.name,
1271 current: branch.current,
1272 isRemote: false,
1273 isDefault: branch.name === defaultBranch,
1274 worktreePath: worktreeMap.get(branch.name) ?? null,
1275 }))
1276 .toSorted((a, b) => {
1277 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1278 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1279 if (aPriority !== bPriority) return aPriority - bPriority;
1280
1281 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1282 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1283 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1284 return a.name.localeCompare(b.name);
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 =
1147 remoteBranchResult.code === 0
1148 ? remoteBranchResult.stdout
1149 .split("\n")
1150 .map(parseBranchLine)
1151 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1152 .map((branch) => {
1153 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1154 const remoteBranch: {
1155 name: string;
1156 current: boolean;
1157 isRemote: boolean;
1158 remoteName?: string;
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 });
1327 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
1328 Effect.gen(function* () {
1329 const targetBranch = input.newBranch ?? input.branch;
1330 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1331 const repoName = path.basename(input.cwd);
1332 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1333 const args = input.newBranch
1334 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1335 : ["worktree", "add", worktreePath, input.branch];
1336
1337 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1338 fallbackErrorMessage: "git worktree add failed",
1339 });
11851340
1186 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
1187 Effect.gen(function* () {
1188 const targetBranch = input.newBranch ?? input.branch;
1189 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1190 const repoName = path.basename(input.cwd);
1191 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
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",
1341 return {
1342 worktree: {
1343 path: worktreePath,
1344 branch: targetBranch,
1345 },
1346 };
11981347 });
11991348
1200 return {
1201 worktree: {
1202 path: worktreePath,
1203 branch: targetBranch,
1204 },
1205 };
1206 });
1349 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) =>
1350 Effect.gen(function* () {
1351 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1352 yield* executeGit(
1353 "GitCore.fetchPullRequestBranch",
1354 input.cwd,
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) =>
1209 Effect.gen(function* () {
1210 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1211 yield* executeGit(
1212 "GitCore.fetchPullRequestBranch",
1213 input.cwd,
1214 [
1368 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>
1369 Effect.gen(function* () {
1370 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
12151371 "fetch",
12161372 "--quiet",
12171373 "--no-tags",
1218 remoteName,
1219 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1220 ],
1221 {
1222 fallbackErrorMessage: "git fetch pull request branch failed",
1223 },
1224 );
1225 }).pipe(Effect.asVoid);
1374 input.remoteName,
1375 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1376 ]);
12261377
1227 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>
1228 Effect.gen(function* () {
1229 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1230 "fetch",
1231 "--quiet",
1232 "--no-tags",
1233 input.remoteName,
1234 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1378 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1379 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1380 yield* runGit(
1381 "GitCore.fetchRemoteBranch.materialize",
1382 input.cwd,
1383 localBranchAlreadyExists
1384 ? ["branch", "--force", input.localBranch, targetRef]
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,
12351395 ]);
12361396
1237 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1238 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1239 yield* runGit(
1240 "GitCore.fetchRemoteBranch.materialize",
1241 input.cwd,
1242 localBranchAlreadyExists
1243 ? ["branch", "--force", input.localBranch, targetRef]
1244 : ["branch", input.localBranch, targetRef],
1245 );
1246 }).pipe(Effect.asVoid);
1247
1248 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1249 runGit("GitCore.setBranchUpstream", input.cwd, [
1250 "branch",
1251 "--set-upstream-to",
1252 `${input.remoteName}/${input.remoteBranch}`,
1253 input.branch,
1254 ]);
1255
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,
1397 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
1398 Effect.gen(function* () {
1399 const args = ["worktree", "remove"];
1400 if (input.force) {
1401 args.push("--force");
1402 }
1403 args.push(input.path);
1404 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
1405 timeoutMs: 15_000,
1406 fallbackErrorMessage: "git worktree remove failed",
1407 }).pipe(
1408 Effect.mapError((error) =>
1409 createGitCommandError(
1410 "GitCore.removeWorktree",
1411 input.cwd,
1412 args,
1413 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
1414 error,
1415 ),
12741416 ),
1275 ),
1276 );
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);
1417 );
1418 });
12851419
1286 yield* executeGit(
1287 "GitCore.renameBranch",
1288 input.cwd,
1289 ["branch", "-m", "--", input.oldBranch, targetBranch],
1290 {
1291 timeoutMs: 10_000,
1292 fallbackErrorMessage: "git branch rename failed",
1293 },
1294 );
1420 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
1421 Effect.gen(function* () {
1422 if (input.oldBranch === input.newBranch) {
1423 return { branch: input.newBranch };
1424 }
1425 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
12951426
1296 return { branch: targetBranch };
1297 });
1427 yield* executeGit(
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) =>
1300 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1301 timeoutMs: 10_000,
1302 fallbackErrorMessage: "git branch create failed",
1303 }).pipe(Effect.asVoid);
1437 return { branch: targetBranch };
1438 });
13041439
1305 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
1306 Effect.gen(function* () {
1307 const [localInputExists, remoteExists] = yield* Effect.all(
1308 [
1309 executeGit(
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 );
1440 const createBranch: GitCoreShape["createBranch"] = (input) =>
1441 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1442 timeoutMs: 10_000,
1443 fallbackErrorMessage: "git branch create failed",
1444 }).pipe(Effect.asVoid);
13301445
1331 const localTrackingBranch = remoteExists
1332 ? yield* executeGit(
1333 "GitCore.checkoutBranch.localTrackingBranch",
1334 input.cwd,
1335 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
1336 {
1337 timeoutMs: 5_000,
1338 allowNonZeroExit: true,
1339 },
1340 ).pipe(
1341 Effect.map((result) =>
1342 result.code === 0
1343 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
1344 : null,
1345 ),
1346 )
1347 : null;
1446 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
1447 Effect.gen(function* () {
1448 const [localInputExists, remoteExists] = yield* Effect.all(
1449 [
1450 executeGit(
1451 "GitCore.checkoutBranch.localInputExists",
1452 input.cwd,
1453 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1454 {
1455 timeoutMs: 5_000,
1456 allowNonZeroExit: true,
1457 },
1458 ).pipe(Effect.map((result) => result.code === 0)),
1459 executeGit(
1460 "GitCore.checkoutBranch.remoteExists",
1461 input.cwd,
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);
1350 const localTrackedBranchTargetExists =
1351 remoteExists && localTrackedBranchCandidate
1472 const localTrackingBranch = remoteExists
13521473 ? yield* executeGit(
1353 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
1474 "GitCore.checkoutBranch.localTrackingBranch",
13541475 input.cwd,
1355 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
1476 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
13561477 {
13571478 timeoutMs: 5_000,
13581479 allowNonZeroExit: true,
13591480 },
1360 ).pipe(Effect.map((result) => result.code === 0))
1361 : false;
1481 ).pipe(
1482 Effect.map((result) =>
1483 result.code === 0
1484 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
1485 : null,
1486 ),
1487 )
1488 : null;
13621489
1363 const checkoutArgs = localInputExists
1364 ? ["checkout", input.branch]
1365 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
1490 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
1491 const localTrackedBranchTargetExists =
1492 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
13661505 ? ["checkout", input.branch]
1367 : remoteExists && !localTrackingBranch
1368 ? ["checkout", "--track", input.branch]
1369 : remoteExists && localTrackingBranch
1370 ? ["checkout", localTrackingBranch]
1371 : ["checkout", input.branch];
1506 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
1507 ? ["checkout", input.branch]
1508 : remoteExists && !localTrackingBranch
1509 ? ["checkout", "--track", 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, {
1374 timeoutMs: 10_000,
1375 fallbackErrorMessage: "git checkout failed",
1519 // Refresh upstream refs in the background so checkout remains responsive.
1520 yield* Effect.forkScoped(
1521 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1522 );
13761523 });
13771524
1378 // Refresh upstream refs in the background so checkout remains responsive.
1379 yield* Effect.forkScoped(
1380 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1525 const initRepo: GitCoreShape["initRepo"] = (input) =>
1526 executeGit("GitCore.initRepo", input.cwd, ["init"], {
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 ),
13811543 );
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 {
1405 status,
1406 statusDetails,
1407 prepareCommitContext,
1408 commit,
1409 pushCurrentBranch,
1410 pullCurrentBranch,
1411 readRangeContext,
1412 readConfigValue,
1413 listBranches,
1414 createWorktree,
1415 fetchPullRequestBranch,
1416 ensureRemote,
1417 fetchRemoteBranch,
1418 setBranchUpstream,
1419 removeWorktree,
1420 renameBranch,
1421 createBranch,
1422 checkoutBranch,
1423 initRepo,
1424 listLocalBranchNames,
1425 } satisfies GitCoreShape;
1426});
1545 return {
1546 execute,
1547 status,
1548 statusDetails,
1549 prepareCommitContext,
1550 commit,
1551 pushCurrentBranch,
1552 pullCurrentBranch,
1553 readRangeContext,
1554 readConfigValue,
1555 listBranches,
1556 createWorktree,
1557 fetchPullRequestBranch,
1558 ensureRemote,
1559 fetchRemoteBranch,
1560 setBranchUpstream,
1561 removeWorktree,
1562 renameBranch,
1563 createBranch,
1564 checkoutBranch,
1565 initRepo,
1566 listLocalBranchNames,
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 {
1515 GitHubCli,
1616} from "../Services/GitHubCli.ts";
1717import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
18import { GitServiceLive } from "./GitService.ts";
19import { GitService } from "../Services/GitService.ts";
2018import { GitCoreLive } from "./GitCore.ts";
19import { GitCore } from "../Services/GitCore.ts";
2120import { makeGitManager } from "./GitManager.ts";
2221import { ServerConfig } from "../../config.ts";
2322
......@@ -107,11 +106,11 @@ function runGit(
107106): Effect.Effect<
108107 { readonly code: number; readonly stdout: string; readonly stderr: string },
109108 GitCommandError,
110 GitService
109 GitCore
111110> {
112111 return Effect.gen(function* () {
113 const gitService = yield* GitService;
114 return yield* gitService.execute({
112 const gitCore = yield* GitCore;
113 return yield* gitCore.execute({
115114 operation: "GitManager.test.runGit",
116115 cwd,
117116 args,
......@@ -125,7 +124,7 @@ function initRepo(
125124): Effect.Effect<
126125 void,
127126 PlatformError.PlatformError | GitCommandError,
128 FileSystem.FileSystem | Scope.Scope | GitService
127 FileSystem.FileSystem | Scope.Scope | GitCore
129128> {
130129 return Effect.gen(function* () {
131130 const fs = yield* FileSystem.FileSystem;
......@@ -141,7 +140,7 @@ function initRepo(
141140function createBareRemote(): Effect.Effect<
142141 string,
143142 PlatformError.PlatformError | GitCommandError,
144 FileSystem.FileSystem | Scope.Scope | GitService
143 FileSystem.FileSystem | Scope.Scope | GitCore
145144> {
146145 return Effect.gen(function* () {
147146 const remoteDir = yield* makeTempDir("t3code-git-remote-");
......@@ -480,7 +479,6 @@ function makeManager(input?: {
480479 });
481480
482481 const gitCoreLayer = GitCoreLive.pipe(
483 Layer.provideMerge(GitServiceLive),
484482 Layer.provideMerge(NodeServices.layer),
485483 Layer.provideMerge(ServerConfigLayer),
486484 );
......@@ -497,7 +495,10 @@ function makeManager(input?: {
497495 );
498496}
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
502503it.layer(GitManagerTestLayer)("GitManager", (it) => {
503504 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 {
2424
2525import 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
2743export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
2844 upstreamRef: string | null;
2945}
......@@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput {
86102 * GitCoreShape - Service API for low-level Git repository interactions.
87103 */
88104export interface GitCoreShape {
105 /**
106 * Execute a raw Git command.
107 */
108 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
109
89110 /**
90111 * Read Git status for a repository.
91112 */
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";
1919
2020import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
22import { GitCoreLive } from "../../git/Layers/GitCore.ts";
2223import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2324import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2425import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
......@@ -260,7 +261,7 @@ describe("CheckpointReactor", () => {
260261 Layer.provideMerge(orchestrationLayer),
261262 Layer.provideMerge(RuntimeReceiptBusLive),
262263 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
263 Layer.provideMerge(CheckpointStoreLive),
264 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))),
264265 Layer.provideMerge(ServerConfigLayer),
265266 Layer.provideMerge(NodeServices.layer),
266267 );
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";
3131import { GitCoreLive } from "./git/Layers/GitCore";
3232import { GitHubCliLive } from "./git/Layers/GitHubCli";
3333import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
34import { GitServiceLive } from "./git/Layers/GitService";
3534import { PtyAdapter } from "./terminal/Services/PTY";
3635import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3736
......@@ -86,8 +85,8 @@ export function makeServerProviderLayer(): Layer.Layer<
8685}
8786
8887export function makeServerRuntimeServicesLayer() {
89 const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
9088 const textGenerationLayer = CodexTextGenerationLive;
89 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
9190
9291 const orchestrationLayer = OrchestrationEngineLive.pipe(
9392 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -97,13 +96,13 @@ export function makeServerRuntimeServicesLayer() {
9796
9897 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
9998 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
100 Layer.provideMerge(CheckpointStoreLive),
99 Layer.provideMerge(checkpointStoreLayer),
101100 );
102101
103102 const runtimeServicesLayer = Layer.mergeAll(
104103 orchestrationLayer,
105104 OrchestrationProjectionSnapshotQueryLive,
106 CheckpointStoreLive,
105 checkpointStoreLayer,
107106 checkpointDiffQueryLayer,
108107 RuntimeReceiptBusLive,
109108 );
......@@ -112,7 +111,7 @@ export function makeServerRuntimeServicesLayer() {
112111 );
113112 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
114113 Layer.provideMerge(runtimeServicesLayer),
115 Layer.provideMerge(gitCoreLayer),
114 Layer.provideMerge(GitCoreLive),
116115 Layer.provideMerge(textGenerationLayer),
117116 );
118117 const checkpointReactorLayer = CheckpointReactorLive.pipe(
......@@ -127,14 +126,14 @@ export function makeServerRuntimeServicesLayer() {
127126 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
128127
129128 const gitManagerLayer = GitManagerLive.pipe(
130 Layer.provideMerge(gitCoreLayer),
129 Layer.provideMerge(GitCoreLive),
131130 Layer.provideMerge(GitHubCliLive),
132131 Layer.provideMerge(textGenerationLayer),
133132 );
134133
135134 return Layer.mergeAll(
136135 orchestrationReactorLayer,
137 gitCoreLayer,
136 GitCoreLive,
138137 gitManagerLayer,
139138 terminalLayer,
140139 KeybindingsLive,
apps/web/package.json+1-1
......@@ -51,7 +51,7 @@
5151 "@vitejs/plugin-react": "^6.0.0",
5252 "@vitest/browser-playwright": "^4.0.18",
5353 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
54 "msw": "^2.12.10",
54 "msw": "2.12.11",
5555 "playwright": "^1.58.2",
5656 "tailwindcss": "^4.0.0",
5757 "typescript": "catalog:",
apps/web/public/mockServiceWorker.js+1-1
......@@ -7,7 +7,7 @@
77 * - Please do NOT modify this file.
88 */
99
10const PACKAGE_VERSION = '2.12.10'
10const PACKAGE_VERSION = '2.12.11'
1111const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
1212const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
1313const activeClientIds = new Set()
bun.lock+1-1
......@@ -112,7 +112,7 @@
112112 "@vitejs/plugin-react": "^6.0.0",
113113 "@vitest/browser-playwright": "^4.0.18",
114114 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
115 "msw": "^2.12.10",
115 "msw": "2.12.11",
116116 "playwright": "^1.58.2",
117117 "tailwindcss": "^4.0.0",
118118 "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();