import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as ByteSize from "effect/ByteSize"; 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 PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, type OrchestrationV2TurnItem, type ProjectId, SourceControlProviderError, type SourceControlWritingStyleSettings, type ThreadId, type ThreadPullRequestKey, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, } from "@t3tools/contracts"; import { hasProjectSettingsOverrides, resolveProjectSettings, } from "@t3tools/shared/projectSettings"; import { mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { type ChangeRequestTerminology, UNKNOWN_SOURCE_CONTROL_CLIENT, } from "@t3tools/source-control-core/shared/definition"; import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, } from "../sourceControl/threadPullRequests.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { historicalMessage, renderHistory, selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { conventionalCommitsTextGenerationPolicy, customTextGenerationPolicy, repositoryConventionsTextGenerationPolicy, } from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; 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 interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { readonly refreshMissingPullRequest?: boolean; } export type GitBranchPullRequest = NonNullable & { readonly repositoryKey: string | null; readonly updatedAt: string | null; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly headSha?: string | null; }; interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; } export class GitManager extends Context.Service< GitManager, { readonly createWorktree: ( input: VcsCreateWorktreeInput, options?: GitVcsDriver.CreateWorktreeOptions, ) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, options?: GitRemoteStatusOptions, ) => Effect.Effect; /** refresh queries the host; freshGitState rereads only git state and is required before persisting. */ readonly branchPullRequest: ( input: { readonly cwd: string; readonly branch: string }, options?: { readonly refresh?: boolean; readonly freshGitState?: 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; readonly subscribePullRequestStateChanges: Effect.Effect< Stream.Stream, never, Scope.Scope >; } >()("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.seconds(60); const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); 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 PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); const PR_THREAD_CONTEXT_BYTES = 16_000; const PR_THREAD_COMMAND_OUTPUT_CHARS = 2_000; const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** * Throttled lookups fail fast, so a flat failure TTL would retry faster than a healthy * lookup refreshes. Backing off per branch keeps a 429 from becoming sustained pressure. */ 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; headSha?: string | undefined; } 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 { const repositoryUrl = sourceControlDefinitions.parseChangeRequestUrl(value)?.link.repositoryUrl; return repositoryUrl === undefined ? null : sourceControlDefinitions.repositoryKeyFromRemote(repositoryUrl); } /** Null when the host did not name the repository the head lives in. */ function resolveHeadRepositoryNameWithOwner(pullRequest: PullRequestHeadRemoteInfo): string | null { return normalizeOptionalString(pullRequest.headRepositoryNameWithOwner); } 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}`; } export function parseRepositoryNameWithOwnerFromRemoteUrl( url: string | null, provider?: Pick< SourceControlProvider.SourceControlProvider["Service"], "repositoryNameFromRemoteUrl" >, ): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) return null; return ( provider?.repositoryNameFromRemoteUrl?.(trimmed) ?? SourceControlProvider.repositoryPathFromRemoteUrl(trimmed) ); } function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { return null; } // The first segment only: a nested path's owner is its top-level group. 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; } 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( resolveHeadRepositoryNameWithOwner(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, ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), 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]`; } // Preserve command evidence when selectHistory drops oversized items; test verdicts are at the tail. function limitCommandOutput(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { if (item.type !== "command_execution" || item.output === undefined) return item; if (item.output.length <= PR_THREAD_COMMAND_OUTPUT_CHARS) return item; return { ...item, output: `[truncated]\n${item.output.slice(-PR_THREAD_COMMAND_OUTPUT_CHARS)}` }; } 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 headBranchProbe( provider: SourceControlProvider.SourceControlProvider["Service"], headSelectors: ReadonlyArray, state: "open" | "all", ) { return ( provider.headBranchProbe?.({ headSelectors, state }) ?? { headSelectors, limit: state === "open" ? 1 : 20, } ); } 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 random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( cwd: string, ) { return yield* sourceControlProvider(cwd).pipe( Effect.map((provider) => sourceControlDefinitions.get(provider.kind).changeRequest), Effect.orElseSucceed(() => UNKNOWN_SOURCE_CONTROL_CLIENT.changeRequest), ); }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const threads = yield* ProjectionStore.ProjectionStoreV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; if (!hasProjectSettingsOverrides(settings)) return { settings, projectId: null as ProjectId | null }; const threadProjectId = input.threadId === undefined ? null : yield* threads.getThreadShell(input.threadId).pipe( Effect.map((thread) => thread?.projectId ?? null), Effect.orElseSucceed(() => null), ); const projectId = threadProjectId ?? input.projectId ?? (yield* projects.findActiveByWorkspaceRoot(input.cwd).pipe( Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), Effect.orElseSucceed(() => null), )); return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); const readThreadContext = Effect.fnUntraced( function* (threadId: ThreadId | undefined) { if (threadId === undefined) return undefined; const { visibleTurnItems } = yield* threads.getThreadProjection(threadId); const selected = selectHistory({ messages: visibleTurnItems.flatMap( ({ item }) => historicalMessage(limitCommandOutput(item)) ?? [], ), coverage: "Digest of the thread that made this change, oldest first.", budget: PR_THREAD_CONTEXT_BYTES, }); return selected.messages.length === 0 ? undefined : renderHistory(selected.messages, selected.context); }, (effect, threadId) => effect.pipe( Effect.catch((error) => Effect.logWarning("GitManager.readThreadContext failed", { threadId, errorTag: error._tag, }).pipe(Effect.as(undefined)), ), ), ); // Hands the base T3 recorded for a branch to the host, whose own tools may keep it elsewhere. // Best-effort: nothing T3 does reads it back from there. const shareBranchBase = Effect.fn("GitManager.shareBranchBase")(function* ( cwd: string, branch: string, ) { const baseBranch = yield* gitCore .readBranchBase(cwd, branch) .pipe(Effect.orElseSucceed(() => null)); if (baseBranch === null) return; const provider = yield* sourceControlProvider(cwd).pipe(Effect.orElseSucceed(() => null)); yield* provider?.recordBranchBase?.({ cwd, branch, baseBranch }) ?? Effect.void; }); // Publishing a branch that tracks its base records that base first, even when the push then // fails, so the host hears of it after every successful push rather than the first attempt. const pushCurrentBranch = Effect.fn("GitManager.pushCurrentBranch")(function* ( cwd: string, branch: string | null, ) { const result = yield* gitCore.pushCurrentBranch(cwd, branch); if (result.status === "pushed" && result.branch) { yield* shareBranchBase(cwd, result.branch); } return result; }); const readWorktreesDirectory = serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.worktreesDirectory), Effect.orElseSucceed(() => ""), ); const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( "GitManager.createWorktree", )(function* (input, options) { const submodules = options?.submodules !== undefined ? options.submodules : yield* projectSettingsFor(input).pipe( Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), ); const worktreesDirectory = yield* readWorktreesDirectory; const result = yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules, }); if (input.newRefName && input.baseRefName) { yield* shareBranchBase(input.cwd, input.newRefName); } return result; }); const readRepositoryInstructions = Effect.fn("GitManager.readRepositoryInstructions")( function* (cwd: string, fileName: string) { const root = yield* fileSystem.realPath(cwd); const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); const relative = path.relative(root, instructionPath); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { return ""; } const info = yield* fileSystem.stat(instructionPath); if (info.type !== "File" || info.size > ByteSize.bytes(20_000)) { return ""; } return (yield* fileSystem.readFileString(instructionPath)).trim(); }, 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 = Effect.fn("GitManager.resolveStylePolicy")(function* ( cwd: string, settings: SourceControlTextGenerationSettings, ) { 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}`] : []), // CLAUDE.md is often a symlink or copy of AGENTS.md; skip the duplicate. ...(claudeInstructions && claudeInstructions !== agentInstructions ? [`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) => random.uuidV4().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 pullRequestHostOptions = (url: string) => { const host = URL.parse(url)?.host; return host ? { host } : {}; }; 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, ...pullRequestHostOptions(pullRequest.url), }); 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), ), ); // The kind comes from the provider, not the remote alone: an unrecognised remote can resolve // to a known host. const changeRequestHeadRef = Effect.fn("GitManager.changeRequestHeadRef")(function* ( cwd: string, number: number, ) { const provider = yield* sourceControlProvider(cwd); const ref = sourceControlDefinitions.get(provider.kind).changeRequestHeadRef?.(number); if (ref === undefined) { return yield* new GitManagerError({ operation: "changeRequestHeadRef", cwd, detail: "This host publishes no ref for a change request's head.", }); } return ref; }); const fetchChangeRequestHeadIntoBranch = Effect.fn("GitManager.fetchChangeRequestHeadIntoBranch")( function* (cwd: string, number: number, branch: string) { const ref = yield* changeRequestHeadRef(cwd, number); yield* gitCore.fetchRefIntoBranch({ cwd, ref, branch }); }, ); const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( function* ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { yield* fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, localBranch).pipe( // Fall back to a branch fetch only for an open PR in the primary remote's repository. // A host can resolve a PR number from another repository, so verify the repository first. // Closed PR branches may have moved past the head recorded at closure. Effect.catch((cause) => pullRequest.isCrossRepository === true || pullRequest.state !== "open" ? Effect.fail(cause) : Effect.gen(function* () { const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); const remoteUrl = yield* gitCore.readConfigValue(cwd, `remote.${remoteName}.url`); const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); if ( remoteUrl === null || pullRequestKey === null || sourceControlDefinitions.canonicalRepositoryKey(pullRequestKey) !== sourceControlDefinitions.canonicalRepositoryKey( sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl), ) ) { return yield* cause; } yield* gitCore.fetchRemoteBranch({ cwd, remoteName, remoteBranch: pullRequest.headBranch, localBranch, }); }), ), ); return; } const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, ...pullRequestHostOptions(pullRequest.url), }); 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) => fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, 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: GitVcsDriver.GitStatusDetails = { 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, }; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true }) .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, ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}), } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateLocalStatusResultCache = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); const 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); }), ); 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"); 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 pullRequestStateChanges = yield* PubSub.sliding(64); const lookupStates = new Map< string, { readonly state: PullRequestInfo["state"]; readonly isDraft: boolean } >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { const parsed = sourceControlDefinitions.parseChangeRequestUrl(pr.url)?.link; if (parsed === undefined || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); const previous = lookupStates.get(id); lookupStates.delete(id); if (lookupStates.size >= PR_LOOKUP_CACHE_CAPACITY) { const oldest = lookupStates.keys().next().value; if (oldest !== undefined) lookupStates.delete(oldest); } lookupStates.set(id, { state: pr.state, isDraft: pr.isDraft === true }); return (previous?.state ?? "open") === pr.state && (previous?.isDraft === true) === (pr.isDraft === true) ? Effect.void : PubSub.publish(pullRequestStateChanges, key); }); 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 }; } if ( details.localBranchExists && details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext)) ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); if (latest !== null) yield* noteLookupState(latest); return { latest, headContext }; }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); return exit.value.latest?.state === "open" ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, }, ); const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key)); 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; } // Config read failures become null; require both remote URLs before treating a mismatch as real. if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; } 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, ) { // Upstream changes must not orphan the same branch's fallback value. 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* getPrLookup(cacheKey).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // 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) => { const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr ? resolveLookupHeadContext(cwd, details).pipe( Effect.map(({ headContext }) => resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, remoteName: headContext.remoteName, headRemoteUrlKey: headContext.headRemoteUrlKey, }), ), ) : Effect.succeed(null); return Effect.logWarning("PR lookup failed.").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, ...(error.command === undefined ? {} : { providerCommand: error.command }), errorDetail: error.detail, } : {}), }), Effect.andThen(fallback), ); }), ); }); 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)); // Whether the remote's host creates change requests in the only remote. A host's tools can let // configuration outside the remotes name another repository; the remote itself otherwise. const targetsOnlyRemote = Effect.fn("targetsOnlyRemote")(function* ( cwd: string, remoteName: string, remote: { readonly remoteUrl: string | null; readonly repositoryNameWithOwner: string | null }, ) { const provider = remote.remoteUrl === null ? null : sourceControlDefinitions.recognizeRemote(remote.remoteUrl); if (remote.remoteUrl === null || provider === null) return true; const context = { provider, remoteName, remoteUrl: remote.remoteUrl }; const handle = yield* sourceControlProviders .resolveHandle({ cwd, context }) .pipe(Effect.orElseSucceed(() => null)); const createsChangeRequestsIn = handle?.provider.createsChangeRequestsIn; if (createsChangeRequestsIn === undefined) return true; return yield* createsChangeRequestsIn({ cwd, context: handle?.context ?? context, repository: remote.repositoryNameWithOwner, }); }); 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")); const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; if (!remoteUrl || provider?.kind !== "unknown") return provider; const handle = yield* sourceControlProviders .resolveHandle({ cwd, context: { provider, remoteName: preferredRemoteName, remoteUrl }, }) .pipe(Effect.orElseSucceed(() => null)); return handle?.context?.provider ?? provider; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, ) { if (!remoteName) { return { remoteUrl: null, remoteUrlKey: null, repositoryNameWithOwner: null, ownerLogin: null, }; } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); if ( remoteUrl !== null && /^https?:\/\//iu.test(remoteUrl) && (repositoryNameWithOwner?.split("/").length ?? 0) > 2 ) { const kind = sourceControlDefinitions.recognizeRemote(remoteUrl)?.kind; const provider = kind === undefined ? undefined : yield* ( kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind) ).pipe(Effect.orElseSucceed(() => undefined)); repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider); } return { remoteUrl, remoteUrlKey: remoteUrl ? sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; }); const resolveHeadAndOriginContexts = (cwd: string, remoteName: string | null) => remoteName === "origin" ? resolveRemoteRepositoryContext(cwd, "origin").pipe( Effect.map((origin) => [origin, origin] as const), ) : Effect.all( [ resolveRemoteRepositoryContext(cwd, remoteName), resolveRemoteRepositoryContext(cwd, "origin"), ], { concurrency: "unbounded" }, ); 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* resolveHeadAndOriginContexts( cwd, remoteName, ); // With no origin to compare against, the only remote is the repository change requests // are created in unless the host's own configuration names another, so a branch on it is // not a fork. With several remotes, or with that other target, it may still be a fork. const isOnlyRemote = remoteName !== null && remoteName !== "origin" && originRepository.remoteUrlKey === null && (yield* gitCore .execute({ operation: "GitManager.resolveBranchHeadContext.remotes", cwd, args: ["remote"], timeoutMs: 5_000, }) .pipe( Effect.map( (result) => result.stdout.split("\n").filter((name) => name.trim().length > 0).length, ), Effect.orElseSucceed(() => 0), )) === 1 && (yield* targetsOnlyRemote(cwd, remoteName, remoteRepository)); const isForeignRemote = remoteName !== null && remoteName !== "origin" && !isOnlyRemote; const isCrossRepository = remoteRepository.repositoryNameWithOwner !== null && originRepository.repositoryNameWithOwner !== null ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== originRepository.repositoryNameWithOwner.toLowerCase() : isForeignRemote && 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 || isForeignRemote; 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; }); // Remote names can contain slashes; match refs literally. 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; }); }); const findPublishedBranchRemote = Effect.fn("findPublishedBranchRemote")(function* ( cwd: string, branch: string, ) { const localRef = `refs/heads/${branch}`; const remoteNames = (yield* gitCore.execute({ operation: "GitManager.findPublishedBranchRemote.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 = yield* gitCore.execute({ operation: "GitManager.findPublishedBranchRemote", cwd, args: [ "for-each-ref", "--format=%(refname)%00%(objectname)", localRef, ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), ], timeoutMs: 5_000, }); const oidByRef = new Map( refs.stdout .split("\n") .map((line) => line.trim().split("\u0000")) .filter((entry): entry is [string, string] => entry.length === 2), ); const localOid = oidByRef.get(localRef); if (!localOid) return null; const remoteRefs = remoteNames.flatMap((name) => { const oid = oidByRef.get(`refs/remotes/${name}/${branch}`); return oid ? [{ name, oid }] : []; }); const exactRemotes = remoteRefs.filter((remote) => remote.oid === localOid); if (exactRemotes.length > 1) return null; let remoteName = exactRemotes[0]?.name; if (remoteName === undefined) { const ancestorRemotes = yield* Effect.forEach( remoteRefs, (remote) => gitCore .execute({ operation: "GitManager.findPublishedBranchRemote.ancestor", cwd, args: ["merge-base", "--is-ancestor", remote.oid, localOid], allowNonZeroExit: true, timeoutMs: 5_000, }) .pipe( Effect.flatMap((result) => { // Exit 1 means non-ancestor; other failures must reject inference rather than pick another fork. if (result.exitCode === 0) return Effect.succeed(remote.name); if (result.exitCode === 1) return Effect.succeed(null); return new GitCommandError({ operation: "GitManager.findPublishedBranchRemote.ancestor", command: "git merge-base --is-ancestor", cwd, exitCode: result.exitCode, detail: "Ancestry probe failed; rejecting ancestry inference.", }); }), ), { concurrency: "unbounded" }, ); const matches = ancestorRemotes.filter((name) => name !== null); if (matches.length !== 1) return null; remoteName = matches[0]; } if (remoteName === undefined) return null; const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); return remoteUrl === null ? null : remoteName; }); // worktree add can set origin/main as upstream; this base is not the branch's published PR head. 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); if (details.upstreamRef === null) { const remoteName = yield* findPublishedBranchRemote(cwd, details.branch).pipe( Effect.orElseSucceed(() => null), ); if (remoteName !== null && remoteName !== headContext.remoteName) { return { headContext: yield* resolveBranchHeadContext(cwd, { ...details, remoteName }), lookup: true, }; } } 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, ).pipe(Effect.orElseSucceed(() => null)); if (remoteName === null) { return { headContext, lookup: false }; } const ownNameContext = yield* resolveBranchHeadContext(cwd, { branch: details.branch, upstreamRef: null, remoteName, }); return { headContext: ownNameContext, lookup: true }; }); 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, trackedRemote] = yield* Effect.all( [matchesRef("refs/remotes"), findRemoteTrackingRemote(cwd, headContext.headBranch, null)], { concurrency: "unbounded" }, ); return tracksAnyRemote && trackedRemote === null; }).pipe(Effect.orElseSucceed(() => false)); }); const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, headContext: Pick< BranchHeadContext, | "headBranch" | "headSelectors" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >, ) { const provider = yield* sourceControlProvider(cwd); const probe = headBranchProbe(provider, headContext.headSelectors, "open"); for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", limit: probe.limit, }); 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(); const provider = yield* sourceControlProvider(cwd); const probe = headBranchProbe(provider, headContext.headSelectors, "all"); for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", limit: probe.limit, }); 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* changeRequestTermsFor(cwd); 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 resolvePrBase = Effect.fn("resolvePrBase")(function* ( cwd: string, branch: string, upstreamRef: string | null, headContext: Pick, ) { const configuredRef = yield* gitCore.readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`); if (configuredRef?.startsWith("refs/heads/") || configuredRef?.startsWith("refs/remotes/")) { const configuredBranch = yield* gitCore.resolveBranchName(cwd, configuredRef); if (configuredBranch) return { baseBranch: configuredBranch, baseRef: configuredRef }; } const configured = yield* gitCore.readBranchBase(cwd, branch); if (configured) { const localRef = configured.startsWith("refs/") ? configured : `refs/heads/${configured}`; const localBranch = yield* gitCore.resolveBranchName(cwd, localRef); if (localBranch) { const shadowsRemote = configured.includes("/") && !configured.startsWith("refs/") && (yield* gitCore.resolveBranchName(cwd, `refs/remotes/${configured}`)) !== null; return { baseBranch: localBranch, baseRef: configured.startsWith("refs/") || shadowsRemote ? localRef : localBranch, }; } if (configured.includes("/") && !configured.startsWith("refs/")) { const remoteRef = `refs/remotes/${configured}`; const remoteBranch = yield* gitCore.resolveBranchName(cwd, remoteRef); if (remoteBranch) return { baseBranch: remoteBranch, baseRef: remoteRef }; } const configuredBranch = yield* gitCore.resolveBranchName(cwd, configured); if (configuredBranch) return { baseBranch: configuredBranch, baseRef: configuredBranch }; } if (upstreamRef && !headContext.isCrossRepository) { const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { remoteName: headContext.remoteName, }); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return { baseBranch: upstreamBranch, baseRef: upstreamBranch }; } } const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), Effect.orElseSucceed(() => null), ); if (defaultFromProvider) { return { baseBranch: defaultFromProvider, baseRef: defaultFromProvider }; } const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), Effect.orElseSucceed(() => null), ); if (defaultFromRemote) { return { baseBranch: defaultFromRemote, baseRef: defaultFromRemote }; } return { baseBranch: "main", baseRef: "main" }; }); const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( cwd: string, baseBranch: string, ) { if (baseBranch.startsWith("refs/")) return baseBranch; 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; includeBranch?: boolean; filePaths?: readonly string[]; settings: SourceControlTextGenerationSettings; }) { const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths, { includePatch: customCommit === null, }); if (!context) { return null; } 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, stage: filePaths ? { filePaths } : {}, ...(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, threadId: ThreadId | undefined, emit: GitActionProgressEmitter, ) { const provider = yield* sourceControlProvider(cwd); const terms = sourceControlDefinitions.get(provider.kind).changeRequest; 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, baseRef } = yield* resolvePrBase( cwd, branch, details.upstreamRef, headContext, ); yield* emit({ kind: "phase_started", phase: "pr", label: `Generating ${terms.shortLabel} content...`, }); const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseRef); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate ? Option.getOrUndefined( yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }), ) : undefined; const threadContext = yield* readThreadContext(threadId); 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 } : {}), ...(threadContext ? { threadContext } : {}), ...(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.ignore))); // The PR already exists at this point, so a failed lookup only loses its metadata. const created = yield* findOpenPr(cwd, headContext).pipe( Effect.catch((error) => Effect.logWarning("GitManager.runPrStep lookup after create failed", { provider: provider.kind, errorTag: error._tag, }).pipe(Effect.as(null)), ), ); 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); }); // Closed PR results can settle threads or remove worktrees; confirm fresh git state before persisting. const prLookupGitStateByKey = new Map< string, { readonly readAtMs: number; readonly value: string | null } >(); const rememberPrLookupGitState = Effect.fn("GitManager.rememberPrLookupGitState")(function* ( cwd: string, key: string, fresh: boolean, read: Effect.Effect, ) { const entryKey = `${cwd}\u0000${key}`; const readAtMs = yield* Clock.currentTimeMillis; const remembered = prLookupGitStateByKey.get(entryKey); if ( !fresh && remembered !== undefined && readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL) ) { return remembered.value; } // invalidateStatus bumps the epoch. A read that started before a T3 git // action, or before a newer read finished, must not replace their state. const epoch = prLookupEpoch(cwd); const value = yield* read; const latest = prLookupGitStateByKey.get(entryKey); if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) { return value; } prLookupGitStateByKey.delete(entryKey); if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) { const oldestKey = prLookupGitStateByKey.keys().next().value; if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey); } prLookupGitStateByKey.set(entryKey, { readAtMs, value }); return value; }); const forgetPrLookupGitState = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.map((cacheCwd) => { const prefix = `${cacheCwd}\u0000`; for (const key of prLookupGitStateByKey.keys()) { if (key.startsWith(prefix)) prLookupGitStateByKey.delete(key); } }), ); const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( function* ( cwd: string, branch: string, remoteNameOverride: string | undefined, fresh: boolean, ) { const readConfig = (key: string) => rememberPrLookupGitState( cwd, `config\u0000${key}`, fresh, gitCore.readConfigValue(cwd, key), ).pipe(Effect.orElseSucceed(() => null)); const readRemoteUrlKey = (remoteName: string | null) => remoteName === null ? Effect.succeed(null) : readConfig(`remote.${remoteName}.url`).pipe( Effect.map((remoteUrl) => remoteUrl ? sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) : null, ), ); const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`)); const [headRemoteUrlKey, targetRemoteUrlKey] = remoteName === "origin" ? yield* readRemoteUrlKey("origin").pipe(Effect.map((key) => [key, key] as const)) : yield* Effect.all([readRemoteUrlKey(remoteName), readRemoteUrlKey("origin")], { concurrency: "unbounded", }); return { remoteName, headRemoteUrlKey: headRemoteUrlKey ?? (remoteName === null ? targetRemoteUrlKey : null), targetRemoteUrlKey, }; }, ); const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* ( cacheCwd: string, branch: string, options: { readonly refresh: boolean; readonly freshGitState: boolean }, ) { const remember = (key: string, read: Effect.Effect) => rememberPrLookupGitState(cacheCwd, key, options.freshGitState, read); const remotes = yield* remember( "remotes", gitCore .execute({ operation: "GitManager.branchPullRequest.remotes", cwd: cacheCwd, args: ["remote"], }) .pipe(Effect.map((result) => result.stdout)), ); const remoteNames = (remotes ?? "") .split("\n") .map((remoteName) => remoteName.trim()) .filter((remoteName) => remoteName.length > 0); const [firstRemoteName] = remoteNames; if (firstRemoteName === undefined) return null; const branchRef = yield* remember( `branchRef\u0000${branch}`, 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}`, ], }) .pipe(Effect.map((result) => result.stdout)), ); const expectedRefName = `refs/heads/${branch}`; const exactBranch = (branchRef ?? "") .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 trackingRemotes = yield* remember( `trackingRemotes\u0000${branch}`, gitCore .execute({ operation: "GitManager.branchPullRequest.remoteTrackingRefs", cwd: cacheCwd, args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], }) .pipe( Effect.map((trackingRefs) => { const refNames = new Set( trackingRefs.stdout .split("\n") .map((remoteRef) => remoteRef.trim()) .filter((remoteRef) => remoteRef.length > 0), ); return remoteNames .filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`)) .join("\n"); }), ), ); const matchingRemoteNames = (trackingRemotes ?? "") .split("\n") .filter((candidate) => candidate.length > 0); 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* remember( `defaultBranch\u0000${defaultRemoteName}`, gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName), ).pipe(Effect.orElseSucceed(() => null)); const cacheKey = prLookupCacheKey(cacheCwd, { branch, upstreamRef, defaultBranch, localBranchExists, ...(localBranchExists ? {} : { remoteName }), }); if (options.refresh) { const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); } let cached = yield* getPrLookup(cacheKey); // Lookup may use a fork instead of the saved upstream; verify the remote actually queried. const identityRemoteName = (headContext: BranchHeadContext) => headContext.remoteName ?? remoteName ?? undefined; const currentIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, identityRemoteName(cached.headContext), options.freshGitState, ); 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* getPrLookup(cacheKey); const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, identityRemoteName(cached.headContext), true, ); 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, headSha: latest.headSha ?? null, // Hosting CLIs may target upstream; the PR URL identifies its actual repository. repositoryKey: pullRequestRepositoryKey(latest.url), }; }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", )(function* ({ cwd, branch }, options) { const cacheCwd = yield* normalizeStatusCacheKey(cwd); const refresh = options?.refresh === true; const freshGitState = refresh || options?.freshGitState === true; const pullRequest = yield* lookupBranchPullRequest(cacheCwd, branch, { refresh, freshGitState, }); if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest; return yield* lookupBranchPullRequest(cacheCwd, branch, { refresh: false, freshGitState: true, }); }); 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); yield* bumpPrLookupEpoch(cwd); yield* forgetPrLookupGitState(cwd); }, ); // A user is waiting on these, so a host's background rate-limit pause must not refuse them. const asUserAction = Effect.provideService(SourceControlRateLimit.Interactive, true); 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 }; }, asUserAction); const ensureNoTrackedChanges = Effect.fn("ensureNoTrackedChanges")(function* ( cwd: string, pullRequestNumber: number, ) { const trackedChanges = yield* gitCore.execute({ operation: "GitManager.preparePullRequestThread.trackedChanges", cwd, args: ["status", "--porcelain", "--untracked-files=no"], }); if (trackedChanges.stdout.trim().length > 0) { return yield* new GitManagerError({ operation: "preparePullRequestThread", cwd, detail: `This repository has uncommitted changes. Commit or stash them before checking out pull request #${pullRequestNumber} here, or check it out in its own folder.`, }); } }); // Some hosts only switch branches on checkout; use the existing lossless refresh to advance them. const moveToPullRequestHead = (cwd: string, headSha: string | undefined) => headSha === undefined || !/^[0-9a-f]{7,64}$/i.test(headSha) ? Effect.succeed(false) : gitCore.resolveCommit({ cwd, revision: headSha }).pipe( Effect.flatMap(({ commitSha }) => gitCore.refreshCheckedOutBranch({ cwd, targetCommit: commitSha }), ), Effect.map((refreshed) => refreshed.onTarget), Effect.orElseSucceed(() => false), ); 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* ensureNoTrackedChanges(input.cwd, pullRequest.number); yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const details = yield* gitCore.statusDetails(input.cwd); const branch = details.branch ?? pullRequest.headBranch; yield* configurePullRequestHeadUpstream( input.cwd, { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), }, branch, ); return { pullRequest, branch, worktreePath: null, isOnPullRequestHead: yield* moveToPullRequestHead(input.cwd, pullRequestSummary.headSha), }; } 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 checked-out branches; advance reused checkouts from inside their worktree. const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* ( worktreePath: string, checkedOutBranch: string, ) { if (checkedOutBranch !== localPullRequestBranch) { // Bare-name matches may be unrelated local branches; preserve their work and tracking config. return { pullRequest, branch: localPullRequestBranch, worktreePath, isOnPullRequestHead: false, }; } // Capture upstream before its forced fetch so rewritten heads remain distinguishable from local commits. const upstreamCommitBeforeFetch = yield* gitCore .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" }) .pipe( Effect.map((resolved) => resolved.commitSha), Effect.orElseSucceed(() => null), ); yield* ensureExistingWorktreeUpstream(worktreePath); // 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. const refreshed = yield* changeRequestHeadRef(worktreePath, pullRequest.number).pipe( Effect.flatMap((ref) => gitCore.fetchRefCommit({ cwd: worktreePath, ref })), // A host that publishes no head ref 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 })), ), ); // Setup can disrupt other threads using this worktree; rerun only when the checkout moved. if (refreshed.moved) { yield* maybeRunSetupScript(worktreePath); } return { pullRequest, branch: localPullRequestBranch, worktreePath, isOnPullRequestHead: refreshed.onTarget, }; }); const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { for (const [index, name] of [ ...new Set([localPullRequestBranch, pullRequest.headBranch]), ].entries()) { let cursor: number | undefined; do { const result = yield* gitCore.listRefs({ cwd, refKind: "local", query: name, cursor, refresh: index === 0 && cursor === undefined, }); for (const branch of result.refs) { if (branch.name !== name) { continue; } if (name === localPullRequestBranch) { return branch; } if (branch.worktreePath) { const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); if (worktreePath !== rootWorktreePath) { return branch; } } } cursor = result.nextCursor ?? undefined; } while (cursor !== undefined); } 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, }, { worktreesDirectory: yield* readWorktreesDirectory, submodules: yield* projectSettingsFor(input).pipe( Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => 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))); }, asUserAction); 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.", }); } 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* projectSettingsFor(input).pipe( Effect.flatMap(({ settings, projectId }) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( Effect.map((providers) => ({ modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( settings, providers, projectId, ), 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* changeRequestTermsFor(input.cwd) : 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(() => 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, input.threadId, 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, }), ), ), ); }, asUserAction, ); return GitManager.of({ createWorktree, localStatus, remoteStatus, status, branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, subscribePullRequestStateChanges: PubSub.subscribe(pullRequestStateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }); }); export const layer = Layer.effect(GitManager, make);