import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { GitCommandError } from "../Errors.ts";import { GitService } from "../Services/GitService.ts";import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; 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 commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`;} 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 } : {}), });} const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const executeGit = ( operation: string, cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => git .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 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", cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true, ); }; const fetchUpstreamRefForStatus = ( cwd: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect<void, GitCommandError> => { 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 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 refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => 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 refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => Effect.gen(function* () { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; yield* fetchUpstreamRef(cwd, upstream); }); const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => Effect.gen(function* () { const branchRecency = yield* executeGit( "GitCore.readBranchRecency", cwd, ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], { timeoutMs: 15_000, allowNonZeroExit: true, }, ); const branchLastCommit = new Map<string, number>(); if (branchRecency.code !== 0) { return branchLastCommit; } 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); } return branchLastCommit; }); const statusDetails: GitCoreShape["statusDetails"] = (cwd) => Effect.gen(function* () { yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); 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<string>(); 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 stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); 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)); 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, openPr: null, })), ); const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => Effect.gen(function* () { 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 stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ "diff", "--cached", "--patch", "--minimal", ]); return { stagedSummary, stagedPatch, }; }); const commit: GitCoreShape["commit"] = (cwd, subject, body) => Effect.gen(function* () { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } yield* runGit("GitCore.commit.commit", cwd, args); 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.", ); } if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { return { status: "skipped_up_to_date" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } if (!details.hasUpstream) { yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ "push", "-u", "origin", branch, ]); return { status: "pushed" as const, branch, upstreamBranch: `origin/${branch}`, setUpstream: true, }; } yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); return { status: "pushed" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.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(Lines 1–500 of 759. Load more
import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { GitCommandError } from "../Errors.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; 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 commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } 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 } : {}), }); } const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const executeGit = ( operation: string, cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => git .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 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", cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true, ); }; const fetchUpstreamRefForStatus = ( cwd: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect<void, GitCommandError> => { 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 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 refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => 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 refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => Effect.gen(function* () { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; yield* fetchUpstreamRef(cwd, upstream); }); const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => Effect.gen(function* () { const branchRecency = yield* executeGit( "GitCore.readBranchRecency", cwd, ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], { timeoutMs: 15_000, allowNonZeroExit: true, }, ); const branchLastCommit = new Map<string, number>(); if (branchRecency.code !== 0) { return branchLastCommit; } 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); } return branchLastCommit; }); const statusDetails: GitCoreShape["statusDetails"] = (cwd) => Effect.gen(function* () { yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); 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<string>(); 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 stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); 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)); 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, openPr: null, })), ); const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => Effect.gen(function* () { 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 stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ "diff", "--cached", "--patch", "--minimal", ]); return { stagedSummary, stagedPatch, }; }); const commit: GitCoreShape["commit"] = (cwd, subject, body) => Effect.gen(function* () { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } yield* runGit("GitCore.commit.commit", cwd, args); 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.", ); } if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { return { status: "skipped_up_to_date" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } if (!details.hasUpstream) { yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ "push", "-u", "origin", branch, ]); return { status: "pushed" as const, branch, upstreamBranch: `origin/${branch}`, setUpstream: true, }; } yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); return { status: "pushed" as const, branch, ...(details.upstreamRef ? { upstreamBranch: details.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(