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 {
294294
295295const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
296296
297const createTrace2Monitor = Effect.fn(function* (
297const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
298298 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
299299 progress: ExecuteGitProgress | undefined,
300300): Effect.fn.Return<
......@@ -321,61 +321,58 @@ const createTrace2Monitor = Effect.fn(function* (
321321 remainder: "",
322322 });
323323
324 const handleTraceLine = (line: string) =>
325 Effect.gen(function* () {
326 const trimmedLine = line.trim();
327 if (trimmedLine.length === 0) {
328 return;
329 }
324 const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) {
325 const trimmedLine = line.trim();
326 if (trimmedLine.length === 0) {
327 return;
328 }
330329
331 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
332 if (Result.isFailure(traceRecord)) {
333 yield* Effect.logDebug(
334 `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
335 traceRecord.failure,
336 );
337 return;
338 }
330 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
331 if (Result.isFailure(traceRecord)) {
332 yield* Effect.logDebug(
333 `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
334 traceRecord.failure,
335 );
336 return;
337 }
339338
340 if (traceRecord.success.child_class !== "hook") {
341 return;
342 }
339 if (traceRecord.success.child_class !== "hook") {
340 return;
341 }
343342
344 const event = traceRecord.success.event;
345 const childKey = trace2ChildKey(traceRecord.success);
346 if (childKey === null) {
347 return;
348 }
349 const started = hookStartByChildKey.get(childKey);
350 const hookNameFromEvent =
351 typeof traceRecord.success.hook_name === "string"
352 ? traceRecord.success.hook_name.trim()
353 : "";
354 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
355 if (hookName.length === 0) {
356 return;
357 }
343 const event = traceRecord.success.event;
344 const childKey = trace2ChildKey(traceRecord.success);
345 if (childKey === null) {
346 return;
347 }
348 const started = hookStartByChildKey.get(childKey);
349 const hookNameFromEvent =
350 typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : "";
351 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
352 if (hookName.length === 0) {
353 return;
354 }
358355
359 if (event === "child_start") {
360 hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() });
361 if (progress.onHookStarted) {
362 yield* progress.onHookStarted(hookName);
363 }
364 return;
356 if (event === "child_start") {
357 hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() });
358 if (progress.onHookStarted) {
359 yield* progress.onHookStarted(hookName);
365360 }
361 return;
362 }
366363
367 if (event === "child_exit") {
368 hookStartByChildKey.delete(childKey);
369 if (progress.onHookFinished) {
370 const code = traceRecord.success.code;
371 yield* progress.onHookFinished({
372 hookName: started?.hookName ?? hookName,
373 exitCode: typeof code === "number" && Number.isInteger(code) ? code : null,
374 durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null,
375 });
376 }
364 if (event === "child_exit") {
365 hookStartByChildKey.delete(childKey);
366 if (progress.onHookFinished) {
367 const code = traceRecord.success.code;
368 yield* progress.onHookFinished({
369 hookName: started?.hookName ?? hookName,
370 exitCode: typeof code === "number" && Number.isInteger(code) ? code : null,
371 durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null,
372 });
377373 }
378 });
374 }
375 });
379376
380377 const deltaMutex = yield* Semaphore.make(1);
381378 const readTraceDelta = deltaMutex.withPermit(
......@@ -418,21 +415,21 @@ const createTrace2Monitor = Effect.fn(function* (
418415 return readTraceDelta;
419416 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
420417
421 yield* Effect.addFinalizer(() =>
422 Effect.gen(function* () {
423 yield* readTraceDelta;
424 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
425 remainder.trim(),
426 {
427 processedChars,
428 remainder: "",
429 },
430 ]);
431 if (finalLine.length > 0) {
432 yield* handleTraceLine(finalLine);
433 }
434 }),
435 );
418 const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () {
419 yield* readTraceDelta;
420 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
421 remainder.trim(),
422 {
423 processedChars,
424 remainder: "",
425 },
426 ]);
427 if (finalLine.length > 0) {
428 yield* handleTraceLine(finalLine);
429 }
430 });
431
432 yield* Effect.addFinalizer(finalizeTrace2Monitor);
436433
437434 return {
438435 env: {
......@@ -442,7 +439,7 @@ const createTrace2Monitor = Effect.fn(function* (
442439 };
443440});
444441
445const collectOutput = Effect.fn(function* <E>(
442const collectOutput = Effect.fn("collectOutput")(function* <E>(
446443 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
447444 stream: Stream.Stream<Uint8Array, E>,
448445 maxOutputBytes: number,
......@@ -455,55 +452,56 @@ const collectOutput = Effect.fn(function* <E>(
455452 let lineBuffer = "";
456453 let truncated = false;
457454
458 const emitCompleteLines = (flush: boolean) =>
459 Effect.gen(function* () {
460 let newlineIndex = lineBuffer.indexOf("\n");
461 while (newlineIndex >= 0) {
462 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
463 lineBuffer = lineBuffer.slice(newlineIndex + 1);
464 if (line.length > 0 && onLine) {
465 yield* onLine(line);
466 }
467 newlineIndex = lineBuffer.indexOf("\n");
455 const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) {
456 let newlineIndex = lineBuffer.indexOf("\n");
457 while (newlineIndex >= 0) {
458 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
459 lineBuffer = lineBuffer.slice(newlineIndex + 1);
460 if (line.length > 0 && onLine) {
461 yield* onLine(line);
468462 }
463 newlineIndex = lineBuffer.indexOf("\n");
464 }
469465
470 if (flush) {
471 const trailing = lineBuffer.replace(/\r$/, "");
472 lineBuffer = "";
473 if (trailing.length > 0 && onLine) {
474 yield* onLine(trailing);
475 }
466 if (flush) {
467 const trailing = lineBuffer.replace(/\r$/, "");
468 lineBuffer = "";
469 if (trailing.length > 0 && onLine) {
470 yield* onLine(trailing);
476471 }
477 });
472 }
473 });
478474
479 yield* Stream.runForEach(stream, (chunk) =>
480 Effect.gen(function* () {
481 if (truncateOutputAtMaxBytes && truncated) {
482 return;
483 }
484 const nextBytes = bytes + chunk.byteLength;
485 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
486 return yield* new GitCommandError({
487 operation: input.operation,
488 command: quoteGitCommand(input.args),
489 cwd: input.cwd,
490 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
491 });
492 }
475 const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) {
476 if (truncateOutputAtMaxBytes && truncated) {
477 return;
478 }
479 const nextBytes = bytes + chunk.byteLength;
480 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
481 return yield* new GitCommandError({
482 operation: input.operation,
483 command: quoteGitCommand(input.args),
484 cwd: input.cwd,
485 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
486 });
487 }
493488
494 const chunkToDecode =
495 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
496 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
497 : chunk;
498 bytes += chunkToDecode.byteLength;
499 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;
489 const chunkToDecode =
490 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
491 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
492 : chunk;
493 bytes += chunkToDecode.byteLength;
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 text += decoded;
503 lineBuffer += decoded;
504 yield* emitCompleteLines(false);
505 }),
506 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
502 yield* Stream.runForEach(stream, processChunk).pipe(
503 Effect.mapError(toGitCommandError(input, "output stream failed.")),
504 );
507505
508506 const remainder = truncated ? "" : decoder.decode();
509507 text += remainder;
......@@ -512,1367 +510,1343 @@ const collectOutput = Effect.fn(function* <E>(
512510 return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text;
513511});
514512
515export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) =>
516 Effect.gen(function* () {
517 const fileSystem = yield* FileSystem.FileSystem;
518 const path = yield* Path.Path;
519 const { worktreesDir } = yield* ServerConfig;
520
521 let execute: GitCoreShape["execute"];
522
523 if (options?.executeOverride) {
524 execute = options.executeOverride;
525 } else {
526 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
527 execute = Effect.fnUntraced(function* (input) {
528 const commandInput = {
529 ...input,
530 args: [...input.args],
531 } as const;
532 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
533 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
534 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
535
536 const commandEffect = Effect.gen(function* () {
537 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
538 Effect.provideService(Path.Path, path),
539 Effect.provideService(FileSystem.FileSystem, fileSystem),
540 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
541 );
542 const child = yield* commandSpawner
543 .spawn(
544 ChildProcess.make("git", commandInput.args, {
545 cwd: commandInput.cwd,
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),
513export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
514 executeOverride?: GitCoreShape["execute"];
515}) {
516 const fileSystem = yield* FileSystem.FileSystem;
517 const path = yield* Path.Path;
518 const { worktreesDir } = yield* ServerConfig;
519
520 let execute: GitCoreShape["execute"];
521
522 if (options?.executeOverride) {
523 execute = options.executeOverride;
524 } else {
525 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
526 execute = Effect.fnUntraced(function* (input) {
527 const commandInput = {
528 ...input,
529 args: [...input.args],
530 } as const;
531 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
532 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
533 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
534
535 const runGitCommand = Effect.fn("runGitCommand")(function* () {
536 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
537 Effect.provideService(Path.Path, path),
538 Effect.provideService(FileSystem.FileSystem, fileSystem),
539 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
540 );
541 const child = yield* commandSpawner
542 .spawn(
543 ChildProcess.make("git", commandInput.args, {
585544 cwd: commandInput.cwd,
586 detail:
587 trimmedStderr.length > 0
588 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
589 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
590 });
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,
545 env: {
546 ...process.env,
547 ...input.env,
548 ...trace2Monitor.env,
549 },
611550 }),
612 ),
613 );
614 });
615 }
551 )
552 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
616553
617 const executeGit = (
618 operation: string,
619 cwd: string,
620 args: readonly string[],
621 options: ExecuteGitOptions = {},
622 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>
623 execute({
624 operation,
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"}`,
554 const [stdout, stderr, exitCode] = yield* Effect.all(
555 [
556 collectOutput(
557 commandInput,
558 child.stdout,
559 maxOutputBytes,
560 truncateOutputAtMaxBytes,
561 input.progress?.onStdoutLine,
654562 ),
655 );
656 }),
657 );
563 collectOutput(
564 commandInput,
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 = (
660 operation: string,
661 cwd: string,
662 args: readonly string[],
663 allowNonZeroExit = false,
664 ): Effect.Effect<void, GitCommandError> =>
665 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
666
667 const runGitStdout = (
668 operation: string,
669 cwd: string,
670 args: readonly string[],
671 allowNonZeroExit = false,
672 ): Effect.Effect<string, GitCommandError> =>
673 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
674 Effect.map((result) => result.stdout),
592 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
593 });
594
595 return yield* runGitCommand().pipe(
596 Effect.scoped,
597 Effect.timeoutOption(timeoutMs),
598 Effect.flatMap((result) =>
599 Option.match(result, {
600 onNone: () =>
601 Effect.fail(
602 new GitCommandError({
603 operation: commandInput.operation,
604 command: quoteGitCommand(commandInput.args),
605 cwd: commandInput.cwd,
606 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
607 }),
608 ),
609 onSome: Effect.succeed,
610 }),
611 ),
675612 );
613 });
614 }
676615
677 const runGitStdoutWithOptions = (
678 operation: string,
679 cwd: string,
680 args: readonly string[],
681 options: ExecuteGitOptions = {},
682 ): Effect.Effect<string, GitCommandError> =>
683 executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout));
684
685 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
686 executeGit(
687 "GitCore.branchExists",
688 cwd,
689 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
690 {
691 allowNonZeroExit: true,
692 timeoutMs: 5_000,
693 },
694 ).pipe(Effect.map((result) => result.code === 0));
695
696 const resolveAvailableBranchName = (
697 cwd: string,
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;
616 const executeGit = (
617 operation: string,
618 cwd: string,
619 args: readonly string[],
620 options: ExecuteGitOptions = {},
621 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>
622 execute({
623 operation,
624 cwd,
625 args,
626 allowNonZeroExit: true,
627 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
628 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
629 ...(options.truncateOutputAtMaxBytes !== undefined
630 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
631 : {}),
632 ...(options.progress ? { progress: options.progress } : {}),
633 }).pipe(
634 Effect.flatMap((result) => {
635 if (options.allowNonZeroExit || result.code === 0) {
636 return Effect.succeed(result);
704637 }
705
706 for (let suffix = 1; suffix <= 100; suffix += 1) {
707 const candidate = `${desiredBranch}-${suffix}`;
708 const isCandidateTaken = yield* branchExists(cwd, candidate);
709 if (!isCandidateTaken) {
710 return candidate;
711 }
638 const stderr = result.stderr.trim();
639 if (stderr.length > 0) {
640 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
712641 }
713
714 return yield* createGitCommandError(
715 "GitCore.renameBranch",
716 cwd,
717 ["branch", "-m", "--", desiredBranch],
718 `Could not find an available branch name for '${desiredBranch}'.`,
642 if (options.fallbackErrorMessage) {
643 return Effect.fail(
644 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
645 );
646 }
647 return Effect.fail(
648 createGitCommandError(
649 operation,
650 cwd,
651 args,
652 `${commandLabel(args)} failed: code=${result.code ?? "null"}`,
653 ),
719654 );
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 = (
723 cwd: string,
724 ): Effect.Effect<
725 { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,
726 GitCommandError
727 > =>
728 Effect.gen(function* () {
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()));
704 for (let suffix = 1; suffix <= 100; suffix += 1) {
705 const candidate = `${desiredBranch}-${suffix}`;
706 const isCandidateTaken = yield* branchExists(cwd, candidate);
707 if (!isCandidateTaken) {
708 return candidate;
709 }
710 }
735711
736 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
737 return null;
738 }
712 return yield* createGitCommandError(
713 "GitCore.renameBranch",
714 cwd,
715 ["branch", "-m", "--", desiredBranch],
716 `Could not find an available branch name for '${desiredBranch}'.`,
717 );
718 });
739719
740 const separatorIndex = upstreamRef.indexOf("/");
741 if (separatorIndex <= 0) {
742 return null;
743 }
744 const remoteName = upstreamRef.slice(0, separatorIndex);
745 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
746 if (remoteName.length === 0 || upstreamBranch.length === 0) {
747 return null;
748 }
720 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
721 const upstreamRef = yield* runGitStdout(
722 "GitCore.resolveCurrentUpstream",
723 cwd,
724 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
725 true,
726 ).pipe(Effect.map((stdout) => stdout.trim()));
749727
750 return {
751 upstreamRef,
752 remoteName,
753 upstreamBranch,
754 };
755 });
728 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
729 return null;
730 }
756731
757 const fetchUpstreamRef = (
758 cwd: string,
759 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
760 ): Effect.Effect<void, GitCommandError> => {
761 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
762 return runGit(
763 "GitCore.fetchUpstreamRef",
764 cwd,
765 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
766 true,
767 );
768 };
732 const separatorIndex = upstreamRef.indexOf("/");
733 if (separatorIndex <= 0) {
734 return null;
735 }
736 const remoteName = upstreamRef.slice(0, separatorIndex);
737 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
738 if (remoteName.length === 0 || upstreamBranch.length === 0) {
739 return null;
740 }
769741
770 const fetchUpstreamRefForStatus = (
771 cwd: string,
772 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
773 ): Effect.Effect<void, GitCommandError> => {
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);
742 return {
743 upstreamRef,
744 remoteName,
745 upstreamBranch,
784746 };
747 });
785748
786 const statusUpstreamRefreshCache = yield* Cache.makeWith({
787 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
788 lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>
789 Effect.gen(function* () {
790 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
791 upstreamRef: cacheKey.upstreamRef,
792 remoteName: cacheKey.remoteName,
793 upstreamBranch: cacheKey.upstreamBranch,
794 });
795 return true as const;
796 }),
797 // Keep successful refreshes warm; drop failures immediately so next request can retry.
798 timeToLive: (exit) =>
799 Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero,
800 });
749 const fetchUpstreamRef = (
750 cwd: string,
751 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
752 ): Effect.Effect<void, GitCommandError> => {
753 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
754 return runGit(
755 "GitCore.fetchUpstreamRef",
756 cwd,
757 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
758 true,
759 );
760 };
801761
802 const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>
803 Effect.gen(function* () {
804 const upstream = yield* resolveCurrentUpstream(cwd);
805 if (!upstream) return;
806 yield* Cache.get(
807 statusUpstreamRefreshCache,
808 new StatusUpstreamRefreshCacheKey({
809 cwd,
810 upstreamRef: upstream.upstreamRef,
811 remoteName: upstream.remoteName,
812 upstreamBranch: upstream.upstreamBranch,
813 }),
814 );
815 });
762 const fetchUpstreamRefForStatus = (
763 cwd: string,
764 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
765 ): Effect.Effect<void, GitCommandError> => {
766 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
767 return executeGit(
768 "GitCore.fetchUpstreamRefForStatus",
769 cwd,
770 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
771 {
772 allowNonZeroExit: true,
773 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
774 },
775 ).pipe(Effect.asVoid);
776 };
816777
817 const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>
818 Effect.gen(function* () {
819 const upstream = yield* resolveCurrentUpstream(cwd);
820 if (!upstream) return;
821 yield* fetchUpstreamRef(cwd, upstream);
822 });
778 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
779 cacheKey: StatusUpstreamRefreshCacheKey,
780 ) {
781 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
782 upstreamRef: cacheKey.upstreamRef,
783 remoteName: cacheKey.remoteName,
784 upstreamBranch: cacheKey.upstreamBranch,
785 });
786 return true as const;
787 });
823788
824 const resolveDefaultBranchName = (
825 cwd: string,
826 remoteName: string,
827 ): Effect.Effect<string | null, GitCommandError> =>
828 executeGit(
829 "GitCore.resolveDefaultBranchName",
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 );
789 const statusUpstreamRefreshCache = yield* Cache.makeWith({
790 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
791 lookup: refreshStatusUpstreamCacheEntry,
792 // Keep successful refreshes warm; drop failures immediately so next request can retry.
793 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),
794 });
841795
842 const remoteBranchExists = (
843 cwd: string,
844 remoteName: string,
845 branch: string,
846 ): Effect.Effect<boolean, GitCommandError> =>
847 executeGit(
848 "GitCore.remoteBranchExists",
796 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
797 cwd: string,
798 ) {
799 const upstream = yield* resolveCurrentUpstream(cwd);
800 if (!upstream) return;
801 yield* Cache.get(
802 statusUpstreamRefreshCache,
803 new StatusUpstreamRefreshCacheKey({
849804 cwd,
850 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
851 {
852 allowNonZeroExit: true,
853 },
854 ).pipe(Effect.map((result) => result.code === 0));
855
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 );
805 upstreamRef: upstream.upstreamRef,
806 remoteName: upstream.remoteName,
807 upstreamBranch: upstream.upstreamBranch,
808 }),
809 );
810 });
865811
866 const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> =>
867 Effect.gen(function* () {
868 if (yield* originRemoteExists(cwd)) {
869 return "origin";
870 }
871 const remotes = yield* listRemoteNames(cwd);
872 const [firstRemote] = remotes;
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 });
812 const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* (
813 cwd: string,
814 ) {
815 const upstream = yield* resolveCurrentUpstream(cwd);
816 if (!upstream) return;
817 yield* fetchUpstreamRef(cwd, upstream);
818 });
883819
884 const resolvePushRemoteName = (
885 cwd: string,
886 branch: string,
887 ): Effect.Effect<string | null, GitCommandError> =>
888 Effect.gen(function* () {
889 const branchPushRemote = yield* runGitStdout(
890 "GitCore.resolvePushRemoteName.branchPushRemote",
891 cwd,
892 ["config", "--get", `branch.${branch}.pushRemote`],
893 true,
894 ).pipe(Effect.map((stdout) => stdout.trim()));
895 if (branchPushRemote.length > 0) {
896 return branchPushRemote;
820 const resolveDefaultBranchName = (
821 cwd: string,
822 remoteName: string,
823 ): Effect.Effect<string | null, GitCommandError> =>
824 executeGit(
825 "GitCore.resolveDefaultBranchName",
826 cwd,
827 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
828 { allowNonZeroExit: true },
829 ).pipe(
830 Effect.map((result) => {
831 if (result.code !== 0) {
832 return null;
897833 }
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(
900 "GitCore.resolvePushRemoteName.remotePushDefault",
901 cwd,
902 ["config", "--get", "remote.pushDefault"],
903 true,
904 ).pipe(Effect.map((stdout) => stdout.trim()));
905 if (pushDefaultRemote.length > 0) {
906 return pushDefaultRemote;
907 }
879 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
880 cwd: string,
881 branch: string,
882 ) {
883 const branchPushRemote = yield* runGitStdout(
884 "GitCore.resolvePushRemoteName.branchPushRemote",
885 cwd,
886 ["config", "--get", `branch.${branch}.pushRemote`],
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)));
910 });
893 const pushDefaultRemote = yield* runGitStdout(
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) =>
913 Effect.gen(function* () {
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 }
903 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
904 });
927905
928 let remoteName = preferredName;
929 let suffix = 1;
930 while (remoteFetchUrls.has(remoteName)) {
931 remoteName = `${preferredName}-${suffix}`;
932 suffix += 1;
933 }
906 const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) {
907 const preferredName = sanitizeRemoteName(input.preferredName);
908 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
909 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [
910 "remote",
911 "-v",
912 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
934913
935 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
936 "remote",
937 "add",
938 remoteName,
939 input.url,
940 ]);
914 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
915 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
941916 return remoteName;
942 });
943
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()));
917 }
918 }
955919
956 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
957 Effect.catch(() => Effect.succeed(null)),
958 );
959 const defaultBranch =
960 primaryRemoteName === null
961 ? null
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 }
920 let remoteName = preferredName;
921 let suffix = 1;
922 while (remoteFetchUrls.has(remoteName)) {
923 remoteName = `${preferredName}-${suffix}`;
924 suffix += 1;
925 }
973926
974 const remotePrefix =
975 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
976 const normalizedCandidate = candidate.startsWith("origin/")
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 }
927 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);
928 return remoteName;
929 });
984930
985 if (yield* branchExists(cwd, normalizedCandidate)) {
986 return normalizedCandidate;
987 }
931 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
932 cwd: string,
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 (
990 primaryRemoteName &&
991 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
992 ) {
993 return `${primaryRemoteName}/${normalizedCandidate}`;
994 }
995 }
958 const remotePrefix =
959 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
960 const normalizedCandidate = candidate.startsWith("origin/")
961 ? candidate.slice("origin/".length)
962 : remotePrefix && candidate.startsWith(remotePrefix)
963 ? candidate.slice(remotePrefix.length)
964 : candidate;
965 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
966 continue;
967 }
996968
997 return null;
998 });
969 if (yield* branchExists(cwd, normalizedCandidate)) {
970 return normalizedCandidate;
971 }
999972
1000 const computeAheadCountAgainstBase = (
1001 cwd: string,
1002 branch: string,
1003 ): Effect.Effect<number, GitCommandError> =>
1004 Effect.gen(function* () {
1005 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);
1006 if (!baseBranch) {
1007 return 0;
1008 }
973 if (
974 primaryRemoteName &&
975 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
976 ) {
977 return `${primaryRemoteName}/${normalizedCandidate}`;
978 }
979 }
1009980
1010 const result = yield* executeGit(
1011 "GitCore.computeAheadCountAgainstBase",
1012 cwd,
1013 ["rev-list", "--count", `${baseBranch}..HEAD`],
1014 { allowNonZeroExit: true },
1015 );
1016 if (result.code !== 0) {
1017 return 0;
1018 }
981 return null;
982 });
1019983
1020 const parsed = Number.parseInt(result.stdout.trim(), 10);
1021 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1022 });
984 const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* (
985 cwd: string,
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> =>
1025 Effect.gen(function* () {
1026 const branchRecency = yield* executeGit(
1027 "GitCore.readBranchRecency",
1028 cwd,
1029 [
1030 "for-each-ref",
1031 "--format=%(refname:short)%09%(committerdate:unix)",
1032 "refs/heads",
1033 "refs/remotes",
1034 ],
1035 {
1036 timeoutMs: 15_000,
1037 allowNonZeroExit: true,
1038 },
1039 );
993 const result = yield* executeGit(
994 "GitCore.computeAheadCountAgainstBase",
995 cwd,
996 ["rev-list", "--count", `${baseBranch}..HEAD`],
997 { allowNonZeroExit: true },
998 );
999 if (result.code !== 0) {
1000 return 0;
1001 }
10401002
1041 const branchLastCommit = new Map<string, number>();
1042 if (branchRecency.code !== 0) {
1043 return branchLastCommit;
1044 }
1003 const parsed = Number.parseInt(result.stdout.trim(), 10);
1004 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1005 });
10451006
1046 for (const line of branchRecency.stdout.split("\n")) {
1047 if (line.length === 0) {
1048 continue;
1049 }
1050 const [name, lastCommitRaw] = line.split("\t");
1051 if (!name) {
1052 continue;
1053 }
1054 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
1055 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
1056 }
1007 const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) {
1008 const branchRecency = yield* executeGit(
1009 "GitCore.readBranchRecency",
1010 cwd,
1011 [
1012 "for-each-ref",
1013 "--format=%(refname:short)%09%(committerdate:unix)",
1014 "refs/heads",
1015 "refs/remotes",
1016 ],
1017 {
1018 timeoutMs: 15_000,
1019 allowNonZeroExit: true,
1020 },
1021 );
10571022
1058 return branchLastCommit;
1059 });
1023 const branchLastCommit = new Map<string, number>();
1024 if (branchRecency.code !== 0) {
1025 return branchLastCommit;
1026 }
10601027
1061 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>
1062 Effect.gen(function* () {
1063 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1028 for (const line of branchRecency.stdout.split("\n")) {
1029 if (line.length === 0) {
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(
1066 [
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 );
1040 return branchLastCommit;
1041 });
10811042
1082 let branch: string | null = null;
1083 let upstreamRef: string | null = null;
1084 let aheadCount = 0;
1085 let behindCount = 0;
1086 let hasWorkingTreeChanges = false;
1087 const changedFilesWithoutNumstat = new Set<string>();
1088
1089 for (const line of statusStdout.split(/\r?\n/g)) {
1090 if (line.startsWith("# branch.head ")) {
1091 const value = line.slice("# branch.head ".length).trim();
1092 branch = value.startsWith("(") ? null : value;
1093 continue;
1094 }
1095 if (line.startsWith("# branch.upstream ")) {
1096 const value = line.slice("# branch.upstream ".length).trim();
1097 upstreamRef = value.length > 0 ? value : null;
1098 continue;
1099 }
1100 if (line.startsWith("# branch.ab ")) {
1101 const value = line.slice("# branch.ab ".length).trim();
1102 const parsed = parseBranchAb(value);
1103 aheadCount = parsed.ahead;
1104 behindCount = parsed.behind;
1105 continue;
1106 }
1107 if (line.trim().length > 0 && !line.startsWith("#")) {
1108 hasWorkingTreeChanges = true;
1109 const pathValue = parsePorcelainPath(line);
1110 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
1111 }
1112 }
1043 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1044 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1045
1046 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
1047 [
1048 runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),
1049 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1050 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),
1051 ],
1052 { concurrency: "unbounded" },
1053 );
1054
1055 let branch: string | null = null;
1056 let upstreamRef: string | null = null;
1057 let aheadCount = 0;
1058 let behindCount = 0;
1059 let hasWorkingTreeChanges = false;
1060 const changedFilesWithoutNumstat = new Set<string>();
1061
1062 for (const line of statusStdout.split(/\r?\n/g)) {
1063 if (line.startsWith("# branch.head ")) {
1064 const value = line.slice("# branch.head ".length).trim();
1065 branch = value.startsWith("(") ? null : value;
1066 continue;
1067 }
1068 if (line.startsWith("# branch.upstream ")) {
1069 const value = line.slice("# branch.upstream ".length).trim();
1070 upstreamRef = value.length > 0 ? value : null;
1071 continue;
1072 }
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) {
1115 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1116 Effect.catch(() => Effect.succeed(0)),
1117 );
1118 behindCount = 0;
1119 }
1087 if (!upstreamRef && branch) {
1088 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1089 Effect.catch(() => Effect.succeed(0)),
1090 );
1091 behindCount = 0;
1092 }
11201093
1121 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1122 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1123 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
1124 for (const entry of [...stagedEntries, ...unstagedEntries]) {
1125 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
1126 existing.insertions += entry.insertions;
1127 existing.deletions += entry.deletions;
1128 fileStatMap.set(entry.path, existing);
1129 }
1094 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1095 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1096 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
1097 for (const entry of [...stagedEntries, ...unstagedEntries]) {
1098 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
1099 existing.insertions += entry.insertions;
1100 existing.deletions += entry.deletions;
1101 fileStatMap.set(entry.path, existing);
1102 }
11301103
1131 let insertions = 0;
1132 let deletions = 0;
1133 const files = Array.from(fileStatMap.entries())
1134 .map(([filePath, stat]) => {
1135 insertions += stat.insertions;
1136 deletions += stat.deletions;
1137 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
1138 })
1139 .toSorted((a, b) => a.path.localeCompare(b.path));
1140
1141 for (const filePath of changedFilesWithoutNumstat) {
1142 if (fileStatMap.has(filePath)) continue;
1143 files.push({ path: filePath, insertions: 0, deletions: 0 });
1144 }
1145 files.sort((a, b) => a.path.localeCompare(b.path));
1104 let insertions = 0;
1105 let deletions = 0;
1106 const files = Array.from(fileStatMap.entries())
1107 .map(([filePath, stat]) => {
1108 insertions += stat.insertions;
1109 deletions += stat.deletions;
1110 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
1111 })
1112 .toSorted((a, b) => a.path.localeCompare(b.path));
1113
1114 for (const filePath of changedFilesWithoutNumstat) {
1115 if (fileStatMap.has(filePath)) continue;
1116 files.push({ path: filePath, insertions: 0, deletions: 0 });
1117 }
1118 files.sort((a, b) => a.path.localeCompare(b.path));
11461119
1147 return {
1148 branch,
1149 upstreamRef,
1150 hasWorkingTreeChanges,
1151 workingTree: {
1152 files,
1153 insertions,
1154 deletions,
1155 },
1156 hasUpstream: upstreamRef !== null,
1157 aheadCount,
1158 behindCount,
1159 };
1160 });
1120 return {
1121 branch,
1122 upstreamRef,
1123 hasWorkingTreeChanges,
1124 workingTree: {
1125 files,
1126 insertions,
1127 deletions,
1128 },
1129 hasUpstream: upstreamRef !== null,
1130 aheadCount,
1131 behindCount,
1132 };
1133 });
11611134
1162 const status: GitCoreShape["status"] = (input) =>
1163 statusDetails(input.cwd).pipe(
1164 Effect.map((details) => ({
1165 branch: details.branch,
1166 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1167 workingTree: details.workingTree,
1168 hasUpstream: details.hasUpstream,
1169 aheadCount: details.aheadCount,
1170 behindCount: details.behindCount,
1171 pr: null,
1172 })),
1135 const status: GitCoreShape["status"] = (input) =>
1136 statusDetails(input.cwd).pipe(
1137 Effect.map((details) => ({
1138 branch: details.branch,
1139 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1140 workingTree: details.workingTree,
1141 hasUpstream: details.hasUpstream,
1142 aheadCount: details.aheadCount,
1143 behindCount: details.behindCount,
1144 pr: null,
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),
11731154 );
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) =>
1176 Effect.gen(function* () {
1177 if (filePaths && filePaths.length > 0) {
1178 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1179 Effect.catch(() => Effect.void),
1180 );
1181 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
1182 "add",
1183 "-A",
1184 "--",
1185 ...filePaths,
1186 ]);
1187 } else {
1188 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1189 }
1165 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [
1166 "diff",
1167 "--cached",
1168 "--name-status",
1169 ]).pipe(Effect.map((stdout) => stdout.trim()));
1170 if (stagedSummary.length === 0) {
1171 return null;
1172 }
11901173
1191 const stagedSummary = yield* runGitStdout(
1192 "GitCore.prepareCommitContext.stagedSummary",
1193 cwd,
1194 ["diff", "--cached", "--name-status"],
1195 ).pipe(Effect.map((stdout) => stdout.trim()));
1196 if (stagedSummary.length === 0) {
1197 return null;
1198 }
1174 const stagedPatch = yield* runGitStdoutWithOptions(
1175 "GitCore.prepareCommitContext.stagedPatch",
1176 cwd,
1177 ["diff", "--cached", "--patch", "--minimal"],
1178 {
1179 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1180 truncateOutputAtMaxBytes: true,
1181 },
1182 );
1183
1184 return {
1185 stagedSummary,
1186 stagedPatch,
1187 };
1188 });
11991189
1200 const stagedPatch = yield* runGitStdoutWithOptions(
1201 "GitCore.prepareCommitContext.stagedPatch",
1190 const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* (
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",
12021230 cwd,
1203 ["diff", "--cached", "--patch", "--minimal"],
1204 {
1205 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1206 truncateOutputAtMaxBytes: true,
1207 },
1231 ["push"],
1232 "Cannot push from detached HEAD.",
12081233 );
1234 }
12091235
1210 return {
1211 stagedSummary,
1212 stagedPatch,
1213 };
1214 });
1215
1216 const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) =>
1217 Effect.gen(function* () {
1218 const args = ["commit", "-m", subject];
1219 const trimmedBody = body.trim();
1220 if (trimmedBody.length > 0) {
1221 args.push("-m", trimmedBody);
1236 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1237 if (hasNoLocalDelta) {
1238 if (details.hasUpstream) {
1239 return {
1240 status: "skipped_up_to_date" as const,
1241 branch,
1242 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1243 };
12221244 }
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) =>
1256 Effect.gen(function* () {
1257 const details = yield* statusDetails(cwd);
1258 const branch = details.branch ?? fallbackBranch;
1259 if (!branch) {
1260 return yield* createGitCommandError(
1261 "GitCore.pushCurrentBranch",
1262 cwd,
1263 ["push"],
1264 "Cannot push from detached HEAD.",
1246 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
1247 Effect.catch(() => Effect.succeed(null)),
1248 );
1249 if (comparableBaseBranch) {
1250 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
1251 Effect.catch(() => Effect.succeed(null)),
12651252 );
1266 }
1267
1268 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1269 if (hasNoLocalDelta) {
1270 if (details.hasUpstream) {
1253 if (!publishRemoteName) {
12711254 return {
12721255 status: "skipped_up_to_date" as const,
12731256 branch,
1274 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
12751257 };
12761258 }
12771259
1278 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
1279 Effect.catch(() => Effect.succeed(null)),
1260 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1261 Effect.catch(() => Effect.succeed(false)),
12801262 );
1281 if (comparableBaseBranch) {
1282 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
1283 Effect.catch(() => Effect.succeed(null)),
1284 );
1285 if (!publishRemoteName) {
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 }
1263 if (hasRemoteBranch) {
1264 return {
1265 status: "skipped_up_to_date" as const,
1266 branch,
1267 };
13011268 }
13021269 }
1270 }
13031271
1304 if (!details.hasUpstream) {
1305 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1306 if (!publishRemoteName) {
1307 return yield* createGitCommandError(
1308 "GitCore.pushCurrentBranch",
1309 cwd,
1310 ["push"],
1311 "Cannot push because no git remote is configured for this repository.",
1312 );
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 };
1272 if (!details.hasUpstream) {
1273 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1274 if (!publishRemoteName) {
1275 return yield* createGitCommandError(
1276 "GitCore.pushCurrentBranch",
1277 cwd,
1278 ["push"],
1279 "Cannot push because no git remote is configured for this repository.",
1280 );
13431281 }
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 ]);
13461305 return {
13471306 status: "pushed" as const,
13481307 branch,
1349 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1308 upstreamBranch: currentUpstream.upstreamRef,
13501309 setUpstream: false,
13511310 };
1352 });
1311 }
13531312
1354 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>
1355 Effect.gen(function* () {
1356 const details = yield* statusDetails(cwd);
1357 const branch = details.branch;
1358 if (!branch) {
1359 return yield* createGitCommandError(
1360 "GitCore.pullCurrentBranch",
1361 cwd,
1362 ["pull", "--ff-only"],
1363 "Cannot pull from detached HEAD.",
1364 );
1365 }
1366 if (!details.hasUpstream) {
1367 return yield* createGitCommandError(
1368 "GitCore.pullCurrentBranch",
1369 cwd,
1370 ["pull", "--ff-only"],
1371 "Current branch has no upstream configured. Push with upstream first.",
1372 );
1373 }
1374 const beforeSha = yield* runGitStdout(
1375 "GitCore.pullCurrentBranch.beforeSha",
1313 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
1314 return {
1315 status: "pushed" as const,
1316 branch,
1317 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1318 setUpstream: false,
1319 };
1320 },
1321 );
1322
1323 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")(
1324 function* (cwd) {
1325 const details = yield* statusDetails(cwd);
1326 const branch = details.branch;
1327 if (!branch) {
1328 return yield* createGitCommandError(
1329 "GitCore.pullCurrentBranch",
13761330 cwd,
1377 ["rev-parse", "HEAD"],
1378 true,
1379 ).pipe(Effect.map((stdout) => stdout.trim()));
1380 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
1381 timeoutMs: 30_000,
1382 fallbackErrorMessage: "git pull failed",
1383 });
1384 const afterSha = yield* runGitStdout(
1385 "GitCore.pullCurrentBranch.afterSha",
1331 ["pull", "--ff-only"],
1332 "Cannot pull from detached HEAD.",
1333 );
1334 }
1335 if (!details.hasUpstream) {
1336 return yield* createGitCommandError(
1337 "GitCore.pullCurrentBranch",
13861338 cwd,
1387 ["rev-parse", "HEAD"],
1388 true,
1389 ).pipe(Effect.map((stdout) => stdout.trim()));
1390
1391 const refreshed = yield* statusDetails(cwd);
1392 return {
1393 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
1394 branch,
1395 upstreamBranch: refreshed.upstreamRef,
1396 };
1339 ["pull", "--ff-only"],
1340 "Current branch has no upstream configured. Push with upstream first.",
1341 );
1342 }
1343 const beforeSha = yield* runGitStdout(
1344 "GitCore.pullCurrentBranch.beforeSha",
1345 cwd,
1346 ["rev-parse", "HEAD"],
1347 true,
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",
13971352 });
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) =>
1400 Effect.gen(function* () {
1401 const range = `${baseBranch}..HEAD`;
1402 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1403 [
1404 runGitStdoutWithOptions(
1405 "GitCore.readRangeContext.log",
1406 cwd,
1407 ["log", "--oneline", range],
1408 {
1409 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1410 truncateOutputAtMaxBytes: true,
1411 },
1412 ),
1413 runGitStdoutWithOptions(
1414 "GitCore.readRangeContext.diffStat",
1415 cwd,
1416 ["diff", "--stat", range],
1417 {
1418 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1419 truncateOutputAtMaxBytes: true,
1420 },
1421 ),
1422 runGitStdoutWithOptions(
1423 "GitCore.readRangeContext.diffPatch",
1424 cwd,
1425 ["diff", "--patch", "--minimal", range],
1426 {
1427 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1428 truncateOutputAtMaxBytes: true,
1429 },
1430 ),
1431 ],
1432 { concurrency: "unbounded" },
1433 );
1369 const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")(
1370 function* (cwd, baseBranch) {
1371 const range = `${baseBranch}..HEAD`;
1372 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1373 [
1374 runGitStdoutWithOptions(
1375 "GitCore.readRangeContext.log",
1376 cwd,
1377 ["log", "--oneline", range],
1378 {
1379 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1380 truncateOutputAtMaxBytes: true,
1381 },
1382 ),
1383 runGitStdoutWithOptions(
1384 "GitCore.readRangeContext.diffStat",
1385 cwd,
1386 ["diff", "--stat", range],
1387 {
1388 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1389 truncateOutputAtMaxBytes: true,
1390 },
1391 ),
1392 runGitStdoutWithOptions(
1393 "GitCore.readRangeContext.diffPatch",
1394 cwd,
1395 ["diff", "--patch", "--minimal", range],
1396 {
1397 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1398 truncateOutputAtMaxBytes: true,
1399 },
1400 ),
1401 ],
1402 { concurrency: "unbounded" },
1403 );
14341404
1435 return {
1436 commitSummary,
1437 diffSummary,
1438 diffPatch,
1439 };
1440 });
1405 return {
1406 commitSummary,
1407 diffSummary,
1408 diffPatch,
1409 };
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) =>
1443 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1444 Effect.map((stdout) => stdout.trim()),
1445 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1433 if (localBranchResult.code !== 0) {
1434 const stderr = localBranchResult.stderr.trim();
1435 if (stderr.toLowerCase().includes("not a git repository")) {
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",
14461443 );
1444 }
14471445
1448 const listBranches: GitCoreShape["listBranches"] = (input) =>
1449 Effect.gen(function* () {
1450 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1451 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1452 );
1453 const localBranchResult = yield* executeGit(
1454 "GitCore.listBranches.branchNoColor",
1455 input.cwd,
1456 ["branch", "--no-color"],
1457 {
1458 timeoutMs: 10_000,
1459 allowNonZeroExit: true,
1460 },
1461 );
1446 const remoteBranchResultEffect = executeGit(
1447 "GitCore.listBranches.remoteBranches",
1448 input.cwd,
1449 ["branch", "--no-color", "--remotes"],
1450 {
1451 timeoutMs: 10_000,
1452 allowNonZeroExit: true,
1453 },
1454 ).pipe(
1455 Effect.catch((error) =>
1456 Effect.logWarning(
1457 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1458 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
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) {
1464 const stderr = localBranchResult.stderr.trim();
1465 if (stderr.toLowerCase().includes("not a git repository")) {
1466 return { branches: [], isRepo: false, hasOriginRemote: false };
1467 }
1468 return yield* createGitCommandError(
1469 "GitCore.listBranches",
1478 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1479 yield* Effect.all(
1480 [
1481 executeGit(
1482 "GitCore.listBranches.defaultRef",
14701483 input.cwd,
1471 ["branch", "--no-color"],
1472 stderr || "git branch failed",
1473 );
1474 }
1475
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: "" })),
1484 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1485 {
1486 timeoutMs: 5_000,
1487 allowNonZeroExit: true,
1488 },
14891489 ),
1490 );
1491
1492 const remoteNamesResultEffect = executeGit(
1493 "GitCore.listBranches.remoteNames",
1494 input.cwd,
1495 ["remote"],
1496 {
1497 timeoutMs: 5_000,
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: "" })),
1490 executeGit(
1491 "GitCore.listBranches.worktreeList",
1492 input.cwd,
1493 ["worktree", "list", "--porcelain"],
1494 {
1495 timeoutMs: 5_000,
1496 allowNonZeroExit: true,
1497 },
15051498 ),
1506 );
1499 remoteBranchResultEffect,
1500 remoteNamesResultEffect,
1501 branchRecencyPromise,
1502 ],
1503 { concurrency: "unbounded" },
1504 );
15071505
1508 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1509 yield* Effect.all(
1510 [
1511 executeGit(
1512 "GitCore.listBranches.defaultRef",
1513 input.cwd,
1514 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1515 {
1516 timeoutMs: 5_000,
1517 allowNonZeroExit: true,
1518 },
1519 ),
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 );
1506 const remoteNames =
1507 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1508 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1509 yield* Effect.logWarning(
1510 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1511 );
1512 }
1513 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1514 yield* Effect.logWarning(
1515 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1516 );
1517 }
15351518
1536 const remoteNames =
1537 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1538 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1539 yield* Effect.logWarning(
1540 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1541 );
1542 }
1543 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1544 yield* Effect.logWarning(
1545 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1519 const defaultBranch =
1520 defaultRef.code === 0
1521 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1522 : null;
1523
1524 const worktreeMap = new Map<string, string>();
1525 if (worktreeList.code === 0) {
1526 let currentPath: string | null = null;
1527 for (const line of worktreeList.stdout.split("\n")) {
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)),
15461533 );
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;
15471539 }
1540 }
1541 }
15481542
1549 const defaultBranch =
1550 defaultRef.code === 0
1551 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1552 : null;
1553
1554 const worktreeMap = new Map<string, string>();
1555 if (worktreeList.code === 0) {
1556 let currentPath: string | null = null;
1557 for (const line of worktreeList.stdout.split("\n")) {
1558 if (line.startsWith("worktree ")) {
1559 const candidatePath = line.slice("worktree ".length);
1560 const exists = yield* fileSystem.stat(candidatePath).pipe(
1561 Effect.map(() => true),
1562 Effect.catch(() => Effect.succeed(false)),
1563 );
1564 currentPath = exists ? candidatePath : null;
1565 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1566 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1567 } else if (line === "") {
1568 currentPath = null;
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") };
1543 const localBranches = localBranchResult.stdout
1544 .split("\n")
1545 .map(parseBranchLine)
1546 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1547 .map((branch) => ({
1548 name: branch.name,
1549 current: branch.current,
1550 isRemote: false,
1551 isDefault: branch.name === defaultBranch,
1552 worktreePath: worktreeMap.get(branch.name) ?? null,
1553 }))
1554 .toSorted((a, b) => {
1555 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1556 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1557 if (aPriority !== bPriority) return aPriority - bPriority;
1558
1559 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1560 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1561 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1562 return a.name.localeCompare(b.name);
16331563 });
16341564
1635 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
1636 Effect.gen(function* () {
1637 const targetBranch = input.newBranch ?? input.branch;
1638 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1639 const repoName = path.basename(input.cwd);
1640 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1641 const args = input.newBranch
1642 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1643 : ["worktree", "add", worktreePath, input.branch];
1644
1645 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1646 fallbackErrorMessage: "git worktree add failed",
1647 });
1565 const remoteBranches =
1566 remoteBranchResult.code === 0
1567 ? remoteBranchResult.stdout
1568 .split("\n")
1569 .map(parseBranchLine)
1570 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1571 .map((branch) => {
1572 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1573 const remoteBranch: {
1574 name: string;
1575 current: boolean;
1576 isRemote: boolean;
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 {
1650 worktree: {
1651 path: worktreePath,
1652 branch: targetBranch,
1653 },
1654 };
1605 const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
1606 function* (input) {
1607 const targetBranch = input.newBranch ?? input.branch;
1608 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1609 const repoName = path.basename(input.cwd);
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",
16551617 });
16561618
1657 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) =>
1658 Effect.gen(function* () {
1659 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1660 yield* executeGit(
1661 "GitCore.fetchPullRequestBranch",
1662 input.cwd,
1663 [
1664 "fetch",
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 ]);
1619 return {
1620 worktree: {
1621 path: worktreePath,
1622 branch: targetBranch,
1623 },
1624 };
1625 },
1626 );
16851627
1686 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1687 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1688 yield* runGit(
1689 "GitCore.fetchRemoteBranch.materialize",
1690 input.cwd,
1691 localBranchAlreadyExists
1692 ? ["branch", "--force", input.localBranch, targetRef]
1693 : ["branch", input.localBranch, targetRef],
1694 );
1695 }).pipe(Effect.asVoid);
1696
1697 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1698 runGit("GitCore.setBranchUpstream", input.cwd, [
1699 "branch",
1700 "--set-upstream-to",
1701 `${input.remoteName}/${input.remoteBranch}`,
1702 input.branch,
1628 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn(
1629 "fetchPullRequestBranch",
1630 )(function* (input) {
1631 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1632 yield* executeGit(
1633 "GitCore.fetchPullRequestBranch",
1634 input.cwd,
1635 [
1636 "fetch",
1637 "--quiet",
1638 "--no-tags",
1639 remoteName,
1640 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1641 ],
1642 {
1643 fallbackErrorMessage: "git fetch pull request branch failed",
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}`,
17031656 ]);
17041657
1705 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
1706 Effect.gen(function* () {
1707 const args = ["worktree", "remove"];
1708 if (input.force) {
1709 args.push("--force");
1710 }
1711 args.push(input.path);
1712 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
1713 timeoutMs: 15_000,
1714 fallbackErrorMessage: "git worktree remove failed",
1715 }).pipe(
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 });
1658 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1659 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1660 yield* runGit(
1661 "GitCore.fetchRemoteBranch.materialize",
1662 input.cwd,
1663 localBranchAlreadyExists
1664 ? ["branch", "--force", input.localBranch, targetRef]
1665 : ["branch", input.localBranch, targetRef],
1666 );
1667 },
1668 );
17271669
1728 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
1729 Effect.gen(function* () {
1730 if (input.oldBranch === input.newBranch) {
1731 return { branch: input.newBranch };
1732 }
1733 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
1734
1735 yield* executeGit(
1736 "GitCore.renameBranch",
1737 input.cwd,
1738 ["branch", "-m", "--", input.oldBranch, targetBranch],
1739 {
1740 timeoutMs: 10_000,
1741 fallbackErrorMessage: "git branch rename failed",
1742 },
1743 );
1670 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
1671 runGit("GitCore.setBranchUpstream", input.cwd, [
1672 "branch",
1673 "--set-upstream-to",
1674 `${input.remoteName}/${input.remoteBranch}`,
1675 input.branch,
1676 ]);
1677
1678 const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")(
1679 function* (input) {
1680 const args = ["worktree", "remove"];
1681 if (input.force) {
1682 args.push("--force");
1683 }
1684 args.push(input.path);
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 };
1746 });
1702 const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) {
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) =>
1749 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1708 yield* executeGit(
1709 "GitCore.renameBranch",
1710 input.cwd,
1711 ["branch", "-m", "--", input.oldBranch, targetBranch],
1712 {
17501713 timeoutMs: 10_000,
1751 fallbackErrorMessage: "git branch create failed",
1752 }).pipe(Effect.asVoid);
1714 fallbackErrorMessage: "git branch rename failed",
1715 },
1716 );
17531717
1754 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
1755 Effect.gen(function* () {
1756 const [localInputExists, remoteExists] = yield* Effect.all(
1757 [
1758 executeGit(
1759 "GitCore.checkoutBranch.localInputExists",
1760 input.cwd,
1761 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1762 {
1763 timeoutMs: 5_000,
1764 allowNonZeroExit: true,
1765 },
1766 ).pipe(Effect.map((result) => result.code === 0)),
1767 executeGit(
1768 "GitCore.checkoutBranch.remoteExists",
1769 input.cwd,
1770 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],
1771 {
1772 timeoutMs: 5_000,
1773 allowNonZeroExit: true,
1774 },
1775 ).pipe(Effect.map((result) => result.code === 0)),
1776 ],
1777 { concurrency: "unbounded" },
1778 );
1718 return { branch: targetBranch };
1719 });
1720
1721 const createBranch: GitCoreShape["createBranch"] = (input) =>
1722 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1723 timeoutMs: 10_000,
1724 fallbackErrorMessage: "git branch create failed",
1725 }).pipe(Effect.asVoid);
1726
1727 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
1728 function* (input) {
1729 const [localInputExists, remoteExists] = yield* Effect.all(
1730 [
1731 executeGit(
1732 "GitCore.checkoutBranch.localInputExists",
1733 input.cwd,
1734 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
1735 {
1736 timeoutMs: 5_000,
1737 allowNonZeroExit: true,
1738 },
1739 ).pipe(Effect.map((result) => result.code === 0)),
1740 executeGit(
1741 "GitCore.checkoutBranch.remoteExists",
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 = remoteExists
1771 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
1772 const localTrackedBranchTargetExists =
1773 remoteExists && localTrackedBranchCandidate
17811774 ? yield* executeGit(
1782 "GitCore.checkoutBranch.localTrackingBranch",
1775 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
17831776 input.cwd,
1784 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
1777 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
17851778 {
17861779 timeoutMs: 5_000,
17871780 allowNonZeroExit: true,
17881781 },
1789 ).pipe(
1790 Effect.map((result) =>
1791 result.code === 0
1792 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
1793 : null,
1794 ),
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
1782 ).pipe(Effect.map((result) => result.code === 0))
1783 : false;
1784
1785 const checkoutArgs = localInputExists
1786 ? ["checkout", input.branch]
1787 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
18131788 ? ["checkout", input.branch]
1814 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
1815 ? ["checkout", input.branch]
1816 : remoteExists && !localTrackingBranch
1817 ? ["checkout", "--track", input.branch]
1818 : remoteExists && localTrackingBranch
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 });
1789 : remoteExists && !localTrackingBranch
1790 ? ["checkout", "--track", input.branch]
1791 : remoteExists && localTrackingBranch
1792 ? ["checkout", localTrackingBranch]
1793 : ["checkout", input.branch];
18261794
1827 // Refresh upstream refs in the background so checkout remains responsive.
1828 yield* Effect.forkScoped(
1829 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1830 );
1795 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
1796 timeoutMs: 10_000,
1797 fallbackErrorMessage: "git checkout failed",
18311798 });
18321799
1833 const initRepo: GitCoreShape["initRepo"] = (input) =>
1834 executeGit("GitCore.initRepo", input.cwd, ["init"], {
1835 timeoutMs: 10_000,
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 ),
1800 // Refresh upstream refs in the background so checkout remains responsive.
1801 yield* Effect.forkScoped(
1802 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
18511803 );
1804 },
1805 );
18521806
1853 return {
1854 execute,
1855 status,
1856 statusDetails,
1857 prepareCommitContext,
1858 commit,
1859 pushCurrentBranch,
1860 pullCurrentBranch,
1861 readRangeContext,
1862 readConfigValue,
1863 listBranches,
1864 createWorktree,
1865 fetchPullRequestBranch,
1866 ensureRemote,
1867 fetchRemoteBranch,
1868 setBranchUpstream,
1869 removeWorktree,
1870 renameBranch,
1871 createBranch,
1872 checkoutBranch,
1873 initRepo,
1874 listLocalBranchNames,
1875 } satisfies GitCoreShape;
1876 });
1807 const initRepo: GitCoreShape["initRepo"] = (input) =>
1808 executeGit("GitCore.initRepo", input.cwd, ["init"], {
1809 timeoutMs: 10_000,
1810 fallbackErrorMessage: "git init failed",
1811 }).pipe(Effect.asVoid);
1812
1813 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
1814 runGitStdout("GitCore.listLocalBranchNames", cwd, [
1815 "branch",
1816 "--list",
1817 "--format=%(refname:short)",
1818 ]).pipe(
1819 Effect.map((stdout) =>
1820 stdout
1821 .split("\n")
1822 .map((line) => line.trim())
1823 .filter((line) => line.length > 0),
1824 ),
1825 );
1826
1827 return {
1828 execute,
1829 status,
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
18781852export const GitCoreLive = Layer.effect(GitCore, makeGitCore());
apps/server/src/git/Layers/GitManager.test.ts+1-1
......@@ -525,7 +525,7 @@ function makeManager(input?: {
525525 serverSettingsLayer,
526526 ).pipe(Layer.provideMerge(NodeServices.layer));
527527
528 return makeGitManager.pipe(
528 return makeGitManager().pipe(
529529 Effect.provide(managerLayer),
530530 Effect.map((manager) => ({ manager, ghCalls })),
531531 );
apps/server/src/git/Layers/GitManager.ts+567-544
......@@ -1,8 +1,13 @@
11import { randomUUID } from "node:crypto";
22import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Path } from "effect";
5import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts";
4import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect";
5import {
6 GitActionProgressEvent,
7 GitActionProgressPhase,
8 GitRunStackedActionResult,
9 ModelSelection,
10} from "@t3tools/contracts";
611import {
712 resolveAutoFeatureBranchName,
813 sanitizeBranchFragment,
......@@ -20,6 +25,7 @@ import { GitCore } from "../Services/GitCore.ts";
2025import { GitHubCli } from "../Services/GitHubCli.ts";
2126import { TextGeneration } from "../Services/TextGeneration.ts";
2227import { ServerSettingsService } from "../../serverSettings.ts";
28import type { GitManagerServiceError } from "../Errors.ts";
2329
2430const COMMIT_TIMEOUT_MS = 10 * 60_000;
2531const MAX_PROGRESS_TEXT_LENGTH = 500;
......@@ -355,7 +361,7 @@ function toPullRequestHeadRemoteInfo(pr: {
355361 };
356362}
357363
358export const makeGitManager = Effect.gen(function* () {
364export const makeGitManager = Effect.fn("makeGitManager")(function* () {
359365 const gitCore = yield* GitCore;
360366 const gitHubCli = yield* GitHubCli;
361367 const textGeneration = yield* TextGeneration;
......@@ -384,12 +390,12 @@ export const makeGitManager = Effect.gen(function* () {
384390 };
385391 };
386392
387 const configurePullRequestHeadUpstream = (
388 cwd: string,
389 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
390 localBranch = pullRequest.headBranch,
391 ) =>
392 Effect.gen(function* () {
393 const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")(
394 function* (
395 cwd: string,
396 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
397 localBranch = pullRequest.headBranch,
398 ) {
393399 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
394400 if (repositoryNameWithOwner.length === 0) {
395401 return;
......@@ -417,7 +423,15 @@ export const makeGitManager = Effect.gen(function* () {
417423 remoteName,
418424 remoteBranch: pullRequest.headBranch,
419425 });
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(
421435 Effect.catch((error) =>
422436 Effect.logWarning(
423437 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
......@@ -425,12 +439,12 @@ export const makeGitManager = Effect.gen(function* () {
425439 ),
426440 );
427441
428 const materializePullRequestHeadBranch = (
429 cwd: string,
430 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
431 localBranch = pullRequest.headBranch,
432 ) =>
433 Effect.gen(function* () {
442 const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")(
443 function* (
444 cwd: string,
445 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
446 localBranch = pullRequest.headBranch,
447 ) {
434448 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
435449
436450 if (repositoryNameWithOwner.length === 0) {
......@@ -470,7 +484,15 @@ export const makeGitManager = Effect.gen(function* () {
470484 remoteName,
471485 remoteBranch: pullRequest.headBranch,
472486 });
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(
474496 Effect.catch(() =>
475497 gitCore.fetchPullRequestBranch({
476498 cwd,
......@@ -487,208 +509,211 @@ export const makeGitManager = Effect.gen(function* () {
487509 const readConfigValueNullable = (cwd: string, key: string) =>
488510 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
489511
490 const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) =>
491 Effect.gen(function* () {
492 if (!remoteName) {
493 return {
494 repositoryNameWithOwner: null,
495 ownerLogin: null,
496 };
497 }
498
499 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
500 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
512 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
513 cwd: string,
514 remoteName: string | null,
515 ) {
516 if (!remoteName) {
501517 return {
502 repositoryNameWithOwner,
503 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
518 repositoryNameWithOwner: null,
519 ownerLogin: null,
504520 };
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* (
508532 cwd: string,
509533 details: { branch: string; upstreamRef: string | null },
510 ) =>
511 Effect.gen(function* () {
512 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
513 const headBranchFromUpstream = details.upstreamRef
514 ? extractBranchFromRef(details.upstreamRef)
515 : "";
516 const headBranch =
517 headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
518
519 const [remoteRepository, originRepository] = yield* Effect.all(
520 [
521 resolveRemoteRepositoryContext(cwd, remoteName),
522 resolveRemoteRepositoryContext(cwd, "origin"),
523 ],
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 }
534 ) {
535 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
536 const headBranchFromUpstream = details.upstreamRef
537 ? extractBranchFromRef(details.upstreamRef)
538 : "";
539 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
540
541 const [remoteRepository, originRepository] = yield* Effect.all(
542 [
543 resolveRemoteRepositoryContext(cwd, remoteName),
544 resolveRemoteRepositoryContext(cwd, "origin"),
545 ],
546 { concurrency: "unbounded" },
547 );
562548
563 return {
564 localBranch: details.branch,
565 headBranch,
549 const isCrossRepository =
550 remoteRepository.repositoryNameWithOwner !== null &&
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(
566571 headSelectors,
567 preferredHeadSelector:
568 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
569 remoteName,
570 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
571 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
572 isCrossRepository,
573 } satisfies BranchHeadContext;
574 });
572 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
573 );
574 }
575 appendUnique(headSelectors, details.branch);
576 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
577 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
578 appendUnique(headSelectors, ownerHeadSelector);
579 appendUnique(
580 headSelectors,
581 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
582 );
583 }
575584
576 const findOpenPr = (cwd: string, headSelectors: ReadonlyArray<string>) =>
577 Effect.gen(function* () {
578 for (const headSelector of headSelectors) {
579 const pullRequests = yield* gitHubCli.listOpenPullRequests({
580 cwd,
581 headSelector,
582 limit: 1,
583 });
585 return {
586 localBranch: details.branch,
587 headBranch,
588 headSelectors,
589 preferredHeadSelector:
590 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
591 remoteName,
592 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
593 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
594 isCrossRepository,
595 } satisfies BranchHeadContext;
596 });
584597
585 const [firstPullRequest] = pullRequests;
586 if (firstPullRequest) {
587 return {
588 number: firstPullRequest.number,
589 title: firstPullRequest.title,
590 url: firstPullRequest.url,
591 baseRefName: firstPullRequest.baseRefName,
592 headRefName: firstPullRequest.headRefName,
593 state: "open",
594 updatedAt: null,
595 } satisfies PullRequestInfo;
596 }
597 }
598 const findOpenPr = Effect.fn("findOpenPr")(function* (
599 cwd: string,
600 headSelectors: ReadonlyArray<string>,
601 ) {
602 for (const headSelector of headSelectors) {
603 const pullRequests = yield* gitHubCli.listOpenPullRequests({
604 cwd,
605 headSelector,
606 limit: 1,
607 });
598608
599 return null;
600 });
609 const [firstPullRequest] = pullRequests;
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 }) =>
603 Effect.gen(function* () {
604 const headContext = yield* resolveBranchHeadContext(cwd, details);
605 const parsedByNumber = new Map<number, PullRequestInfo>();
623 return null;
624 });
606625
607 for (const headSelector of headContext.headSelectors) {
608 const stdout = yield* gitHubCli
609 .execute({
610 cwd,
611 args: [
612 "pr",
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 }
626 const findLatestPr = Effect.fn("findLatestPr")(function* (
627 cwd: string,
628 details: { branch: string; upstreamRef: string | null },
629 ) {
630 const headContext = yield* resolveBranchHeadContext(cwd, details);
631 const parsedByNumber = new Map<number, PullRequestInfo>();
630632
631 const parsedJson = yield* Effect.try({
632 try: () => JSON.parse(raw) as unknown,
633 catch: (cause) =>
634 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
635 });
633 for (const headSelector of headContext.headSelectors) {
634 const stdout = yield* gitHubCli
635 .execute({
636 cwd,
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)) {
638 parsedByNumber.set(pr.number, pr);
639 }
652 const raw = stdout.trim();
653 if (raw.length === 0) {
654 continue;
640655 }
641656
642 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {
643 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
644 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
645 return right - left;
657 const parsedJson = yield* Effect.try({
658 try: () => JSON.parse(raw) as unknown,
659 catch: (cause) =>
660 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
646661 });
647662
648 const latestOpenPr = parsed.find((pr) => pr.state === "open");
649 if (latestOpenPr) {
650 return latestOpenPr;
663 for (const pr of parsePullRequestList(parsedJson)) {
664 parsedByNumber.set(pr.number, pr);
651665 }
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;
653672 });
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* (
656682 cwd: string,
657683 branch: string,
658684 upstreamRef: string | null,
659685 headContext: Pick<BranchHeadContext, "isCrossRepository">,
660 ) =>
661 Effect.gen(function* () {
662 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
663 if (configured) return configured;
664
665 if (upstreamRef && !headContext.isCrossRepository) {
666 const upstreamBranch = extractBranchFromRef(upstreamRef);
667 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
668 return upstreamBranch;
669 }
686 ) {
687 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
688 if (configured) return configured;
689
690 if (upstreamRef && !headContext.isCrossRepository) {
691 const upstreamBranch = extractBranchFromRef(upstreamRef);
692 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
693 return upstreamBranch;
670694 }
695 }
671696
672 const defaultFromGh = yield* gitHubCli
673 .getDefaultBranch({ cwd })
674 .pipe(Effect.catch(() => Effect.succeed(null)));
675 if (defaultFromGh) {
676 return defaultFromGh;
677 }
697 const defaultFromGh = yield* gitHubCli
698 .getDefaultBranch({ cwd })
699 .pipe(Effect.catch(() => Effect.succeed(null)));
700 if (defaultFromGh) {
701 return defaultFromGh;
702 }
678703
679 return "main";
680 });
704 return "main";
705 });
681706
682 const resolveCommitAndBranchSuggestion = (input: {
683 cwd: string;
684 branch: string | null;
685 commitMessage?: string;
686 /** When true, also produce a semantic feature branch name. */
687 includeBranch?: boolean;
688 filePaths?: readonly string[];
689 modelSelection: ModelSelection;
690 }) =>
691 Effect.gen(function* () {
707 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
708 function* (input: {
709 cwd: string;
710 branch: string | null;
711 commitMessage?: string;
712 /** When true, also produce a semantic feature branch name. */
713 includeBranch?: boolean;
714 filePaths?: readonly string[];
715 modelSelection: ModelSelection;
716 }) {
692717 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
693718 if (!context) {
694719 return null;
......@@ -723,9 +748,10 @@ export const makeGitManager = Effect.gen(function* () {
723748 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
724749 commitMessage: formatCommitMessage(generated.subject, generated.body),
725750 };
726 });
751 },
752 );
727753
728 const runCommitStep = (
754 const runCommitStep = Effect.fn("runCommitStep")(function* (
729755 modelSelection: ModelSelection,
730756 cwd: string,
731757 action: "commit" | "commit_push" | "commit_push_pr",
......@@ -735,196 +761,198 @@ export const makeGitManager = Effect.gen(function* () {
735761 filePaths?: readonly string[],
736762 progressReporter?: GitActionProgressReporter,
737763 actionId?: string,
738 ) =>
739 Effect.gen(function* () {
740 const emit = (event: GitActionProgressPayload) =>
741 progressReporter && actionId
742 ? progressReporter.publish({
743 actionId,
744 cwd,
745 action,
746 ...event,
747 } as GitActionProgressEvent)
748 : Effect.void;
749
750 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
751 if (!suggestion) {
752 const needsGeneration = !commitMessage?.trim();
753 if (needsGeneration) {
754 yield* emit({
755 kind: "phase_started",
756 phase: "commit",
757 label: "Generating commit message...",
758 });
759 }
760 suggestion = yield* resolveCommitAndBranchSuggestion({
761 cwd,
762 branch,
763 ...(commitMessage ? { commitMessage } : {}),
764 ...(filePaths ? { filePaths } : {}),
765 modelSelection,
764 ) {
765 const emit = (event: GitActionProgressPayload) =>
766 progressReporter && actionId
767 ? progressReporter.publish({
768 actionId,
769 cwd,
770 action,
771 ...event,
772 } as GitActionProgressEvent)
773 : Effect.void;
774
775 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
776 if (!suggestion) {
777 const needsGeneration = !commitMessage?.trim();
778 if (needsGeneration) {
779 yield* emit({
780 kind: "phase_started",
781 phase: "commit",
782 label: "Generating commit message...",
766783 });
767784 }
768 if (!suggestion) {
769 return { status: "skipped_no_changes" as const };
770 }
771
772 yield* emit({
773 kind: "phase_started",
774 phase: "commit",
775 label: "Committing...",
785 suggestion = yield* resolveCommitAndBranchSuggestion({
786 cwd,
787 branch,
788 ...(commitMessage ? { commitMessage } : {}),
789 ...(filePaths ? { filePaths } : {}),
790 modelSelection,
776791 });
792 }
793 if (!suggestion) {
794 return { status: "skipped_no_changes" as const };
795 }
777796
778 let currentHookName: string | null = null;
779 const commitProgress =
780 progressReporter && actionId
781 ? {
782 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
783 const sanitized = sanitizeProgressText(text);
784 if (!sanitized) {
785 return Effect.void;
786 }
787 return emit({
788 kind: "hook_output",
789 hookName: currentHookName,
790 stream,
791 text: sanitized,
792 });
793 },
794 onHookStarted: (hookName: string) => {
795 currentHookName = hookName;
796 return emit({
797 kind: "hook_started",
798 hookName,
799 });
800 },
801 onHookFinished: ({
797 yield* emit({
798 kind: "phase_started",
799 phase: "commit",
800 label: "Committing...",
801 });
802
803 let currentHookName: string | null = null;
804 const commitProgress =
805 progressReporter && actionId
806 ? {
807 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
808 const sanitized = sanitizeProgressText(text);
809 if (!sanitized) {
810 return Effect.void;
811 }
812 return emit({
813 kind: "hook_output",
814 hookName: currentHookName,
815 stream,
816 text: sanitized,
817 });
818 },
819 onHookStarted: (hookName: string) => {
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",
802840 hookName,
803841 exitCode,
804842 durationMs,
805 }: {
806 hookName: string;
807 exitCode: number | null;
808 durationMs: number | null;
809 }) => {
810 if (currentHookName === hookName) {
811 currentHookName = null;
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 };
843 });
844 },
845 }
846 : null;
847 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
848 timeoutMs: COMMIT_TIMEOUT_MS,
849 ...(commitProgress ? { progress: commitProgress } : {}),
840850 });
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) =>
843 Effect.gen(function* () {
844 const details = yield* gitCore.statusDetails(cwd);
845 const branch = details.branch ?? fallbackBranch;
846 if (!branch) {
847 return yield* gitManagerError(
848 "runPrStep",
849 "Cannot create a pull request from detached HEAD.",
850 );
851 }
852 if (!details.hasUpstream) {
853 return yield* gitManagerError(
854 "runPrStep",
855 "Current branch has not been pushed. Push before creating a PR.",
856 );
857 }
867 const runPrStep = Effect.fn("runPrStep")(function* (
868 modelSelection: ModelSelection,
869 cwd: string,
870 fallbackBranch: string | null,
871 ) {
872 const details = yield* gitCore.statusDetails(cwd);
873 const branch = details.branch ?? fallbackBranch;
874 if (!branch) {
875 return yield* gitManagerError(
876 "runPrStep",
877 "Cannot create a pull request from detached HEAD.",
878 );
879 }
880 if (!details.hasUpstream) {
881 return yield* gitManagerError(
882 "runPrStep",
883 "Current branch has not been pushed. Push before creating a PR.",
884 );
885 }
858886
859 const headContext = yield* resolveBranchHeadContext(cwd, {
860 branch,
861 upstreamRef: details.upstreamRef,
862 });
887 const headContext = yield* resolveBranchHeadContext(cwd, {
888 branch,
889 upstreamRef: details.upstreamRef,
890 });
863891
864 const existing = yield* findOpenPr(cwd, headContext.headSelectors);
865 if (existing) {
866 return {
867 status: "opened_existing" as const,
868 url: existing.url,
869 number: existing.number,
870 baseBranch: existing.baseRefName,
871 headBranch: existing.headRefName,
872 title: existing.title,
873 };
874 }
892 const existing = yield* findOpenPr(cwd, headContext.headSelectors);
893 if (existing) {
894 return {
895 status: "opened_existing" as const,
896 url: existing.url,
897 number: existing.number,
898 baseBranch: existing.baseRefName,
899 headBranch: existing.headRefName,
900 title: existing.title,
901 };
902 }
875903
876 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
877 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
904 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
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({
880927 cwd,
881928 baseBranch,
882 headBranch: headContext.headBranch,
883 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
884 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
885 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
886 modelSelection,
887 });
888
889 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
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
929 headSelector: headContext.preferredHeadSelector,
930 title: generated.title,
931 bodyFile,
932 })
933 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
934
935 const created = yield* findOpenPr(cwd, headContext.headSelectors);
936 if (!created) {
917937 return {
918938 status: "created" as const,
919 url: created.url,
920 number: created.number,
921 baseBranch: created.baseRefName,
922 headBranch: created.headRefName,
923 title: created.title,
939 baseBranch,
940 headBranch: headContext.headBranch,
941 title: generated.title,
924942 };
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) {
928956 const details = yield* gitCore.statusDetails(input.cwd);
929957
930958 const pr =
......@@ -949,7 +977,7 @@ export const makeGitManager = Effect.gen(function* () {
949977 };
950978 });
951979
952 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(
980 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
953981 function* (input) {
954982 const pullRequest = yield* gitHubCli
955983 .getPullRequest({
......@@ -962,185 +990,185 @@ export const makeGitManager = Effect.gen(function* () {
962990 },
963991 );
964992
965 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced(
966 function* (input) {
967 const normalizedReference = normalizePullRequestReference(input.reference);
968 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
969 const pullRequestSummary = yield* gitHubCli.getPullRequest({
993 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
994 "preparePullRequestThread",
995 )(function* (input) {
996 const normalizedReference = normalizePullRequestReference(input.reference);
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({
9701006 cwd: input.cwd,
9711007 reference: normalizedReference,
1008 force: true,
9721009 });
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") {
976 yield* gitHubCli.checkoutPullRequest({
977 cwd: input.cwd,
978 reference: normalizedReference,
979 force: true,
980 });
981 const details = yield* gitCore.statusDetails(input.cwd);
982 yield* configurePullRequestHeadUpstream(
983 input.cwd,
984 {
985 ...pullRequest,
986 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
987 },
988 details.branch ?? pullRequest.headBranch,
989 );
990 return {
991 pullRequest,
992 branch: details.branch ?? pullRequest.headBranch,
993 worktreePath: null,
994 };
995 }
1026 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1027 worktreePath: string,
1028 ) {
1029 const details = yield* gitCore.statusDetails(worktreePath);
1030 yield* configurePullRequestHeadUpstream(
1031 worktreePath,
1032 {
1033 ...pullRequest,
1034 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1035 },
1036 details.branch ?? pullRequest.headBranch,
1037 );
1038 });
9961039
997 const ensureExistingWorktreeUpstream = (worktreePath: string) =>
998 Effect.gen(function* () {
999 const details = yield* gitCore.statusDetails(worktreePath);
1000 yield* configurePullRequestHeadUpstream(
1001 worktreePath,
1002 {
1003 ...pullRequest,
1004 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1005 },
1006 details.branch ?? pullRequest.headBranch,
1040 const pullRequestWithRemoteInfo = {
1041 ...pullRequest,
1042 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1043 } as const;
1044 const localPullRequestBranch =
1045 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1046
1047 const findLocalHeadBranch = (cwd: string) =>
1048 gitCore.listBranches({ cwd }).pipe(
1049 Effect.map((result) => {
1050 const localBranch = result.branches.find(
1051 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
10071052 );
1008 });
1009
1010 const pullRequestWithRemoteInfo = {
1011 ...pullRequest,
1012 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1013 } as const;
1014 const localPullRequestBranch =
1015 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1016
1017 const findLocalHeadBranch = (cwd: string) =>
1018 gitCore.listBranches({ cwd }).pipe(
1019 Effect.map((result) => {
1020 const localBranch = result.branches.find(
1021 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1022 );
1023 if (localBranch) {
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,
1053 if (localBranch) {
1054 return localBranch;
1055 }
1056 if (localPullRequestBranch === pullRequest.headBranch) {
1057 return null;
1058 }
1059 return (
1060 result.branches.find(
1061 (branch) =>
1062 !branch.isRemote &&
1063 branch.name === pullRequest.headBranch &&
1064 branch.worktreePath !== null &&
1065 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1066 ) ?? null
1067 );
1068 }),
10671069 );
10681070
1069 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1070 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1071 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1072 : null;
1073 if (
1074 existingBranchAfterFetch?.worktreePath &&
1075 existingBranchAfterFetchPath !== rootWorktreePath
1076 ) {
1077 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1078 return {
1079 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,
1071 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1072 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1073 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1074 : null;
1075 if (
1076 existingBranchBeforeFetch?.worktreePath &&
1077 existingBranchBeforeFetchPath !== rootWorktreePath
1078 ) {
1079 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1080 return {
1081 pullRequest,
10931082 branch: localPullRequestBranch,
1094 path: null,
1095 });
1096 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1083 worktreePath: existingBranchBeforeFetch.worktreePath,
1084 };
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);
10981108 return {
10991109 pullRequest,
1100 branch: worktree.worktree.branch,
1101 worktreePath: worktree.worktree.path,
1110 branch: localPullRequestBranch,
1111 worktreePath: existingBranchAfterFetch.worktreePath,
11021112 };
1103 },
1104 );
1113 }
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* (
11071136 modelSelection: ModelSelection,
11081137 cwd: string,
11091138 branch: string | null,
11101139 commitMessage?: string,
11111140 filePaths?: readonly string[],
1112 ) =>
1113 Effect.gen(function* () {
1114 const suggestion = yield* resolveCommitAndBranchSuggestion({
1115 cwd,
1116 branch,
1117 ...(commitMessage ? { commitMessage } : {}),
1118 ...(filePaths ? { filePaths } : {}),
1119 includeBranch: true,
1120 modelSelection,
1121 });
1122 if (!suggestion) {
1123 return yield* gitManagerError(
1124 "runFeatureBranchStep",
1125 "Cannot create a feature branch because there are no changes to commit.",
1126 );
1127 }
1141 ) {
1142 const suggestion = yield* resolveCommitAndBranchSuggestion({
1143 cwd,
1144 branch,
1145 ...(commitMessage ? { commitMessage } : {}),
1146 ...(filePaths ? { filePaths } : {}),
1147 includeBranch: true,
1148 modelSelection,
1149 });
1150 if (!suggestion) {
1151 return yield* gitManagerError(
1152 "runFeatureBranchStep",
1153 "Cannot create a feature branch because there are no changes to commit.",
1154 );
1155 }
11281156
1129 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1130 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
1131 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
1157 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1158 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
1159 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
11321160
1133 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });
1134 yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));
1161 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });
1162 yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));
11351163
1136 return {
1137 branchStep: { status: "created" as const, name: resolvedBranch },
1138 resolvedCommitMessage: suggestion.commitMessage,
1139 resolvedCommitSuggestion: suggestion,
1140 };
1141 });
1164 return {
1165 branchStep: { status: "created" as const, name: resolvedBranch },
1166 resolvedCommitMessage: suggestion.commitMessage,
1167 resolvedCommitSuggestion: suggestion,
1168 };
1169 });
11421170
1143 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(
1171 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
11441172 function* (input, options) {
11451173 const progress = createProgressEmitter(input, options);
11461174 const phases: GitActionProgressPhase[] = [
......@@ -1149,9 +1177,12 @@ export const makeGitManager = Effect.gen(function* () {
11491177 ...(input.action !== "commit" ? (["push"] as const) : []),
11501178 ...(input.action === "commit_push_pr" ? (["pr"] as const) : []),
11511179 ];
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 > {
11551186 yield* progress.emit({
11561187 kind: "action_started",
11571188 phases,
......@@ -1183,7 +1214,7 @@ export const makeGitManager = Effect.gen(function* () {
11831214 );
11841215
11851216 if (input.featureBranch) {
1186 currentPhase = "branch";
1217 yield* Ref.set(currentPhase, Option.some("branch"));
11871218 yield* progress.emit({
11881219 kind: "phase_started",
11891220 phase: "branch",
......@@ -1205,7 +1236,7 @@ export const makeGitManager = Effect.gen(function* () {
12051236
12061237 const currentBranch = branchStep.name ?? initialStatus.branch;
12071238
1208 currentPhase = "commit";
1239 yield* Ref.set(currentPhase, Option.some("commit"));
12091240 const commit = yield* runCommitStep(
12101241 modelSelection,
12111242 input.cwd,
......@@ -1226,12 +1257,8 @@ export const makeGitManager = Effect.gen(function* () {
12261257 label: "Pushing...",
12271258 })
12281259 .pipe(
1229 Effect.flatMap(() =>
1230 Effect.gen(function* () {
1231 currentPhase = "push";
1232 return yield* gitCore.pushCurrentBranch(input.cwd, currentBranch);
1233 }),
1234 ),
1260 Effect.tap(() => Ref.set(currentPhase, Option.some("push"))),
1261 Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)),
12351262 )
12361263 : { status: "skipped_not_requested" as const };
12371264
......@@ -1243,12 +1270,8 @@ export const makeGitManager = Effect.gen(function* () {
12431270 label: "Creating PR...",
12441271 })
12451272 .pipe(
1246 Effect.flatMap(() =>
1247 Effect.gen(function* () {
1248 currentPhase = "pr";
1249 return yield* runPrStep(modelSelection, input.cwd, currentBranch);
1250 }),
1251 ),
1273 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
1274 Effect.flatMap(() => runPrStep(modelSelection, input.cwd, currentBranch)),
12521275 )
12531276 : { status: "skipped_not_requested" as const };
12541277
......@@ -1266,15 +1289,15 @@ export const makeGitManager = Effect.gen(function* () {
12661289 return result;
12671290 });
12681291
1269 return yield* runAction.pipe(
1270 Effect.catch((error) =>
1271 progress
1272 .emit({
1292 return yield* runAction().pipe(
1293 Effect.tapError((error) =>
1294 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1295 progress.emit({
12731296 kind: "action_failed",
1274 phase: currentPhase,
1297 phase: Option.getOrNull(phase),
12751298 message: error.message,
1276 })
1277 .pipe(Effect.flatMap(() => Effect.fail(error))),
1299 }),
1300 ),
12781301 ),
12791302 );
12801303 },
......@@ -1288,4 +1311,4 @@ export const makeGitManager = Effect.gen(function* () {
12881311 } satisfies GitManagerShape;
12891312});
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* () {
2424})
2525```
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
2738## Summary
2839
2940- Total non-test candidates: `322`
......@@ -32,9 +43,9 @@ const new = Effect.fn('functionName')(function* () {
3243
3344- [ ] `apps/server/src/provider/Layers/ProviderService.ts`
3445- [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts`
35- [ ] `apps/server/src/provider/Layers/CodexAdapter.ts`
36- [ ] `apps/server/src/git/Layers/GitCore.ts`
37- [ ] `apps/server/src/git/Layers/GitManager.ts`
46- [x] `apps/server/src/provider/Layers/CodexAdapter.ts`
47- [x] `apps/server/src/git/Layers/GitCore.ts`
48- [x] `apps/server/src/git/Layers/GitManager.ts`
3849- [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
3950- [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
4051- [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`
......@@ -57,25 +68,25 @@ const new = Effect.fn('functionName')(function* () {
5768
5869### `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)
61- [ ] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L317)
62- [ ] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L449)
63- [ ] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1178)
64- [ ] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1217)
65- [ ] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1316)
66- [ ] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1697)
67- [ ] Service methods and callback wrappers in this file
71- [x] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L513)
72- [x] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L324)
73- [x] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L455)
74- [x] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1190)
75- [x] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1223)
76- [x] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1323)
77- [x] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1727)
78- [x] Service methods and callback wrappers in this file
6879
6980### `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)
72- [ ] [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)
74- [ ] [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)
76- [ ] [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)
78- [ ] Remaining helpers and nested callback wrappers in this file
82- [x] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387)
83- [x] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428)
84- [x] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576)
85- [x] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602)
86- [x] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728)
87- [x] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842)
88- [x] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106)
89- [x] Remaining helpers and nested callback wrappers in this file
7990
8091### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`)
8192
......@@ -113,11 +124,11 @@ const new = Effect.fn('functionName')(function* () {
113124
114125### `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)
117- [ ] [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)
119- [ ] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555)
120- [ ] Remaining nested callback wrappers in this file
127- [x] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317)
128- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399)
129- [x] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546)
130- [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555)
131- [x] Remaining nested callback wrappers in this file
121132
122133### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`)
123134
......@@ -181,3 +192,7 @@ const new = Effect.fn('functionName')(function* () {
181192- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`)
182193- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`)
183194- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`)
195
196```
197
198```