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 * as Schema from "effect/Schema"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, SourceControlProviderError, type SourceControlWritingStyleSettings, } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { conventionalCommitsTextGenerationPolicy, customTextGenerationPolicy, repositoryConventionsTextGenerationPolicy, } from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.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 { detectPrTemplate } from "../sourceControl/PrTemplateDetection.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 GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ readonly refreshMissingPullRequest?: boolean; } export type GitBranchPullRequest = NonNullable & { readonly repositoryKey: string | null; readonly updatedAt: string | null; readonly closedAt?: string | null; readonly mergedAt?: string | null; }; interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; } export class GitManager extends Context.Service< GitManager, { readonly status: ( input: VcsStatusInput, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, options?: GitRemoteStatusOptions, ) => Effect.Effect; /** Resolve the PR for a saved branch without changing the current checkout. */ readonly branchPullRequest: ( input: { readonly cwd: string; readonly branch: string }, options?: { readonly refresh?: boolean }, ) => 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; // Matches the automatic settlement sweep cadence so every background sweep // reads fresh branch state: an external merge settles within about a minute // instead of waiting out a longer cache. Unpublished branches never reach the // host (a local probe answers first), and failed lookups still back off // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** * How long a failed PR lookup is cached, given the number of consecutive * failures for that branch. * * A hosting provider rejects a throttled request immediately, so caching every * failure for a flat 20s made a rate-limited poller re-ask *faster* than a * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 * into sustained pressure. Backing off per branch keeps the retry rate below * the healthy rate once a branch has failed more than a couple of times. */ export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); } 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"; isDraft?: boolean; closedAt?: string | null; mergedAt?: string | null; 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; targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; } export function pullRequestRepositoryKey(value: string): string | null { try { const url = new URL(value); const match = /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( url.pathname, ); if (match?.[1] === undefined) return null; url.pathname = match[1]; url.search = ""; url.hash = ""; return normalizeGitRemoteUrl(url.toString()); } catch { return null; } } function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/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 parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.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; } // GitLab reports the top-level group as owner. The full path distinguishes subgroups. 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", ...(summary.isDraft === true ? { isDraft: true } : {}), closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, 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"; isDraft?: boolean; updatedAt: string | null; } { return { number: pr.number, title: pr.title, url: pr.url, baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, ...(pr.isDraft === true ? { isDraft: true } : {}), updatedAt: Option.match(pr.updatedAt, { onNone: () => null, onSome: (updatedAt) => DateTime.formatIso(updatedAt), }), }; } 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; return isSshRemoteUrl(url); } 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 providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); if (!instructionPath.startsWith(`${root}${path.sep}`)) { return ""; } const info = yield* fileSystem.stat(instructionPath); if (info.type !== "File" || info.size > FileSystem.Size(20_000)) { return ""; } return (yield* fileSystem.readFileString(instructionPath)).trim(); }).pipe(Effect.orElseSucceed(() => "")); const readRecentCommitSubjects = (cwd: string) => gitCore .execute({ operation: "GitManager.readRecentCommitSubjects", cwd, args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], }) .pipe( Effect.map((result) => result.stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0), ), Effect.orElseSucceed(() => []), ); const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => Effect.gen(function* () { switch (settings.style.mode) { case "conventional_commits": return conventionalCommitsTextGenerationPolicy; case "custom": return customTextGenerationPolicy( settings.style.customInstructions ? { commitInstructions: settings.style.customInstructions, changeRequestInstructions: settings.style.customInstructions, } : {}, ); case "repo_conventions": { const subjects = yield* readRecentCommitSubjects(cwd); const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); const isClaudeWriter = settings.modelSelection.instanceId === "claudeAgent" || (yield* providerRegistry.getProviders).some( (provider) => provider.instanceId === settings.modelSelection.instanceId && provider.driver === "claudeAgent", ); const claudeInstructions = isClaudeWriter ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") : ""; const examples = [ ...(subjects.length > 0 ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] : []), ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), ].join("\n\n"); if (!examples) { return repositoryConventionsTextGenerationPolicy; } return { ...repositoryConventionsTextGenerationPolicy, commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, }; } } }); 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 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. Automatic settlement validates repository URLs // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; localBranchExists?: boolean; remoteName?: string | null; }, ) => [ cwd, details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", details.localBranchExists === false ? "0" : "1", details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits // longer before the next attempt. Cleared as soon as a lookup succeeds. const prLookupFailureStreakByKey = new Map(); const nextPrLookupFailureTtl = (key: string) => { if ( !prLookupFailureStreakByKey.has(key) && prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY ) { const oldestKey = prLookupFailureStreakByKey.keys().next().value; if (oldestKey !== undefined) { prLookupFailureStreakByKey.delete(oldestKey); } } const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; prLookupFailureStreakByKey.set(key, streak); return prLookupFailureTtl(streak); }; const prLookupCache = yield* Cache.makeWith( (key: string) => { const [ cwd = "", branch = "", upstreamRef = "", defaultBranch = "", branchExists = "1", remoteName = "", ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, localBranchExists: branchExists !== "0", ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); if (!lookup) { return { latest: null, headContext }; } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. if ( details.localBranchExists && details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext)) ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); return { latest, headContext }; }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); return PR_LOOKUP_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, }, ); // 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; defaultBranch: string | null; isDefaultBranch: boolean; }, refreshMissingPullRequest = false, ) { // 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}`; const cacheKey = prLookupCacheKey(cwd, details); if (refreshMissingPullRequest) { const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(cached) && cached.value.latest === null) { yield* Cache.invalidate(prLookupCache, cacheKey); } } return yield* Cache.get(prLookupCache, cacheKey).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, ...(isSourceControlProviderError(error) ? { provider: error.provider, providerOperation: error.operation, providerCommand: error.command ?? "unknown", errorDetail: error.detail, } : {}), }), Effect.andThen(resolveLookupHeadContext(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?: 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, defaultBranch: details.defaultBranch, isDefaultBranch: details.isDefaultBranch, }, options?.refreshMissingPullRequest, ) : 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 = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; }); const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( function* (cwd: string, branch: string, remoteNameOverride?: string) { const remoteName = remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); const [headRemote, targetRemote] = yield* Effect.all( [ resolveRemoteRepositoryContext(cwd, remoteName), resolveRemoteRepositoryContext(cwd, "origin"), ], { concurrency: "unbounded" }, ); return { remoteName, headRemoteUrlKey: headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), targetRemoteUrlKey: targetRemote.remoteUrlKey, }; }, ); const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { const remoteName = details.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), targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, } satisfies BranchHeadContext; }); // The remote that holds a ref named after the local branch, or null when // none does. Remote names may contain slashes, so refs are matched literally // per remote instead of with a glob. When several remotes hold the name, the // preferred remote wins, then origin, then the first configured remote. const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* ( cwd: string, branch: string, preferredRemoteName: string | null, ) { if (branch.length === 0) return null; return yield* Effect.gen(function* () { const remoteNames = (yield* gitCore.execute({ operation: "GitManager.findRemoteTrackingRemote.remotes", cwd, args: ["remote"], timeoutMs: 5_000, })).stdout .split("\n") .map((name) => name.trim()) .filter((name) => name.length > 0); if (remoteNames.length === 0) return null; const refs = new Set( (yield* gitCore.execute({ operation: "GitManager.findRemoteTrackingRemote.refs", cwd, args: [ "for-each-ref", "--format=%(refname)", ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), ], timeoutMs: 5_000, })).stdout .split("\n") .map((ref) => ref.trim()) .filter((ref) => ref.length > 0), ); const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`)); if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) { return preferredRemoteName; } if (matching.includes("origin")) return "origin"; return matching[0] ?? null; }).pipe(Effect.orElseSucceed(() => null)); }); // `git worktree add -b feature origin/main` makes the new local branch track // origin/main. That upstream is the branch's base, not its published PR // head. Looking up PRs for it can attach an old reverse merge from main and // auto-settle an unrelated feature thread. // // The branch may still have been pushed under its own name by a plain // `git push feature` that never moved the upstream. When a remote // holds a ref for the local name, look the PR up by that name on that // remote. Without such a ref there is nothing to ask the host about, so // `lookup` is false and no API call is spent. Both the cached lookup and the // failure fallback resolve through here so the last-known PR compares // against the same head branch. const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; remoteName?: string; }, ) { const headContext = yield* resolveBranchHeadContext(cwd, details); const upstreamHeadIsDefault = headContext.headBranch === details.defaultBranch || (details.defaultBranch === null && (headContext.headBranch === "main" || headContext.headBranch === "master")); if ( headContext.headBranch === details.branch || !upstreamHeadIsDefault || headContext.isCrossRepository ) { return { headContext, lookup: true }; } const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); if (remoteName === null) { return { headContext, lookup: false }; } const ownNameContext = yield* resolveBranchHeadContext(cwd, { branch: details.branch, upstreamRef: null, remoteName, }); return { headContext: ownNameContext, lookup: true }; }); /** * Whether git has no record of this branch on any remote, so a change request * cannot exist for it and asking the provider is a guaranteed-empty API call. * * `git push` writes the remote-tracking ref even without `-u` (how most * terminal and agent pushes land), and configured upstream metadata survives * when a merged change request's remote branch is deleted. Together they * distinguish branches known to have reached a host from genuinely local * branches. The ref glob spans every remote so a fork branch still counts. A * repository that tracks no remotes at all cannot answer the question, * because then every branch looks unpublished; it, and any failed probe, * keeps the lookup. */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, headContext: Pick, ) { if (headContext.headBranch.length === 0) { return false; } const matchesRef = (pattern: string) => gitCore .execute({ operation: "GitManager.isUnpublishedBranch", cwd, args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], timeoutMs: 5_000, }) .pipe(Effect.map((result) => result.stdout.trim().length > 0)); return yield* Effect.gen(function* () { const [configuredRemote, configuredMerge] = yield* Effect.all( [ gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), ], { concurrency: "unbounded" }, ); if (configuredRemote !== null && configuredMerge !== null) { return false; } const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], { concurrency: "unbounded" }, ); return tracksAnyRemote && !tracksThisBranch; }).pipe(Effect.orElseSucceed(() => false)); }); 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 { ...firstPullRequest, 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; } // The provider lookup can fail for reasons unrelated to the branch, so fall // back to what the remote itself records before assuming a name. A repository // whose default branch is master would otherwise get a base branch that does // not exist. const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), Effect.orElseSucceed(() => null), ); if (defaultFromRemote) { return defaultFromRemote; } 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[]; settings: SourceControlTextGenerationSettings; }) { 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 policy = yield* resolveStylePolicy(input.cwd, input.settings); 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 } : {}), ...(policy ? { policy } : {}), modelSelection: input.settings.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* ( settings: SourceControlTextGenerationSettings, 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 } : {}), settings, }); } 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* ( settings: SourceControlTextGenerationSettings, 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 policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = settings.style.followChangeRequestTemplates && provider.kind === "github" ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) : undefined; 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), ...(changeRequestTemplate ? { changeRequestTemplate } : {}), ...(policy ? { policy } : {}), modelSelection: settings.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 || options?.refreshMissingPullRequest) { 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 branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", )(function* ({ cwd, branch }, options) { const cacheCwd = yield* normalizeStatusCacheKey(cwd); const remotes = yield* gitCore.execute({ operation: "GitManager.branchPullRequest.remotes", cwd: cacheCwd, args: ["remote"], }); const remoteNames = remotes.stdout .split("\n") .map((remoteName) => remoteName.trim()) .filter((remoteName) => remoteName.length > 0); const [firstRemoteName] = remoteNames; if (firstRemoteName === undefined) return null; const branchRef = yield* gitCore.execute({ operation: "GitManager.branchPullRequest.branchRef", cwd: cacheCwd, args: [ "for-each-ref", "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", `refs/heads/${branch}`, ], }); const expectedRefName = `refs/heads/${branch}`; const exactBranch = branchRef.stdout .split("\n") .find((line) => line.split("\u0000", 1)[0] === expectedRefName); const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = exactBranch?.split("\u0000") ?? []; const localBranchExists = refName.length > 0; let upstreamRef: string | null = null; let remoteName: string | null = null; if (savedUpstream.length > 0) { if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { return yield* new GitManagerError({ operation: "branchPullRequest", cwd: cacheCwd, detail: `Saved upstream for ${branch} is incomplete.`, }); } remoteName = savedRemoteName; const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); upstreamRef = `${remoteName}/${upstreamBranch}`; } else if (!localBranchExists) { const trackingRefs = yield* gitCore.execute({ operation: "GitManager.branchPullRequest.remoteTrackingRefs", cwd: cacheCwd, args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], }); const refNames = new Set( trackingRefs.stdout .split("\n") .map((remoteRef) => remoteRef.trim()) .filter((remoteRef) => remoteRef.length > 0), ); const matchingRemoteNames = remoteNames.filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`), ); if (matchingRemoteNames.length > 1) { return yield* new GitManagerError({ operation: "branchPullRequest", cwd: cacheCwd, detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, }); } remoteName = matchingRemoteNames[0] ?? null; if (remoteName !== null) { upstreamRef = `${remoteName}/${branch}`; } } const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; const defaultBranch = yield* gitCore .resolveDefaultBranchName(cacheCwd, defaultRemoteName) .pipe(Effect.orElseSucceed(() => null)); const cacheKey = prLookupCacheKey(cacheCwd, { branch, upstreamRef, defaultBranch, localBranchExists, ...(localBranchExists ? {} : { remoteName }), }); if (options?.refresh) { // A completed turn can create a PR or reuse a merged PR's branch. // Refresh successful answers, but keep failed lookups' retry backoff. const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); } let cached = yield* Cache.get(prLookupCache, cacheKey); // The cached head context may have resolved on a different remote than // the saved upstream: a branch tracking origin/main but pushed to a fork // is looked up on the fork. Verify against the remote the lookup used. const identityRemoteName = (headContext: BranchHeadContext) => headContext.remoteName ?? remoteName ?? undefined; const currentIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, identityRemoteName(cached.headContext), ); const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => !( (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) ); const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => headContext.headRemoteUrlKey === identity.headRemoteUrlKey && headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; if (!canVerifyIdentity(cached.headContext, currentIdentity)) { return yield* new GitManagerError({ operation: "branchPullRequest", cwd: cacheCwd, detail: `Repository identity for ${branch} could not be verified.`, }); } if (!hasSameIdentity(cached.headContext, currentIdentity)) { yield* Cache.invalidate(prLookupCache, cacheKey); cached = yield* Cache.get(prLookupCache, cacheKey); const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, identityRemoteName(cached.headContext), ); if ( !canVerifyIdentity(cached.headContext, refreshedIdentity) || !hasSameIdentity(cached.headContext, refreshedIdentity) ) { return yield* new GitManagerError({ operation: "branchPullRequest", cwd: cacheCwd, detail: `Repository identity for ${branch} changed during pull request lookup.`, }); } } const { latest } = cached; if (latest === null) return null; if ( (branch === defaultBranch || (defaultBranch === null && (branch === "main" || branch === "master"))) && latest.state !== "open" ) { return null; } return { ...toStatusPr(latest), closedAt: latest.closedAt ?? null, mergedAt: latest.mergedAt ?? null, // Hosting CLIs can select an upstream repository instead of origin. // The returned PR URL names the repository that actually owns it. repositoryKey: pullRequestRepositoryKey(latest.url), }; }); 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, isOnPullRequestHead: true, }; } 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); // Git refuses to move a branch that is checked out in a worktree, so the // reuse paths cannot go through materializePullRequestHeadBranch and instead // advance the checkout from inside the worktree. A worktree that cannot be // moved (no reachable head, local commits, dirty tree) is still handed // back, because stranding the thread is worse than reporting the staleness. const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* ( worktreePath: string, checkedOutBranch: string, ) { if (checkedOutBranch !== localPullRequestBranch) { // findLocalHeadBranch also accepts a branch that merely shares the head's bare name — // a fork PR opened from "main" matches the user's own local main. That checkout is // somebody else's work, so it keeps its tracking config and nothing else. yield* ensureExistingWorktreeUpstream(worktreePath); return { pullRequest, branch: localPullRequestBranch, worktreePath, isOnPullRequestHead: false, }; } // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref, // and once that has jumped to a rewritten head there is no way left to tell a checkout // that holds nothing of its own from one carrying local commits. const upstreamCommitBeforeFetch = yield* gitCore .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" }) .pipe( Effect.map((resolved) => resolved.commitSha), Effect.orElseSucceed(() => null), ); yield* ensureExistingWorktreeUpstream(worktreePath); const refreshed = yield* gitCore // The pull request's own ref, because it is the only thing that certainly names its // head. The branch's upstream does not: configuring it is best-effort, so a branch cut // from `origin/main` whose head branch has since been deleted still resolves — and // following it would move the checkout onto main and call that the pull request. .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number }) .pipe( // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, // taken only where it is the head branch's own rather than whatever the checkout // happened to be cut from. Effect.catch(() => Effect.gen(function* () { const details = yield* gitCore.statusDetails(worktreePath); if ( details.upstreamRef === null || !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) ) { return yield* new GitManagerError({ operation: "preparePullRequestThread", cwd: worktreePath, detail: "The pull request head could not be resolved for this checkout.", }); } return yield* gitCore.resolveCommit({ cwd: worktreePath, revision: details.upstreamRef, }); }), ), Effect.flatMap((target) => gitCore.refreshCheckedOutBranch({ cwd: worktreePath, targetCommit: target.commitSha, resetWhenHeadCommit: upstreamCommitBeforeFetch, }), ), Effect.catch((error) => Effect.logWarning( "GitManager.preparePullRequestThread reused worktree refresh failed", { worktreePath, localBranch: localPullRequestBranch, cause: error, }, ).pipe(Effect.as({ moved: false, onTarget: false })), ), ); // Only when the checkout actually moved: another thread may be running in this worktree, // and re-running the setup script under it buys nothing when the code did not change. if (refreshed.moved) { yield* maybeRunSetupScript(worktreePath); } return { pullRequest, branch: localPullRequestBranch, worktreePath, isOnPullRequestHead: refreshed.onTarget, }; }); const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { const result = yield* gitCore.listRefs({ cwd, refresh: true }); 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 ) { return yield* reuseExistingWorktree( existingBranchBeforeFetch.worktreePath, existingBranchBeforeFetch.name, ); } 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 ) { return yield* reuseExistingWorktree( existingBranchAfterFetch.worktreePath, existingBranchAfterFetch.name, ); } 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, isOnPullRequestHead: true, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, branch: string | null, commitMessage?: string, filePaths?: readonly string[], ) { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, settings, }); 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 textGenerationSettings = yield* serverSettingsService.getSettings.pipe( Effect.flatMap((settings) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( Effect.map((providers) => ({ modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( settings, providers, ), style: settings.sourceControlWritingStyle, })), ), ), 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( textGenerationSettings, 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( textGenerationSettings, 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(textGenerationSettings, 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, branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, }); }); export const layer = Layer.effect(GitManager, make);