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, normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { getChangeRequestTerminologyForKind, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as 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 class GitManager extends Context.Service< GitManager, { readonly status: ( input: VcsStatusInput, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => 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; } >()("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; const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); const PR_LOOKUP_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; headRemoteUrlKey: 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}`; } interface PullRequestHeadIdentity { readonly repositoryNameWithOwner: string | null; readonly ownerLogin: string | null; } function resolveExpectedHeadIdentity( headContext: Pick, ): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( headContext.headRepositoryNameWithOwner, ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), }; } function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( resolvePullRequestHeadRepositoryNameWithOwner(pr), ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), }; } export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >, ): boolean { if (pr.headRefName !== headContext.headBranch) { return false; } const expectedHead = resolveExpectedHeadIdentity(headContext); const pullRequestHead = resolvePullRequestHeadIdentity(pr); if (expectedHead.repositoryNameWithOwner) { if (pullRequestHead.repositoryNameWithOwner) { if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { return false; } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } if (headContext.isCrossRepository) { if (pr.isCrossRepository === false) { return false; } if ( (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && !pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin ) { return false; } return true; } if (pr.isCrossRepository === true) { if ( (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) ) { 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 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 make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const textGeneration = yield* TextGeneration.TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => new GitManagerError({ operation: "randomUUIDv4", cwd, detail: "Failed to generate Git operation identifier.", cause, }), ), ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => (options?.actionId === undefined ? randomUUIDv4(input.cwd) : 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", { cwd, localBranch, headBranch: pullRequest.headBranch, cause: error, }).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((primaryCause) => gitCore .fetchPullRequestBranch({ cwd, prNumber: pullRequest.number, branch: localBranch, }) .pipe( Effect.mapError( (fallbackCause) => new GitPullRequestMaterializationError({ cwd, pullRequestNumber: pullRequest.number, headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), headBranch: pullRequest.headBranch, localBranch, cause: new AggregateError( [primaryCause, fallbackCause], `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, { cause: primaryCause }, ), }), ), ), ), ); 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.orElseSucceed(() => 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 GitVcsDriver.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)), ); // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh // on their own, slower cadence: ahead/behind counts stay fresh on every // status poll while the PR association is re-fetched at most once per // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump // the epoch (invalidateStatus) to bypass the cache immediately. const prLookupEpochByCwd = new Map(); const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; const bumpPrLookupEpoch = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.map((cacheKey) => { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the // segments can contain a NUL byte, and refs are never empty, so "" decodes // back to a null upstreamRef. const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); const prLookupCache = yield* Cache.makeWith( (key: string) => { const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, }; return resolveBranchHeadContext(cwd, details).pipe( Effect.flatMap((headContext) => findLatestPrForHeadContext(cwd, headContext).pipe( Effect.map((latest) => ({ latest, headContext })), ), ), ); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), }, ); // A transient lookup failure (rate limit, network blip) must not clear an // already-known PR badge, so the last successful answer per branch sticks // around as the fallback. Keep the resolved head context with it so a // branch retargeted to another remote/fork cannot inherit the old badge. interface LastKnownPr { readonly pr: ReturnType | null; readonly upstreamRef: string | null; readonly headBranch: string; readonly remoteName: string | null; readonly headRemoteUrlKey: string | null; } const lastKnownPrByBranchKey = new Map(); const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { if ( !lastKnownPrByBranchKey.has(branchKey) && lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY ) { const oldestKey = lastKnownPrByBranchKey.keys().next().value; if (oldestKey !== undefined) { lastKnownPrByBranchKey.delete(oldestKey); } } lastKnownPrByBranchKey.set(branchKey, entry); }; const resolveLastKnownPr = ( branchKey: string, current: Pick, ): ReturnType | null => { const lastKnown = lastKnownPrByBranchKey.get(branchKey); if (!lastKnown) return null; if (lastKnown.headBranch !== current.headBranch) { return null; } // The normalized URL catches both remote-alias changes and an existing // alias being repointed. Both sides must be resolved before treating a // mismatch as real: `readConfigValueNullable` swallows any git-config // read failure into `null`, so a transient failure to resolve the // *current* remote URL must read as "unknown", not as "no remote" — the // latter would otherwise drop an already-known PR badge on every hiccup. if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; } // If the remote URL can't be compared, fall back to the remote identity // encoded by tracked branches — same "both sides known" requirement, for // the same reason. A null-to-non-null transition (upstream/remoteName) // is allowed because that is the expected first-push case. if ( lastKnown.upstreamRef !== null && current.upstreamRef !== null && lastKnown.remoteName !== null && current.remoteName !== null ) { return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; } return lastKnown.pr; }; const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, ) { // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first // `push -u`) must not orphan the fallback value for the same branch. const branchKey = `${cwd}\u0000${details.branch}`; return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // 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 { pr: null, headContext }; } return { pr: toStatusPr(latest), headContext }; }), Effect.tap(({ pr, headContext }) => Effect.sync(() => rememberLastKnownPr(branchKey, { pr, upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, remoteName: headContext.remoteName, headRemoteUrlKey: headContext.headRemoteUrlKey, }), ), ), Effect.map(({ pr }) => pr), Effect.catch((error) => Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( Effect.annotateLogs({ operation: "lookupStatusPr", branch: details.branch, errorTag: typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : typeof error, }), Effect.andThen(resolveBranchHeadContext(cwd, details)), Effect.map((headContext) => resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, remoteName: headContext.remoteName, headRemoteUrlKey: headContext.headRemoteUrlKey, }), ), ), ), ); }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); if (details === null || !details.isRepo) { return null; } const pr = details.branch !== null ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, isDefaultBranch: details.isDefaultBranch, }) : null; return { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, aheadOfDefaultCount: details.aheadOfDefaultCount, pr, } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { 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.orElseSucceed(() => 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 { remoteUrlKey: null, repositoryNameWithOwner: null, ownerLogin: null, }; } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, 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, headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), 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 findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( cwd: string, headContext: BranchHeadContext, ) { 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.orElseSucceed(() => 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.orElseSucceed(() => 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.orElseSucceed(() => null), ); if (defaultFromProvider) { return defaultFromProvider; } return "main"; }); const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( cwd: string, baseBranch: string, ) { const remoteName = yield* gitCore .resolvePrimaryRemoteName(cwd) .pipe(Effect.orElseSucceed(() => null)); if (!remoteName) return baseBranch; return yield* gitCore .resolveRemoteTrackingCommit({ cwd, refName: baseBranch, fallbackRemoteName: remoteName, }) .pipe( Effect.map((resolved) => resolved.commitSha), Effect.orElseSucceed(() => baseBranch), ); }); 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* new GitManagerError({ operation: "runPrStep", cwd, detail: "Cannot create a pull request from detached HEAD.", }); } if (!details.hasUpstream) { return yield* new GitManagerError({ operation: "runPrStep", cwd, detail: "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 baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); 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(cwd)}.md`, ); yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( Effect.mapError( (cause) => new GitManagerError({ operation: "runPrStep", cwd, detail: "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: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); return yield* Cache.get(localStatusResultCache, cacheKey); }, ); const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); if (options?.refreshUpstream === false) { return yield* readRemoteStatus(cacheKey, options); } return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { concurrency: "unbounded", }); return mergeGitStatusParts(local, remote); }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); }); const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn( "invalidateRemoteStatus", )(function* (cwd) { yield* invalidateRemoteStatusResultCache(cwd); }); const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); // Full invalidation is the explicit-freshness path (git actions, user // refresh); it also bypasses the slow PR-lookup cache. The periodic // status poll only invalidates local/remote and keeps the PR cache warm. yield* bumpPrLookupEpoch(cwd); }, ); const resolvePullRequest: GitManager["Service"]["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: GitManager["Service"]["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 setup script failed", { threadId: input.threadId, worktreePath, cause: error, }).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* new GitManagerError({ operation: "preparePullRequestThread", cwd: input.cwd, detail: "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* new GitManagerError({ operation: "preparePullRequestThread", cwd: input.cwd, detail: "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* new GitManagerError({ operation: "runFeatureBranchStep", cwd, detail: "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: GitManager["Service"]["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* new GitManagerError({ operation: "runStackedAction", cwd: input.cwd, detail: "Feature-branch checkout is only supported for commit actions.", }); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { return yield* new GitManagerError({ operation: "runStackedAction", cwd: input.cwd, detail: "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* new GitManagerError({ operation: "runStackedAction", cwd: input.cwd, detail: "Cannot push from detached HEAD.", }); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { return yield* new GitManagerError({ operation: "runStackedAction", cwd: input.cwd, detail: "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) => new GitManagerError({ operation: "runStackedAction", cwd: input.cwd, detail: "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.orElseSucceed(() => 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 GitManager.of({ localStatus, remoteStatus, status, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, }); }); export const layer = Layer.effect(GitManager, make);