import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { getChangeRequestTerminologyForKind, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; import { GitManagerError } from "@t3tools/contracts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; } export interface GitRunStackedActionOptions { readonly actionId?: string; readonly progressReporter?: GitActionProgressReporter; } export interface GitManagerShape { readonly status: ( input: VcsStatusInput, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, ) => Effect.Effect; } export class GitManager extends Context.Service()( "t3/git/GitManager", ) {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { return error.message.toLowerCase().includes("not a git repository"); } interface OpenPrInfo { number: number; title: string; url: string; baseRefName: string; headRefName: string; } interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; updatedAt: Option.Option; } const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( Order.flip(Option.makeOrder(DateTime.Order)), (pullRequest) => pullRequest.updatedAt, ); interface ResolvedPullRequest { number: number; title: string; url: string; baseBranch: string; headBranch: string; state: "open" | "closed" | "merged"; } interface PullRequestHeadRemoteInfo { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; headRepositoryOwnerLogin?: string | null | undefined; } interface BranchHeadContext { localBranch: string; headBranch: string; headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; } function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null; } function resolveHeadRepositoryNameWithOwner( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, ): string | null { const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; if (explicitRepository.length > 0) { return explicitRepository; } if (!pullRequest.isCrossRepository) { return null; } const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); if (ownerLogin.length === 0 || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`; } function resolvePullRequestWorktreeLocalBranchName( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, ): string { if (!pullRequest.isCrossRepository) { return pullRequest.headBranch; } const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; return `t3code/pr-${pullRequest.number}/${suffix}`; } function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { return null; } const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } function normalizeOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null; } function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null; } function resolvePullRequestHeadRepositoryNameWithOwner( pr: PullRequestHeadRemoteInfo & { url: string }, ) { const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); if (explicitRepository) { return explicitRepository; } if (!pr.isCrossRepository) { return null; } const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); if (!ownerLogin || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`; } function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >, ): boolean { if (pr.headRefName !== headContext.headBranch) { return false; } const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( headContext.headRepositoryNameWithOwner, ); const expectedHeadOwner = normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(expectedHeadRepository); const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( resolvePullRequestHeadRepositoryNameWithOwner(pr), ); const prHeadOwner = normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(prHeadRepository); if (headContext.isCrossRepository) { if (pr.isCrossRepository === false) { return false; } if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { return false; } if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { return false; } if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { return false; } return true; } if (pr.isCrossRepository === true) { return false; } if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { return false; } if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { return false; } return true; } function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), ...(summary.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } : {}), ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), }; } function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, detail, ...(cause !== undefined ? { cause } : {}), }); } function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; } function shortenSha(sha: string | undefined): string | null { if (!sha) return null; return sha.slice(0, SHORT_SHA_LENGTH); } function truncateText( value: string | undefined, maxLength = TOAST_DESCRIPTION_MAX, ): string | undefined { if (!value) return undefined; if (value.length <= maxLength) return value; if (maxLength <= 3) return "...".slice(0, maxLength); return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } function withDescription(title: string, description: string | undefined) { return description ? { title, description } : { title }; } function summarizeGitActionResult( result: Pick, terms: ChangeRequestTerminology, ): { title: string; description?: string; } { if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } if (result.push.status === "pushed") { const shortSha = shortenSha(result.commit.commitSha); const branch = result.push.upstreamBranch ?? result.push.branch; const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; const branchPart = branch ? ` to ${branch}` : ""; return withDescription( `Pushed${pushedCommitPart}${branchPart}`, truncateText(result.commit.subject), ); } if (result.commit.status === "created") { const shortSha = shortenSha(result.commit.commitSha); const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; return withDescription(title, truncateText(result.commit.subject)); } return { title: "Done" }; } function sanitizeCommitMessage(generated: { subject: string; body: string; branch?: string | undefined; }): { subject: string; body: string; branch?: string | undefined; } { const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const subject = rawSubject.replace(/[.]+$/g, "").trim(); const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; return { subject: safeSubject, body: generated.body.trim(), ...(generated.branch !== undefined ? { branch: generated.branch } : {}), }; } function sanitizeProgressText(value: string): string | null { const trimmed = value.trim(); if (trimmed.length === 0) { return null; } if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { return trimmed; } return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); } interface CommitAndBranchSuggestion { subject: string; body: string; branch?: string | undefined; commitMessage: string; } function isCommitAction( action: GitStackedAction, ): action is "commit" | "commit_push" | "commit_push_pr" { return action === "commit" || action === "commit_push" || action === "commit_push_pr"; } function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { return subject; } return `${subject}\n\n${trimmedBody}`; } function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { const normalized = raw.replace(/\r\n/g, "\n").trim(); if (normalized.length === 0) { return null; } const [firstLine, ...rest] = normalized.split("\n"); const subject = firstLine?.trim() ?? ""; if (subject.length === 0) { return null; } return { subject, body: rest.join("\n").trim(), }; } function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { return; } values.push(trimmed); } function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; baseRef: string; headRef: string; state: "open" | "closed" | "merged"; } { return { number: pr.number, title: pr.title, url: pr.url, baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, }; } function normalizePullRequestReference(reference: string): string { const trimmed = reference.trim(); const hashNumber = /^#(\d+)$/.exec(trimmed); return hashNumber?.[1] ?? trimmed; } function toResolvedPullRequest(pr: { number: number; title: string; url: string; baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; }): ResolvedPullRequest { return { number: pr.number, title: pr.title, url: pr.url, baseBranch: pr.baseRefName, headBranch: pr.headRefName, state: pr.state ?? "open", }; } function shouldPreferSshRemote(url: string | null): boolean { if (!url) return false; const trimmed = url.trim(); return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); } function toPullRequestHeadRemoteInfo(pr: { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; headRepositoryOwnerLogin?: string | null | undefined; }): PullRequestHeadRemoteInfo { return { ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), ...(pr.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } : {}), ...(pr.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } : {}), }; } export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry; const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettingsService; const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError((cause) => gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), ), ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe( Effect.map((actionId) => { const reporter = options?.progressReporter; const emit = (event: GitActionProgressPayload) => reporter ? reporter.publish({ actionId, cwd: input.cwd, action: input.action, ...event, } as GitActionProgressEvent) : Effect.void; return { actionId, emit, }; }), ); const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( function* ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) { const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); yield* gitCore.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: pullRequest.headBranch, }); yield* gitCore.setBranchUpstream({ cwd, branch: localBranch, remoteName, remoteBranch: pullRequest.headBranch, }); return; } if (repositoryNameWithOwner.length === 0) { return; } const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || repositoryNameWithOwner.split("/")[0]?.trim() || "fork"; const remoteName = yield* gitCore.ensureRemote({ cwd, preferredName: preferredRemoteName, url: remoteUrl, }); yield* gitCore.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: pullRequest.headBranch, }); yield* gitCore.setBranchUpstream({ cwd, branch: localBranch, remoteName, remoteBranch: pullRequest.headBranch, }); }, ); 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}`, ).pipe(Effect.asVoid), ), ); const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( function* ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { yield* gitCore.fetchPullRequestBranch({ cwd, prNumber: pullRequest.number, branch: localBranch, }); return; } const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || repositoryNameWithOwner.split("/")[0]?.trim() || "fork"; const remoteName = yield* gitCore.ensureRemote({ cwd, preferredName: preferredRemoteName, url: remoteUrl, }); yield* gitCore.fetchRemoteBranch({ cwd, remoteName, remoteBranch: pullRequest.headBranch, localBranch, }); yield* gitCore.setBranchUpstream({ cwd, branch: localBranch, remoteName, remoteBranch: pullRequest.headBranch, }); }, ); const materializePullRequestHeadBranch = ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( Effect.catch(() => gitCore.fetchPullRequestBranch({ cwd, prNumber: pullRequest.number, branch: localBranch, }), ), ); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); const normalizeStatusCacheKey = canonicalizeExistingPath; const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, isDefaultBranch: false, branch: null, upstreamRef: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, } satisfies GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetailsLocal(cwd) .pipe( Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); const hostingProvider = details.isRepo ? yield* resolveHostingProvider(cwd, details.branch) : null; return { isRepo: details.isRepo, ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), hasPrimaryRemote: details.hasOriginRemote, isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateLocalStatusResultCache = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetails(cwd) .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); if (details === null || !details.isRepo) { return null; } const pr = details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, }).pipe( Effect.map((latest) => { if (!latest) return null; // On the default branch, only surface open PRs. // Merged/closed matches are usually reverse-merge history, not the thread's PR context. if (details.isDefaultBranch && latest.state !== "open") return null; return toStatusPr(latest); }), Effect.catch(() => Effect.succeed(null)), ) : null; return { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, aheadOfDefaultCount: details.aheadOfDefaultCount, pr, } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateRemoteStatusResultCache = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), ); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( cwd: string, branch: string | null, ) { const preferredRemoteName = branch === null ? "origin" : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); const remoteUrl = (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, ) { if (!remoteName) { return { repositoryNameWithOwner: null, ownerLogin: null, }; } 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 }, ) { const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; const shouldProbeLocalBranchSelector = headBranchFromUpstream.length === 0 || headBranch === 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, ); } if (shouldProbeLocalBranchSelector) { appendUnique(headSelectors, details.branch); } appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { appendUnique(headSelectors, ownerHeadSelector); appendUnique( headSelectors, remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, ); } return { localBranch: details.branch, headBranch, headSelectors, preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, } satisfies BranchHeadContext; }); const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, headContext: Pick< BranchHeadContext, | "headBranch" | "headSelectors" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >, ) { for (const headSelector of headContext.headSelectors) { const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ cwd, headSelector, state: "open", limit: 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); const firstPullRequest = normalizedPullRequests.find((pullRequest) => matchesBranchHeadContext(pullRequest, headContext), ); if (firstPullRequest) { return { number: firstPullRequest.number, title: firstPullRequest.title, url: firstPullRequest.url, baseRefName: firstPullRequest.baseRefName, headRefName: firstPullRequest.headRefName, state: "open", updatedAt: Option.none(), } satisfies PullRequestInfo; } } return null; }); const findLatestPr = Effect.fn("findLatestPr")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null }, ) { const headContext = yield* resolveBranchHeadContext(cwd, details); const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ cwd, headSelector, state: "all", limit: 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { if (!matchesBranchHeadContext(pr, headContext)) { continue; } parsedByNumber.set(pr.number, pr); } } const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { return latestOpenPr; } return parsed[0] ?? null; }); const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, ) { const terms = yield* sourceControlProvider(cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), ); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; let finalBranchContext: { branch: string; upstreamRef: string | null; hasUpstream: boolean; } | null = null; if (result.action !== "commit") { const finalStatus = yield* gitCore.statusDetails(cwd); if (finalStatus.branch) { finalBranchContext = { branch: finalStatus.branch, upstreamRef: finalStatus.upstreamRef, hasUpstream: finalStatus.hasUpstream, }; currentBranchIsDefault = finalStatus.isDefaultBranch; } } const explicitResultPr = (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url ? { url: result.pr.url, state: "open" as const, } : null; const shouldLookupExistingOpenPr = (result.action === "commit_push" || result.action === "push") && result.push.status === "pushed" && result.branch.status !== "created" && !currentBranchIsDefault && explicitResultPr === null && finalBranchContext?.hasUpstream === true; if (shouldLookupExistingOpenPr && finalBranchContext) { latestOpenPr = yield* resolveBranchHeadContext(cwd, { branch: finalBranchContext.branch, upstreamRef: finalBranchContext.upstreamRef, }).pipe( Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), Effect.catch(() => Effect.succeed(null)), ); } const openPr = latestOpenPr ?? explicitResultPr; const cta = result.action === "commit" && result.commit.status === "created" ? { kind: "run_action" as const, label: "Push", action: { kind: "push" as const }, } : (result.action === "push" || result.action === "create_pr" || result.action === "commit_push" || result.action === "commit_push_pr") && openPr?.url && (!currentBranchIsDefault || result.pr.status === "created" || result.pr.status === "opened_existing") ? { kind: "open_pr" as const, label: `View ${terms.shortLabel}`, url: openPr.url, } : (result.action === "push" || result.action === "commit_push") && result.push.status === "pushed" && !currentBranchIsDefault ? { kind: "run_action" as const, label: `Create ${terms.shortLabel}`, action: { kind: "create_pr" as const }, } : { kind: "none" as const, }; return { ...summary, cta, }; }); const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( cwd: string, branch: string, upstreamRef: string | null, headContext: Pick, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; if (upstreamRef && !headContext.isCrossRepository) { const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { remoteName: headContext.remoteName, }); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return upstreamBranch; } } const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), Effect.catch(() => Effect.succeed(null)), ); if (defaultFromProvider) { return defaultFromProvider; } return "main"; }); 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; } const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); if (customCommit) { return { subject: customCommit.subject, body: customCommit.body, ...(input.includeBranch ? { branch: sanitizeFeatureBranchName(customCommit.subject) } : {}), commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), }; } const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), modelSelection: input.modelSelection, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); return { subject: generated.subject, body: generated.body, ...(generated.branch !== undefined ? { branch: generated.branch } : {}), commitMessage: formatCommitMessage(generated.subject, generated.body), }; }, ); const runCommitStep = Effect.fn("runCommitStep")(function* ( modelSelection: ModelSelection, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], progressReporter?: GitActionProgressReporter, actionId?: string, ) { 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, }); } if (!suggestion) { return { status: "skipped_no_changes" as const }; } 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, }); }, } : 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 = Effect.fn("runPrStep")(function* ( modelSelection: ModelSelection, cwd: string, fallbackBranch: string | null, emit: GitActionProgressEmitter, ) { const provider = yield* sourceControlProvider(cwd); const terms = getChangeRequestTerminologyForKind(provider.kind); 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 existing = yield* findOpenPr(cwd, headContext); 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); yield* emit({ kind: "phase_started", phase: "pr", label: `Generating ${terms.shortLabel} content...`, }); 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 bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); yield* fileSystem .writeFileString(bodyFile, generated.body) .pipe( Effect.mapError((cause) => gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), ), ); yield* emit({ kind: "phase_started", phase: "pr", label: `Creating ${terms.singular}...`, }); yield* provider .createChangeRequest({ cwd, baseRefName: 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); if (!created) { return { status: "created" as const, 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 localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); return yield* Cache.get(localStatusResultCache, cacheKey); }); const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); return mergeGitStatusParts(local, remote); }); const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); }); const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( "invalidateRemoteStatus", )(function* (cwd) { yield* invalidateRemoteStatusResultCache(cwd); }); const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); }, ); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) .getChangeRequest({ cwd: input.cwd, reference: normalizePullRequestReference(input.reference), }) .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); return { pullRequest }; }, ); const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { const maybeRunSetupScript = (worktreePath: string) => { if (!input.threadId) { return Effect.void; } return projectSetupScriptRunner .runForThread({ threadId: input.threadId, projectCwd: input.cwd, worktreePath, }) .pipe( Effect.catch((error) => Effect.logWarning( `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, ).pipe(Effect.asVoid), ), ); }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ 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 = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { const result = yield* gitCore.listRefs({ cwd }); const localBranch = result.refs.find( (branch) => !branch.isRemote && branch.name === localPullRequestBranch, ); if (localBranch) { return localBranch; } if (localPullRequestBranch === pullRequest.headBranch) { return null; } for (const branch of result.refs) { if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) { continue; } const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); if (worktreePath !== rootWorktreePath) { return branch; } } return null; }); const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath ? yield* 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( input.cwd, pullRequestWithRemoteInfo, localPullRequestBranch, ); const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath ? yield* 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 worktree = yield* gitCore.createWorktree({ cwd: input.cwd, refName: localPullRequestBranch, path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); return { pullRequest, branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( modelSelection: ModelSelection, cwd: string, branch: string | null, commitMessage?: string, filePaths?: readonly string[], ) { 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); yield* gitCore.createRef({ cwd, refName: resolvedBranch }); yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); return { branchStep: { status: "created" as const, name: resolvedBranch }, resolvedCommitMessage: suggestion.commitMessage, resolvedCommitSuggestion: suggestion, }; }); const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = yield* createProgressEmitter(input, options); const currentPhase = yield* Ref.make>(Option.none()); const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< GitRunStackedActionResult, GitManagerServiceError > { const initialStatus = yield* gitCore.statusDetails(input.cwd); const wantsCommit = isCommitAction(input.action); const wantsPush = input.action === "push" || input.action === "commit_push" || input.action === "commit_push_pr" || (input.action === "create_pr" && (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { return yield* gitManagerError( "runStackedAction", "Feature-branch checkout is only supported for commit actions.", ); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { return yield* gitManagerError( "runStackedAction", "Commit local changes before creating a PR.", ); } const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), ...(wantsPush ? (["push"] as const) : []), ...(wantsPr ? (["pr"] as const) : []), ]; yield* progress.emit({ kind: "action_started", phases, }); if (!input.featureBranch && wantsPush && !initialStatus.branch) { return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { return yield* gitManagerError( "runStackedAction", "Cannot create a pull request from detached HEAD.", ); } let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; const modelSelection = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.textGenerationModelSelection), Effect.mapError((cause) => gitManagerError("runStackedAction", "Failed to get server settings.", cause), ), ); if (input.featureBranch) { yield* Ref.set(currentPhase, Option.some("branch")); yield* progress.emit({ kind: "phase_started", phase: "branch", label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( modelSelection, input.cwd, initialStatus.branch, input.commitMessage, input.filePaths, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; preResolvedCommitSuggestion = result.resolvedCommitSuggestion; } else { branchStep = { status: "skipped_not_requested" as const }; } const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr ? yield* sourceControlProvider(input.cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), ) : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( Effect.flatMap(() => runCommitStep( modelSelection, input.cwd, commitAction, currentBranch, commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, options?.progressReporter, progress.actionId, ), ), ) : { status: "skipped_not_requested" as const }; const push = wantsPush ? yield* progress .emit({ kind: "phase_started", phase: "push", label: "Pushing...", }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; const pr = wantsPr ? yield* progress .emit({ kind: "phase_started", phase: "pr", label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), ), ) : { status: "skipped_not_requested" as const }; const toast = yield* buildCompletionToast(input.cwd, { action: input.action, branch: branchStep, commit, push, pr, }); const result = { action: input.action, branch: branchStep, commit, push, pr, toast, }; yield* progress.emit({ kind: "action_finished", result, }); return result; }); return yield* runAction().pipe( Effect.ensuring(invalidateStatus(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ kind: "action_failed", phase: Option.getOrNull(phase), message: error.message, }), ), ), ); }, ); return { localStatus, remoteStatus, status, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, } satisfies GitManagerShape; }); export const layer = Layer.effect(GitManager, makeGitManager());