From 0c57404b066d5c687103af8a7abf0edb0b493107 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 21:59:19 -0700 Subject: [PATCH 1/8] Mark Codex adapter Effect.fn checklist complete - Update the checklist entries for `CodexAdapter.ts` - Mark the remaining nested callback wrappers as done --- docs/effect-fn-checklist.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md index 0d28171aa2..33eecb286a 100644 --- a/docs/effect-fn-checklist.md +++ b/docs/effect-fn-checklist.md @@ -32,7 +32,7 @@ const new = Effect.fn('functionName')(function* () { - [ ] `apps/server/src/provider/Layers/ProviderService.ts` - [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` -- [ ] `apps/server/src/provider/Layers/CodexAdapter.ts` +- [x] `apps/server/src/provider/Layers/CodexAdapter.ts` - [ ] `apps/server/src/git/Layers/GitCore.ts` - [ ] `apps/server/src/git/Layers/GitManager.ts` - [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` @@ -113,11 +113,11 @@ const new = Effect.fn('functionName')(function* () { ### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`) -- [ ] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317) -- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399) -- [ ] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546) -- [ ] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) -- [ ] Remaining nested callback wrappers in this file +- [x] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317) +- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399) +- [x] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546) +- [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) +- [x] Remaining nested callback wrappers in this file ### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`) From 6ed548a5160c9308588f251eeeddd70401073eac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:08:43 -0700 Subject: [PATCH 2/8] Annotate Git core effect helpers - Name Effect.fn helpers in GitCore for clearer traces - Update the effect function checklist docs --- apps/server/src/git/Layers/GitCore.ts | 2668 +++++++++++----------- apps/server/src/git/Layers/GitManager.ts | 854 +++---- docs/effect-fn-checklist.md | 34 +- 3 files changed, 1782 insertions(+), 1774 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index fcb2f9c58e..64ed409508 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -294,7 +294,7 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); -const createTrace2Monitor = Effect.fn(function* ( +const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, progress: ExecuteGitProgress | undefined, ): Effect.fn.Return< @@ -321,61 +321,58 @@ const createTrace2Monitor = Effect.fn(function* ( remainder: "", }); - const handleTraceLine = (line: string) => - Effect.gen(function* () { - const trimmedLine = line.trim(); - if (trimmedLine.length === 0) { - return; - } + const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + return; + } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); - if (Result.isFailure(traceRecord)) { - yield* Effect.logDebug( - `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, - traceRecord.failure, - ); - return; - } + const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + if (Result.isFailure(traceRecord)) { + yield* Effect.logDebug( + `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + traceRecord.failure, + ); + return; + } - if (traceRecord.success.child_class !== "hook") { - return; - } + if (traceRecord.success.child_class !== "hook") { + return; + } - const event = traceRecord.success.event; - const childKey = trace2ChildKey(traceRecord.success); - if (childKey === null) { - return; - } - const started = hookStartByChildKey.get(childKey); - const hookNameFromEvent = - typeof traceRecord.success.hook_name === "string" - ? traceRecord.success.hook_name.trim() - : ""; - const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); - if (hookName.length === 0) { - return; - } + const event = traceRecord.success.event; + const childKey = trace2ChildKey(traceRecord.success); + if (childKey === null) { + return; + } + const started = hookStartByChildKey.get(childKey); + const hookNameFromEvent = + typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; + const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); + if (hookName.length === 0) { + return; + } - if (event === "child_start") { - hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); - if (progress.onHookStarted) { - yield* progress.onHookStarted(hookName); - } - return; + if (event === "child_start") { + hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); + if (progress.onHookStarted) { + yield* progress.onHookStarted(hookName); } + return; + } - if (event === "child_exit") { - hookStartByChildKey.delete(childKey); - if (progress.onHookFinished) { - const code = traceRecord.success.code; - yield* progress.onHookFinished({ - hookName: started?.hookName ?? hookName, - exitCode: typeof code === "number" && Number.isInteger(code) ? code : null, - durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null, - }); - } + if (event === "child_exit") { + hookStartByChildKey.delete(childKey); + if (progress.onHookFinished) { + const code = traceRecord.success.code; + yield* progress.onHookFinished({ + hookName: started?.hookName ?? hookName, + exitCode: typeof code === "number" && Number.isInteger(code) ? code : null, + durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null, + }); } - }); + } + }); const deltaMutex = yield* Semaphore.make(1); const readTraceDelta = deltaMutex.withPermit( @@ -418,21 +415,21 @@ const createTrace2Monitor = Effect.fn(function* ( return readTraceDelta; }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* readTraceDelta; - const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ - remainder.trim(), - { - processedChars, - remainder: "", - }, - ]); - if (finalLine.length > 0) { - yield* handleTraceLine(finalLine); - } - }), - ); + const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { + yield* readTraceDelta; + const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ + remainder.trim(), + { + processedChars, + remainder: "", + }, + ]); + if (finalLine.length > 0) { + yield* handleTraceLine(finalLine); + } + }); + + yield* Effect.addFinalizer(finalizeTrace2Monitor); return { env: { @@ -442,7 +439,7 @@ const createTrace2Monitor = Effect.fn(function* ( }; }); -const collectOutput = Effect.fn(function* ( +const collectOutput = Effect.fn("collectOutput")(function* ( input: Pick, stream: Stream.Stream, maxOutputBytes: number, @@ -455,55 +452,56 @@ const collectOutput = Effect.fn(function* ( let lineBuffer = ""; let truncated = false; - const emitCompleteLines = (flush: boolean) => - Effect.gen(function* () { - let newlineIndex = lineBuffer.indexOf("\n"); - while (newlineIndex >= 0) { - const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); - lineBuffer = lineBuffer.slice(newlineIndex + 1); - if (line.length > 0 && onLine) { - yield* onLine(line); - } - newlineIndex = lineBuffer.indexOf("\n"); + const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { + let newlineIndex = lineBuffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + lineBuffer = lineBuffer.slice(newlineIndex + 1); + if (line.length > 0 && onLine) { + yield* onLine(line); } + newlineIndex = lineBuffer.indexOf("\n"); + } - if (flush) { - const trailing = lineBuffer.replace(/\r$/, ""); - lineBuffer = ""; - if (trailing.length > 0 && onLine) { - yield* onLine(trailing); - } + if (flush) { + const trailing = lineBuffer.replace(/\r$/, ""); + lineBuffer = ""; + if (trailing.length > 0 && onLine) { + yield* onLine(trailing); } - }); + } + }); - yield* Stream.runForEach(stream, (chunk) => - Effect.gen(function* () { - if (truncateOutputAtMaxBytes && truncated) { - return; - } - const nextBytes = bytes + chunk.byteLength; - if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { - return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - }); - } + const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { + if (truncateOutputAtMaxBytes && truncated) { + return; + } + const nextBytes = bytes + chunk.byteLength; + if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } - const chunkToDecode = - truncateOutputAtMaxBytes && nextBytes > maxOutputBytes - ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) - : chunk; - bytes += chunkToDecode.byteLength; - truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + const chunkToDecode = + truncateOutputAtMaxBytes && nextBytes > maxOutputBytes + ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) + : chunk; + bytes += chunkToDecode.byteLength; + truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + + const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); + text += decoded; + lineBuffer += decoded; + yield* emitCompleteLines(false); + }); - const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); - text += decoded; - lineBuffer += decoded; - yield* emitCompleteLines(false); - }), - ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); + yield* Stream.runForEach(stream, processChunk).pipe( + Effect.mapError(toGitCommandError(input, "output stream failed.")), + ); const remainder = truncated ? "" : decoder.decode(); text += remainder; @@ -512,1367 +510,1343 @@ const collectOutput = Effect.fn(function* ( return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; }); -export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfig; - - let execute: GitCoreShape["execute"]; - - if (options?.executeOverride) { - execute = options.executeOverride; - } else { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - execute = Effect.fnUntraced(function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; - - const commandEffect = Effect.gen(function* () { - const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), - ); - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - env: { - ...process.env, - ...input.env, - ...trace2Monitor.env, - }, - }), - ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput( - commandInput, - child.stdout, - maxOutputBytes, - truncateOutputAtMaxBytes, - input.progress?.onStdoutLine, - ), - collectOutput( - commandInput, - child.stderr, - maxOutputBytes, - truncateOutputAtMaxBytes, - input.progress?.onStderrLine, - ), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), - ), - ], - { concurrency: "unbounded" }, - ); - yield* trace2Monitor.flush; - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); - return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), +export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { + executeOverride?: GitCoreShape["execute"]; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; + + let execute: GitCoreShape["execute"]; + + if (options?.executeOverride) { + execute = options.executeOverride; + } else { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + execute = Effect.fnUntraced(function* (input) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; + + const runGitCommand = Effect.fn("runGitCommand")(function* () { + const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), + ); + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - }); - } - - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; - }); - - return yield* commandEffect.pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, - }), - ), - onSome: Effect.succeed, + env: { + ...process.env, + ...input.env, + ...trace2Monitor.env, + }, }), - ), - ); - }); - } + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - const executeGit = ( - operation: string, - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, - ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => - execute({ - operation, - cwd, - args, - allowNonZeroExit: true, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), - ...(options.truncateOutputAtMaxBytes !== undefined - ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } - : {}), - ...(options.progress ? { progress: options.progress } : {}), - }).pipe( - Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.code === 0) { - return Effect.succeed(result); - } - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); - } - if (options.fallbackErrorMessage) { - return Effect.fail( - createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), - ); - } - return Effect.fail( - createGitCommandError( - operation, - cwd, - args, - `${commandLabel(args)} failed: code=${result.code ?? "null"}`, + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + commandInput, + child.stdout, + maxOutputBytes, + truncateOutputAtMaxBytes, + input.progress?.onStdoutLine, ), - ); - }), - ); + collectOutput( + commandInput, + child.stderr, + maxOutputBytes, + truncateOutputAtMaxBytes, + input.progress?.onStderrLine, + ), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + ], + { concurrency: "unbounded" }, + ); + yield* trace2Monitor.flush; + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } - const runGit = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); - - const runGitStdout = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( - Effect.map((result) => result.stdout), + return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + }); + + return yield* runGitCommand().pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), ); + }); + } - const runGitStdoutWithOptions = ( - operation: string, - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, - ): Effect.Effect => - executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); - - const branchExists = (cwd: string, branch: string): Effect.Effect => - executeGit( - "GitCore.branchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], - { - allowNonZeroExit: true, - timeoutMs: 5_000, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const resolveAvailableBranchName = ( - cwd: string, - desiredBranch: string, - ): Effect.Effect => - Effect.gen(function* () { - const isDesiredTaken = yield* branchExists(cwd, desiredBranch); - if (!isDesiredTaken) { - return desiredBranch; + const executeGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => + execute({ + operation, + cwd, + args, + allowNonZeroExit: true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), + ...(options.progress ? { progress: options.progress } : {}), + }).pipe( + Effect.flatMap((result) => { + if (options.allowNonZeroExit || result.code === 0) { + return Effect.succeed(result); } - - for (let suffix = 1; suffix <= 100; suffix += 1) { - const candidate = `${desiredBranch}-${suffix}`; - const isCandidateTaken = yield* branchExists(cwd, candidate); - if (!isCandidateTaken) { - return candidate; - } + const stderr = result.stderr.trim(); + if (stderr.length > 0) { + return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); } - - return yield* createGitCommandError( - "GitCore.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, + if (options.fallbackErrorMessage) { + return Effect.fail( + createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), + ); + } + return Effect.fail( + createGitCommandError( + operation, + cwd, + args, + `${commandLabel(args)} failed: code=${result.code ?? "null"}`, + ), ); - }); + }), + ); + + const runGit = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + + const runGitStdout = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( + Effect.map((result) => result.stdout), + ); + + const runGitStdoutWithOptions = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); + + const branchExists = (cwd: string, branch: string): Effect.Effect => + executeGit( + "GitCore.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.code === 0)); + + const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( + cwd: string, + desiredBranch: string, + ) { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; + } - const resolveCurrentUpstream = ( - cwd: string, - ): Effect.Effect< - { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, - GitCommandError - > => - Effect.gen(function* () { - const upstreamRef = yield* runGitStdout( - "GitCore.resolveCurrentUpstream", - cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } - if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return null; - } + return yield* createGitCommandError( + "GitCore.renameBranch", + cwd, + ["branch", "-m", "--", desiredBranch], + `Could not find an available branch name for '${desiredBranch}'.`, + ); + }); - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0) { - return null; - } - const remoteName = upstreamRef.slice(0, separatorIndex); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1); - if (remoteName.length === 0 || upstreamBranch.length === 0) { - return null; - } + const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { + const upstreamRef = yield* runGitStdout( + "GitCore.resolveCurrentUpstream", + cwd, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - return { - upstreamRef, - remoteName, - upstreamBranch, - }; - }); + if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { + return null; + } - const fetchUpstreamRef = ( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - return runGit( - "GitCore.fetchUpstreamRef", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - true, - ); - }; + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0) { + return null; + } + const remoteName = upstreamRef.slice(0, separatorIndex); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1); + if (remoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } - const fetchUpstreamRefForStatus = ( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - return executeGit( - "GitCore.fetchUpstreamRefForStatus", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - { - allowNonZeroExit: true, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); + return { + upstreamRef, + remoteName, + upstreamBranch, }; + }); - const statusUpstreamRefreshCache = yield* Cache.makeWith({ - capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => - Effect.gen(function* () { - yield* fetchUpstreamRefForStatus(cacheKey.cwd, { - upstreamRef: cacheKey.upstreamRef, - remoteName: cacheKey.remoteName, - upstreamBranch: cacheKey.upstreamBranch, - }); - return true as const; - }), - // Keep successful refreshes warm; drop failures immediately so next request can retry. - timeToLive: (exit) => - Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero, - }); + const fetchUpstreamRef = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return runGit( + "GitCore.fetchUpstreamRef", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + true, + ); + }; - const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect => - Effect.gen(function* () { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* Cache.get( - statusUpstreamRefreshCache, - new StatusUpstreamRefreshCacheKey({ - cwd, - upstreamRef: upstream.upstreamRef, - remoteName: upstream.remoteName, - upstreamBranch: upstream.upstreamBranch, - }), - ); - }); + const fetchUpstreamRefForStatus = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return executeGit( + "GitCore.fetchUpstreamRefForStatus", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + { + allowNonZeroExit: true, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe(Effect.asVoid); + }; - const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect => - Effect.gen(function* () { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* fetchUpstreamRef(cwd, upstream); - }); + const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( + cacheKey: StatusUpstreamRefreshCacheKey, + ) { + yield* fetchUpstreamRefForStatus(cacheKey.cwd, { + upstreamRef: cacheKey.upstreamRef, + remoteName: cacheKey.remoteName, + upstreamBranch: cacheKey.upstreamBranch, + }); + return true as const; + }); - const resolveDefaultBranchName = ( - cwd: string, - remoteName: string, - ): Effect.Effect => - executeGit( - "GitCore.resolveDefaultBranchName", - cwd, - ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); - }), - ); + const statusUpstreamRefreshCache = yield* Cache.makeWith({ + capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + lookup: refreshStatusUpstreamCacheEntry, + // Keep successful refreshes warm; drop failures immediately so next request can retry. + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), + }); - const remoteBranchExists = ( - cwd: string, - remoteName: string, - branch: string, - ): Effect.Effect => - executeGit( - "GitCore.remoteBranchExists", + const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( + cwd: string, + ) { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + yield* Cache.get( + statusUpstreamRefreshCache, + new StatusUpstreamRefreshCacheKey({ cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], - { - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { - allowNonZeroExit: true, - }).pipe(Effect.map((result) => result.code === 0)); - - const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), - ); + upstreamRef: upstream.upstreamRef, + remoteName: upstream.remoteName, + upstreamBranch: upstream.upstreamBranch, + }), + ); + }); - const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => - Effect.gen(function* () { - if (yield* originRemoteExists(cwd)) { - return "origin"; - } - const remotes = yield* listRemoteNames(cwd); - const [firstRemote] = remotes; - if (firstRemote) { - return firstRemote; - } - return yield* createGitCommandError( - "GitCore.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); - }); + const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* ( + cwd: string, + ) { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + yield* fetchUpstreamRef(cwd, upstream); + }); - const resolvePushRemoteName = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", - cwd, - ["config", "--get", `branch.${branch}.pushRemote`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (branchPushRemote.length > 0) { - return branchPushRemote; + const resolveDefaultBranchName = ( + cwd: string, + remoteName: string, + ): Effect.Effect => + executeGit( + "GitCore.resolveDefaultBranchName", + cwd, + ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; } + return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); + }), + ); + + const remoteBranchExists = ( + cwd: string, + remoteName: string, + branch: string, + ): Effect.Effect => + executeGit( + "GitCore.remoteBranchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], + { + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)); + + const originRemoteExists = (cwd: string): Effect.Effect => + executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.code === 0)); + + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), + ); + + const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* createGitCommandError( + "GitCore.resolvePrimaryRemoteName", + cwd, + ["remote"], + "No git remote is configured for this repository.", + ); + }); - const pushDefaultRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.remotePushDefault", - cwd, - ["config", "--get", "remote.pushDefault"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { - return pushDefaultRemote; - } + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + cwd: string, + branch: string, + ) { + const branchPushRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.branchPushRemote", + cwd, + ["config", "--get", `branch.${branch}.pushRemote`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (branchPushRemote.length > 0) { + return branchPushRemote; + } - return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); - }); + const pushDefaultRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.remotePushDefault", + cwd, + ["config", "--get", "remote.pushDefault"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (pushDefaultRemote.length > 0) { + return pushDefaultRemote; + } - const ensureRemote: GitCoreShape["ensureRemote"] = (input) => - Effect.gen(function* () { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout( - "GitCore.ensureRemote.listRemoteUrls", - input.cwd, - ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; - } - } + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + }); - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [ + "remote", + "-v", + ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - yield* runGit("GitCore.ensureRemote.add", input.cwd, [ - "remote", - "add", - remoteName, - input.url, - ]); + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { return remoteName; - }); - - const resolveBaseBranchForNoUpstream = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const configuredBaseBranch = yield* runGitStdout( - "GitCore.resolveBaseBranchForNoUpstream.config", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); + } + } - const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - const defaultBranch = - primaryRemoteName === null - ? null - : yield* resolveDefaultBranchName(cwd, primaryRemoteName); - const candidates = [ - configuredBaseBranch.length > 0 ? configuredBaseBranch : null, - defaultBranch, - ...DEFAULT_BASE_BRANCH_CANDIDATES, - ]; - - for (const candidate of candidates) { - if (!candidate) { - continue; - } + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } - const remotePrefix = - primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; - const normalizedCandidate = candidate.startsWith("origin/") - ? candidate.slice("origin/".length) - : remotePrefix && candidate.startsWith(remotePrefix) - ? candidate.slice(remotePrefix.length) - : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { - continue; - } + yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]); + return remoteName; + }); - if (yield* branchExists(cwd, normalizedCandidate)) { - return normalizedCandidate; - } + const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( + cwd: string, + branch: string, + ) { + const configuredBaseBranch = yield* runGitStdout( + "GitCore.resolveBaseBranchForNoUpstream.config", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const defaultBranch = + primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); + const candidates = [ + configuredBaseBranch.length > 0 ? configuredBaseBranch : null, + defaultBranch, + ...DEFAULT_BASE_BRANCH_CANDIDATES, + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } - if ( - primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) - ) { - return `${primaryRemoteName}/${normalizedCandidate}`; - } - } + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; + const normalizedCandidate = candidate.startsWith("origin/") + ? candidate.slice("origin/".length) + : remotePrefix && candidate.startsWith(remotePrefix) + ? candidate.slice(remotePrefix.length) + : candidate; + if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { + continue; + } - return null; - }); + if (yield* branchExists(cwd, normalizedCandidate)) { + return normalizedCandidate; + } - const computeAheadCountAgainstBase = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); - if (!baseBranch) { - return 0; - } + if ( + primaryRemoteName && + (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } + } - const result = yield* executeGit( - "GitCore.computeAheadCountAgainstBase", - cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], - { allowNonZeroExit: true }, - ); - if (result.code !== 0) { - return 0; - } + return null; + }); - const parsed = Number.parseInt(result.stdout.trim(), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; - }); + const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( + cwd: string, + branch: string, + ) { + const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); + if (!baseBranch) { + return 0; + } - const readBranchRecency = (cwd: string): Effect.Effect, GitCommandError> => - Effect.gen(function* () { - const branchRecency = yield* executeGit( - "GitCore.readBranchRecency", - cwd, - [ - "for-each-ref", - "--format=%(refname:short)%09%(committerdate:unix)", - "refs/heads", - "refs/remotes", - ], - { - timeoutMs: 15_000, - allowNonZeroExit: true, - }, - ); + const result = yield* executeGit( + "GitCore.computeAheadCountAgainstBase", + cwd, + ["rev-list", "--count", `${baseBranch}..HEAD`], + { allowNonZeroExit: true }, + ); + if (result.code !== 0) { + return 0; + } - const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { - return branchLastCommit; - } + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }); - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; - } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; - } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } + const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { + const branchRecency = yield* executeGit( + "GitCore.readBranchRecency", + cwd, + [ + "for-each-ref", + "--format=%(refname:short)%09%(committerdate:unix)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 15_000, + allowNonZeroExit: true, + }, + ); - return branchLastCommit; - }); + const branchLastCommit = new Map(); + if (branchRecency.code !== 0) { + return branchLastCommit; + } - const statusDetails: GitCoreShape["statusDetails"] = (cwd) => - Effect.gen(function* () { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + for (const line of branchRecency.stdout.split("\n")) { + if (line.length === 0) { + continue; + } + const [name, lastCommitRaw] = line.split("\t"); + if (!name) { + continue; + } + const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); + } - const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( - [ - runGitStdout("GitCore.statusDetails.status", cwd, [ - "status", - "--porcelain=2", - "--branch", - ]), - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ - "diff", - "--cached", - "--numstat", - ]), - ], - { concurrency: "unbounded" }, - ); + return branchLastCommit; + }); - let branch: string | null = null; - let upstreamRef: string | null = null; - let aheadCount = 0; - let behindCount = 0; - let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); - - for (const line of statusStdout.split(/\r?\n/g)) { - if (line.startsWith("# branch.head ")) { - const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; - continue; - } - if (line.startsWith("# branch.upstream ")) { - const value = line.slice("# branch.upstream ".length).trim(); - upstreamRef = value.length > 0 ? value : null; - continue; - } - if (line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; - continue; - } - if (line.trim().length > 0 && !line.startsWith("#")) { - hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); - } - } + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( + [ + runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]), + runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]), + ], + { concurrency: "unbounded" }, + ); + + let branch: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); + + for (const line of statusStdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + branch = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); + } + } - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(0)), - ); - behindCount = 0; - } + if (!upstreamRef && branch) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + behindCount = 0; + } - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); - const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); - } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); + const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const fileStatMap = new Map(); + for (const entry of [...stagedEntries, ...unstagedEntries]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } - let insertions = 0; - let deletions = 0; - const files = Array.from(fileStatMap.entries()) - .map(([filePath, stat]) => { - insertions += stat.insertions; - deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); - - for (const filePath of changedFilesWithoutNumstat) { - if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); - } - files.sort((a, b) => a.path.localeCompare(b.path)); + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); - return { - branch, - upstreamRef, - hasWorkingTreeChanges, - workingTree: { - files, - insertions, - deletions, - }, - hasUpstream: upstreamRef !== null, - aheadCount, - behindCount, - }; - }); + return { + branch, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + }; + }); - const status: GitCoreShape["status"] = (input) => - statusDetails(input.cwd).pipe( - Effect.map((details) => ({ - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr: null, - })), + const status: GitCoreShape["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr: null, + })), + ); + + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( + "prepareCommitContext", + )(function* (cwd, filePaths) { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), ); + yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => - Effect.gen(function* () { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), - ); - yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ - "add", - "-A", - "--", - ...filePaths, - ]); - } else { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); - } + const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ + "diff", + "--cached", + "--name-status", + ]).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } - const stagedSummary = yield* runGitStdout( - "GitCore.prepareCommitContext.stagedSummary", - cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } + const stagedPatch = yield* runGitStdoutWithOptions( + "GitCore.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + return { + stagedSummary, + stagedPatch, + }; + }); - const stagedPatch = yield* runGitStdoutWithOptions( - "GitCore.prepareCommitContext.stagedPatch", + const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* ( + cwd, + subject, + body, + options?: GitCommitOptions, + ) { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + const progress = + options?.progress?.onOutputLine === undefined + ? options?.progress + : { + ...options.progress, + onStdoutLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, + onStderrLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, + }; + yield* executeGit("GitCore.commit.commit", cwd, args, { + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(progress ? { progress } : {}), + }).pipe(Effect.asVoid); + const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ + "rev-parse", + "HEAD", + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( + function* (cwd, fallbackBranch) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", cwd, - ["diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, + ["push"], + "Cannot push from detached HEAD.", ); + } - return { - stagedSummary, - stagedPatch, - }; - }); - - const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) => - Effect.gen(function* () { - const args = ["commit", "-m", subject]; - const trimmedBody = body.trim(); - if (trimmedBody.length > 0) { - args.push("-m", trimmedBody); + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; } - const progress = options?.progress - ? { - ...(options.progress.onOutputLine - ? { - onStdoutLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? - Effect.void, - onStderrLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? - Effect.void, - } - : {}), - ...(options.progress.onHookStarted - ? { onHookStarted: options.progress.onHookStarted } - : {}), - ...(options.progress.onHookFinished - ? { onHookFinished: options.progress.onHookFinished } - : {}), - } - : null; - yield* executeGit("GitCore.commit.commit", cwd, args, { - ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); - const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ - "rev-parse", - "HEAD", - ]).pipe(Effect.map((stdout) => stdout.trim())); - - return { commitSha }; - }); - const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => - Effect.gen(function* () { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), ); - } - - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { + if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; } } + } - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); - } - yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - branch, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${publishRemoteName}/${branch}`, - setUpstream: true, - }; - } - - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, - }; + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); } + yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + branch, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${branch}`, + setUpstream: true, + }; + } - yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:${currentUpstream.upstreamBranch}`, + ]); return { status: "pushed" as const, branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + upstreamBranch: currentUpstream.upstreamRef, setUpstream: false, }; - }); + } - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => - Effect.gen(function* () { - const details = yield* statusDetails(cwd); - const branch = details.branch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); - } - const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", + yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }, + ); + + const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( + function* (cwd) { + const details = yield* statusDetails(cwd); + const branch = details.branch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const refreshed = yield* statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - branch, - upstreamBranch: refreshed.upstreamRef, - }; + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitCore.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", }); + const afterSha = yield* runGitStdout( + "GitCore.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + branch, + upstreamBranch: refreshed.upstreamRef, + }; + }, + ); - const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => - Effect.gen(function* () { - const range = `${baseBranch}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitCore.readRangeContext.log", - cwd, - ["log", "--oneline", range], - { - maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffStat", - cwd, - ["diff", "--stat", range], - { - maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffPatch", - cwd, - ["diff", "--patch", "--minimal", range], - { - maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - ], - { concurrency: "unbounded" }, - ); + const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( + function* (cwd, baseBranch) { + const range = `${baseBranch}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitCore.readRangeContext.log", + cwd, + ["log", "--oneline", range], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitCore.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitCore.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); - return { - commitSummary, - diffSummary, - diffPatch, - }; - }); + return { + commitSummary, + diffSummary, + diffPatch, + }; + }, + ); + + const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => + runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), + ); + + const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitCore.listBranches.branchNoColor", + input.cwd, + ["branch", "--no-color"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ); - const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => - runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( - Effect.map((stdout) => stdout.trim()), - Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), + if (localBranchResult.code !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { branches: [], isRepo: false, hasOriginRemote: false }; + } + return yield* createGitCommandError( + "GitCore.listBranches", + input.cwd, + ["branch", "--no-color"], + stderr || "git branch failed", ); + } - const listBranches: GitCoreShape["listBranches"] = (input) => - Effect.gen(function* () { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitCore.listBranches.branchNoColor", - input.cwd, - ["branch", "--no-color"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ); + const remoteBranchResultEffect = executeGit( + "GitCore.listBranches.remoteBranches", + input.cwd, + ["branch", "--no-color", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, + ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + ), + ); + + const remoteNamesResultEffect = executeGit( + "GitCore.listBranches.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + ), + ); - if (localBranchResult.code !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false, hasOriginRemote: false }; - } - return yield* createGitCommandError( - "GitCore.listBranches", + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitCore.listBranches.defaultRef", input.cwd, - ["branch", "--no-color"], - stderr || "git branch failed", - ); - } - - const remoteBranchResultEffect = executeGit( - "GitCore.listBranches.remoteBranches", - input.cwd, - ["branch", "--no-color", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, ), - ); - - const remoteNamesResultEffect = executeGit( - "GitCore.listBranches.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + executeGit( + "GitCore.listBranches.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, ), - ); + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitCore.listBranches.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitCore.listBranches.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, - ); + const remoteNames = + remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, + ); + } + if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } - const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, - ); - } - if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + const defaultBranch = + defaultRef.code === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.code === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; } + } + } - const defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - const worktreeMap = new Map(); - if (worktreeList.code === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } - } - } - - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => ({ - name: branch.name, - current: branch.current, - isRemote: false, - isDefault: branch.name === defaultBranch, - worktreePath: worktreeMap.get(branch.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - - const remoteBranches = - remoteBranchResult.code === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: branch.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const branches = [...localBranches, ...remoteBranches]; - - return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => ({ + name: branch.name, + current: branch.current, + isRemote: false, + isDefault: branch.name === defaultBranch, + worktreePath: worktreeMap.get(branch.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); }); - const createWorktree: GitCoreShape["createWorktree"] = (input) => - Effect.gen(function* () { - const targetBranch = input.newBranch ?? input.branch; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newBranch - ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] - : ["worktree", "add", worktreePath, input.branch]; - - yield* executeGit("GitCore.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", - }); + const remoteBranches = + remoteBranchResult.code === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: branch.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const branches = [...localBranches, ...remoteBranches]; + + return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; + }); - return { - worktree: { - path: worktreePath, - branch: targetBranch, - }, - }; + const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( + function* (input) { + const targetBranch = input.newBranch ?? input.branch; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newBranch + ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] + : ["worktree", "add", worktreePath, input.branch]; + + yield* executeGit("GitCore.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", }); - const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => - Effect.gen(function* () { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitCore.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", - }, - ); - }).pipe(Effect.asVoid); - - const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => - Effect.gen(function* () { - yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); + return { + worktree: { + path: worktreePath, + branch: targetBranch, + }, + }; + }, + ); - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitCore.fetchRemoteBranch.materialize", - input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], - ); - }).pipe(Effect.asVoid); - - const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => - runGit("GitCore.setBranchUpstream", input.cwd, [ - "branch", - "--set-upstream-to", - `${input.remoteName}/${input.remoteBranch}`, - input.branch, + const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn( + "fetchPullRequestBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitCore.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, + ); + }); + + const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( + function* (input) { + yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, ]); - const removeWorktree: GitCoreShape["removeWorktree"] = (input) => - Effect.gen(function* () { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - yield* executeGit("GitCore.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitCore.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, - error, - ), - ), - ); - }); + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitCore.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }, + ); - const renameBranch: GitCoreShape["renameBranch"] = (input) => - Effect.gen(function* () { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - - yield* executeGit( - "GitCore.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); + const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => + runGit("GitCore.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, + ]); + + const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( + function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitCore.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitCore.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, + error, + ), + ), + ); + }, + ); - return { branch: targetBranch }; - }); + const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - const createBranch: GitCoreShape["createBranch"] = (input) => - executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + yield* executeGit( + "GitCore.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }).pipe(Effect.asVoid); + fallbackErrorMessage: "git branch rename failed", + }, + ); - const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => - Effect.gen(function* () { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitCore.checkoutBranch.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - executeGit( - "GitCore.checkoutBranch.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - ], - { concurrency: "unbounded" }, - ); + return { branch: targetBranch }; + }); + + const createBranch: GitCoreShape["createBranch"] = (input) => + executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }).pipe(Effect.asVoid); + + const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( + function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitCore.checkoutBranch.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + executeGit( + "GitCore.checkoutBranch.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + ], + { concurrency: "unbounded" }, + ); + + const localTrackingBranch = remoteExists + ? yield* executeGit( + "GitCore.checkoutBranch.localTrackingBranch", + input.cwd, + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.map((result) => + result.code === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) + : null, + ), + ) + : null; - const localTrackingBranch = remoteExists + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitCore.checkoutBranch.localTrackingBranch", + "GitCore.checkoutBranch.localTrackedBranchTargetExists", input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe( - Effect.map((result) => - result.code === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) - : null, - ), - ) - : null; - - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate - ? yield* executeGit( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)) - : false; - - const checkoutArgs = localInputExists + ).pipe(Effect.map((result) => result.code === 0)) + : false; + + const checkoutArgs = localInputExists + ? ["checkout", input.branch] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.branch] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.branch]; - - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.branch] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.branch]; - // Refresh upstream refs in the background so checkout remains responsive. - yield* Effect.forkScoped( - refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), - ); + yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", }); - const initRepo: GitCoreShape["initRepo"] = (input) => - executeGit("GitCore.initRepo", input.cwd, ["init"], { - timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", - }).pipe(Effect.asVoid); - - const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => - runGitStdout("GitCore.listLocalBranchNames", cwd, [ - "branch", - "--list", - "--format=%(refname:short)", - ]).pipe( - Effect.map((stdout) => - stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0), - ), + // Refresh upstream refs in the background so checkout remains responsive. + yield* Effect.forkScoped( + refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), ); + }, + ); - return { - execute, - status, - statusDetails, - prepareCommitContext, - commit, - pushCurrentBranch, - pullCurrentBranch, - readRangeContext, - readConfigValue, - listBranches, - createWorktree, - fetchPullRequestBranch, - ensureRemote, - fetchRemoteBranch, - setBranchUpstream, - removeWorktree, - renameBranch, - createBranch, - checkoutBranch, - initRepo, - listLocalBranchNames, - } satisfies GitCoreShape; - }); + const initRepo: GitCoreShape["initRepo"] = (input) => + executeGit("GitCore.initRepo", input.cwd, ["init"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git init failed", + }).pipe(Effect.asVoid); + + const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitCore.listLocalBranchNames", cwd, [ + "branch", + "--list", + "--format=%(refname:short)", + ]).pipe( + Effect.map((stdout) => + stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + ); + + return { + execute, + status, + statusDetails, + prepareCommitContext, + commit, + pushCurrentBranch, + pullCurrentBranch, + readRangeContext, + readConfigValue, + listBranches, + createWorktree, + fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, + removeWorktree, + renameBranch, + createBranch, + checkoutBranch, + initRepo, + listLocalBranchNames, + } satisfies GitCoreShape; +}); export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 6fd86e1d58..0348b0087f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts"; +import { + GitActionProgressEvent, + GitActionProgressPhase, + GitRunStackedActionResult, + ModelSelection, +} from "@t3tools/contracts"; import { resolveAutoFeatureBranchName, sanitizeBranchFragment, @@ -20,6 +25,7 @@ import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import type { GitManagerServiceError } from "../Errors.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -384,12 +390,12 @@ export const makeGitManager = Effect.gen(function* () { }; }; - const configurePullRequestHeadUpstream = ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) => - Effect.gen(function* () { + const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { return; @@ -417,7 +423,15 @@ export const makeGitManager = Effect.gen(function* () { remoteName, remoteBranch: pullRequest.headBranch, }); - }).pipe( + }, + ); + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => Effect.logWarning( `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, @@ -425,12 +439,12 @@ export const makeGitManager = Effect.gen(function* () { ), ); - const materializePullRequestHeadBranch = ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) => - Effect.gen(function* () { + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { @@ -470,7 +484,15 @@ export const makeGitManager = Effect.gen(function* () { remoteName, remoteBranch: pullRequest.headBranch, }); - }).pipe( + }, + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( Effect.catch(() => gitCore.fetchPullRequestBranch({ cwd, @@ -487,208 +509,211 @@ export const makeGitManager = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); - const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) => - Effect.gen(function* () { - if (!remoteName) { - return { - repositoryNameWithOwner: null, - ownerLogin: null, - }; - } - - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( + cwd: string, + remoteName: string | null, + ) { + if (!remoteName) { return { - repositoryNameWithOwner, - ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + repositoryNameWithOwner: null, + ownerLogin: null, }; - }); + } - const resolveBranchHeadContext = ( + const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + return { + repositoryNameWithOwner, + ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; + }); + + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null }, - ) => - Effect.gen(function* () { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); - const headBranchFromUpstream = details.upstreamRef - ? extractBranchFromRef(details.upstreamRef) - : ""; - const headBranch = - headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - - const [remoteRepository, originRepository] = yield* Effect.all( - [ - resolveRemoteRepositoryContext(cwd, remoteName), - resolveRemoteRepositoryContext(cwd, "origin"), - ], - { concurrency: "unbounded" }, - ); - - const isCrossRepository = - remoteRepository.repositoryNameWithOwner !== null && - originRepository.repositoryNameWithOwner !== null - ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== - originRepository.repositoryNameWithOwner.toLowerCase() - : remoteName !== null && - remoteName !== "origin" && - remoteRepository.repositoryNameWithOwner !== null; - - const ownerHeadSelector = - remoteRepository.ownerLogin && headBranch.length > 0 - ? `${remoteRepository.ownerLogin}:${headBranch}` - : null; - const remoteAliasHeadSelector = - remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; - const shouldProbeRemoteOwnedSelectors = - isCrossRepository || (remoteName !== null && remoteName !== "origin"); - - const headSelectors: string[] = []; - if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } - appendUnique(headSelectors, details.branch); - appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); - if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } + ) { + const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const headBranchFromUpstream = details.upstreamRef + ? extractBranchFromRef(details.upstreamRef) + : ""; + const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + + const [remoteRepository, originRepository] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); - return { - localBranch: details.branch, - headBranch, + const isCrossRepository = + remoteRepository.repositoryNameWithOwner !== null && + originRepository.repositoryNameWithOwner !== null + ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== + originRepository.repositoryNameWithOwner.toLowerCase() + : remoteName !== null && + remoteName !== "origin" && + remoteRepository.repositoryNameWithOwner !== null; + + const ownerHeadSelector = + remoteRepository.ownerLogin && headBranch.length > 0 + ? `${remoteRepository.ownerLogin}:${headBranch}` + : null; + const remoteAliasHeadSelector = + remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; + const shouldProbeRemoteOwnedSelectors = + isCrossRepository || (remoteName !== null && remoteName !== "origin"); + + const headSelectors: string[] = []; + if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( headSelectors, - preferredHeadSelector: - ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, - remoteName, - headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, - headRepositoryOwnerLogin: remoteRepository.ownerLogin, - isCrossRepository, - } satisfies BranchHeadContext; - }); + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + appendUnique(headSelectors, details.branch); + appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); + if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } - const findOpenPr = (cwd: string, headSelectors: ReadonlyArray) => - Effect.gen(function* () { - for (const headSelector of headSelectors) { - const pullRequests = yield* gitHubCli.listOpenPullRequests({ - cwd, - headSelector, - limit: 1, - }); + return { + localBranch: details.branch, + headBranch, + headSelectors, + preferredHeadSelector: + ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, + remoteName, + headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, + headRepositoryOwnerLogin: remoteRepository.ownerLogin, + isCrossRepository, + } satisfies BranchHeadContext; + }); - const [firstPullRequest] = pullRequests; - if (firstPullRequest) { - return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, - state: "open", - updatedAt: null, - } satisfies PullRequestInfo; - } - } + const findOpenPr = Effect.fn("findOpenPr")(function* ( + cwd: string, + headSelectors: ReadonlyArray, + ) { + for (const headSelector of headSelectors) { + const pullRequests = yield* gitHubCli.listOpenPullRequests({ + cwd, + headSelector, + limit: 1, + }); - return null; - }); + const [firstPullRequest] = pullRequests; + if (firstPullRequest) { + return { + number: firstPullRequest.number, + title: firstPullRequest.title, + url: firstPullRequest.url, + baseRefName: firstPullRequest.baseRefName, + headRefName: firstPullRequest.headRefName, + state: "open", + updatedAt: null, + } satisfies PullRequestInfo; + } + } - const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) => - Effect.gen(function* () { - const headContext = yield* resolveBranchHeadContext(cwd, details); - const parsedByNumber = new Map(); + return null; + }); - for (const headSelector of headContext.headSelectors) { - const stdout = yield* gitHubCli - .execute({ - cwd, - args: [ - "pr", - "list", - "--head", - headSelector, - "--state", - "all", - "--limit", - "20", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", - ], - }) - .pipe(Effect.map((result) => result.stdout)); - - const raw = stdout.trim(); - if (raw.length === 0) { - continue; - } + const findLatestPr = Effect.fn("findLatestPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const parsedByNumber = new Map(); - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), - }); + for (const headSelector of headContext.headSelectors) { + const stdout = yield* gitHubCli + .execute({ + cwd, + args: [ + "pr", + "list", + "--head", + headSelector, + "--state", + "all", + "--limit", + "20", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ], + }) + .pipe(Effect.map((result) => result.stdout)); - for (const pr of parsePullRequestList(parsedJson)) { - parsedByNumber.set(pr.number, pr); - } + const raw = stdout.trim(); + if (raw.length === 0) { + continue; } - const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { - const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; - const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; - return right - left; + const parsedJson = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), }); - const latestOpenPr = parsed.find((pr) => pr.state === "open"); - if (latestOpenPr) { - return latestOpenPr; + for (const pr of parsePullRequestList(parsedJson)) { + parsedByNumber.set(pr.number, pr); } - return parsed[0] ?? null; + } + + const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { + const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; + const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; + return right - left; }); - const resolveBaseBranch = ( + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } + return parsed[0] ?? null; + }); + + const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( cwd: string, branch: string, upstreamRef: string | null, headContext: Pick, - ) => - Effect.gen(function* () { - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; - - if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchFromRef(upstreamRef); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { - return upstreamBranch; - } + ) { + const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + if (configured) return configured; + + if (upstreamRef && !headContext.isCrossRepository) { + const upstreamBranch = extractBranchFromRef(upstreamRef); + if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + return upstreamBranch; } + } - const defaultFromGh = yield* gitHubCli - .getDefaultBranch({ cwd }) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; - } + const defaultFromGh = yield* gitHubCli + .getDefaultBranch({ cwd }) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (defaultFromGh) { + return defaultFromGh; + } - return "main"; - }); + return "main"; + }); - const resolveCommitAndBranchSuggestion = (input: { - cwd: string; - branch: string | null; - commitMessage?: string; - /** When true, also produce a semantic feature branch name. */ - includeBranch?: boolean; - filePaths?: readonly string[]; - modelSelection: ModelSelection; - }) => - Effect.gen(function* () { + const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( + function* (input: { + cwd: string; + branch: string | null; + commitMessage?: string; + /** When true, also produce a semantic feature branch name. */ + includeBranch?: boolean; + filePaths?: readonly string[]; + modelSelection: ModelSelection; + }) { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { return null; @@ -723,9 +748,10 @@ export const makeGitManager = Effect.gen(function* () { ...(generated.branch !== undefined ? { branch: generated.branch } : {}), commitMessage: formatCommitMessage(generated.subject, generated.body), }; - }); + }, + ); - const runCommitStep = ( + const runCommitStep = Effect.fn("runCommitStep")(function* ( modelSelection: ModelSelection, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", @@ -735,194 +761,196 @@ export const makeGitManager = Effect.gen(function* () { filePaths?: readonly string[], progressReporter?: GitActionProgressReporter, actionId?: string, - ) => - Effect.gen(function* () { - const emit = (event: GitActionProgressPayload) => - progressReporter && actionId - ? progressReporter.publish({ - actionId, - cwd, - action, - ...event, - } as GitActionProgressEvent) - : Effect.void; - - let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; - if (!suggestion) { - const needsGeneration = !commitMessage?.trim(); - if (needsGeneration) { - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Generating commit message...", - }); - } - suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - modelSelection, + ) { + const emit = (event: GitActionProgressPayload) => + progressReporter && actionId + ? progressReporter.publish({ + actionId, + cwd, + action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; + if (!suggestion) { + const needsGeneration = !commitMessage?.trim(); + if (needsGeneration) { + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Generating commit message...", }); } - if (!suggestion) { - return { status: "skipped_no_changes" as const }; - } - - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Committing...", + suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + modelSelection, }); + } + if (!suggestion) { + return { status: "skipped_no_changes" as const }; + } - let currentHookName: string | null = null; - const commitProgress = - progressReporter && actionId - ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, - onHookFinished: ({ + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Committing...", + }); + + let currentHookName: string | null = null; + const commitProgress = + progressReporter && actionId + ? { + onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName: currentHookName, + stream, + text: sanitized, + }); + }, + onHookStarted: (hookName: string) => { + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }, + onHookFinished: ({ + hookName, + exitCode, + durationMs, + }: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => { + if (currentHookName === hookName) { + currentHookName = null; + } + return emit({ + kind: "hook_finished", hookName, exitCode, durationMs, - }: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => { - if (currentHookName === hookName) { - currentHookName = null; - } - return emit({ - kind: "hook_finished", - hookName, - exitCode, - durationMs, - }); - }, - } - : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); - if (currentHookName !== null) { - yield* emit({ - kind: "hook_finished", - hookName: currentHookName, - exitCode: 0, - durationMs: null, - }); - currentHookName = null; - } - return { - status: "created" as const, - commitSha, - subject: suggestion.subject, - }; + }); + }, + } + : null; + const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(commitProgress ? { progress: commitProgress } : {}), }); + if (currentHookName !== null) { + yield* emit({ + kind: "hook_finished", + hookName: currentHookName, + exitCode: 0, + durationMs: null, + }); + currentHookName = null; + } + return { + status: "created" as const, + commitSha, + subject: suggestion.subject, + }; + }); - const runPrStep = (modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null) => - Effect.gen(function* () { - const details = yield* gitCore.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); - } + const runPrStep = Effect.fn("runPrStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + fallbackBranch: string | null, + ) { + const details = yield* gitCore.statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* gitManagerError( + "runPrStep", + "Cannot create a pull request from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* gitManagerError( + "runPrStep", + "Current branch has not been pushed. Push before creating a PR.", + ); + } - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); + const headContext = yield* resolveBranchHeadContext(cwd, { + branch, + upstreamRef: details.upstreamRef, + }); - const existing = yield* findOpenPr(cwd, headContext.headSelectors); - if (existing) { - return { - status: "opened_existing" as const, - url: existing.url, - number: existing.number, - baseBranch: existing.baseRefName, - headBranch: existing.headRefName, - title: existing.title, - }; - } + const existing = yield* findOpenPr(cwd, headContext.headSelectors); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); - const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + modelSelection, + }); - const generated = yield* textGeneration.generatePrContent({ + const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); + yield* fileSystem + .writeFileString(bodyFile, generated.body) + .pipe( + Effect.mapError((cause) => + gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), + ), + ); + yield* gitHubCli + .createPullRequest({ cwd, baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - modelSelection, - }); - - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); - yield* gitHubCli - .createPullRequest({ - cwd, - baseBranch, - headSelector: headContext.preferredHeadSelector, - title: generated.title, - bodyFile, - }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - - const created = yield* findOpenPr(cwd, headContext.headSelectors); - if (!created) { - return { - status: "created" as const, - baseBranch, - headBranch: headContext.headBranch, - title: generated.title, - }; - } - + headSelector: headContext.preferredHeadSelector, + title: generated.title, + bodyFile, + }) + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); + + const created = yield* findOpenPr(cwd, headContext.headSelectors); + if (!created) { return { status: "created" as const, - url: created.url, - number: created.number, - baseBranch: created.baseRefName, - headBranch: created.headRefName, - title: created.title, + baseBranch, + headBranch: headContext.headBranch, + title: generated.title, }; - }); + } + + return { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + }; + }); const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { const details = yield* gitCore.statusDetails(input.cwd); @@ -994,18 +1022,19 @@ export const makeGitManager = Effect.gen(function* () { }; } - const ensureExistingWorktreeUpstream = (worktreePath: string) => - Effect.gen(function* () { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); const pullRequestWithRemoteInfo = { ...pullRequest, @@ -1103,45 +1132,47 @@ export const makeGitManager = Effect.gen(function* () { }, ); - const runFeatureBranchStep = ( + const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( modelSelection: ModelSelection, cwd: string, branch: string | null, commitMessage?: string, filePaths?: readonly string[], - ) => - Effect.gen(function* () { - const suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - includeBranch: true, - modelSelection, - }); - if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); - } + ) { + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + includeBranch: true, + modelSelection, + }); + if (!suggestion) { + return yield* gitManagerError( + "runFeatureBranchStep", + "Cannot create a feature branch because there are no changes to commit.", + ); + } - const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); - const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); - const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); + const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); + const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); - yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch })); + yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); + yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch })); - return { - branchStep: { status: "created" as const, name: resolvedBranch }, - resolvedCommitMessage: suggestion.commitMessage, - resolvedCommitSuggestion: suggestion, - }; - }); + return { + branchStep: { status: "created" as const, name: resolvedBranch }, + resolvedCommitMessage: suggestion.commitMessage, + resolvedCommitSuggestion: suggestion, + }; + }); const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( - function* (input, options) { + function* ( + input, + options, + ): Effect.fn.Return { const progress = createProgressEmitter(input, options); const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), @@ -1151,7 +1182,10 @@ export const makeGitManager = Effect.gen(function* () { ]; let currentPhase: GitActionProgressPhase | null = null; - const runAction = Effect.gen(function* () { + const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< + GitRunStackedActionResult, + GitManagerServiceError + > { yield* progress.emit({ kind: "action_started", phases, @@ -1226,12 +1260,12 @@ export const makeGitManager = Effect.gen(function* () { label: "Pushing...", }) .pipe( - Effect.flatMap(() => - Effect.gen(function* () { + Effect.tap(() => + Effect.sync(() => { currentPhase = "push"; - return yield* gitCore.pushCurrentBranch(input.cwd, currentBranch); }), ), + Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; @@ -1243,12 +1277,12 @@ export const makeGitManager = Effect.gen(function* () { label: "Creating PR...", }) .pipe( - Effect.flatMap(() => - Effect.gen(function* () { + Effect.tap(() => + Effect.sync(() => { currentPhase = "pr"; - return yield* runPrStep(modelSelection, input.cwd, currentBranch); }), ), + Effect.flatMap(() => runPrStep(modelSelection, input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; @@ -1266,8 +1300,8 @@ export const makeGitManager = Effect.gen(function* () { return result; }); - return yield* runAction.pipe( - Effect.catch((error) => + return yield* runAction().pipe( + Effect.catch((error: GitManagerServiceError) => progress .emit({ kind: "action_failed", diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md index 33eecb286a..beca3f5327 100644 --- a/docs/effect-fn-checklist.md +++ b/docs/effect-fn-checklist.md @@ -33,7 +33,7 @@ const new = Effect.fn('functionName')(function* () { - [ ] `apps/server/src/provider/Layers/ProviderService.ts` - [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` - [x] `apps/server/src/provider/Layers/CodexAdapter.ts` -- [ ] `apps/server/src/git/Layers/GitCore.ts` +- [x] `apps/server/src/git/Layers/GitCore.ts` - [ ] `apps/server/src/git/Layers/GitManager.ts` - [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` - [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` @@ -57,25 +57,25 @@ const new = Effect.fn('functionName')(function* () { ### `apps/server/src/git/Layers/GitCore.ts` (`58`) -- [ ] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L495) -- [ ] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L317) -- [ ] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L449) -- [ ] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1178) -- [ ] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1217) -- [ ] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1316) -- [ ] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1697) -- [ ] Service methods and callback wrappers in this file +- [x] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L513) +- [x] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L324) +- [x] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L455) +- [x] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1190) +- [x] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1223) +- [x] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1323) +- [x] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1727) +- [x] Service methods and callback wrappers in this file ### `apps/server/src/git/Layers/GitManager.ts` (`28`) -- [ ] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387) -- [ ] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428) -- [ ] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576) -- [ ] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602) -- [ ] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728) -- [ ] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842) -- [ ] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106) -- [ ] Remaining helpers and nested callback wrappers in this file +- [x] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387) +- [x] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428) +- [x] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576) +- [x] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602) +- [x] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728) +- [x] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842) +- [x] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106) +- [x] Remaining helpers and nested callback wrappers in this file ### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`) From ef098f5eb9a054e3d51bd7fe969d50ebdc7c8b72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:10:09 -0700 Subject: [PATCH 3/8] checklist --- apps/server/src/git/Layers/GitManager.ts | 4 ++-- docs/effect-fn-checklist.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 0348b0087f..04d6968f39 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -361,7 +361,7 @@ function toPullRequestHeadRemoteInfo(pr: { }; } -export const makeGitManager = Effect.gen(function* () { +export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; @@ -1322,4 +1322,4 @@ export const makeGitManager = Effect.gen(function* () { } satisfies GitManagerShape; }); -export const GitManagerLive = Layer.effect(GitManager, makeGitManager); +export const GitManagerLive = Layer.effect(GitManager, makeGitManager()); diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md index beca3f5327..dd68259401 100644 --- a/docs/effect-fn-checklist.md +++ b/docs/effect-fn-checklist.md @@ -34,7 +34,7 @@ const new = Effect.fn('functionName')(function* () { - [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` - [x] `apps/server/src/provider/Layers/CodexAdapter.ts` - [x] `apps/server/src/git/Layers/GitCore.ts` -- [ ] `apps/server/src/git/Layers/GitManager.ts` +- [x] `apps/server/src/git/Layers/GitManager.ts` - [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` - [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` - [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` From f152454d4c75cda3f0f72f41fb2f568f12dc4645 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:16:48 -0700 Subject: [PATCH 4/8] document how to type annotate --- docs/effect-fn-checklist.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md index dd68259401..442c2399b3 100644 --- a/docs/effect-fn-checklist.md +++ b/docs/effect-fn-checklist.md @@ -24,6 +24,8 @@ const new = Effect.fn('functionName')(function* () { }) ``` +Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return {})` to annotate the return type of the function if needed: + ## Summary - Total non-test candidates: `322` From 98c0e973263b293c6882168cbaecc0386e6ea9e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:19:52 -0700 Subject: [PATCH 5/8] document pipe --- docs/effect-fn-checklist.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md index 442c2399b3..e8e5be1554 100644 --- a/docs/effect-fn-checklist.md +++ b/docs/effect-fn-checklist.md @@ -24,7 +24,16 @@ const new = Effect.fn('functionName')(function* () { }) ``` -Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return {})` to annotate the return type of the function if needed: +- Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return {})` to annotate the return type of the function if needed. + +- The 2nd argument works as a pipe, and it gets the effect and input as arguments: + +```ts +Effect.fn("name")( + function* (input: Input): Effect.fn.Return {}, + (effect, input) => Effect.catch(effect, (reason) => Effect.logWarning("Err", { input, reason })), +); +``` ## Summary @@ -183,3 +192,7 @@ Use `Effect.fn('name')(function* (input: Input): Effect.fn.Return {})` - [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) - [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`) - [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`) + +``` + +``` From 66ae9fb242b57b24cf925527d820e54831a79d1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:20:29 -0700 Subject: [PATCH 6/8] fixx --- apps/server/src/git/Layers/GitManager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d13c389fc8..db82ea4c72 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -525,7 +525,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); - return makeGitManager.pipe( + return makeGitManager().pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); From 098df800090c060a4149ac679b82473d347c3c3c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:21:46 -0700 Subject: [PATCH 7/8] cool --- apps/server/src/git/Layers/GitManager.ts | 269 +++++++++++------------ 1 file changed, 133 insertions(+), 136 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 04d6968f39..3db9ba986f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -952,7 +952,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { + const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { const details = yield* gitCore.statusDetails(input.cwd); const pr = @@ -977,7 +977,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced( + const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { const pullRequest = yield* gitHubCli .getPullRequest({ @@ -990,147 +990,147 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced( - function* (input) { - const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ + const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( + "preparePullRequestThread", + )(function* (input) { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ cwd: input.cwd, reference: normalizedReference, + force: true, }); - const pullRequest = toResolvedPullRequest(pullRequestSummary); - - if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - force: true, - }); - const details = yield* gitCore.statusDetails(input.cwd); - yield* configurePullRequestHeadUpstream( - input.cwd, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - return { - pullRequest, - branch: details.branch ?? pullRequest.headBranch, - worktreePath: null, - }; - } - - const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( - worktreePath: string, - ) { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); - - const pullRequestWithRemoteInfo = { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), - ); - - const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) - : null; - if ( - existingBranchBeforeFetch?.worktreePath && - existingBranchBeforeFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; - } - if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "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.", - ); - } - - yield* materializePullRequestHeadBranch( + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( input.cwd, - pullRequestWithRemoteInfo, - localPullRequestBranch, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } - const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) - : null; - if ( - existingBranchAfterFetch?.worktreePath && - existingBranchAfterFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, - }; - } - if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "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.", - ); - } + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, branch: localPullRequestBranch, - path: null, - }); - yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); return { pullRequest, - branch: worktree.worktree.branch, - worktreePath: worktree.worktree.path, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, }; - }, - ); + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, + }; + }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( modelSelection: ModelSelection, @@ -1168,11 +1168,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( - function* ( - input, - options, - ): Effect.fn.Return { + const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( + function* (input, options) { const progress = createProgressEmitter(input, options); const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), From aa4eb6da7ad23a2030e87a1507911783bc60197f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 22:27:22 -0700 Subject: [PATCH 8/8] ref --- apps/server/src/git/Layers/GitManager.ts | 32 +++++++++--------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 3db9ba986f..dc082674b7 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, @@ -1177,7 +1177,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ...(input.action !== "commit" ? (["push"] as const) : []), ...(input.action === "commit_push_pr" ? (["pr"] as const) : []), ]; - let currentPhase: GitActionProgressPhase | null = null; + const currentPhase = yield* Ref.make>(Option.none()); const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< GitRunStackedActionResult, @@ -1214,7 +1214,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); if (input.featureBranch) { - currentPhase = "branch"; + yield* Ref.set(currentPhase, Option.some("branch")); yield* progress.emit({ kind: "phase_started", phase: "branch", @@ -1236,7 +1236,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; - currentPhase = "commit"; + yield* Ref.set(currentPhase, Option.some("commit")); const commit = yield* runCommitStep( modelSelection, input.cwd, @@ -1257,11 +1257,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { label: "Pushing...", }) .pipe( - Effect.tap(() => - Effect.sync(() => { - currentPhase = "push"; - }), - ), + Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; @@ -1274,11 +1270,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { label: "Creating PR...", }) .pipe( - Effect.tap(() => - Effect.sync(() => { - currentPhase = "pr"; - }), - ), + Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => runPrStep(modelSelection, input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; @@ -1298,14 +1290,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.catch((error: GitManagerServiceError) => - progress - .emit({ + Effect.tapError((error) => + Effect.flatMap(Ref.get(currentPhase), (phase) => + progress.emit({ kind: "action_failed", - phase: currentPhase, + phase: Option.getOrNull(phase), message: error.message, - }) - .pipe(Effect.flatMap(() => Effect.fail(error))), + }), + ), ), ); },