authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-28 22:35:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 22:35:18-07:00
log7f3387a86cf6ed2e9bb7318db56b1ab19de80e69
tree2feade6c1fc9245e4c0dd30a75487025e14ffa65
parent5462d501774a25b8cc16a572d6e6b44dc54bc796
signature Signed by PGP key B5690EEEBB952194

Annotate Git core Effect.fn helpers (#1511)


4 files changed, 1928 insertions(+), 1916 deletions(-)

apps/server/src/git/Layers/GitCore.ts+1321-1347
...@@ -294,7 +294,7 @@ function trace2ChildKey(record: Record<string, unknown>): string | null {...@@ -294,7 +294,7 @@ function trace2ChildKey(record: Record<string, unknown>): string | null {
294294
295const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);295const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
296296
297const createTrace2Monitor = Effect.fn(function* (297const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
298 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,298 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
299 progress: ExecuteGitProgress | undefined,299 progress: ExecuteGitProgress | undefined,
300): Effect.fn.Return<300): Effect.fn.Return<
...@@ -321,61 +321,58 @@ const createTrace2Monitor = Effect.fn(function* (...@@ -321,61 +321,58 @@ const createTrace2Monitor = Effect.fn(function* (
321 remainder: "",321 remainder: "",
322 });322 });
323323
324 const handleTraceLine = (line: string) =>324 const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) {
325 Effect.gen(function* () {325 const trimmedLine = line.trim();
326 const trimmedLine = line.trim();326 if (trimmedLine.length === 0) {
327 if (trimmedLine.length === 0) {327 return;
328 return;328 }
329 }
330329
331 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);330 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
332 if (Result.isFailure(traceRecord)) {331 if (Result.isFailure(traceRecord)) {
333 yield* Effect.logDebug(332 yield* Effect.logDebug(
334 `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,333 `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
335 traceRecord.failure,334 traceRecord.failure,
336 );335 );
337 return;336 return;
338 }337 }
339338
340 if (traceRecord.success.child_class !== "hook") {339 if (traceRecord.success.child_class !== "hook") {
341 return;340 return;
342 }341 }
343342
344 const event = traceRecord.success.event;343 const event = traceRecord.success.event;
345 const childKey = trace2ChildKey(traceRecord.success);344 const childKey = trace2ChildKey(traceRecord.success);
346 if (childKey === null) {345 if (childKey === null) {
347 return;346 return;
348 }347 }
349 const started = hookStartByChildKey.get(childKey);348 const started = hookStartByChildKey.get(childKey);
350 const hookNameFromEvent =349 const hookNameFromEvent =
351 typeof traceRecord.success.hook_name === "string"350 typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : "";
352 ? traceRecord.success.hook_name.trim()351 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
353 : "";352 if (hookName.length === 0) {
354 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");353 return;
355 if (hookName.length === 0) {354 }
356 return;
357 }
358355
359 if (event === "child_start") {356 if (event === "child_start") {
360 hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() });357 hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() });
361 if (progress.onHookStarted) {358 if (progress.onHookStarted) {
362 yield* progress.onHookStarted(hookName);359 yield* progress.onHookStarted(hookName);
363 }
364 return;
365 }360 }
361 return;
362 }
366363
367 if (event === "child_exit") {364 if (event === "child_exit") {
368 hookStartByChildKey.delete(childKey);365 hookStartByChildKey.delete(childKey);
369 if (progress.onHookFinished) {366 if (progress.onHookFinished) {
370 const code = traceRecord.success.code;367 const code = traceRecord.success.code;
371 yield* progress.onHookFinished({368 yield* progress.onHookFinished({
372 hookName: started?.hookName ?? hookName,369 hookName: started?.hookName ?? hookName,
373 exitCode: typeof code === "number" && Number.isInteger(code) ? code : null,370 exitCode: typeof code === "number" && Number.isInteger(code) ? code : null,
374 durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null,371 durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null,
375 });372 });
376 }
377 }373 }
378 });374 }
375 });
379376
380 const deltaMutex = yield* Semaphore.make(1);377 const deltaMutex = yield* Semaphore.make(1);
381 const readTraceDelta = deltaMutex.withPermit(378 const readTraceDelta = deltaMutex.withPermit(
...@@ -418,21 +415,21 @@ const createTrace2Monitor = Effect.fn(function* (...@@ -418,21 +415,21 @@ const createTrace2Monitor = Effect.fn(function* (
418 return readTraceDelta;415 return readTraceDelta;
419 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);416 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
420417
421 yield* Effect.addFinalizer(() =>418 const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () {
422 Effect.gen(function* () {419 yield* readTraceDelta;
423 yield* readTraceDelta;420 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
424 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [421 remainder.trim(),
425 remainder.trim(),422 {
426 {423 processedChars,
427 processedChars,424 remainder: "",
428 remainder: "",425 },
429 },426 ]);
430 ]);427 if (finalLine.length > 0) {
431 if (finalLine.length > 0) {428 yield* handleTraceLine(finalLine);
432 yield* handleTraceLine(finalLine);429 }
433 }430 });
434 }),431
435 );432 yield* Effect.addFinalizer(finalizeTrace2Monitor);
436433
437 return {434 return {
438 env: {435 env: {
...@@ -442,7 +439,7 @@ const createTrace2Monitor = Effect.fn(function* (...@@ -442,7 +439,7 @@ const createTrace2Monitor = Effect.fn(function* (
442 };439 };
443});440});
444441
445const collectOutput = Effect.fn(function* <E>(442const collectOutput = Effect.fn("collectOutput")(function* <E>(
446 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,443 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
447 stream: Stream.Stream<Uint8Array, E>,444 stream: Stream.Stream<Uint8Array, E>,
448 maxOutputBytes: number,445 maxOutputBytes: number,
...@@ -455,55 +452,56 @@ const collectOutput = Effect.fn(function* <E>(...@@ -455,55 +452,56 @@ const collectOutput = Effect.fn(function* <E>(
455 let lineBuffer = "";452 let lineBuffer = "";
456 let truncated = false;453 let truncated = false;
457454
458 const emitCompleteLines = (flush: boolean) =>455 const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) {
459 Effect.gen(function* () {456 let newlineIndex = lineBuffer.indexOf("\n");
460 let newlineIndex = lineBuffer.indexOf("\n");457 while (newlineIndex >= 0) {
461 while (newlineIndex >= 0) {458 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
462 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");459 lineBuffer = lineBuffer.slice(newlineIndex + 1);
463 lineBuffer = lineBuffer.slice(newlineIndex + 1);460 if (line.length > 0 && onLine) {
464 if (line.length > 0 && onLine) {461 yield* onLine(line);
465 yield* onLine(line);
466 }
467 newlineIndex = lineBuffer.indexOf("\n");
468 }462 }
463 newlineIndex = lineBuffer.indexOf("\n");
464 }
469465
470 if (flush) {466 if (flush) {
471 const trailing = lineBuffer.replace(/\r$/, "");467 const trailing = lineBuffer.replace(/\r$/, "");
472 lineBuffer = "";468 lineBuffer = "";
473 if (trailing.length > 0 && onLine) {469 if (trailing.length > 0 && onLine) {
474 yield* onLine(trailing);470 yield* onLine(trailing);
475 }
476 }471 }
477 });472 }
473 });
478474
479 yield* Stream.runForEach(stream, (chunk) =>475 const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) {
480 Effect.gen(function* () {476 if (truncateOutputAtMaxBytes && truncated) {
481 if (truncateOutputAtMaxBytes && truncated) {477 return;
482 return;478 }
483 }479 const nextBytes = bytes + chunk.byteLength;
484 const nextBytes = bytes + chunk.byteLength;480 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
485 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {481 return yield* new GitCommandError({
486 return yield* new GitCommandError({482 operation: input.operation,
487 operation: input.operation,483 command: quoteGitCommand(input.args),
488 command: quoteGitCommand(input.args),484 cwd: input.cwd,
489 cwd: input.cwd,485 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
490 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,486 });
491 });487 }
492 }
493488
494 const chunkToDecode =489 const chunkToDecode =
495 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes490 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
496 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))491 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
497 : chunk;492 : chunk;
498 bytes += chunkToDecode.byteLength;493 bytes += chunkToDecode.byteLength;
499 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;494 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;
495
496 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
497 text += decoded;
498 lineBuffer += decoded;
499 yield* emitCompleteLines(false);
500 });
500501
501 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });502 yield* Stream.runForEach(stream, processChunk).pipe(
502 text += decoded;503 Effect.mapError(toGitCommandError(input, "output stream failed.")),
503 lineBuffer += decoded;504 );
504 yield* emitCompleteLines(false);
505 }),
506 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
507505
508 const remainder = truncated ? "" : decoder.decode();506 const remainder = truncated ? "" : decoder.decode();
509 text += remainder;507 text += remainder;
...@@ -512,1367 +510,1343 @@ const collectOutput = Effect.fn(function* <E>(...@@ -512,1367 +510,1343 @@ const collectOutput = Effect.fn(function* <E>(
512 return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text;510 return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text;
513});511});
514512
515export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) =>513export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
516 Effect.gen(function* () {514 executeOverride?: GitCoreShape["execute"];
517 const fileSystem = yield* FileSystem.FileSystem;515}) {
518 const path = yield* Path.Path;516 const fileSystem = yield* FileSystem.FileSystem;
519 const { worktreesDir } = yield* ServerConfig;517 const path = yield* Path.Path;
520518 const { worktreesDir } = yield* ServerConfig;
521 let execute: GitCoreShape["execute"];519
522520 let execute: GitCoreShape["execute"];
523 if (options?.executeOverride) {521
524 execute = options.executeOverride;522 if (options?.executeOverride) {
525 } else {523 execute = options.executeOverride;
526 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;524 } else {
527 execute = Effect.fnUntraced(function* (input) {525 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
528 const commandInput = {526 execute = Effect.fnUntraced(function* (input) {
529 ...input,527 const commandInput = {
530 args: [...input.args],528 ...input,
531 } as const;529 args: [...input.args],
532 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;530 } as const;
533 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;531 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
534 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;532 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
535533 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
536 const commandEffect = Effect.gen(function* () {534
537 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(535 const runGitCommand = Effect.fn("runGitCommand")(function* () {
538 Effect.provideService(Path.Path, path),536 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
539 Effect.provideService(FileSystem.FileSystem, fileSystem),537 Effect.provideService(Path.Path, path),
540 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),538 Effect.provideService(FileSystem.FileSystem, fileSystem),
541 );539 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
542 const child = yield* commandSpawner540 );
543 .spawn(541 const child = yield* commandSpawner
544 ChildProcess.make("git", commandInput.args, {542 .spawn(
545 cwd: commandInput.cwd,543 ChildProcess.make("git", commandInput.args, {
546 env: {
547 ...process.env,
548 ...input.env,
549 ...trace2Monitor.env,
550 },
551 }),
552 )
553 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
554
555 const [stdout, stderr, exitCode] = yield* Effect.all(
556 [
557 collectOutput(
558 commandInput,
559 child.stdout,
560 maxOutputBytes,
561 truncateOutputAtMaxBytes,
562 input.progress?.onStdoutLine,
563 ),
564 collectOutput(
565 commandInput,
566 child.stderr,
567 maxOutputBytes,
568 truncateOutputAtMaxBytes,
569 input.progress?.onStderrLine,
570 ),
571 child.exitCode.pipe(
572 Effect.map((value) => Number(value)),
573 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
574 ),
575 ],
576 { concurrency: "unbounded" },
577 );
578 yield* trace2Monitor.flush;
579
580 if (!input.allowNonZeroExit && exitCode !== 0) {
581 const trimmedStderr = stderr.trim();
582 return yield* new GitCommandError({
583 operation: commandInput.operation,
584 command: quoteGitCommand(commandInput.args),
585 cwd: commandInput.cwd,544 cwd: commandInput.cwd,
586 detail:545 env: {
587 trimmedStderr.length > 0546 ...process.env,
588 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`547 ...input.env,
589 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,548 ...trace2Monitor.env,
590 });549 },
591 }
592
593 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
594 });
595
596 return yield* commandEffect.pipe(
597 Effect.scoped,
598 Effect.timeoutOption(timeoutMs),
599 Effect.flatMap((result) =>
600 Option.match(result, {
601 onNone: () =>
602 Effect.fail(
603 new GitCommandError({
604 operation: commandInput.operation,
605 command: quoteGitCommand(commandInput.args),
606 cwd: commandInput.cwd,
607 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
608 }),
609 ),
610 onSome: Effect.succeed,
611 }),550 }),
612 ),551 )
613 );552 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
614 });
615 }
616553
617 const executeGit = (554 const [stdout, stderr, exitCode] = yield* Effect.all(
618 operation: string,555 [
619 cwd: string,556 collectOutput(
620 args: readonly string[],557 commandInput,
621 options: ExecuteGitOptions = {},558 child.stdout,
622 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>559 maxOutputBytes,
623 execute({560 truncateOutputAtMaxBytes,
624 operation,561 input.progress?.onStdoutLine,
625 cwd,
626 args,
627 allowNonZeroExit: true,
628 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
629 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
630 ...(options.truncateOutputAtMaxBytes !== undefined
631 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
632 : {}),
633 ...(options.progress ? { progress: options.progress } : {}),
634 }).pipe(
635 Effect.flatMap((result) => {
636 if (options.allowNonZeroExit || result.code === 0) {
637 return Effect.succeed(result);
638 }
639 const stderr = result.stderr.trim();
640 if (stderr.length > 0) {
641 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
642 }
643 if (options.fallbackErrorMessage) {
644 return Effect.fail(
645 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
646 );
647 }
648 return Effect.fail(
649 createGitCommandError(
650 operation,
651 cwd,
652 args,
653 `${commandLabel(args)} failed: code=${result.code ?? "null"}`,
654 ),562 ),
655 );563 collectOutput(
656 }),564 commandInput,
657 );565 child.stderr,
566 maxOutputBytes,
567 truncateOutputAtMaxBytes,
568 input.progress?.onStderrLine,
569 ),
570 child.exitCode.pipe(
571 Effect.map((value) => Number(value)),
572 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
573 ),
574 ],
575 { concurrency: "unbounded" },
576 );
577 yield* trace2Monitor.flush;
578
579 if (!input.allowNonZeroExit && exitCode !== 0) {
580 const trimmedStderr = stderr.trim();
581 return yield* new GitCommandError({
582 operation: commandInput.operation,
583 command: quoteGitCommand(commandInput.args),
584 cwd: commandInput.cwd,
585 detail:
586 trimmedStderr.length > 0
587 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
588 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
589 });
590 }
658591
659 const runGit = (592 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
660 operation: string,593 });
661 cwd: string,594
662 args: readonly string[],595 return yield* runGitCommand().pipe(
663 allowNonZeroExit = false,596 Effect.scoped,
664 ): Effect.Effect<void, GitCommandError> =>597 Effect.timeoutOption(timeoutMs),
665 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);598 Effect.flatMap((result) =>
666599 Option.match(result, {
667 const runGitStdout = (600 onNone: () =>
668 operation: string,601 Effect.fail(
669 cwd: string,602 new GitCommandError({
670 args: readonly string[],603 operation: commandInput.operation,
671 allowNonZeroExit = false,604 command: quoteGitCommand(commandInput.args),
672 ): Effect.Effect<string, GitCommandError> =>605 cwd: commandInput.cwd,
673 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(606 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
674 Effect.map((result) => result.stdout),607 }),
608 ),
609 onSome: Effect.succeed,
610 }),
611 ),
675 );612 );
613 });
614 }
676615
677 const runGitStdoutWithOptions = (616 const executeGit = (
678 operation: string,617 operation: string,
679 cwd: string,618 cwd: string,
680 args: readonly string[],619 args: readonly string[],
681 options: ExecuteGitOptions = {},620 options: ExecuteGitOptions = {},
682 ): Effect.Effect<string, GitCommandError> =>621 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>
683 executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout));622 execute({
684623 operation,
685 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>624 cwd,
686 executeGit(625 args,
687 "GitCore.branchExists",626 allowNonZeroExit: true,
688 cwd,627 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
689 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],628 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
690 {629 ...(options.truncateOutputAtMaxBytes !== undefined
691 allowNonZeroExit: true,630 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
692 timeoutMs: 5_000,631 : {}),
693 },632 ...(options.progress ? { progress: options.progress } : {}),
694 ).pipe(Effect.map((result) => result.code === 0));633 }).pipe(
695634 Effect.flatMap((result) => {
696 const resolveAvailableBranchName = (635 if (options.allowNonZeroExit || result.code === 0) {
697 cwd: string,636 return Effect.succeed(result);
698 desiredBranch: string,
699 ): Effect.Effect<string, GitCommandError> =>
700 Effect.gen(function* () {
701 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
702 if (!isDesiredTaken) {
703 return desiredBranch;
704 }637 }
705638 const stderr = result.stderr.trim();
706 for (let suffix = 1; suffix <= 100; suffix += 1) {639 if (stderr.length > 0) {
707 const candidate = `${desiredBranch}-${suffix}`;640 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
708 const isCandidateTaken = yield* branchExists(cwd, candidate);
709 if (!isCandidateTaken) {
710 return candidate;
711 }
712 }641 }
713642 if (options.fallbackErrorMessage) {
714 return yield* createGitCommandError(643 return Effect.fail(
715 "GitCore.renameBranch",644 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
716 cwd,645 );
717 ["branch", "-m", "--", desiredBranch],646 }
718 `Could not find an available branch name for '${desiredBranch}'.`,647 return Effect.fail(
648 createGitCommandError(
649 operation,
650 cwd,
651 args,
652 `${commandLabel(args)} failed: code=${result.code ?? "null"}`,
653 ),
719 );654 );
720 });655 }),
656 );
657
658 const runGit = (
659 operation: string,
660 cwd: string,
661 args: readonly string[],
662 allowNonZeroExit = false,
663 ): Effect.Effect<void, GitCommandError> =>
664 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
665
666 const runGitStdout = (
667 operation: string,
668 cwd: string,
669 args: readonly string[],
670 allowNonZeroExit = false,
671 ): Effect.Effect<string, GitCommandError> =>
672 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
673 Effect.map((result) => result.stdout),
674 );
675
676 const runGitStdoutWithOptions = (
677 operation: string,
678 cwd: string,
679 args: readonly string[],
680 options: ExecuteGitOptions = {},
681 ): Effect.Effect<string, GitCommandError> =>
682 executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout));
683
684 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
685 executeGit(
686 "GitCore.branchExists",
687 cwd,
688 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
689 {
690 allowNonZeroExit: true,
691 timeoutMs: 5_000,
692 },
693 ).pipe(Effect.map((result) => result.code === 0));
694
695 const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* (
696 cwd: string,
697 desiredBranch: string,
698 ) {
699 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
700 if (!isDesiredTaken) {
701 return desiredBranch;
702 }
721703
722 const resolveCurrentUpstream = (704 for (let suffix = 1; suffix <= 100; suffix += 1) {
723 cwd: string,705 const candidate = `${desiredBranch}-${suffix}`;
724 ): Effect.Effect<706 const isCandidateTaken = yield* branchExists(cwd, candidate);
725 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,707 if (!isCandidateTaken) {
726 GitCommandError708 return candidate;
727 > =>709 }
728 Effect.gen(function* () {710 }
729 const upstreamRef = yield* runGitStdout(
730 "GitCore.resolveCurrentUpstream",
731 cwd,
732 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
733 true,
734 ).pipe(Effect.map((stdout) => stdout.trim()));
735711
736 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {712 return yield* createGitCommandError(
737 return null;713 "GitCore.renameBranch",
738 }714 cwd,
715 ["branch", "-m", "--", desiredBranch],
716 `Could not find an available branch name for '${desiredBranch}'.`,
717 );
718 });
739719
740 const separatorIndex = upstreamRef.indexOf("/");720 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
741 if (separatorIndex <= 0) {721 const upstreamRef = yield* runGitStdout(
742 return null;722 "GitCore.resolveCurrentUpstream",
743 }723 cwd,
744 const remoteName = upstreamRef.slice(0, separatorIndex);724 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
745 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);725 true,
746 if (remoteName.length === 0 || upstreamBranch.length === 0) {726 ).pipe(Effect.map((stdout) => stdout.trim()));
747 return null;
748 }
749727
750 return {728 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
751 upstreamRef,729 return null;
752 remoteName,730 }
753 upstreamBranch,
754 };
755 });
756731
757 const fetchUpstreamRef = (732 const separatorIndex = upstreamRef.indexOf("/");
758 cwd: string,733 if (separatorIndex <= 0) {
759 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },734 return null;
760 ): Effect.Effect<void, GitCommandError> => {735 }
761 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;736 const remoteName = upstreamRef.slice(0, separatorIndex);
762 return runGit(737 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
763 "GitCore.fetchUpstreamRef",738 if (remoteName.length === 0 || upstreamBranch.length === 0) {
764 cwd,739 return null;
765 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],740 }
766 true,
767 );
768 };
769741
770 const fetchUpstreamRefForStatus = (742 return {
771 cwd: string,743 upstreamRef,
772 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },744 remoteName,
773 ): Effect.Effect<void, GitCommandError> => {745 upstreamBranch,
774 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
775 return executeGit(
776 "GitCore.fetchUpstreamRefForStatus",
777 cwd,
778 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
779 {
780 allowNonZeroExit: true,
781 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
782 },
783 ).pipe(Effect.asVoid);
784 };746 };
747 });
785748
786 const statusUpstreamRefreshCache = yield* Cache.makeWith({749 const fetchUpstreamRef = (
787 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,750 cwd: string,
788 lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>751 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
789 Effect.gen(function* () {752 ): Effect.Effect<void, GitCommandError> => {
790 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {753 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
791 upstreamRef: cacheKey.upstreamRef,754 return runGit(
792 remoteName: cacheKey.remoteName,755 "GitCore.fetchUpstreamRef",
793 upstreamBranch: cacheKey.upstreamBranch,756 cwd,
794 });757 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
795 return true as const;758 true,
796 }),759 );
797 // Keep successful refreshes warm; drop failures immediately so next request can retry.760 };
798 timeToLive: (exit) =>
799 Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero,
800 });
801761
802 const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>762 const fetchUpstreamRefForStatus = (
803 Effect.gen(function* () {763 cwd: string,
804 const upstream = yield* resolveCurrentUpstream(cwd);764 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
805 if (!upstream) return;765 ): Effect.Effect<void, GitCommandError> => {
806 yield* Cache.get(766 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
807 statusUpstreamRefreshCache,767 return executeGit(
808 new StatusUpstreamRefreshCacheKey({768 "GitCore.fetchUpstreamRefForStatus",
809 cwd,769 cwd,
810 upstreamRef: upstream.upstreamRef,770 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
811 remoteName: upstream.remoteName,771 {
812 upstreamBranch: upstream.upstreamBranch,772 allowNonZeroExit: true,
813 }),773 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
814 );774 },
815 });775 ).pipe(Effect.asVoid);
776 };
816777
817 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>778 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
818 Effect.gen(function* () {779 cacheKey: StatusUpstreamRefreshCacheKey,
819 const upstream = yield* resolveCurrentUpstream(cwd);780 ) {
820 if (!upstream) return;781 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
821 yield* fetchUpstreamRef(cwd, upstream);782 upstreamRef: cacheKey.upstreamRef,
822 });783 remoteName: cacheKey.remoteName,
784 upstreamBranch: cacheKey.upstreamBranch,
785 });
786 return true as const;
787 });
823788
824 const resolveDefaultBranchName = (789 const statusUpstreamRefreshCache = yield* Cache.makeWith({
825 cwd: string,790 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
826 remoteName: string,791 lookup: refreshStatusUpstreamCacheEntry,
827 ): Effect.Effect<string | null, GitCommandError> =>792 // Keep successful refreshes warm; drop failures immediately so next request can retry.
828 executeGit(793 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),
829 "GitCore.resolveDefaultBranchName",794 });
830 cwd,
831 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
832 { allowNonZeroExit: true },
833 ).pipe(
834 Effect.map((result) => {
835 if (result.code !== 0) {
836 return null;
837 }
838 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
839 }),
840 );
841795
842 const remoteBranchExists = (796 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
843 cwd: string,797 cwd: string,
844 remoteName: string,798 ) {
845 branch: string,799 const upstream = yield* resolveCurrentUpstream(cwd);
846 ): Effect.Effect<boolean, GitCommandError> =>800 if (!upstream) return;
847 executeGit(801 yield* Cache.get(
848 "GitCore.remoteBranchExists",802 statusUpstreamRefreshCache,
803 new StatusUpstreamRefreshCacheKey({
849 cwd,804 cwd,
850 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],805 upstreamRef: upstream.upstreamRef,
851 {806 remoteName: upstream.remoteName,
852 allowNonZeroExit: true,807 upstreamBranch: upstream.upstreamBranch,
853 },808 }),
854 ).pipe(Effect.map((result) => result.code === 0));809 );
855810 });
856 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
857 executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
858 allowNonZeroExit: true,
859 }).pipe(Effect.map((result) => result.code === 0));
860
861 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
862 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
863 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
864 );
865811
866 const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> =>812 const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* (
867 Effect.gen(function* () {813 cwd: string,
868 if (yield* originRemoteExists(cwd)) {814 ) {
869 return "origin";815 const upstream = yield* resolveCurrentUpstream(cwd);
870 }816 if (!upstream) return;
871 const remotes = yield* listRemoteNames(cwd);817 yield* fetchUpstreamRef(cwd, upstream);
872 const [firstRemote] = remotes;818 });
873 if (firstRemote) {
874 return firstRemote;
875 }
876 return yield* createGitCommandError(
877 "GitCore.resolvePrimaryRemoteName",
878 cwd,
879 ["remote"],
880 "No git remote is configured for this repository.",
881 );
882 });
883819
884 const resolvePushRemoteName = (820 const resolveDefaultBranchName = (
885 cwd: string,821 cwd: string,
886 branch: string,822 remoteName: string,
887 ): Effect.Effect<string | null, GitCommandError> =>823 ): Effect.Effect<string | null, GitCommandError> =>
888 Effect.gen(function* () {824 executeGit(
889 const branchPushRemote = yield* runGitStdout(825 "GitCore.resolveDefaultBranchName",
890 "GitCore.resolvePushRemoteName.branchPushRemote",826 cwd,
891 cwd,827 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
892 ["config", "--get", `branch.${branch}.pushRemote`],828 { allowNonZeroExit: true },
893 true,829 ).pipe(
894 ).pipe(Effect.map((stdout) => stdout.trim()));830 Effect.map((result) => {
895 if (branchPushRemote.length > 0) {831 if (result.code !== 0) {
896 return branchPushRemote;832 return null;
897 }833 }
834 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
835 }),
836 );
837
838 const remoteBranchExists = (
839 cwd: string,
840 remoteName: string,
841 branch: string,
842 ): Effect.Effect<boolean, GitCommandError> =>
843 executeGit(
844 "GitCore.remoteBranchExists",
845 cwd,
846 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
847 {
848 allowNonZeroExit: true,
849 },
850 ).pipe(Effect.map((result) => result.code === 0));
851
852 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
853 executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
854 allowNonZeroExit: true,
855 }).pipe(Effect.map((result) => result.code === 0));
856
857 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
858 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
859 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
860 );
861
862 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
863 if (yield* originRemoteExists(cwd)) {
864 return "origin";
865 }
866 const remotes = yield* listRemoteNames(cwd);
867 const [firstRemote] = remotes;
868 if (firstRemote) {
869 return firstRemote;
870 }
871 return yield* createGitCommandError(
872 "GitCore.resolvePrimaryRemoteName",
873 cwd,
874 ["remote"],
875 "No git remote is configured for this repository.",
876 );
877 });
898878
899 const pushDefaultRemote = yield* runGitStdout(879 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
900 "GitCore.resolvePushRemoteName.remotePushDefault",880 cwd: string,
901 cwd,881 branch: string,
902 ["config", "--get", "remote.pushDefault"],882 ) {
903 true,883 const branchPushRemote = yield* runGitStdout(
904 ).pipe(Effect.map((stdout) => stdout.trim()));884 "GitCore.resolvePushRemoteName.branchPushRemote",
905 if (pushDefaultRemote.length > 0) {885 cwd,
906 return pushDefaultRemote;886 ["config", "--get", `branch.${branch}.pushRemote`],
907 }887 true,
888 ).pipe(Effect.map((stdout) => stdout.trim()));
889 if (branchPushRemote.length > 0) {
890 return branchPushRemote;
891 }
908892
909 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));893 const pushDefaultRemote = yield* runGitStdout(
910 });894 "GitCore.resolvePushRemoteName.remotePushDefault",
895 cwd,
896 ["config", "--get", "remote.pushDefault"],
897 true,
898 ).pipe(Effect.map((stdout) => stdout.trim()));
899 if (pushDefaultRemote.length > 0) {
900 return pushDefaultRemote;
901 }
911902
912 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>903 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
913 Effect.gen(function* () {904 });
914 const preferredName = sanitizeRemoteName(input.preferredName);
915 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
916 const remoteFetchUrls = yield* runGitStdout(
917 "GitCore.ensureRemote.listRemoteUrls",
918 input.cwd,
919 ["remote", "-v"],
920 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
921
922 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
923 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
924 return remoteName;
925 }
926 }
927905
928 let remoteName = preferredName;906 const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) {
929 let suffix = 1;907 const preferredName = sanitizeRemoteName(input.preferredName);
930 while (remoteFetchUrls.has(remoteName)) {908 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
931 remoteName = `${preferredName}-${suffix}`;909 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [
932 suffix += 1;910 "remote",
933 }911 "-v",
912 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
934913
935 yield* runGit("GitCore.ensureRemote.add", input.cwd, [914 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
936 "remote",915 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
937 "add",
938 remoteName,
939 input.url,
940 ]);
941 return remoteName;916 return remoteName;
942 });917 }
943918 }
944 const resolveBaseBranchForNoUpstream = (
945 cwd: string,
946 branch: string,
947 ): Effect.Effect<string | null, GitCommandError> =>
948 Effect.gen(function* () {
949 const configuredBaseBranch = yield* runGitStdout(
950 "GitCore.resolveBaseBranchForNoUpstream.config",
951 cwd,
952 ["config", "--get", `branch.${branch}.gh-merge-base`],
953 true,
954 ).pipe(Effect.map((stdout) => stdout.trim()));
955919
956 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(920 let remoteName = preferredName;
957 Effect.catch(() => Effect.succeed(null)),921 let suffix = 1;
958 );922 while (remoteFetchUrls.has(remoteName)) {
959 const defaultBranch =923 remoteName = `${preferredName}-${suffix}`;
960 primaryRemoteName === null924 suffix += 1;
961 ? null925 }
962 : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
963 const candidates = [
964 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
965 defaultBranch,
966 ...DEFAULT_BASE_BRANCH_CANDIDATES,
967 ];
968
969 for (const candidate of candidates) {
970 if (!candidate) {
971 continue;
972 }
973926
974 const remotePrefix =927 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);
975 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;928 return remoteName;
976 const normalizedCandidate = candidate.startsWith("origin/")929 });
977 ? candidate.slice("origin/".length)
978 : remotePrefix && candidate.startsWith(remotePrefix)
979 ? candidate.slice(remotePrefix.length)
980 : candidate;
981 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
982 continue;
983 }
984930
985 if (yield* branchExists(cwd, normalizedCandidate)) {931 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
986 return normalizedCandidate;932 cwd: string,
987 }933 branch: string,
934 ) {
935 const configuredBaseBranch = yield* runGitStdout(
936 "GitCore.resolveBaseBranchForNoUpstream.config",
937 cwd,
938 ["config", "--get", `branch.${branch}.gh-merge-base`],
939 true,
940 ).pipe(Effect.map((stdout) => stdout.trim()));
941
942 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
943 Effect.catch(() => Effect.succeed(null)),
944 );
945 const defaultBranch =
946 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
947 const candidates = [
948 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
949 defaultBranch,
950 ...DEFAULT_BASE_BRANCH_CANDIDATES,
951 ];
952
953 for (const candidate of candidates) {
954 if (!candidate) {
955 continue;
956 }
988957
989 if (958 const remotePrefix =
990 primaryRemoteName &&959 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
991 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))960 const normalizedCandidate = candidate.startsWith("origin/")
992 ) {961 ? candidate.slice("origin/".length)
993 return `${primaryRemoteName}/${normalizedCandidate}`;962 : remotePrefix && candidate.startsWith(remotePrefix)
994 }963 ? candidate.slice(remotePrefix.length)
995 }964 : candidate;
965 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
966 continue;
967 }
996968
997 return null;969 if (yield* branchExists(cwd, normalizedCandidate)) {
998 });970 return normalizedCandidate;
971 }
999972
1000 const computeAheadCountAgainstBase = (973 if (
1001 cwd: string,974 primaryRemoteName &&
1002 branch: string,975 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
1003 ): Effect.Effect<number, GitCommandError> =>976 ) {
1004 Effect.gen(function* () {977 return `${primaryRemoteName}/${normalizedCandidate}`;
1005 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);978 }
1006 if (!baseBranch) {979 }
1007 return 0;
1008 }
1009980
1010 const result = yield* executeGit(981 return null;
1011 "GitCore.computeAheadCountAgainstBase",982 });
1012 cwd,
1013 ["rev-list", "--count", `${baseBranch}..HEAD`],
1014 { allowNonZeroExit: true },
1015 );
1016 if (result.code !== 0) {
1017 return 0;
1018 }
1019983
1020 const parsed = Number.parseInt(result.stdout.trim(), 10);984 const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* (
1021 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;985 cwd: string,
1022 });986 branch: string,
987 ) {
988 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);
989 if (!baseBranch) {
990 return 0;
991 }
1023992
1024 const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> =>993 const result = yield* executeGit(
1025 Effect.gen(function* () {994 "GitCore.computeAheadCountAgainstBase",
1026 const branchRecency = yield* executeGit(995 cwd,
1027 "GitCore.readBranchRecency",996 ["rev-list", "--count", `${baseBranch}..HEAD`],
1028 cwd,997 { allowNonZeroExit: true },
1029 [998 );
1030 "for-each-ref",999 if (result.code !== 0) {
1031 "--format=%(refname:short)%09%(committerdate:unix)",1000 return 0;
1032 "refs/heads",1001 }
1033 "refs/remotes",
1034 ],
1035 {
1036 timeoutMs: 15_000,
1037 allowNonZeroExit: true,
1038 },
1039 );
10401002
1041 const branchLastCommit = new Map<string, number>();1003 const parsed = Number.parseInt(result.stdout.trim(), 10);
1042 if (branchRecency.code !== 0) {1004 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1043 return branchLastCommit;1005 });
1044 }
10451006
1046 for (const line of branchRecency.stdout.split("\n")) {1007 const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) {
1047 if (line.length === 0) {1008 const branchRecency = yield* executeGit(
1048 continue;1009 "GitCore.readBranchRecency",
1049 }1010 cwd,
1050 const [name, lastCommitRaw] = line.split("\t");1011 [
1051 if (!name) {1012 "for-each-ref",
1052 continue;1013 "--format=%(refname:short)%09%(committerdate:unix)",
1053 }1014 "refs/heads",
1054 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);1015 "refs/remotes",
1055 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);1016 ],
1056 }1017 {
1018 timeoutMs: 15_000,
1019 allowNonZeroExit: true,
1020 },
1021 );
10571022
1058 return branchLastCommit;1023 const branchLastCommit = new Map<string, number>();
1059 });1024 if (branchRecency.code !== 0) {
1025 return branchLastCommit;
1026 }
10601027
1061 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>1028 for (const line of branchRecency.stdout.split("\n")) {
1062 Effect.gen(function* () {1029 if (line.length === 0) {
1063 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));1030 continue;
1031 }
1032 const [name, lastCommitRaw] = line.split("\t");
1033 if (!name) {
1034 continue;
1035 }
1036 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
1037 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
1038 }
10641039
1065 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(1040 return branchLastCommit;
1066 [1041 });
1067 runGitStdout("GitCore.statusDetails.status", cwd, [
1068 "status",
1069 "--porcelain=2",
1070 "--branch",
1071 ]),
1072 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1073 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
1074 "diff",
1075 "--cached",
1076 "--numstat",
1077 ]),
1078 ],
1079 { concurrency: "unbounded" },
1080 );
10811042
1082 let branch: string | null = null;1043 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1083 let upstreamRef: string | null = null;1044 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1084 let aheadCount = 0;1045
1085 let behindCount = 0;1046 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
1086 let hasWorkingTreeChanges = false;1047 [
1087 const changedFilesWithoutNumstat = new Set<string>();1048 runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),
10881049 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1089 for (const line of statusStdout.split(/\r?\n/g)) {1050 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),
1090 if (line.startsWith("# branch.head ")) {1051 ],
1091 const value = line.slice("# branch.head ".length).trim();1052 { concurrency: "unbounded" },
1092 branch = value.startsWith("(") ? null : value;1053 );
1093 continue;1054
1094 }1055 let branch: string | null = null;
1095 if (line.startsWith("# branch.upstream ")) {1056 let upstreamRef: string | null = null;
1096 const value = line.slice("# branch.upstream ".length).trim();1057 let aheadCount = 0;
1097 upstreamRef = value.length > 0 ? value : null;1058 let behindCount = 0;
1098 continue;1059 let hasWorkingTreeChanges = false;
1099 }1060 const changedFilesWithoutNumstat = new Set<string>();
1100 if (line.startsWith("# branch.ab ")) {1061
1101 const value = line.slice("# branch.ab ".length).trim();1062 for (const line of statusStdout.split(/\r?\n/g)) {
1102 const parsed = parseBranchAb(value);1063 if (line.startsWith("# branch.head ")) {
1103 aheadCount = parsed.ahead;1064 const value = line.slice("# branch.head ".length).trim();
1104 behindCount = parsed.behind;1065 branch = value.startsWith("(") ? null : value;
1105 continue;1066 continue;
1106 }1067 }
1107 if (line.trim().length > 0 && !line.startsWith("#")) {1068 if (line.startsWith("# branch.upstream ")) {
1108 hasWorkingTreeChanges = true;1069 const value = line.slice("# branch.upstream ".length).trim();
1109 const pathValue = parsePorcelainPath(line);1070 upstreamRef = value.length > 0 ? value : null;
1110 if (pathValue) changedFilesWithoutNumstat.add(pathValue);1071 continue;
1111 }1072 }
1112 }1073 if (line.startsWith("# branch.ab ")) {
1074 const value = line.slice("# branch.ab ".length).trim();
1075 const parsed = parseBranchAb(value);
1076 aheadCount = parsed.ahead;
1077 behindCount = parsed.behind;
1078 continue;
1079 }
1080 if (line.trim().length > 0 && !line.startsWith("#")) {
1081 hasWorkingTreeChanges = true;
1082 const pathValue = parsePorcelainPath(line);
1083 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
1084 }
1085 }
11131086
1114 if (!upstreamRef && branch) {1087 if (!upstreamRef && branch) {
1115 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(1088 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1116 Effect.catch(() => Effect.succeed(0)),1089 Effect.catch(() => Effect.succeed(0)),
1117 );1090 );
1118 behindCount = 0;1091 behindCount = 0;
1119 }1092 }
11201093
1121 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);1094 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1122 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);1095 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1123 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();1096 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
1124 for (const entry of [...stagedEntries, ...unstagedEntries]) {1097 for (const entry of [...stagedEntries, ...unstagedEntries]) {
1125 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };1098 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
1126 existing.insertions += entry.insertions;1099 existing.insertions += entry.insertions;
1127 existing.deletions += entry.deletions;1100 existing.deletions += entry.deletions;
1128 fileStatMap.set(entry.path, existing);1101 fileStatMap.set(entry.path, existing);
1129 }1102 }
11301103
1131 let insertions = 0;1104 let insertions = 0;
1132 let deletions = 0;1105 let deletions = 0;
1133 const files = Array.from(fileStatMap.entries())1106 const files = Array.from(fileStatMap.entries())
1134 .map(([filePath, stat]) => {1107 .map(([filePath, stat]) => {
1135 insertions += stat.insertions;1108 insertions += stat.insertions;
1136 deletions += stat.deletions;1109 deletions += stat.deletions;
1137 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };1110 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
1138 })1111 })
1139 .toSorted((a, b) => a.path.localeCompare(b.path));1112 .toSorted((a, b) => a.path.localeCompare(b.path));
11401113
1141 for (const filePath of changedFilesWithoutNumstat) {1114 for (const filePath of changedFilesWithoutNumstat) {
1142 if (fileStatMap.has(filePath)) continue;1115 if (fileStatMap.has(filePath)) continue;
1143 files.push({ path: filePath, insertions: 0, deletions: 0 });1116 files.push({ path: filePath, insertions: 0, deletions: 0 });
1144 }1117 }
1145 files.sort((a, b) => a.path.localeCompare(b.path));1118 files.sort((a, b) => a.path.localeCompare(b.path));
11461119
1147 return {1120 return {
1148 branch,1121 branch,
1149 upstreamRef,1122 upstreamRef,
1150 hasWorkingTreeChanges,1123 hasWorkingTreeChanges,
1151 workingTree: {1124 workingTree: {
1152 files,1125 files,
1153 insertions,1126 insertions,
1154 deletions,1127 deletions,
1155 },1128 },
1156 hasUpstream: upstreamRef !== null,1129 hasUpstream: upstreamRef !== null,
1157 aheadCount,1130 aheadCount,
1158 behindCount,1131 behindCount,
1159 };1132 };
1160 });1133 });
11611134
1162 const status: GitCoreShape["status"] = (input) =>1135 const status: GitCoreShape["status"] = (input) =>
1163 statusDetails(input.cwd).pipe(1136 statusDetails(input.cwd).pipe(
1164 Effect.map((details) => ({1137 Effect.map((details) => ({
1165 branch: details.branch,1138 branch: details.branch,
1166 hasWorkingTreeChanges: details.hasWorkingTreeChanges,1139 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1167 workingTree: details.workingTree,1140 workingTree: details.workingTree,
1168 hasUpstream: details.hasUpstream,1141 hasUpstream: details.hasUpstream,
1169 aheadCount: details.aheadCount,1142 aheadCount: details.aheadCount,
1170 behindCount: details.behindCount,1143 behindCount: details.behindCount,
1171 pr: null,1144 pr: null,
1172 })),1145 })),
1146 );
1147
1148 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn(
1149 "prepareCommitContext",
1150 )(function* (cwd, filePaths) {
1151 if (filePaths && filePaths.length > 0) {
1152 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1153 Effect.catch(() => Effect.void),
1173 );1154 );
1155 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
1156 "add",
1157 "-A",
1158 "--",
1159 ...filePaths,
1160 ]);
1161 } else {
1162 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1163 }
11741164
1175 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>1165 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [
1176 Effect.gen(function* () {1166 "diff",
1177 if (filePaths && filePaths.length > 0) {1167 "--cached",
1178 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(1168 "--name-status",
1179 Effect.catch(() => Effect.void),1169 ]).pipe(Effect.map((stdout) => stdout.trim()));
1180 );1170 if (stagedSummary.length === 0) {
1181 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [1171 return null;
1182 "add",1172 }
1183 "-A",
1184 "--",
1185 ...filePaths,
1186 ]);
1187 } else {
1188 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1189 }
11901173
1191 const stagedSummary = yield* runGitStdout(1174 const stagedPatch = yield* runGitStdoutWithOptions(
1192 "GitCore.prepareCommitContext.stagedSummary",1175 "GitCore.prepareCommitContext.stagedPatch",
1193 cwd,1176 cwd,
1194 ["diff", "--cached", "--name-status"],1177 ["diff", "--cached", "--patch", "--minimal"],
1195 ).pipe(Effect.map((stdout) => stdout.trim()));1178 {
1196 if (stagedSummary.length === 0) {1179 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1197 return null;1180 truncateOutputAtMaxBytes: true,
1198 }1181 },
1182 );
1183
1184 return {
1185 stagedSummary,
1186 stagedPatch,
1187 };
1188 });
11991189
1200 const stagedPatch = yield* runGitStdoutWithOptions(1190 const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* (
1201 "GitCore.prepareCommitContext.stagedPatch",1191 cwd,
1192 subject,
1193 body,
1194 options?: GitCommitOptions,
1195 ) {
1196 const args = ["commit", "-m", subject];
1197 const trimmedBody = body.trim();
1198 if (trimmedBody.length > 0) {
1199 args.push("-m", trimmedBody);
1200 }
1201 const progress =
1202 options?.progress?.onOutputLine === undefined
1203 ? options?.progress
1204 : {
1205 ...options.progress,
1206 onStdoutLine: (line: string) =>
1207 options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void,
1208 onStderrLine: (line: string) =>
1209 options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void,
1210 };
1211 yield* executeGit("GitCore.commit.commit", cwd, args, {
1212 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1213 ...(progress ? { progress } : {}),
1214 }).pipe(Effect.asVoid);
1215 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
1216 "rev-parse",
1217 "HEAD",
1218 ]).pipe(Effect.map((stdout) => stdout.trim()));
1219
1220 return { commitSha };
1221 });
1222
1223 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")(
1224 function* (cwd, fallbackBranch) {
1225 const details = yield* statusDetails(cwd);
1226 const branch = details.branch ?? fallbackBranch;
1227 if (!branch) {
1228 return yield* createGitCommandError(
1229 "GitCore.pushCurrentBranch",
1202 cwd,1230 cwd,
1203 ["diff", "--cached", "--patch", "--minimal"],1231 ["push"],
1204 {1232 "Cannot push from detached HEAD.",
1205 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1206 truncateOutputAtMaxBytes: true,
1207 },
1208 );1233 );
1234 }
12091235
1210 return {1236 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1211 stagedSummary,1237 if (hasNoLocalDelta) {
1212 stagedPatch,1238 if (details.hasUpstream) {
1213 };1239 return {
1214 });1240 status: "skipped_up_to_date" as const,
12151241 branch,
1216 const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) =>1242 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1217 Effect.gen(function* () {1243 };
1218 const args = ["commit", "-m", subject];
1219 const trimmedBody = body.trim();
1220 if (trimmedBody.length > 0) {
1221 args.push("-m", trimmedBody);
1222 }1244 }
1223 const progress = options?.progress
1224 ? {
1225 ...(options.progress.onOutputLine
1226 ? {
1227 onStdoutLine: (line: string) =>
1228 options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ??
1229 Effect.void,
1230 onStderrLine: (line: string) =>
1231 options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ??
1232 Effect.void,
1233 }
1234 : {}),
1235 ...(options.progress.onHookStarted
1236 ? { onHookStarted: options.progress.onHookStarted }
1237 : {}),
1238 ...(options.progress.onHookFinished
1239 ? { onHookFinished: options.progress.onHookFinished }
1240 : {}),
1241 }
1242 : null;
1243 yield* executeGit("GitCore.commit.commit", cwd, args, {
1244 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1245 ...(progress ? { progress } : {}),
1246 }).pipe(Effect.asVoid);
1247 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
1248 "rev-parse",
1249 "HEAD",
1250 ]).pipe(Effect.map((stdout) => stdout.trim()));
1251
1252 return { commitSha };
1253 });
12541245
1255 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) =>1246 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
1256 Effect.gen(function* () {1247 Effect.catch(() => Effect.succeed(null)),
1257 const details = yield* statusDetails(cwd);1248 );
1258 const branch = details.branch ?? fallbackBranch;1249 if (comparableBaseBranch) {
1259 if (!branch) {1250 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
1260 return yield* createGitCommandError(1251 Effect.catch(() => Effect.succeed(null)),
1261 "GitCore.pushCurrentBranch",
1262 cwd,
1263 ["push"],
1264 "Cannot push from detached HEAD.",
1265 );1252 );
1266 }1253 if (!publishRemoteName) {
1267
1268 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1269 if (hasNoLocalDelta) {
1270 if (details.hasUpstream) {
1271 return {1254 return {
1272 status: "skipped_up_to_date" as const,1255 status: "skipped_up_to_date" as const,
1273 branch,1256 branch,
1274 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1275 };1257 };
1276 }1258 }
12771259
1278 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(1260 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1279 Effect.catch(() => Effect.succeed(null)),1261 Effect.catch(() => Effect.succeed(false)),
1280 );1262 );
1281 if (comparableBaseBranch) {1263 if (hasRemoteBranch) {
1282 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(1264 return {
1283 Effect.catch(() => Effect.succeed(null)),1265 status: "skipped_up_to_date" as const,
1284 );1266 branch,
1285 if (!publishRemoteName) {1267 };
1286 return {
1287 status: "skipped_up_to_date" as const,
1288 branch,
1289 };
1290 }
1291
1292 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1293 Effect.catch(() => Effect.succeed(false)),
1294 );
1295 if (hasRemoteBranch) {
1296 return {
1297 status: "skipped_up_to_date" as const,
1298 branch,
1299 };
1300 }
1301 }1268 }
1302 }1269 }
1270 }
13031271
1304 if (!details.hasUpstream) {1272 if (!details.hasUpstream) {
1305 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);1273 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1306 if (!publishRemoteName) {1274 if (!publishRemoteName) {
1307 return yield* createGitCommandError(1275 return yield* createGitCommandError(
1308 "GitCore.pushCurrentBranch",1276 "GitCore.pushCurrentBranch",
1309 cwd,1277 cwd,
1310 ["push"],1278 ["push"],
1311 "Cannot push because no git remote is configured for this repository.",1279 "Cannot push because no git remote is configured for this repository.",
1312 );1280 );
1313 }
1314 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
1315 "push",
1316 "-u",
1317 publishRemoteName,
1318 branch,
1319 ]);
1320 return {
1321 status: "pushed" as const,
1322 branch,
1323 upstreamBranch: `${publishRemoteName}/${branch}`,
1324 setUpstream: true,
1325 };
1326 }
1327
1328 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
1329 Effect.catch(() => Effect.succeed(null)),
1330 );
1331 if (currentUpstream) {
1332 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
1333 "push",
1334 currentUpstream.remoteName,
1335 `HEAD:${currentUpstream.upstreamBranch}`,
1336 ]);
1337 return {
1338 status: "pushed" as const,
1339 branch,
1340 upstreamBranch: currentUpstream.upstreamRef,
1341 setUpstream: false,
1342 };
1343 }1281 }
1282 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
1283 "push",
1284 "-u",
1285 publishRemoteName,
1286 branch,
1287 ]);
1288 return {
1289 status: "pushed" as const,
1290 branch,
1291 upstreamBranch: `${publishRemoteName}/${branch}`,
1292 setUpstream: true,
1293 };
1294 }
13441295
1345 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);1296 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
1297 Effect.catch(() => Effect.succeed(null)),
1298 );
1299 if (currentUpstream) {
1300 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
1301 "push",
1302 currentUpstream.remoteName,
1303 `HEAD:${currentUpstream.upstreamBranch}`,
1304 ]);
1346 return {1305 return {
1347 status: "pushed" as const,1306 status: "pushed" as const,
1348 branch,1307 branch,
1349 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),1308 upstreamBranch: currentUpstream.upstreamRef,
1350 setUpstream: false,1309 setUpstream: false,
1351 };1310 };
1352 });1311 }
13531312
1354 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>1313 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
1355 Effect.gen(function* () {1314 return {
1356 const details = yield* statusDetails(cwd);1315 status: "pushed" as const,
1357 const branch = details.branch;1316 branch,
1358 if (!branch) {1317 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1359 return yield* createGitCommandError(1318 setUpstream: false,
1360 "GitCore.pullCurrentBranch",1319 };
1361 cwd,1320 },
1362 ["pull", "--ff-only"],1321 );
1363 "Cannot pull from detached HEAD.",1322
1364 );1323 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")(
1365 }1324 function* (cwd) {
1366 if (!details.hasUpstream) {1325 const details = yield* statusDetails(cwd);
1367 return yield* createGitCommandError(1326 const branch = details.branch;
1368 "GitCore.pullCurrentBranch",1327 if (!branch) {
1369 cwd,1328 return yield* createGitCommandError(
1370 ["pull", "--ff-only"],1329 "GitCore.pullCurrentBranch",
1371 "Current branch has no upstream configured. Push with upstream first.",
1372 );
1373 }
1374 const beforeSha = yield* runGitStdout(
1375 "GitCore.pullCurrentBranch.beforeSha",
1376 cwd,1330 cwd,
1377 ["rev-parse", "HEAD"],1331 ["pull", "--ff-only"],
1378 true,1332 "Cannot pull from detached HEAD.",
1379 ).pipe(Effect.map((stdout) => stdout.trim()));1333 );
1380 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {1334 }
1381 timeoutMs: 30_000,1335 if (!details.hasUpstream) {
1382 fallbackErrorMessage: "git pull failed",1336 return yield* createGitCommandError(
1383 });1337 "GitCore.pullCurrentBranch",
1384 const afterSha = yield* runGitStdout(
1385 "GitCore.pullCurrentBranch.afterSha",
1386 cwd,1338 cwd,
1387 ["rev-parse", "HEAD"],1339 ["pull", "--ff-only"],
1388 true,1340 "Current branch has no upstream configured. Push with upstream first.",
1389 ).pipe(Effect.map((stdout) => stdout.trim()));1341 );
13901342 }
1391 const refreshed = yield* statusDetails(cwd);1343 const beforeSha = yield* runGitStdout(
1392 return {1344 "GitCore.pullCurrentBranch.beforeSha",
1393 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",1345 cwd,
1394 branch,1346 ["rev-parse", "HEAD"],
1395 upstreamBranch: refreshed.upstreamRef,1347 true,
1396 };1348 ).pipe(Effect.map((stdout) => stdout.trim()));
1349 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
1350 timeoutMs: 30_000,
1351 fallbackErrorMessage: "git pull failed",
1397 });1352 });
1353 const afterSha = yield* runGitStdout(
1354 "GitCore.pullCurrentBranch.afterSha",
1355 cwd,
1356 ["rev-parse", "HEAD"],
1357 true,
1358 ).pipe(Effect.map((stdout) => stdout.trim()));
1359
1360 const refreshed = yield* statusDetails(cwd);
1361 return {
1362 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
1363 branch,
1364 upstreamBranch: refreshed.upstreamRef,
1365 };
1366 },
1367 );
13981368
1399 const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) =>1369 const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")(
1400 Effect.gen(function* () {1370 function* (cwd, baseBranch) {
1401 const range = `${baseBranch}..HEAD`;1371 const range = `${baseBranch}..HEAD`;
1402 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(1372 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1403 [1373 [
1404 runGitStdoutWithOptions(1374 runGitStdoutWithOptions(
1405 "GitCore.readRangeContext.log",1375 "GitCore.readRangeContext.log",
1406 cwd,1376 cwd,
1407 ["log", "--oneline", range],1377 ["log", "--oneline", range],
1408 {1378 {
1409 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,1379 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1410 truncateOutputAtMaxBytes: true,1380 truncateOutputAtMaxBytes: true,
1411 },1381 },
1412 ),1382 ),
1413 runGitStdoutWithOptions(1383 runGitStdoutWithOptions(
1414 "GitCore.readRangeContext.diffStat",1384 "GitCore.readRangeContext.diffStat",
1415 cwd,1385 cwd,
1416 ["diff", "--stat", range],1386 ["diff", "--stat", range],
1417 {1387 {
1418 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,1388 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1419 truncateOutputAtMaxBytes: true,1389 truncateOutputAtMaxBytes: true,
1420 },1390 },
1421 ),1391 ),
1422 runGitStdoutWithOptions(1392 runGitStdoutWithOptions(
1423 "GitCore.readRangeContext.diffPatch",1393 "GitCore.readRangeContext.diffPatch",
1424 cwd,1394 cwd,
1425 ["diff", "--patch", "--minimal", range],1395 ["diff", "--patch", "--minimal", range],
1426 {1396 {
1427 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,1397 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1428 truncateOutputAtMaxBytes: true,1398 truncateOutputAtMaxBytes: true,
1429 },1399 },
1430 ),1400 ),
1431 ],1401 ],
1432 { concurrency: "unbounded" },1402 { concurrency: "unbounded" },
1433 );1403 );
14341404
1435 return {1405 return {
1436 commitSummary,1406 commitSummary,
1437 diffSummary,1407 diffSummary,
1438 diffPatch,1408 diffPatch,
1439 };1409 };
1440 });1410 },
1411 );
1412
1413 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>
1414 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1415 Effect.map((stdout) => stdout.trim()),
1416 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1417 );
1418
1419 const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) {
1420 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1421 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1422 );
1423 const localBranchResult = yield* executeGit(
1424 "GitCore.listBranches.branchNoColor",
1425 input.cwd,
1426 ["branch", "--no-color"],
1427 {
1428 timeoutMs: 10_000,
1429 allowNonZeroExit: true,
1430 },
1431 );
14411432
1442 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>1433 if (localBranchResult.code !== 0) {
1443 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(1434 const stderr = localBranchResult.stderr.trim();
1444 Effect.map((stdout) => stdout.trim()),1435 if (stderr.toLowerCase().includes("not a git repository")) {
1445 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),1436 return { branches: [], isRepo: false, hasOriginRemote: false };
1437 }
1438 return yield* createGitCommandError(
1439 "GitCore.listBranches",
1440 input.cwd,
1441 ["branch", "--no-color"],
1442 stderr || "git branch failed",
1446 );1443 );
1444 }
14471445
1448 const listBranches: GitCoreShape["listBranches"] = (input) =>1446 const remoteBranchResultEffect = executeGit(
1449 Effect.gen(function* () {1447 "GitCore.listBranches.remoteBranches",
1450 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(1448 input.cwd,
1451 Effect.catch(() => Effect.succeed(new Map<string, number>())),1449 ["branch", "--no-color", "--remotes"],
1452 );1450 {
1453 const localBranchResult = yield* executeGit(1451 timeoutMs: 10_000,
1454 "GitCore.listBranches.branchNoColor",1452 allowNonZeroExit: true,
1455 input.cwd,1453 },
1456 ["branch", "--no-color"],1454 ).pipe(
1457 {1455 Effect.catch((error) =>
1458 timeoutMs: 10_000,1456 Effect.logWarning(
1459 allowNonZeroExit: true,1457 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1460 },1458 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1461 );1459 ),
1460 );
1461
1462 const remoteNamesResultEffect = executeGit(
1463 "GitCore.listBranches.remoteNames",
1464 input.cwd,
1465 ["remote"],
1466 {
1467 timeoutMs: 5_000,
1468 allowNonZeroExit: true,
1469 },
1470 ).pipe(
1471 Effect.catch((error) =>
1472 Effect.logWarning(
1473 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1474 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1475 ),
1476 );
14621477
1463 if (localBranchResult.code !== 0) {1478 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1464 const stderr = localBranchResult.stderr.trim();1479 yield* Effect.all(
1465 if (stderr.toLowerCase().includes("not a git repository")) {1480 [
1466 return { branches: [], isRepo: false, hasOriginRemote: false };1481 executeGit(
1467 }1482 "GitCore.listBranches.defaultRef",
1468 return yield* createGitCommandError(
1469 "GitCore.listBranches",
1470 input.cwd,1483 input.cwd,
1471 ["branch", "--no-color"],1484 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1472 stderr || "git branch failed",1485 {
1473 );1486 timeoutMs: 5_000,
1474 }1487 allowNonZeroExit: true,
14751488 },
1476 const remoteBranchResultEffect = executeGit(
1477 "GitCore.listBranches.remoteBranches",
1478 input.cwd,
1479 ["branch", "--no-color", "--remotes"],
1480 {
1481 timeoutMs: 10_000,
1482 allowNonZeroExit: true,
1483 },
1484 ).pipe(
1485 Effect.catch((error) =>
1486 Effect.logWarning(
1487 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1488 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1489 ),1489 ),
1490 );1490 executeGit(
14911491 "GitCore.listBranches.worktreeList",
1492 const remoteNamesResultEffect = executeGit(1492 input.cwd,
1493 "GitCore.listBranches.remoteNames",1493 ["worktree", "list", "--porcelain"],
1494 input.cwd,1494 {
1495 ["remote"],1495 timeoutMs: 5_000,
1496 {1496 allowNonZeroExit: true,
1497 timeoutMs: 5_000,1497 },
1498 allowNonZeroExit: true,
1499 },
1500 ).pipe(
1501 Effect.catch((error) =>
1502 Effect.logWarning(
1503 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1504 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1505 ),1498 ),
1506 );1499 remoteBranchResultEffect,
1500 remoteNamesResultEffect,
1501 branchRecencyPromise,
1502 ],
1503 { concurrency: "unbounded" },
1504 );
15071505
1508 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =1506 const remoteNames =
1509 yield* Effect.all(1507 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1510 [1508 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1511 executeGit(1509 yield* Effect.logWarning(
1512 "GitCore.listBranches.defaultRef",1510 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1513 input.cwd,1511 );
1514 ["symbolic-ref", "refs/remotes/origin/HEAD"],1512 }
1515 {1513 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1516 timeoutMs: 5_000,1514 yield* Effect.logWarning(
1517 allowNonZeroExit: true,1515 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1518 },1516 );
1519 ),1517 }
1520 executeGit(
1521 "GitCore.listBranches.worktreeList",
1522 input.cwd,
1523 ["worktree", "list", "--porcelain"],
1524 {
1525 timeoutMs: 5_000,
1526 allowNonZeroExit: true,
1527 },
1528 ),
1529 remoteBranchResultEffect,
1530 remoteNamesResultEffect,
1531 branchRecencyPromise,
1532 ],
1533 { concurrency: "unbounded" },
1534 );
15351518
1536 const remoteNames =1519 const defaultBranch =
1537 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];1520 defaultRef.code === 0
1538 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {1521 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1539 yield* Effect.logWarning(1522 : null;
1540 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,1523
1541 );1524 const worktreeMap = new Map<string, string>();
1542 }1525 if (worktreeList.code === 0) {
1543 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {1526 let currentPath: string | null = null;
1544 yield* Effect.logWarning(1527 for (const line of worktreeList.stdout.split("\n")) {
1545 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,1528 if (line.startsWith("worktree ")) {
1529 const candidatePath = line.slice("worktree ".length);
1530 const exists = yield* fileSystem.stat(candidatePath).pipe(
1531 Effect.map(() => true),
1532 Effect.catch(() => Effect.succeed(false)),
1546 );1533 );
1534 currentPath = exists ? candidatePath : null;
1535 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1536 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1537 } else if (line === "") {
1538 currentPath = null;
1547 }1539 }
1540 }
1541 }
15481542
1549 const defaultBranch =1543 const localBranches = localBranchResult.stdout
1550 defaultRef.code === 01544 .split("\n")
1551 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")1545 .map(parseBranchLine)
1552 : null;1546 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
15531547 .map((branch) => ({
1554 const worktreeMap = new Map<string, string>();1548 name: branch.name,
1555 if (worktreeList.code === 0) {1549 current: branch.current,
1556 let currentPath: string | null = null;1550 isRemote: false,
1557 for (const line of worktreeList.stdout.split("\n")) {1551 isDefault: branch.name === defaultBranch,
1558 if (line.startsWith("worktree ")) {1552 worktreePath: worktreeMap.get(branch.name) ?? null,
1559 const candidatePath = line.slice("worktree ".length);1553 }))
1560 const exists = yield* fileSystem.stat(candidatePath).pipe(1554 .toSorted((a, b) => {
1561 Effect.map(() => true),1555 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1562 Effect.catch(() => Effect.succeed(false)),1556 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1563 );1557 if (aPriority !== bPriority) return aPriority - bPriority;
1564 currentPath = exists ? candidatePath : null;1558
1565 } else if (line.startsWith("branch refs/heads/") && currentPath) {1559 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1566 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);1560 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1567 } else if (line === "") {1561 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1568 currentPath = null;1562 return a.name.localeCompare(b.name);
1569 }
1570 }
1571 }
1572
1573 const localBranches = localBranchResult.stdout
1574 .split("\n")
1575 .map(parseBranchLine)
1576 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1577 .map((branch) => ({
1578 name: branch.name,
1579 current: branch.current,
1580 isRemote: false,
1581 isDefault: branch.name === defaultBranch,
1582 worktreePath: worktreeMap.get(branch.name) ?? null,
1583 }))
1584 .toSorted((a, b) => {
1585 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1586 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1587 if (aPriority !== bPriority) return aPriority - bPriority;
1588
1589 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1590 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1591 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1592 return a.name.localeCompare(b.name);
1593 });
1594
1595 const remoteBranches =
1596 remoteBranchResult.code === 0
1597 ? remoteBranchResult.stdout
1598 .split("\n")
1599 .map(parseBranchLine)
1600 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1601 .map((branch) => {
1602 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1603 const remoteBranch: {
1604 name: string;
1605 current: boolean;
1606 isRemote: boolean;
1607 remoteName?: string;
1608 isDefault: boolean;
1609 worktreePath: string | null;
1610 } = {
1611 name: branch.name,
1612 current: false,
1613 isRemote: true,
1614 isDefault: false,
1615 worktreePath: null,
1616 };
1617 if (parsedRemoteRef) {
1618 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1619 }
1620 return remoteBranch;
1621 })
1622 .toSorted((a, b) => {
1623 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1624 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1625 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1626 return a.name.localeCompare(b.name);
1627 })
1628 : [];
1629
1630 const branches = [...localBranches, ...remoteBranches];
1631
1632 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1633 });1563 });
16341564
1635 const createWorktree: GitCoreShape["createWorktree"] = (input) =>1565 const remoteBranches =
1636 Effect.gen(function* () {1566 remoteBranchResult.code === 0
1637 const targetBranch = input.newBranch ?? input.branch;1567 ? remoteBranchResult.stdout
1638 const sanitizedBranch = targetBranch.replace(/\//g, "-");1568 .split("\n")
1639 const repoName = path.basename(input.cwd);1569 .map(parseBranchLine)
1640 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);1570 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1641 const args = input.newBranch1571 .map((branch) => {
1642 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]1572 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1643 : ["worktree", "add", worktreePath, input.branch];1573 const remoteBranch: {
16441574 name: string;
1645 yield* executeGit("GitCore.createWorktree", input.cwd, args, {1575 current: boolean;
1646 fallbackErrorMessage: "git worktree add failed",1576 isRemote: boolean;
1647 });1577 remoteName?: string;
1578 isDefault: boolean;
1579 worktreePath: string | null;
1580 } = {
1581 name: branch.name,
1582 current: false,
1583 isRemote: true,
1584 isDefault: false,
1585 worktreePath: null,
1586 };
1587 if (parsedRemoteRef) {
1588 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1589 }
1590 return remoteBranch;
1591 })
1592 .toSorted((a, b) => {
1593 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1594 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1595 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1596 return a.name.localeCompare(b.name);
1597 })
1598 : [];
1599
1600 const branches = [...localBranches, ...remoteBranches];
1601
1602 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1603 });
16481604
1649 return {1605 const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
1650 worktree: {1606 function* (input) {
1651 path: worktreePath,1607 const targetBranch = input.newBranch ?? input.branch;
1652 branch: targetBranch,1608 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1653 },1609 const repoName = path.basename(input.cwd);
1654 };1610 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1611 const args = input.newBranch
1612 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1613 : ["worktree", "add", worktreePath, input.branch];
1614
1615 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1616 fallbackErrorMessage: "git worktree add failed",
1655 });1617 });
16561618
1657 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) =>1619 return {
1658 Effect.gen(function* () {1620 worktree: {
1659 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);1621 path: worktreePath,
1660 yield* executeGit(1622 branch: targetBranch,
1661 "GitCore.fetchPullRequestBranch",1623 },
1662 input.cwd,1624 };
1663 [1625 },
1664 "fetch",1626 );
1665 "--quiet",
1666 "--no-tags",
1667 remoteName,
1668 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1669 ],
1670 {
1671 fallbackErrorMessage: "git fetch pull request branch failed",
1672 },
1673 );
1674 }).pipe(Effect.asVoid);
1675
1676 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) =>
1677 Effect.gen(function* () {
1678 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1679 "fetch",
1680 "--quiet",
1681 "--no-tags",
1682 input.remoteName,
1683 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1684 ]);
16851627
1686 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);1628 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn(
1687 const targetRef = `${input.remoteName}/${input.remoteBranch}`;1629 "fetchPullRequestBranch",
1688 yield* runGit(1630 )(function* (input) {
1689 "GitCore.fetchRemoteBranch.materialize",1631 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1690 input.cwd,1632 yield* executeGit(
1691 localBranchAlreadyExists1633 "GitCore.fetchPullRequestBranch",
1692 ? ["branch", "--force", input.localBranch, targetRef]1634 input.cwd,
1693 : ["branch", input.localBranch, targetRef],1635 [
1694 );1636 "fetch",
1695 }).pipe(Effect.asVoid);1637 "--quiet",
16961638 "--no-tags",
1697 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>1639 remoteName,
1698 runGit("GitCore.setBranchUpstream", input.cwd, [1640 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1699 "branch",1641 ],
1700 "--set-upstream-to",1642 {
1701 `${input.remoteName}/${input.remoteBranch}`,1643 fallbackErrorMessage: "git fetch pull request branch failed",
1702 input.branch,1644 },
1645 );
1646 });
1647
1648 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")(
1649 function* (input) {
1650 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1651 "fetch",
1652 "--quiet",
1653 "--no-tags",
1654 input.remoteName,
1655 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1703 ]);1656 ]);
17041657
1705 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>1658 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1706 Effect.gen(function* () {1659 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1707 const args = ["worktree", "remove"];1660 yield* runGit(
1708 if (input.force) {1661 "GitCore.fetchRemoteBranch.materialize",
1709 args.push("--force");1662 input.cwd,
1710 }1663 localBranchAlreadyExists
1711 args.push(input.path);1664 ? ["branch", "--force", input.localBranch, targetRef]
1712 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {1665 : ["branch", input.localBranch, targetRef],
1713 timeoutMs: 15_000,1666 );
1714 fallbackErrorMessage: "git worktree remove failed",1667 },
1715 }).pipe(1668 );
1716 Effect.mapError((error) =>
1717 createGitCommandError(
1718 "GitCore.removeWorktree",
1719 input.cwd,
1720 args,
1721 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
1722 error,
1723 ),
1724 ),
1725 );
1726 });
17271669
1728 const renameBranch: GitCoreShape["renameBranch"] = (input) =>1670 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1729 Effect.gen(function* () {1671 runGit("GitCore.setBranchUpstream", input.cwd, [
1730 if (input.oldBranch === input.newBranch) {1672 "branch",
1731 return { branch: input.newBranch };1673 "--set-upstream-to",
1732 }1674 `${input.remoteName}/${input.remoteBranch}`,
1733 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);1675 input.branch,
17341676 ]);
1735 yield* executeGit(1677
1736 "GitCore.renameBranch",1678 const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")(
1737 input.cwd,1679 function* (input) {
1738 ["branch", "-m", "--", input.oldBranch, targetBranch],1680 const args = ["worktree", "remove"];
1739 {1681 if (input.force) {
1740 timeoutMs: 10_000,1682 args.push("--force");
1741 fallbackErrorMessage: "git branch rename failed",1683 }
1742 },1684 args.push(input.path);
1743 );1685 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
1686 timeoutMs: 15_000,
1687 fallbackErrorMessage: "git worktree remove failed",
1688 }).pipe(
1689 Effect.mapError((error) =>
1690 createGitCommandError(
1691 "GitCore.removeWorktree",
1692 input.cwd,
1693 args,
1694 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
1695 error,
1696 ),
1697 ),
1698 );
1699 },
1700 );
17441701
1745 return { branch: targetBranch };1702 const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) {
1746 });1703 if (input.oldBranch === input.newBranch) {
1704 return { branch: input.newBranch };
1705 }
1706 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
17471707
1748 const createBranch: GitCoreShape["createBranch"] = (input) =>1708 yield* executeGit(
1749 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {1709 "GitCore.renameBranch",
1710 input.cwd,
1711 ["branch", "-m", "--", input.oldBranch, targetBranch],
1712 {
1750 timeoutMs: 10_000,1713 timeoutMs: 10_000,
1751 fallbackErrorMessage: "git branch create failed",1714 fallbackErrorMessage: "git branch rename failed",
1752 }).pipe(Effect.asVoid);1715 },
1716 );
17531717
1754 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>1718 return { branch: targetBranch };
1755 Effect.gen(function* () {1719 });
1756 const [localInputExists, remoteExists] = yield* Effect.all(1720
1757 [1721 const createBranch: GitCoreShape["createBranch"] = (input) =>
1758 executeGit(1722 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1759 "GitCore.checkoutBranch.localInputExists",1723 timeoutMs: 10_000,
1760 input.cwd,1724 fallbackErrorMessage: "git branch create failed",
1761 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],1725 }).pipe(Effect.asVoid);
1762 {1726
1763 timeoutMs: 5_000,1727 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
1764 allowNonZeroExit: true,1728 function* (input) {
1765 },1729 const [localInputExists, remoteExists] = yield* Effect.all(
1766 ).pipe(Effect.map((result) => result.code === 0)),1730 [
1767 executeGit(1731 executeGit(
1768 "GitCore.checkoutBranch.remoteExists",1732 "GitCore.checkoutBranch.localInputExists",
1769 input.cwd,1733 input.cwd,
1770 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],1734 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1771 {1735 {
1772 timeoutMs: 5_000,1736 timeoutMs: 5_000,
1773 allowNonZeroExit: true,1737 allowNonZeroExit: true,
1774 },1738 },
1775 ).pipe(Effect.map((result) => result.code === 0)),1739 ).pipe(Effect.map((result) => result.code === 0)),
1776 ],1740 executeGit(
1777 { concurrency: "unbounded" },1741 "GitCore.checkoutBranch.remoteExists",
1778 );1742 input.cwd,
1743 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],
1744 {
1745 timeoutMs: 5_000,
1746 allowNonZeroExit: true,
1747 },
1748 ).pipe(Effect.map((result) => result.code === 0)),
1749 ],
1750 { concurrency: "unbounded" },
1751 );
1752
1753 const localTrackingBranch = remoteExists
1754 ? yield* executeGit(
1755 "GitCore.checkoutBranch.localTrackingBranch",
1756 input.cwd,
1757 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
1758 {
1759 timeoutMs: 5_000,
1760 allowNonZeroExit: true,
1761 },
1762 ).pipe(
1763 Effect.map((result) =>
1764 result.code === 0
1765 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
1766 : null,
1767 ),
1768 )
1769 : null;
17791770
1780 const localTrackingBranch = remoteExists1771 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
1772 const localTrackedBranchTargetExists =
1773 remoteExists && localTrackedBranchCandidate
1781 ? yield* executeGit(1774 ? yield* executeGit(
1782 "GitCore.checkoutBranch.localTrackingBranch",1775 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
1783 input.cwd,1776 input.cwd,
1784 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],1777 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
1785 {1778 {
1786 timeoutMs: 5_000,1779 timeoutMs: 5_000,
1787 allowNonZeroExit: true,1780 allowNonZeroExit: true,
1788 },1781 },
1789 ).pipe(1782 ).pipe(Effect.map((result) => result.code === 0))
1790 Effect.map((result) =>1783 : false;
1791 result.code === 01784
1792 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)1785 const checkoutArgs = localInputExists
1793 : null,1786 ? ["checkout", input.branch]
1794 ),1787 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
1795 )
1796 : null;
1797
1798 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
1799 const localTrackedBranchTargetExists =
1800 remoteExists && localTrackedBranchCandidate
1801 ? yield* executeGit(
1802 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
1803 input.cwd,
1804 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
1805 {
1806 timeoutMs: 5_000,
1807 allowNonZeroExit: true,
1808 },
1809 ).pipe(Effect.map((result) => result.code === 0))
1810 : false;
1811
1812 const checkoutArgs = localInputExists
1813 ? ["checkout", input.branch]1788 ? ["checkout", input.branch]
1814 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists1789 : remoteExists && !localTrackingBranch
1815 ? ["checkout", input.branch]1790 ? ["checkout", "--track", input.branch]
1816 : remoteExists && !localTrackingBranch1791 : remoteExists && localTrackingBranch
1817 ? ["checkout", "--track", input.branch]1792 ? ["checkout", localTrackingBranch]
1818 : remoteExists && localTrackingBranch1793 : ["checkout", input.branch];
1819 ? ["checkout", localTrackingBranch]
1820 : ["checkout", input.branch];
1821
1822 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
1823 timeoutMs: 10_000,
1824 fallbackErrorMessage: "git checkout failed",
1825 });
18261794
1827 // Refresh upstream refs in the background so checkout remains responsive.1795 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
1828 yield* Effect.forkScoped(1796 timeoutMs: 10_000,
1829 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),1797 fallbackErrorMessage: "git checkout failed",
1830 );
1831 });1798 });
18321799
1833 const initRepo: GitCoreShape["initRepo"] = (input) =>1800 // Refresh upstream refs in the background so checkout remains responsive.
1834 executeGit("GitCore.initRepo", input.cwd, ["init"], {1801 yield* Effect.forkScoped(
1835 timeoutMs: 10_000,1802 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1836 fallbackErrorMessage: "git init failed",
1837 }).pipe(Effect.asVoid);
1838
1839 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1840 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1841 "branch",
1842 "--list",
1843 "--format=%(refname:short)",
1844 ]).pipe(
1845 Effect.map((stdout) =>
1846 stdout
1847 .split("\n")
1848 .map((line) => line.trim())
1849 .filter((line) => line.length > 0),
1850 ),
1851 );1803 );
1804 },
1805 );
18521806
1853 return {1807 const initRepo: GitCoreShape["initRepo"] = (input) =>
1854 execute,1808 executeGit("GitCore.initRepo", input.cwd, ["init"], {
1855 status,1809 timeoutMs: 10_000,
1856 statusDetails,1810 fallbackErrorMessage: "git init failed",
1857 prepareCommitContext,1811 }).pipe(Effect.asVoid);
1858 commit,1812
1859 pushCurrentBranch,1813 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1860 pullCurrentBranch,1814 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1861 readRangeContext,1815 "branch",
1862 readConfigValue,1816 "--list",
1863 listBranches,1817 "--format=%(refname:short)",
1864 createWorktree,1818 ]).pipe(
1865 fetchPullRequestBranch,1819 Effect.map((stdout) =>
1866 ensureRemote,1820 stdout
1867 fetchRemoteBranch,1821 .split("\n")
1868 setBranchUpstream,1822 .map((line) => line.trim())
1869 removeWorktree,1823 .filter((line) => line.length > 0),
1870 renameBranch,1824 ),
1871 createBranch,1825 );
1872 checkoutBranch,1826
1873 initRepo,1827 return {
1874 listLocalBranchNames,1828 execute,
1875 } satisfies GitCoreShape;1829 status,
1876 });1830 statusDetails,
1831 prepareCommitContext,
1832 commit,
1833 pushCurrentBranch,
1834 pullCurrentBranch,
1835 readRangeContext,
1836 readConfigValue,
1837 listBranches,
1838 createWorktree,
1839 fetchPullRequestBranch,
1840 ensureRemote,
1841 fetchRemoteBranch,
1842 setBranchUpstream,
1843 removeWorktree,
1844 renameBranch,
1845 createBranch,
1846 checkoutBranch,
1847 initRepo,
1848 listLocalBranchNames,
1849 } satisfies GitCoreShape;
1850});
18771851
1878export const GitCoreLive = Layer.effect(GitCore, makeGitCore());1852export const GitCoreLive = Layer.effect(GitCore, makeGitCore());
apps/server/src/git/Layers/GitManager.test.ts+1-1
...@@ -525,7 +525,7 @@ function makeManager(input?: {...@@ -525,7 +525,7 @@ function makeManager(input?: {
525 serverSettingsLayer,525 serverSettingsLayer,
526 ).pipe(Layer.provideMerge(NodeServices.layer));526 ).pipe(Layer.provideMerge(NodeServices.layer));
527527
528 return makeGitManager.pipe(528 return makeGitManager().pipe(
529 Effect.provide(managerLayer),529 Effect.provide(managerLayer),
530 Effect.map((manager) => ({ manager, ghCalls })),530 Effect.map((manager) => ({ manager, ghCalls })),
531 );531 );
apps/server/src/git/Layers/GitManager.ts+567-544
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Path } from "effect";4import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect";
5import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";5import {
6 GitActionProgressEvent,
7 GitActionProgressPhase,
8 GitRunStackedActionResult,
9 ModelSelection,
10} from "@t3tools/contracts";
6import {11import {
7 resolveAutoFeatureBranchName,12 resolveAutoFeatureBranchName,
8 sanitizeBranchFragment,13 sanitizeBranchFragment,
...@@ -20,6 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";...@@ -20,6 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";
20import { GitHubCli } from "../Services/GitHubCli.ts";25import { GitHubCli } from "../Services/GitHubCli.ts";
21import { TextGeneration } from "../Services/TextGeneration.ts";26import { TextGeneration } from "../Services/TextGeneration.ts";
22import { ServerSettingsService } from "../../serverSettings.ts";27import { ServerSettingsService } from "../../serverSettings.ts";
28import type { GitManagerServiceError } from "../Errors.ts";
2329
24const COMMIT_TIMEOUT_MS = 10 * 60_000;30const COMMIT_TIMEOUT_MS = 10 * 60_000;
25const MAX_PROGRESS_TEXT_LENGTH = 500;31const MAX_PROGRESS_TEXT_LENGTH = 500;
...@@ -355,7 +361,7 @@ function toPullRequestHeadRemoteInfo(pr: {...@@ -355,7 +361,7 @@ function toPullRequestHeadRemoteInfo(pr: {
355 };361 };
356}362}
357363
358export const makeGitManager = Effect.gen(function* () {364export const makeGitManager = Effect.fn("makeGitManager")(function* () {
359 const gitCore = yield* GitCore;365 const gitCore = yield* GitCore;
360 const gitHubCli = yield* GitHubCli;366 const gitHubCli = yield* GitHubCli;
361 const textGeneration = yield* TextGeneration;367 const textGeneration = yield* TextGeneration;
...@@ -384,12 +390,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -384,12 +390,12 @@ export const makeGitManager = Effect.gen(function* () {
384 };390 };
385 };391 };
386392
387 const configurePullRequestHeadUpstream = (393 const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")(
388 cwd: string,394 function* (
389 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,395 cwd: string,
390 localBranch = pullRequest.headBranch,396 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
391 ) =>397 localBranch = pullRequest.headBranch,
392 Effect.gen(function* () {398 ) {
393 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";399 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
394 if (repositoryNameWithOwner.length === 0) {400 if (repositoryNameWithOwner.length === 0) {
395 return;401 return;
...@@ -417,7 +423,15 @@ export const makeGitManager = Effect.gen(function* () {...@@ -417,7 +423,15 @@ export const makeGitManager = Effect.gen(function* () {
417 remoteName,423 remoteName,
418 remoteBranch: pullRequest.headBranch,424 remoteBranch: pullRequest.headBranch,
419 });425 });
420 }).pipe(426 },
427 );
428
429 const configurePullRequestHeadUpstream = (
430 cwd: string,
431 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
432 localBranch = pullRequest.headBranch,
433 ) =>
434 configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe(
421 Effect.catch((error) =>435 Effect.catch((error) =>
422 Effect.logWarning(436 Effect.logWarning(
423 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,437 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
...@@ -425,12 +439,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -425,12 +439,12 @@ export const makeGitManager = Effect.gen(function* () {
425 ),439 ),
426 );440 );
427441
428 const materializePullRequestHeadBranch = (442 const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")(
429 cwd: string,443 function* (
430 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,444 cwd: string,
431 localBranch = pullRequest.headBranch,445 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
432 ) =>446 localBranch = pullRequest.headBranch,
433 Effect.gen(function* () {447 ) {
434 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";448 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
435449
436 if (repositoryNameWithOwner.length === 0) {450 if (repositoryNameWithOwner.length === 0) {
...@@ -470,7 +484,15 @@ export const makeGitManager = Effect.gen(function* () {...@@ -470,7 +484,15 @@ export const makeGitManager = Effect.gen(function* () {
470 remoteName,484 remoteName,
471 remoteBranch: pullRequest.headBranch,485 remoteBranch: pullRequest.headBranch,
472 });486 });
473 }).pipe(487 },
488 );
489
490 const materializePullRequestHeadBranch = (
491 cwd: string,
492 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
493 localBranch = pullRequest.headBranch,
494 ) =>
495 materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
474 Effect.catch(() =>496 Effect.catch(() =>
475 gitCore.fetchPullRequestBranch({497 gitCore.fetchPullRequestBranch({
476 cwd,498 cwd,
...@@ -487,208 +509,211 @@ export const makeGitManager = Effect.gen(function* () {...@@ -487,208 +509,211 @@ export const makeGitManager = Effect.gen(function* () {
487 const readConfigValueNullable = (cwd: string, key: string) =>509 const readConfigValueNullable = (cwd: string, key: string) =>
488 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));510 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
489511
490 const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) =>512 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
491 Effect.gen(function* () {513 cwd: string,
492 if (!remoteName) {514 remoteName: string | null,
493 return {515 ) {
494 repositoryNameWithOwner: null,516 if (!remoteName) {
495 ownerLogin: null,
496 };
497 }
498
499 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
500 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
501 return {517 return {
502 repositoryNameWithOwner,518 repositoryNameWithOwner: null,
503 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),519 ownerLogin: null,
504 };520 };
505 });521 }
506522
507 const resolveBranchHeadContext = (523 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
524 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
525 return {
526 repositoryNameWithOwner,
527 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
528 };
529 });
530
531 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
508 cwd: string,532 cwd: string,
509 details: { branch: string; upstreamRef: string | null },533 details: { branch: string; upstreamRef: string | null },
510 ) =>534 ) {
511 Effect.gen(function* () {535 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
512 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);536 const headBranchFromUpstream = details.upstreamRef
513 const headBranchFromUpstream = details.upstreamRef537 ? extractBranchFromRef(details.upstreamRef)
514 ? extractBranchFromRef(details.upstreamRef)538 : "";
515 : "";539 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
516 const headBranch =540
517 headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;541 const [remoteRepository, originRepository] = yield* Effect.all(
518542 [
519 const [remoteRepository, originRepository] = yield* Effect.all(543 resolveRemoteRepositoryContext(cwd, remoteName),
520 [544 resolveRemoteRepositoryContext(cwd, "origin"),
521 resolveRemoteRepositoryContext(cwd, remoteName),545 ],
522 resolveRemoteRepositoryContext(cwd, "origin"),546 { concurrency: "unbounded" },
523 ],547 );
524 { concurrency: "unbounded" },
525 );
526
527 const isCrossRepository =
528 remoteRepository.repositoryNameWithOwner !== null &&
529 originRepository.repositoryNameWithOwner !== null
530 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
531 originRepository.repositoryNameWithOwner.toLowerCase()
532 : remoteName !== null &&
533 remoteName !== "origin" &&
534 remoteRepository.repositoryNameWithOwner !== null;
535
536 const ownerHeadSelector =
537 remoteRepository.ownerLogin && headBranch.length > 0
538 ? `${remoteRepository.ownerLogin}:${headBranch}`
539 : null;
540 const remoteAliasHeadSelector =
541 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
542 const shouldProbeRemoteOwnedSelectors =
543 isCrossRepository || (remoteName !== null && remoteName !== "origin");
544
545 const headSelectors: string[] = [];
546 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
547 appendUnique(headSelectors, ownerHeadSelector);
548 appendUnique(
549 headSelectors,
550 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
551 );
552 }
553 appendUnique(headSelectors, details.branch);
554 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
555 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
556 appendUnique(headSelectors, ownerHeadSelector);
557 appendUnique(
558 headSelectors,
559 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
560 );
561 }
562548
563 return {549 const isCrossRepository =
564 localBranch: details.branch,550 remoteRepository.repositoryNameWithOwner !== null &&
565 headBranch,551 originRepository.repositoryNameWithOwner !== null
552 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
553 originRepository.repositoryNameWithOwner.toLowerCase()
554 : remoteName !== null &&
555 remoteName !== "origin" &&
556 remoteRepository.repositoryNameWithOwner !== null;
557
558 const ownerHeadSelector =
559 remoteRepository.ownerLogin && headBranch.length > 0
560 ? `${remoteRepository.ownerLogin}:${headBranch}`
561 : null;
562 const remoteAliasHeadSelector =
563 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
564 const shouldProbeRemoteOwnedSelectors =
565 isCrossRepository || (remoteName !== null && remoteName !== "origin");
566
567 const headSelectors: string[] = [];
568 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
569 appendUnique(headSelectors, ownerHeadSelector);
570 appendUnique(
566 headSelectors,571 headSelectors,
567 preferredHeadSelector:572 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
568 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,573 );
569 remoteName,574 }
570 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,575 appendUnique(headSelectors, details.branch);
571 headRepositoryOwnerLogin: remoteRepository.ownerLogin,576 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
572 isCrossRepository,577 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
573 } satisfies BranchHeadContext;578 appendUnique(headSelectors, ownerHeadSelector);
574 });579 appendUnique(
580 headSelectors,
581 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
582 );
583 }
575584
576 const findOpenPr = (cwd: string, headSelectors: ReadonlyArray<string>) =>585 return {
577 Effect.gen(function* () {586 localBranch: details.branch,
578 for (const headSelector of headSelectors) {587 headBranch,
579 const pullRequests = yield* gitHubCli.listOpenPullRequests({588 headSelectors,
580 cwd,589 preferredHeadSelector:
581 headSelector,590 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
582 limit: 1,591 remoteName,
583 });592 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
593 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
594 isCrossRepository,
595 } satisfies BranchHeadContext;
596 });
584597
585 const [firstPullRequest] = pullRequests;598 const findOpenPr = Effect.fn("findOpenPr")(function* (
586 if (firstPullRequest) {599 cwd: string,
587 return {600 headSelectors: ReadonlyArray<string>,
588 number: firstPullRequest.number,601 ) {
589 title: firstPullRequest.title,602 for (const headSelector of headSelectors) {
590 url: firstPullRequest.url,603 const pullRequests = yield* gitHubCli.listOpenPullRequests({
591 baseRefName: firstPullRequest.baseRefName,604 cwd,
592 headRefName: firstPullRequest.headRefName,605 headSelector,
593 state: "open",606 limit: 1,
594 updatedAt: null,607 });
595 } satisfies PullRequestInfo;
596 }
597 }
598608
599 return null;609 const [firstPullRequest] = pullRequests;
600 });610 if (firstPullRequest) {
611 return {
612 number: firstPullRequest.number,
613 title: firstPullRequest.title,
614 url: firstPullRequest.url,
615 baseRefName: firstPullRequest.baseRefName,
616 headRefName: firstPullRequest.headRefName,
617 state: "open",
618 updatedAt: null,
619 } satisfies PullRequestInfo;
620 }
621 }
601622
602 const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) =>623 return null;
603 Effect.gen(function* () {624 });
604 const headContext = yield* resolveBranchHeadContext(cwd, details);
605 const parsedByNumber = new Map<number, PullRequestInfo>();
606625
607 for (const headSelector of headContext.headSelectors) {626 const findLatestPr = Effect.fn("findLatestPr")(function* (
608 const stdout = yield* gitHubCli627 cwd: string,
609 .execute({628 details: { branch: string; upstreamRef: string | null },
610 cwd,629 ) {
611 args: [630 const headContext = yield* resolveBranchHeadContext(cwd, details);
612 "pr",631 const parsedByNumber = new Map<number, PullRequestInfo>();
613 "list",
614 "--head",
615 headSelector,
616 "--state",
617 "all",
618 "--limit",
619 "20",
620 "--json",
621 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
622 ],
623 })
624 .pipe(Effect.map((result) => result.stdout));
625
626 const raw = stdout.trim();
627 if (raw.length === 0) {
628 continue;
629 }
630632
631 const parsedJson = yield* Effect.try({633 for (const headSelector of headContext.headSelectors) {
632 try: () => JSON.parse(raw) as unknown,634 const stdout = yield* gitHubCli
633 catch: (cause) =>635 .execute({
634 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),636 cwd,
635 });637 args: [
638 "pr",
639 "list",
640 "--head",
641 headSelector,
642 "--state",
643 "all",
644 "--limit",
645 "20",
646 "--json",
647 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
648 ],
649 })
650 .pipe(Effect.map((result) => result.stdout));
636651
637 for (const pr of parsePullRequestList(parsedJson)) {652 const raw = stdout.trim();
638 parsedByNumber.set(pr.number, pr);653 if (raw.length === 0) {
639 }654 continue;
640 }655 }
641656
642 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {657 const parsedJson = yield* Effect.try({
643 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;658 try: () => JSON.parse(raw) as unknown,
644 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;659 catch: (cause) =>
645 return right - left;660 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
646 });661 });
647662
648 const latestOpenPr = parsed.find((pr) => pr.state === "open");663 for (const pr of parsePullRequestList(parsedJson)) {
649 if (latestOpenPr) {664 parsedByNumber.set(pr.number, pr);
650 return latestOpenPr;
651 }665 }
652 return parsed[0] ?? null;666 }
667
668 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {
669 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
670 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
671 return right - left;
653 });672 });
654673
655 const resolveBaseBranch = (674 const latestOpenPr = parsed.find((pr) => pr.state === "open");
675 if (latestOpenPr) {
676 return latestOpenPr;
677 }
678 return parsed[0] ?? null;
679 });
680
681 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
656 cwd: string,682 cwd: string,
657 branch: string,683 branch: string,
658 upstreamRef: string | null,684 upstreamRef: string | null,
659 headContext: Pick<BranchHeadContext, "isCrossRepository">,685 headContext: Pick<BranchHeadContext, "isCrossRepository">,
660 ) =>686 ) {
661 Effect.gen(function* () {687 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
662 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);688 if (configured) return configured;
663 if (configured) return configured;689
664690 if (upstreamRef && !headContext.isCrossRepository) {
665 if (upstreamRef && !headContext.isCrossRepository) {691 const upstreamBranch = extractBranchFromRef(upstreamRef);
666 const upstreamBranch = extractBranchFromRef(upstreamRef);692 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
667 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {693 return upstreamBranch;
668 return upstreamBranch;
669 }
670 }694 }
695 }
671696
672 const defaultFromGh = yield* gitHubCli697 const defaultFromGh = yield* gitHubCli
673 .getDefaultBranch({ cwd })698 .getDefaultBranch({ cwd })
674 .pipe(Effect.catch(() => Effect.succeed(null)));699 .pipe(Effect.catch(() => Effect.succeed(null)));
675 if (defaultFromGh) {700 if (defaultFromGh) {
676 return defaultFromGh;701 return defaultFromGh;
677 }702 }
678703
679 return "main";704 return "main";
680 });705 });
681706
682 const resolveCommitAndBranchSuggestion = (input: {707 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
683 cwd: string;708 function* (input: {
684 branch: string | null;709 cwd: string;
685 commitMessage?: string;710 branch: string | null;
686 /** When true, also produce a semantic feature branch name. */711 commitMessage?: string;
687 includeBranch?: boolean;712 /** When true, also produce a semantic feature branch name. */
688 filePaths?: readonly string[];713 includeBranch?: boolean;
689 modelSelection: ModelSelection;714 filePaths?: readonly string[];
690 }) =>715 modelSelection: ModelSelection;
691 Effect.gen(function* () {716 }) {
692 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);717 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
693 if (!context) {718 if (!context) {
694 return null;719 return null;
...@@ -723,9 +748,10 @@ export const makeGitManager = Effect.gen(function* () {...@@ -723,9 +748,10 @@ export const makeGitManager = Effect.gen(function* () {
723 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),748 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
724 commitMessage: formatCommitMessage(generated.subject, generated.body),749 commitMessage: formatCommitMessage(generated.subject, generated.body),
725 };750 };
726 });751 },
752 );
727753
728 const runCommitStep = (754 const runCommitStep = Effect.fn("runCommitStep")(function* (
729 modelSelection: ModelSelection,755 modelSelection: ModelSelection,
730 cwd: string,756 cwd: string,
731 action: "commit" | "commit_push" | "commit_push_pr",757 action: "commit" | "commit_push" | "commit_push_pr",
...@@ -735,196 +761,198 @@ export const makeGitManager = Effect.gen(function* () {...@@ -735,196 +761,198 @@ export const makeGitManager = Effect.gen(function* () {
735 filePaths?: readonly string[],761 filePaths?: readonly string[],
736 progressReporter?: GitActionProgressReporter,762 progressReporter?: GitActionProgressReporter,
737 actionId?: string,763 actionId?: string,
738 ) =>764 ) {
739 Effect.gen(function* () {765 const emit = (event: GitActionProgressPayload) =>
740 const emit = (event: GitActionProgressPayload) =>766 progressReporter && actionId
741 progressReporter && actionId767 ? progressReporter.publish({
742 ? progressReporter.publish({768 actionId,
743 actionId,769 cwd,
744 cwd,770 action,
745 action,771 ...event,
746 ...event,772 } as GitActionProgressEvent)
747 } as GitActionProgressEvent)773 : Effect.void;
748 : Effect.void;774
749775 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
750 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;776 if (!suggestion) {
751 if (!suggestion) {777 const needsGeneration = !commitMessage?.trim();
752 const needsGeneration = !commitMessage?.trim();778 if (needsGeneration) {
753 if (needsGeneration) {779 yield* emit({
754 yield* emit({780 kind: "phase_started",
755 kind: "phase_started",781 phase: "commit",
756 phase: "commit",782 label: "Generating commit message...",
757 label: "Generating commit message...",
758 });
759 }
760 suggestion = yield* resolveCommitAndBranchSuggestion({
761 cwd,
762 branch,
763 ...(commitMessage ? { commitMessage } : {}),
764 ...(filePaths ? { filePaths } : {}),
765 modelSelection,
766 });783 });
767 }784 }
768 if (!suggestion) {785 suggestion = yield* resolveCommitAndBranchSuggestion({
769 return { status: "skipped_no_changes" as const };786 cwd,
770 }787 branch,
771788 ...(commitMessage ? { commitMessage } : {}),
772 yield* emit({789 ...(filePaths ? { filePaths } : {}),
773 kind: "phase_started",790 modelSelection,
774 phase: "commit",
775 label: "Committing...",
776 });791 });
792 }
793 if (!suggestion) {
794 return { status: "skipped_no_changes" as const };
795 }
777796
778 let currentHookName: string | null = null;797 yield* emit({
779 const commitProgress =798 kind: "phase_started",
780 progressReporter && actionId799 phase: "commit",
781 ? {800 label: "Committing...",
782 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {801 });
783 const sanitized = sanitizeProgressText(text);802
784 if (!sanitized) {803 let currentHookName: string | null = null;
785 return Effect.void;804 const commitProgress =
786 }805 progressReporter && actionId
787 return emit({806 ? {
788 kind: "hook_output",807 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
789 hookName: currentHookName,808 const sanitized = sanitizeProgressText(text);
790 stream,809 if (!sanitized) {
791 text: sanitized,810 return Effect.void;
792 });811 }
793 },812 return emit({
794 onHookStarted: (hookName: string) => {813 kind: "hook_output",
795 currentHookName = hookName;814 hookName: currentHookName,
796 return emit({815 stream,
797 kind: "hook_started",816 text: sanitized,
798 hookName,817 });
799 });818 },
800 },819 onHookStarted: (hookName: string) => {
801 onHookFinished: ({820 currentHookName = hookName;
821 return emit({
822 kind: "hook_started",
823 hookName,
824 });
825 },
826 onHookFinished: ({
827 hookName,
828 exitCode,
829 durationMs,
830 }: {
831 hookName: string;
832 exitCode: number | null;
833 durationMs: number | null;
834 }) => {
835 if (currentHookName === hookName) {
836 currentHookName = null;
837 }
838 return emit({
839 kind: "hook_finished",
802 hookName,840 hookName,
803 exitCode,841 exitCode,
804 durationMs,842 durationMs,
805 }: {843 });
806 hookName: string;844 },
807 exitCode: number | null;845 }
808 durationMs: number | null;846 : null;
809 }) => {847 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
810 if (currentHookName === hookName) {848 timeoutMs: COMMIT_TIMEOUT_MS,
811 currentHookName = null;849 ...(commitProgress ? { progress: commitProgress } : {}),
812 }
813 return emit({
814 kind: "hook_finished",
815 hookName,
816 exitCode,
817 durationMs,
818 });
819 },
820 }
821 : null;
822 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
823 timeoutMs: COMMIT_TIMEOUT_MS,
824 ...(commitProgress ? { progress: commitProgress } : {}),
825 });
826 if (currentHookName !== null) {
827 yield* emit({
828 kind: "hook_finished",
829 hookName: currentHookName,
830 exitCode: 0,
831 durationMs: null,
832 });
833 currentHookName = null;
834 }
835 return {
836 status: "created" as const,
837 commitSha,
838 subject: suggestion.subject,
839 };
840 });850 });
851 if (currentHookName !== null) {
852 yield* emit({
853 kind: "hook_finished",
854 hookName: currentHookName,
855 exitCode: 0,
856 durationMs: null,
857 });
858 currentHookName = null;
859 }
860 return {
861 status: "created" as const,
862 commitSha,
863 subject: suggestion.subject,
864 };
865 });
841866
842 const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) =>867 const runPrStep = Effect.fn("runPrStep")(function* (
843 Effect.gen(function* () {868 modelSelection: ModelSelection,
844 const details = yield* gitCore.statusDetails(cwd);869 cwd: string,
845 const branch = details.branch ?? fallbackBranch;870 fallbackBranch: string | null,
846 if (!branch) {871 ) {
847 return yield* gitManagerError(872 const details = yield* gitCore.statusDetails(cwd);
848 "runPrStep",873 const branch = details.branch ?? fallbackBranch;
849 "Cannot create a pull request from detached HEAD.",874 if (!branch) {
850 );875 return yield* gitManagerError(
851 }876 "runPrStep",
852 if (!details.hasUpstream) {877 "Cannot create a pull request from detached HEAD.",
853 return yield* gitManagerError(878 );
854 "runPrStep",879 }
855 "Current branch has not been pushed. Push before creating a PR.",880 if (!details.hasUpstream) {
856 );881 return yield* gitManagerError(
857 }882 "runPrStep",
883 "Current branch has not been pushed. Push before creating a PR.",
884 );
885 }
858886
859 const headContext = yield* resolveBranchHeadContext(cwd, {887 const headContext = yield* resolveBranchHeadContext(cwd, {
860 branch,888 branch,
861 upstreamRef: details.upstreamRef,889 upstreamRef: details.upstreamRef,
862 });890 });
863891
864 const existing = yield* findOpenPr(cwd, headContext.headSelectors);892 const existing = yield* findOpenPr(cwd, headContext.headSelectors);
865 if (existing) {893 if (existing) {
866 return {894 return {
867 status: "opened_existing" as const,895 status: "opened_existing" as const,
868 url: existing.url,896 url: existing.url,
869 number: existing.number,897 number: existing.number,
870 baseBranch: existing.baseRefName,898 baseBranch: existing.baseRefName,
871 headBranch: existing.headRefName,899 headBranch: existing.headRefName,
872 title: existing.title,900 title: existing.title,
873 };901 };
874 }902 }
875903
876 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);904 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
877 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);905 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
906
907 const generated = yield* textGeneration.generatePrContent({
908 cwd,
909 baseBranch,
910 headBranch: headContext.headBranch,
911 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
912 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
913 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
914 modelSelection,
915 });
878916
879 const generated = yield* textGeneration.generatePrContent({917 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
918 yield* fileSystem
919 .writeFileString(bodyFile, generated.body)
920 .pipe(
921 Effect.mapError((cause) =>
922 gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),
923 ),
924 );
925 yield* gitHubCli
926 .createPullRequest({
880 cwd,927 cwd,
881 baseBranch,928 baseBranch,
882 headBranch: headContext.headBranch,929 headSelector: headContext.preferredHeadSelector,
883 commitSummary: limitContext(rangeContext.commitSummary, 20_000),930 title: generated.title,
884 diffSummary: limitContext(rangeContext.diffSummary, 20_000),931 bodyFile,
885 diffPatch: limitContext(rangeContext.diffPatch, 60_000),932 })
886 modelSelection,933 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
887 });934
888935 const created = yield* findOpenPr(cwd, headContext.headSelectors);
889 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);936 if (!created) {
890 yield* fileSystem
891 .writeFileString(bodyFile, generated.body)
892 .pipe(
893 Effect.mapError((cause) =>
894 gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),
895 ),
896 );
897 yield* gitHubCli
898 .createPullRequest({
899 cwd,
900 baseBranch,
901 headSelector: headContext.preferredHeadSelector,
902 title: generated.title,
903 bodyFile,
904 })
905 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
906
907 const created = yield* findOpenPr(cwd, headContext.headSelectors);
908 if (!created) {
909 return {
910 status: "created" as const,
911 baseBranch,
912 headBranch: headContext.headBranch,
913 title: generated.title,
914 };
915 }
916
917 return {937 return {
918 status: "created" as const,938 status: "created" as const,
919 url: created.url,939 baseBranch,
920 number: created.number,940 headBranch: headContext.headBranch,
921 baseBranch: created.baseRefName,941 title: generated.title,
922 headBranch: created.headRefName,
923 title: created.title,
924 };942 };
925 });943 }
944
945 return {
946 status: "created" as const,
947 url: created.url,
948 number: created.number,
949 baseBranch: created.baseRefName,
950 headBranch: created.headRefName,
951 title: created.title,
952 };
953 });
926954
927 const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {955 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
928 const details = yield* gitCore.statusDetails(input.cwd);956 const details = yield* gitCore.statusDetails(input.cwd);
929957
930 const pr =958 const pr =
...@@ -949,7 +977,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -949,7 +977,7 @@ export const makeGitManager = Effect.gen(function* () {
949 };977 };
950 });978 });
951979
952 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(980 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
953 function* (input) {981 function* (input) {
954 const pullRequest = yield* gitHubCli982 const pullRequest = yield* gitHubCli
955 .getPullRequest({983 .getPullRequest({
...@@ -962,185 +990,185 @@ export const makeGitManager = Effect.gen(function* () {...@@ -962,185 +990,185 @@ export const makeGitManager = Effect.gen(function* () {
962 },990 },
963 );991 );
964992
965 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced(993 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
966 function* (input) {994 "preparePullRequestThread",
967 const normalizedReference = normalizePullRequestReference(input.reference);995 )(function* (input) {
968 const rootWorktreePath = canonicalizeExistingPath(input.cwd);996 const normalizedReference = normalizePullRequestReference(input.reference);
969 const pullRequestSummary = yield* gitHubCli.getPullRequest({997 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
998 const pullRequestSummary = yield* gitHubCli.getPullRequest({
999 cwd: input.cwd,
1000 reference: normalizedReference,
1001 });
1002 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1003
1004 if (input.mode === "local") {
1005 yield* gitHubCli.checkoutPullRequest({
970 cwd: input.cwd,1006 cwd: input.cwd,
971 reference: normalizedReference,1007 reference: normalizedReference,
1008 force: true,
972 });1009 });
973 const pullRequest = toResolvedPullRequest(pullRequestSummary);1010 const details = yield* gitCore.statusDetails(input.cwd);
1011 yield* configurePullRequestHeadUpstream(
1012 input.cwd,
1013 {
1014 ...pullRequest,
1015 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1016 },
1017 details.branch ?? pullRequest.headBranch,
1018 );
1019 return {
1020 pullRequest,
1021 branch: details.branch ?? pullRequest.headBranch,
1022 worktreePath: null,
1023 };
1024 }
9741025
975 if (input.mode === "local") {1026 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
976 yield* gitHubCli.checkoutPullRequest({1027 worktreePath: string,
977 cwd: input.cwd,1028 ) {
978 reference: normalizedReference,1029 const details = yield* gitCore.statusDetails(worktreePath);
979 force: true,1030 yield* configurePullRequestHeadUpstream(
980 });1031 worktreePath,
981 const details = yield* gitCore.statusDetails(input.cwd);1032 {
982 yield* configurePullRequestHeadUpstream(1033 ...pullRequest,
983 input.cwd,1034 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
984 {1035 },
985 ...pullRequest,1036 details.branch ?? pullRequest.headBranch,
986 ...toPullRequestHeadRemoteInfo(pullRequestSummary),1037 );
987 },1038 });
988 details.branch ?? pullRequest.headBranch,
989 );
990 return {
991 pullRequest,
992 branch: details.branch ?? pullRequest.headBranch,
993 worktreePath: null,
994 };
995 }
9961039
997 const ensureExistingWorktreeUpstream = (worktreePath: string) =>1040 const pullRequestWithRemoteInfo = {
998 Effect.gen(function* () {1041 ...pullRequest,
999 const details = yield* gitCore.statusDetails(worktreePath);1042 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1000 yield* configurePullRequestHeadUpstream(1043 } as const;
1001 worktreePath,1044 const localPullRequestBranch =
1002 {1045 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1003 ...pullRequest,1046
1004 ...toPullRequestHeadRemoteInfo(pullRequestSummary),1047 const findLocalHeadBranch = (cwd: string) =>
1005 },1048 gitCore.listBranches({ cwd }).pipe(
1006 details.branch ?? pullRequest.headBranch,1049 Effect.map((result) => {
1050 const localBranch = result.branches.find(
1051 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1007 );1052 );
1008 });1053 if (localBranch) {
10091054 return localBranch;
1010 const pullRequestWithRemoteInfo = {1055 }
1011 ...pullRequest,1056 if (localPullRequestBranch === pullRequest.headBranch) {
1012 ...toPullRequestHeadRemoteInfo(pullRequestSummary),1057 return null;
1013 } as const;1058 }
1014 const localPullRequestBranch =1059 return (
1015 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);1060 result.branches.find(
10161061 (branch) =>
1017 const findLocalHeadBranch = (cwd: string) =>1062 !branch.isRemote &&
1018 gitCore.listBranches({ cwd }).pipe(1063 branch.name === pullRequest.headBranch &&
1019 Effect.map((result) => {1064 branch.worktreePath !== null &&
1020 const localBranch = result.branches.find(1065 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1021 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,1066 ) ?? null
1022 );1067 );
1023 if (localBranch) {1068 }),
1024 return localBranch;
1025 }
1026 if (localPullRequestBranch === pullRequest.headBranch) {
1027 return null;
1028 }
1029 return (
1030 result.branches.find(
1031 (branch) =>
1032 !branch.isRemote &&
1033 branch.name === pullRequest.headBranch &&
1034 branch.worktreePath !== null &&
1035 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1036 ) ?? null
1037 );
1038 }),
1039 );
1040
1041 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1042 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1043 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1044 : null;
1045 if (
1046 existingBranchBeforeFetch?.worktreePath &&
1047 existingBranchBeforeFetchPath !== rootWorktreePath
1048 ) {
1049 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1050 return {
1051 pullRequest,
1052 branch: localPullRequestBranch,
1053 worktreePath: existingBranchBeforeFetch.worktreePath,
1054 };
1055 }
1056 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1057 return yield* gitManagerError(
1058 "preparePullRequestThread",
1059 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1060 );
1061 }
1062
1063 yield* materializePullRequestHeadBranch(
1064 input.cwd,
1065 pullRequestWithRemoteInfo,
1066 localPullRequestBranch,
1067 );1069 );
10681070
1069 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);1071 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1070 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath1072 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1071 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)1073 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1072 : null;1074 : null;
1073 if (1075 if (
1074 existingBranchAfterFetch?.worktreePath &&1076 existingBranchBeforeFetch?.worktreePath &&
1075 existingBranchAfterFetchPath !== rootWorktreePath1077 existingBranchBeforeFetchPath !== rootWorktreePath
1076 ) {1078 ) {
1077 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);1079 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1078 return {1080 return {
1079 pullRequest,1081 pullRequest,
1080 branch: localPullRequestBranch,
1081 worktreePath: existingBranchAfterFetch.worktreePath,
1082 };
1083 }
1084 if (existingBranchAfterFetchPath === rootWorktreePath) {
1085 return yield* gitManagerError(
1086 "preparePullRequestThread",
1087 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1088 );
1089 }
1090
1091 const worktree = yield* gitCore.createWorktree({
1092 cwd: input.cwd,
1093 branch: localPullRequestBranch,1082 branch: localPullRequestBranch,
1094 path: null,1083 worktreePath: existingBranchBeforeFetch.worktreePath,
1095 });1084 };
1096 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);1085 }
1086 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1087 return yield* gitManagerError(
1088 "preparePullRequestThread",
1089 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1090 );
1091 }
1092
1093 yield* materializePullRequestHeadBranch(
1094 input.cwd,
1095 pullRequestWithRemoteInfo,
1096 localPullRequestBranch,
1097 );
10971098
1099 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1100 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1101 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1102 : null;
1103 if (
1104 existingBranchAfterFetch?.worktreePath &&
1105 existingBranchAfterFetchPath !== rootWorktreePath
1106 ) {
1107 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1098 return {1108 return {
1099 pullRequest,1109 pullRequest,
1100 branch: worktree.worktree.branch,1110 branch: localPullRequestBranch,
1101 worktreePath: worktree.worktree.path,1111 worktreePath: existingBranchAfterFetch.worktreePath,
1102 };1112 };
1103 },1113 }
1104 );1114 if (existingBranchAfterFetchPath === rootWorktreePath) {
1115 return yield* gitManagerError(
1116 "preparePullRequestThread",
1117 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1118 );
1119 }
11051120
1106 const runFeatureBranchStep = (1121 const worktree = yield* gitCore.createWorktree({
1122 cwd: input.cwd,
1123 branch: localPullRequestBranch,
1124 path: null,
1125 });
1126 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1127
1128 return {
1129 pullRequest,
1130 branch: worktree.worktree.branch,
1131 worktreePath: worktree.worktree.path,
1132 };
1133 });
1134
1135 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
1107 modelSelection: ModelSelection,1136 modelSelection: ModelSelection,
1108 cwd: string,1137 cwd: string,
1109 branch: string | null,1138 branch: string | null,
1110 commitMessage?: string,1139 commitMessage?: string,
1111 filePaths?: readonly string[],1140 filePaths?: readonly string[],
1112 ) =>1141 ) {
1113 Effect.gen(function* () {1142 const suggestion = yield* resolveCommitAndBranchSuggestion({
1114 const suggestion = yield* resolveCommitAndBranchSuggestion({1143 cwd,
1115 cwd,1144 branch,
1116 branch,1145 ...(commitMessage ? { commitMessage } : {}),
1117 ...(commitMessage ? { commitMessage } : {}),1146 ...(filePaths ? { filePaths } : {}),
1118 ...(filePaths ? { filePaths } : {}),1147 includeBranch: true,
1119 includeBranch: true,1148 modelSelection,
1120 modelSelection,1149 });
1121 });1150 if (!suggestion) {
1122 if (!suggestion) {1151 return yield* gitManagerError(
1123 return yield* gitManagerError(1152 "runFeatureBranchStep",
1124 "runFeatureBranchStep",1153 "Cannot create a feature branch because there are no changes to commit.",
1125 "Cannot create a feature branch because there are no changes to commit.",1154 );
1126 );1155 }
1127 }
11281156
1129 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);1157 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1130 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);1158 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
1131 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);1159 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
11321160
1133 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });1161 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });
1134 yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));1162 yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));
11351163
1136 return {1164 return {
1137 branchStep: { status: "created" as const, name: resolvedBranch },1165 branchStep: { status: "created" as const, name: resolvedBranch },
1138 resolvedCommitMessage: suggestion.commitMessage,1166 resolvedCommitMessage: suggestion.commitMessage,
1139 resolvedCommitSuggestion: suggestion,1167 resolvedCommitSuggestion: suggestion,
1140 };1168 };
1141 });1169 });
11421170
1143 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(1171 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
1144 function* (input, options) {1172 function* (input, options) {
1145 const progress = createProgressEmitter(input, options);1173 const progress = createProgressEmitter(input, options);
1146 const phases: GitActionProgressPhase[] = [1174 const phases: GitActionProgressPhase[] = [
...@@ -1149,9 +1177,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1149,9 +1177,12 @@ export const makeGitManager = Effect.gen(function* () {
1149 ...(input.action !== "commit" ? (["push"] as const) : []),1177 ...(input.action !== "commit" ? (["push"] as const) : []),
1150 ...(input.action === "commit_push_pr" ? (["pr"] as const) : []),1178 ...(input.action === "commit_push_pr" ? (["pr"] as const) : []),
1151 ];1179 ];
1152 let currentPhase: GitActionProgressPhase | null = null;1180 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
11531181
1154 const runAction = Effect.gen(function* () {1182 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
1183 GitRunStackedActionResult,
1184 GitManagerServiceError
1185 > {
1155 yield* progress.emit({1186 yield* progress.emit({
1156 kind: "action_started",1187 kind: "action_started",
1157 phases,1188 phases,
...@@ -1183,7 +1214,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1183,7 +1214,7 @@ export const makeGitManager = Effect.gen(function* () {
1183 );1214 );
11841215
1185 if (input.featureBranch) {1216 if (input.featureBranch) {
1186 currentPhase = "branch";1217 yield* Ref.set(currentPhase, Option.some("branch"));
1187 yield* progress.emit({1218 yield* progress.emit({
1188 kind: "phase_started",1219 kind: "phase_started",
1189 phase: "branch",1220 phase: "branch",
...@@ -1205,7 +1236,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1205,7 +1236,7 @@ export const makeGitManager = Effect.gen(function* () {
12051236
1206 const currentBranch = branchStep.name ?? initialStatus.branch;1237 const currentBranch = branchStep.name ?? initialStatus.branch;
12071238
1208 currentPhase = "commit";1239 yield* Ref.set(currentPhase, Option.some("commit"));
1209 const commit = yield* runCommitStep(1240 const commit = yield* runCommitStep(
1210 modelSelection,1241 modelSelection,
1211 input.cwd,1242 input.cwd,
...@@ -1226,12 +1257,8 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1226,12 +1257,8 @@ export const makeGitManager = Effect.gen(function* () {
1226 label: "Pushing...",1257 label: "Pushing...",
1227 })1258 })
1228 .pipe(1259 .pipe(
1229 Effect.flatMap(() =>1260 Effect.tap(() => Ref.set(currentPhase, Option.some("push"))),
1230 Effect.gen(function* () {1261 Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)),
1231 currentPhase = "push";
1232 return yield* gitCore.pushCurrentBranch(input.cwd, currentBranch);
1233 }),
1234 ),
1235 )1262 )
1236 : { status: "skipped_not_requested" as const };1263 : { status: "skipped_not_requested" as const };
12371264
...@@ -1243,12 +1270,8 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1243,12 +1270,8 @@ export const makeGitManager = Effect.gen(function* () {
1243 label: "Creating PR...",1270 label: "Creating PR...",
1244 })1271 })
1245 .pipe(1272 .pipe(
1246 Effect.flatMap(() =>1273 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
1247 Effect.gen(function* () {1274 Effect.flatMap(() => runPrStep(modelSelection, input.cwd, currentBranch)),
1248 currentPhase = "pr";
1249 return yield* runPrStep(modelSelection, input.cwd, currentBranch);
1250 }),
1251 ),
1252 )1275 )
1253 : { status: "skipped_not_requested" as const };1276 : { status: "skipped_not_requested" as const };
12541277
...@@ -1266,15 +1289,15 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1266,15 +1289,15 @@ export const makeGitManager = Effect.gen(function* () {
1266 return result;1289 return result;
1267 });1290 });
12681291
1269 return yield* runAction.pipe(1292 return yield* runAction().pipe(
1270 Effect.catch((error) =>1293 Effect.tapError((error) =>
1271 progress1294 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1272 .emit({1295 progress.emit({
1273 kind: "action_failed",1296 kind: "action_failed",
1274 phase: currentPhase,1297 phase: Option.getOrNull(phase),
1275 message: error.message,1298 message: error.message,
1276 })1299 }),
1277 .pipe(Effect.flatMap(() => Effect.fail(error))),1300 ),
1278 ),1301 ),
1279 );1302 );
1280 },1303 },
...@@ -1288,4 +1311,4 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1288,4 +1311,4 @@ export const makeGitManager = Effect.gen(function* () {
1288 } satisfies GitManagerShape;1311 } satisfies GitManagerShape;
1289});1312});
12901313
1291export const GitManagerLive = Layer.effect(GitManager, makeGitManager);1314export const GitManagerLive = Layer.effect(GitManager, makeGitManager());
docs/effect-fn-checklist.md+39-24
...@@ -24,6 +24,17 @@ const new = Effect.fn('functionName')(function* () {...@@ -24,6 +24,17 @@ const new = Effect.fn('functionName')(function* () {
24})24})
25```25```
2626
27- Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return<A, E, R> {})` to annotate the return type of the function if needed.
28
29- The 2nd argument works as a pipe, and it gets the effect and input as arguments:
30
31```ts
32Effect.fn("name")(
33 function* (input: Input): Effect.fn.Return<A, E, R> {},
34 (effect, input) => Effect.catch(effect, (reason) => Effect.logWarning("Err", { input, reason })),
35);
36```
37
27## Summary38## Summary
2839
29- Total non-test candidates: `322`40- Total non-test candidates: `322`
...@@ -32,9 +43,9 @@ const new = Effect.fn('functionName')(function* () {...@@ -32,9 +43,9 @@ const new = Effect.fn('functionName')(function* () {
3243
33- [ ] `apps/server/src/provider/Layers/ProviderService.ts`44- [ ] `apps/server/src/provider/Layers/ProviderService.ts`
34- [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts`45- [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts`
35- [ ] `apps/server/src/provider/Layers/CodexAdapter.ts`46- [x] `apps/server/src/provider/Layers/CodexAdapter.ts`
36- [ ] `apps/server/src/git/Layers/GitCore.ts`47- [x] `apps/server/src/git/Layers/GitCore.ts`
37- [ ] `apps/server/src/git/Layers/GitManager.ts`48- [x] `apps/server/src/git/Layers/GitManager.ts`
38- [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`49- [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
39- [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`50- [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
40- [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`51- [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`
...@@ -57,25 +68,25 @@ const new = Effect.fn('functionName')(function* () {...@@ -57,25 +68,25 @@ const new = Effect.fn('functionName')(function* () {
5768
58### `apps/server/src/git/Layers/GitCore.ts` (`58`)69### `apps/server/src/git/Layers/GitCore.ts` (`58`)
5970
60- [ ] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L495)71- [x] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L513)
61- [ ] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L317)72- [x] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L324)
62- [ ] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L449)73- [x] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L455)
63- [ ] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1178)74- [x] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1190)
64- [ ] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1217)75- [x] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1223)
65- [ ] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1316)76- [x] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1323)
66- [ ] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1697)77- [x] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1727)
67- [ ] Service methods and callback wrappers in this file78- [x] Service methods and callback wrappers in this file
6879
69### `apps/server/src/git/Layers/GitManager.ts` (`28`)80### `apps/server/src/git/Layers/GitManager.ts` (`28`)
7081
71- [ ] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387)82- [x] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387)
72- [ ] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428)83- [x] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428)
73- [ ] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576)84- [x] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576)
74- [ ] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602)85- [x] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602)
75- [ ] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728)86- [x] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728)
76- [ ] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842)87- [x] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842)
77- [ ] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106)88- [x] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106)
78- [ ] Remaining helpers and nested callback wrappers in this file89- [x] Remaining helpers and nested callback wrappers in this file
7990
80### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`)91### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`)
8192
...@@ -113,11 +124,11 @@ const new = Effect.fn('functionName')(function* () {...@@ -113,11 +124,11 @@ const new = Effect.fn('functionName')(function* () {
113124
114### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`)125### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`)
115126
116- [ ] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317)127- [x] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317)
117- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399)128- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399)
118- [ ] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546)129- [x] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546)
119- [ ] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555)130- [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555)
120- [ ] Remaining nested callback wrappers in this file131- [x] Remaining nested callback wrappers in this file
121132
122### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`)133### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`)
123134
...@@ -181,3 +192,7 @@ const new = Effect.fn('functionName')(function* () {...@@ -181,3 +192,7 @@ const new = Effect.fn('functionName')(function* () {
181- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`)192- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`)
182- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`)193- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`)
183- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`)194- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`)
195
196```
197
198```