import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stream,} from "effect";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "../Errors.ts";import { GitCore, type GitCoreShape, type ExecuteGitInput, type ExecuteGitResult,} from "../Services/GitCore.ts";import { ServerConfig } from "../../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000;const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; class StatusUpstreamRefreshCacheKey extends Data.Class<{ cwd: string; upstreamRef: string; remoteName: string; upstreamBranch: string;}> {} interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined;} function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), };} function parseNumstatEntries( stdout: string,): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries;} function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [filePath] = fromTab.split("\t"); return filePath?.trim().length ? filePath.trim() : null; } const parts = line.trim().split(/\s+/g); const filePath = parts.at(-1) ?? ""; return filePath.length > 0 ? filePath : null;} function parseBranchLine(line: string): { name: string; current: boolean } | null { const trimmed = line.trim(); if (trimmed.length === 0) return null; const name = trimmed.replace(/^[*+]\s+/, ""); // Exclude symbolic refs like: "origin/HEAD -> origin/main". // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". if (name.includes(" -> ") || name.startsWith("(")) return null; return { name, current: trimmed.startsWith("* "), };} function parseRemoteNames(stdout: string): ReadonlyArray<string> { return stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .toSorted((a, b) => b.length - a.length);} function sanitizeRemoteName(value: string): string { const sanitized = value .trim() .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); return sanitized.length > 0 ? sanitized : "fork";} function normalizeRemoteUrl(value: string): string { return value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase();} function parseRemoteFetchUrls(stdout: string): Map<string, string> { const remotes = new Map<string, string>(); for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { continue; } remotes.set(remoteName, remoteUrl); } return remotes;} function parseRemoteRefWithRemoteNames( branchName: string, remoteNames: ReadonlyArray<string>,): { remoteRef: string; remoteName: string; localBranch: string } | null { const trimmedBranchName = branchName.trim(); if (trimmedBranchName.length === 0) return null; for (const remoteName of remoteNames) { const remotePrefix = `${remoteName}/`; if (!trimmedBranchName.startsWith(remotePrefix)) { continue; } const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); if (localBranch.length === 0) { return null; } return { remoteRef: trimmedBranchName, remoteName, localBranch, }; } return null;} function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; const upstreamBranch = upstreamBranchRaw.trim(); if (branchName.length === 0 || upstreamBranch.length === 0) { continue; } if (upstreamBranch === upstreamRef) { return branchName; } } return null;} function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; } const localBranch = branchName.slice(separatorIndex + 1).trim(); return localBranch.length > 0 ? localBranch : null;} function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`;} function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; if (!trimmed.startsWith(prefix)) { return null; } const branch = trimmed.slice(prefix.length).trim(); return branch.length > 0 ? branch : null;} function createGitCommandError( operation: string, cwd: string, args: readonly string[], detail: string, cause?: unknown,): GitCommandError { return new GitCommandError({ operation, command: commandLabel(args), cwd, detail, ...(cause !== undefined ? { cause } : {}), });} function quoteGitCommand(args: ReadonlyArray<string>): string { return `git ${args.join(" ")}`;} function toGitCommandError( input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, detail: string,) { return (cause: unknown) => Schema.is(GitCommandError)(cause) ? cause : new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), cwd: input.cwd, detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, ...(cause !== undefined ? { cause } : {}), });} const collectOutput = Effect.fn(function* <E>( input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, stream: Stream.Stream<Uint8Array, E>, maxOutputBytes: number,): Effect.fn.Return<string, GitCommandError> { const decoder = new TextDecoder(); let bytes = 0; let text = ""; yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () { bytes += chunk.byteLength; if (bytes > 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.`, }); } text += decoder.decode(chunk, { stream: true }); }), ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); text += decoder.decode(); return 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 commandEffect = Effect.gen(function* () { const child = yield* commandSpawner .spawn( ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, ...(input.env ? { env: input.env } : {}), }), ) .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput(commandInput, child.stdout, maxOutputBytes), collectOutput(commandInput, child.stderr, maxOutputBytes), child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), ), ], { concurrency: "unbounded" }, ); 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}.`, }); } 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, }), ), ); }); } 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 } : {}), }).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 runGit = ( operation: string, cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect<void, GitCommandError> => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); const runGitStdout = ( operation: string, cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect<string, GitCommandError> => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( Effect.map((result) => result.stdout), ); const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => 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<string, GitCommandError> => Effect.gen(function* () { const isDesiredTaken = yield* branchExists(cwd, desiredBranch); if (!isDesiredTaken) { return desiredBranch; } for (let suffix = 1; suffix <= 100; suffix += 1) { const candidate = `${desiredBranch}-${suffix}`; const isCandidateTaken = yield* branchExists(cwd, candidate); if (!isCandidateTaken) { return candidate; } } return yield* createGitCommandError( "GitCore.renameBranch", cwd, ["branch", "-m", "--", desiredBranch], `Could not find an available branch name for '${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())); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; } 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; } return { upstreamRef, remoteName, upstreamBranch, }; }); const fetchUpstreamRef = ( cwd: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect<void, GitCommandError> => { const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; return runGit( "GitCore.fetchUpstreamRef",Lines 1–500 of 1570. Load more
import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "../Errors.ts"; import { GitCore, type GitCoreShape, type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; import { ServerConfig } from "../../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; class StatusUpstreamRefreshCacheKey extends Data.Class<{ cwd: string; upstreamRef: string; remoteName: string; upstreamBranch: string; }> {} interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), }; } function parseNumstatEntries( stdout: string, ): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries; } function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [filePath] = fromTab.split("\t"); return filePath?.trim().length ? filePath.trim() : null; } const parts = line.trim().split(/\s+/g); const filePath = parts.at(-1) ?? ""; return filePath.length > 0 ? filePath : null; } function parseBranchLine(line: string): { name: string; current: boolean } | null { const trimmed = line.trim(); if (trimmed.length === 0) return null; const name = trimmed.replace(/^[*+]\s+/, ""); // Exclude symbolic refs like: "origin/HEAD -> origin/main". // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". if (name.includes(" -> ") || name.startsWith("(")) return null; return { name, current: trimmed.startsWith("* "), }; } function parseRemoteNames(stdout: string): ReadonlyArray<string> { return stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .toSorted((a, b) => b.length - a.length); } function sanitizeRemoteName(value: string): string { const sanitized = value .trim() .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); return sanitized.length > 0 ? sanitized : "fork"; } function normalizeRemoteUrl(value: string): string { return value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); } function parseRemoteFetchUrls(stdout: string): Map<string, string> { const remotes = new Map<string, string>(); for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { continue; } remotes.set(remoteName, remoteUrl); } return remotes; } function parseRemoteRefWithRemoteNames( branchName: string, remoteNames: ReadonlyArray<string>, ): { remoteRef: string; remoteName: string; localBranch: string } | null { const trimmedBranchName = branchName.trim(); if (trimmedBranchName.length === 0) return null; for (const remoteName of remoteNames) { const remotePrefix = `${remoteName}/`; if (!trimmedBranchName.startsWith(remotePrefix)) { continue; } const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); if (localBranch.length === 0) { return null; } return { remoteRef: trimmedBranchName, remoteName, localBranch, }; } return null; } function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; const upstreamBranch = upstreamBranchRaw.trim(); if (branchName.length === 0 || upstreamBranch.length === 0) { continue; } if (upstreamBranch === upstreamRef) { return branchName; } } return null; } function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; } const localBranch = branchName.slice(separatorIndex + 1).trim(); return localBranch.length > 0 ? localBranch : null; } function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; if (!trimmed.startsWith(prefix)) { return null; } const branch = trimmed.slice(prefix.length).trim(); return branch.length > 0 ? branch : null; } function createGitCommandError( operation: string, cwd: string, args: readonly string[], detail: string, cause?: unknown, ): GitCommandError { return new GitCommandError({ operation, command: commandLabel(args), cwd, detail, ...(cause !== undefined ? { cause } : {}), }); } function quoteGitCommand(args: ReadonlyArray<string>): string { return `git ${args.join(" ")}`; } function toGitCommandError( input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, detail: string, ) { return (cause: unknown) => Schema.is(GitCommandError)(cause) ? cause : new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), cwd: input.cwd, detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, ...(cause !== undefined ? { cause } : {}), }); } const collectOutput = Effect.fn(function* <E>( input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, stream: Stream.Stream<Uint8Array, E>, maxOutputBytes: number, ): Effect.fn.Return<string, GitCommandError> { const decoder = new TextDecoder(); let bytes = 0; let text = ""; yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () { bytes += chunk.byteLength; if (bytes > 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.`, }); } text += decoder.decode(chunk, { stream: true }); }), ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); text += decoder.decode(); return 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 commandEffect = Effect.gen(function* () { const child = yield* commandSpawner .spawn( ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, ...(input.env ? { env: input.env } : {}), }), ) .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput(commandInput, child.stdout, maxOutputBytes), collectOutput(commandInput, child.stderr, maxOutputBytes), child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), ), ], { concurrency: "unbounded" }, ); 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}.`, }); } 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, }), ), ); }); } 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 } : {}), }).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 runGit = ( operation: string, cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect<void, GitCommandError> => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); const runGitStdout = ( operation: string, cwd: string, args: readonly string[], allowNonZeroExit = false, ): Effect.Effect<string, GitCommandError> => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( Effect.map((result) => result.stdout), ); const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => 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<string, GitCommandError> => Effect.gen(function* () { const isDesiredTaken = yield* branchExists(cwd, desiredBranch); if (!isDesiredTaken) { return desiredBranch; } for (let suffix = 1; suffix <= 100; suffix += 1) { const candidate = `${desiredBranch}-${suffix}`; const isCandidateTaken = yield* branchExists(cwd, candidate); if (!isCandidateTaken) { return candidate; } } return yield* createGitCommandError( "GitCore.renameBranch", cwd, ["branch", "-m", "--", desiredBranch], `Could not find an available branch name for '${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())); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; } 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; } return { upstreamRef, remoteName, upstreamBranch, }; }); const fetchUpstreamRef = ( cwd: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect<void, GitCommandError> => { const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; return runGit( "GitCore.fetchUpstreamRef",