1import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Cache from "effect/Cache";
4import * as Clock from "effect/Clock";
5import * as Context from "effect/Context";
6import * as Crypto from "effect/Crypto";
7import * as ByteSize from "effect/ByteSize";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Order from "effect/Order";
16import * as Path from "effect/Path";
17import * as PubSub from "effect/PubSub";
18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";
22import {
23 GitActionProgressEvent,
24 GitActionProgressPhase,
25 GitCommandError,
26 GitPreparePullRequestThreadInput,
27 GitPreparePullRequestThreadResult,
28 GitPullRequestRefInput,
29 GitResolvePullRequestResult,
30 GitRunStackedActionInput,
31 GitRunStackedActionResult,
32 GitStackedAction,
33 VcsStatusInput,
34 type VcsStatusLocalResult,
35 type VcsStatusRemoteResult,
36 VcsStatusResult,
37 ModelSelection,
38 type ProjectId,
39 SourceControlProviderError,
40 type SourceControlWritingStyleSettings,
41 type ThreadId,
42 type ThreadPullRequestKey,
43 type VcsCreateWorktreeInput,
44 type VcsCreateWorktreeResult,
45} from "@t3tools/contracts";
46import {
47 hasProjectSettingsOverrides,
48 resolveProjectSettings,
49} from "@t3tools/shared/projectSettings";
50import {
51 detectSourceControlProviderFromGitRemoteUrl,
52 mergeGitStatusParts,
53 normalizeGitRemoteUrl,
54 resolveAutoFeatureBranchName,
55 sanitizeBranchFragment,
56 sanitizeFeatureBranchName,
57} from "@t3tools/shared/git";
58import {
59 canonicalRepositoryKey,
60 getChangeRequestTerminologyForKind,
61 isSshRemoteUrl,
62 type ChangeRequestTerminology,
63} from "@t3tools/shared/sourceControl";
64import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
65import {
66 normalizeThreadPullRequestKey,
67 threadPullRequestKeyOf,
68} from "@t3tools/shared/threadPullRequests";
69
70import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";
71import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
72import * as TextGeneration from "../textGeneration/TextGeneration.ts";
73import {
74 conventionalCommitsTextGenerationPolicy,
75 customTextGenerationPolicy,
76 repositoryConventionsTextGenerationPolicy,
77} from "../textGeneration/TextGenerationPresets.ts";
78import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
79import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
80import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
81import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
82import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
83import { detachStackFrame } from "./detachStackFrame.ts";
84import * as ServerSettings from "../serverSettings.ts";
85import type { GitManagerServiceError } from "@t3tools/contracts";
86import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
87import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
88import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
89import type { ChangeRequest } from "@t3tools/contracts";
90
91export interface GitActionProgressReporter {
92 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
93}
94
95export interface GitRunStackedActionOptions {
96 readonly actionId?: string;
97 readonly progressReporter?: GitActionProgressReporter;
98}
99
100export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions {
101 /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */
102 readonly refreshMissingPullRequest?: boolean;
103}
104
105export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {
106 readonly repositoryKey: string | null;
107 readonly updatedAt: string | null;
108 readonly closedAt?: string | null;
109 readonly mergedAt?: string | null;
110 /** The pull request's head commit, when the host read reports it. */
111 readonly headSha?: string | null;
112};
113
114interface SourceControlTextGenerationSettings {
115 readonly modelSelection: ModelSelection;
116 readonly style: SourceControlWritingStyleSettings;
117}
118
119export class GitManager extends Context.Service<
120 GitManager,
121 {
122 readonly createWorktree: (
123 input: VcsCreateWorktreeInput,
124 options?: GitVcsDriver.CreateWorktreeOptions,
125 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
126 readonly status: (
127 input: VcsStatusInput,
128 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
129 readonly localStatus: (
130 input: VcsStatusInput,
131 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
132 readonly remoteStatus: (
133 input: VcsStatusInput,
134 options?: GitRemoteStatusOptions,
135 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
136 /**
137 * Resolve the PR for a saved branch without changing the current checkout.
138 * `refresh` asks the host again. `freshGitState` rereads the branch's git
139 * state without forcing a host refresh; use it before persisting a result.
140 */
141 readonly branchPullRequest: (
142 input: { readonly cwd: string; readonly branch: string },
143 options?: { readonly refresh?: boolean; readonly freshGitState?: boolean },
144 ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
145 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
146 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
147 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
148 readonly resolvePullRequest: (
149 input: GitPullRequestRefInput,
150 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
151 readonly preparePullRequestThread: (
152 input: GitPreparePullRequestThreadInput,
153 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
154 readonly runStackedAction: (
155 input: GitRunStackedActionInput,
156 options?: GitRunStackedActionOptions,
157 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
158 /**
159 * Pull requests a branch PR lookup saw in a new state (merged, closed, reopened), so thread
160 * links can catch up before the next sync sweep. Best effort.
161 */
162 readonly subscribePullRequestStateChanges: Effect.Effect<
163 Stream.Stream<ThreadPullRequestKey>,
164 never,
165 Scope.Scope
166 >;
167 }
168>()("t3/git/GitManager") {}
169
170const COMMIT_TIMEOUT_MS = 10 * 60_000;
171const MAX_PROGRESS_TEXT_LENGTH = 500;
172const SHORT_SHA_LENGTH = 7;
173const TOAST_DESCRIPTION_MAX = 72;
174const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
175const STATUS_RESULT_CACHE_CAPACITY = 2_048;
176// Matches the automatic settlement sweep cadence so every background sweep
177// reads fresh branch state: an external merge settles within about a minute
178// instead of waiting out a longer cache. Unpublished branches never reach the
179// host (a local probe answers first), and failed lookups still back off
180// exponentially via prLookupFailureTtl, so throttling pressure still drops
181// under 429s instead of amplifying it.
182const PR_LOOKUP_CACHE_TTL = Duration.seconds(60);
183// Answers without an open PR ("no PR yet", merged, closed) only change when
184// someone opens a PR, and the paths that do that in-app (turn end, push,
185// create PR, user refresh) bypass this cache. Re-asking every minute for each
186// idle branch was the bulk of background GitHub quota use, so these wait out
187// a longer TTL and a PR opened outside the app shows up within minutes.
188const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
189const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
190const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
191const PR_LOOKUP_CACHE_CAPACITY = 2_048;
192// How long branchPullRequest reuses the git state around a cached PR answer.
193// Matches PR_LOOKUP_NO_OPEN_PR_CACHE_TTL, the delay already accepted for a
194// change made outside the app.
195const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5);
196const isSourceControlProviderError = Schema.is(SourceControlProviderError);
197
198/**
199 * How long a failed PR lookup is cached, given the number of consecutive
200 * failures for that branch.
201 *
202 * A hosting provider rejects a throttled request immediately, so caching every
203 * failure for a flat 20s made a rate-limited poller re-ask *faster* than a
204 * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429
205 * into sustained pressure. Backing off per branch keeps the retry rate below
206 * the healthy rate once a branch has failed more than a couple of times.
207 */
208export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration {
209 const exponent = Math.max(0, consecutiveFailures - 1);
210 const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent);
211 return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL);
212}
213type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
214type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
215type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;
216
217function isNotGitRepositoryError(error: GitCommandError): boolean {
218 return error.message.toLowerCase().includes("not a git repository");
219}
220
221interface OpenPrInfo {
222 number: number;
223 title: string;
224 url: string;
225 baseRefName: string;
226 headRefName: string;
227}
228
229interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
230 state: "open" | "closed" | "merged";
231 isDraft?: boolean;
232 closedAt?: string | null;
233 mergedAt?: string | null;
234 updatedAt: Option.Option<DateTime.Utc>;
235 headSha?: string | undefined;
236}
237
238const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
239 Order.flip(Option.makeOrder(DateTime.Order)),
240 (pullRequest) => pullRequest.updatedAt,
241);
242
243interface ResolvedPullRequest {
244 number: number;
245 title: string;
246 url: string;
247 baseBranch: string;
248 headBranch: string;
249 state: "open" | "closed" | "merged";
250}
251
252interface PullRequestHeadRemoteInfo {
253 isCrossRepository?: boolean | undefined;
254 headRepositoryNameWithOwner?: string | null | undefined;
255 headRepositoryOwnerLogin?: string | null | undefined;
256}
257
258interface BranchHeadContext {
259 localBranch: string;
260 headBranch: string;
261 headSelectors: ReadonlyArray<string>;
262 preferredHeadSelector: string;
263 remoteName: string | null;
264 headRemoteUrlKey: string | null;
265 targetRemoteUrlKey: string | null;
266 headRepositoryNameWithOwner: string | null;
267 headRepositoryOwnerLogin: string | null;
268 isCrossRepository: boolean;
269}
270
271export function pullRequestRepositoryKey(value: string): string | null {
272 try {
273 const url = new URL(value);
274 const match =
275 /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec(
276 url.pathname,
277 );
278 if (match?.[1] === undefined) return null;
279 url.pathname = match[1];
280 url.search = "";
281 url.hash = "";
282 return normalizeGitRemoteUrl(url.toString());
283 } catch {
284 return null;
285 }
286}
287
288function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
289 const trimmed = url.trim();
290 const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
291 const repositoryName = match?.[1]?.trim() ?? "";
292 return repositoryName.length > 0 ? repositoryName : null;
293}
294
295function resolveHeadRepositoryNameWithOwner(
296 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
297): string | null {
298 const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
299 if (explicitRepository.length > 0) {
300 return explicitRepository;
301 }
302
303 if (!pullRequest.isCrossRepository) {
304 return null;
305 }
306
307 const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? "";
308 const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url);
309 if (ownerLogin.length === 0 || !repositoryName) {
310 return null;
311 }
312
313 return `${ownerLogin}/${repositoryName}`;
314}
315
316function resolvePullRequestWorktreeLocalBranchName(
317 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
318): string {
319 if (!pullRequest.isCrossRepository) {
320 return pullRequest.headBranch;
321 }
322
323 const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim();
324 const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head";
325 return `t3code/pr-${pullRequest.number}/${suffix}`;
326}
327
328/** The repository's `owner/name` from a remote URL, as `provider` reads its remote paths. */
329export function parseRepositoryNameWithOwnerFromRemoteUrl(
330 url: string | null,
331 provider?: Pick<
332 SourceControlProvider.SourceControlProvider["Service"],
333 "repositoryNameFromRemoteUrl"
334 >,
335): string | null {
336 const trimmed = url?.trim() ?? "";
337 if (trimmed.length === 0) return null;
338 return (
339 provider?.repositoryNameFromRemoteUrl?.(trimmed) ??
340 SourceControlProvider.repositoryPathFromRemoteUrl(trimmed)
341 );
342}
343
344function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
345 const trimmed = nameWithOwner?.trim() ?? "";
346 if (trimmed.length === 0) {
347 return null;
348 }
349 // GitLab reports the top-level group as owner. The full path distinguishes subgroups.
350 const [ownerLogin] = trimmed.split("/");
351 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
352 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
353}
354
355function normalizeOptionalString(value: string | null | undefined): string | null {
356 const trimmed = value?.trim() ?? "";
357 return trimmed.length > 0 ? trimmed : null;
358}
359
360function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null {
361 const normalized = normalizeOptionalString(value);
362 return normalized ? normalized.toLowerCase() : null;
363}
364
365function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null {
366 const normalized = normalizeOptionalString(value);
367 return normalized ? normalized.toLowerCase() : null;
368}
369
370function resolvePullRequestHeadRepositoryNameWithOwner(
371 pr: PullRequestHeadRemoteInfo & { url: string },
372) {
373 const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner);
374 if (explicitRepository) {
375 return explicitRepository;
376 }
377
378 if (!pr.isCrossRepository) {
379 return null;
380 }
381
382 const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin);
383 const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url);
384 if (!ownerLogin || !repositoryName) {
385 return null;
386 }
387
388 return `${ownerLogin}/${repositoryName}`;
389}
390
391interface PullRequestHeadIdentity {
392 readonly repositoryNameWithOwner: string | null;
393 readonly ownerLogin: string | null;
394}
395
396function resolveExpectedHeadIdentity(
397 headContext: Pick<BranchHeadContext, "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin">,
398): PullRequestHeadIdentity {
399 const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
400 headContext.headRepositoryNameWithOwner,
401 );
402 return {
403 repositoryNameWithOwner,
404 ownerLogin:
405 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
406 parseRepositoryOwnerLogin(repositoryNameWithOwner),
407 };
408}
409
410function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity {
411 const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
412 resolvePullRequestHeadRepositoryNameWithOwner(pr),
413 );
414 return {
415 repositoryNameWithOwner,
416 ownerLogin:
417 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
418 parseRepositoryOwnerLogin(repositoryNameWithOwner),
419 };
420}
421
422export function matchesBranchHeadContext(
423 pr: PullRequestInfo,
424 headContext: Pick<
425 BranchHeadContext,
426 "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository"
427 >,
428): boolean {
429 if (pr.headRefName !== headContext.headBranch) {
430 return false;
431 }
432
433 const expectedHead = resolveExpectedHeadIdentity(headContext);
434 const pullRequestHead = resolvePullRequestHeadIdentity(pr);
435
436 if (expectedHead.repositoryNameWithOwner) {
437 if (pullRequestHead.repositoryNameWithOwner) {
438 if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) {
439 return false;
440 }
441 }
442 if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
443 if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
444 return false;
445 }
446 }
447 }
448
449 if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
450 if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
451 return false;
452 }
453 }
454
455 if (headContext.isCrossRepository) {
456 if (pr.isCrossRepository === false) {
457 return false;
458 }
459 if (
460 (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) &&
461 !pullRequestHead.repositoryNameWithOwner &&
462 !pullRequestHead.ownerLogin
463 ) {
464 return false;
465 }
466 return true;
467 }
468
469 if (pr.isCrossRepository === true) {
470 if (
471 (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) ||
472 (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin)
473 ) {
474 return false;
475 }
476 }
477
478 return true;
479}
480
481function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
482 return {
483 number: summary.number,
484 title: summary.title,
485 url: summary.url,
486 baseRefName: summary.baseRefName,
487 headRefName: summary.headRefName,
488 ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
489 state: summary.state ?? "open",
490 ...(summary.isDraft === true ? { isDraft: true } : {}),
491 closedAt: summary.closedAt ?? null,
492 mergedAt: summary.mergedAt ?? null,
493 updatedAt: summary.updatedAt,
494 ...(summary.isCrossRepository !== undefined
495 ? { isCrossRepository: summary.isCrossRepository }
496 : {}),
497 ...(summary.headRepositoryNameWithOwner !== undefined
498 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
499 : {}),
500 ...(summary.headRepositoryOwnerLogin !== undefined
501 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
502 : {}),
503 };
504}
505
506function limitContext(value: string, maxChars: number): string {
507 if (value.length <= maxChars) return value;
508 return `${value.slice(0, maxChars)}\n\n[truncated]`;
509}
510
511function shortenSha(sha: string | undefined): string | null {
512 if (!sha) return null;
513 return sha.slice(0, SHORT_SHA_LENGTH);
514}
515
516function truncateText(
517 value: string | undefined,
518 maxLength = TOAST_DESCRIPTION_MAX,
519): string | undefined {
520 if (!value) return undefined;
521 if (value.length <= maxLength) return value;
522 if (maxLength <= 3) return "...".slice(0, maxLength);
523 return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
524}
525
526function withDescription(title: string, description: string | undefined) {
527 return description ? { title, description } : { title };
528}
529
530function summarizeGitActionResult(
531 result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">,
532 terms: ChangeRequestTerminology,
533): {
534 title: string;
535 description?: string;
536} {
537 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
538 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
539 const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`;
540 return withDescription(title, truncateText(result.pr.title));
541 }
542
543 if (result.push.status === "pushed") {
544 const shortSha = shortenSha(result.commit.commitSha);
545 const branch = result.push.upstreamBranch ?? result.push.branch;
546 const pushedCommitPart = shortSha ? ` ${shortSha}` : "";
547 const branchPart = branch ? ` to ${branch}` : "";
548 return withDescription(
549 `Pushed${pushedCommitPart}${branchPart}`,
550 truncateText(result.commit.subject),
551 );
552 }
553
554 if (result.commit.status === "created") {
555 const shortSha = shortenSha(result.commit.commitSha);
556 const title = shortSha ? `Committed ${shortSha}` : "Committed changes";
557 return withDescription(title, truncateText(result.commit.subject));
558 }
559
560 return { title: "Done" };
561}
562
563function sanitizeCommitMessage(generated: {
564 subject: string;
565 body: string;
566 branch?: string | undefined;
567}): {
568 subject: string;
569 body: string;
570 branch?: string | undefined;
571} {
572 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
573 const subject = rawSubject.replace(/[.]+$/g, "").trim();
574 const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";
575 return {
576 subject: safeSubject,
577 body: generated.body.trim(),
578 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
579 };
580}
581
582function sanitizeProgressText(value: string): string | null {
583 const trimmed = value.trim();
584 if (trimmed.length === 0) {
585 return null;
586 }
587 if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) {
588 return trimmed;
589 }
590 return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd();
591}
592
593interface CommitAndBranchSuggestion {
594 subject: string;
595 body: string;
596 branch?: string | undefined;
597 commitMessage: string;
598}
599
600function isCommitAction(
601 action: GitStackedAction,
602): action is "commit" | "commit_push" | "commit_push_pr" {
603 return action === "commit" || action === "commit_push" || action === "commit_push_pr";
604}
605
606function formatCommitMessage(subject: string, body: string): string {
607 const trimmedBody = body.trim();
608 if (trimmedBody.length === 0) {
609 return subject;
610 }
611 return `${subject}\n\n${trimmedBody}`;
612}
613
614function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {
615 const normalized = raw.replace(/\r\n/g, "\n").trim();
616 if (normalized.length === 0) {
617 return null;
618 }
619
620 const [firstLine, ...rest] = normalized.split("\n");
621 const subject = firstLine?.trim() ?? "";
622 if (subject.length === 0) {
623 return null;
624 }
625
626 return {
627 subject,
628 body: rest.join("\n").trim(),
629 };
630}
631
632/** The selectors and page size `provider` asks about when looking up a branch's change requests. */
633function headBranchProbe(
634 provider: SourceControlProvider.SourceControlProvider["Service"],
635 headSelectors: ReadonlyArray<string>,
636 state: "open" | "all",
637) {
638 return (
639 provider.headBranchProbe?.({ headSelectors, state }) ?? {
640 headSelectors,
641 limit: state === "open" ? 1 : 20,
642 }
643 );
644}
645
646function appendUnique(values: string[], next: string | null | undefined): void {
647 const trimmed = next?.trim() ?? "";
648 if (trimmed.length === 0 || values.includes(trimmed)) {
649 return;
650 }
651 values.push(trimmed);
652}
653
654function toStatusPr(pr: PullRequestInfo): {
655 number: number;
656 title: string;
657 url: string;
658 baseRef: string;
659 headRef: string;
660 state: "open" | "closed" | "merged";
661 isDraft?: boolean;
662 updatedAt: string | null;
663} {
664 return {
665 number: pr.number,
666 title: pr.title,
667 url: pr.url,
668 baseRef: pr.baseRefName,
669 headRef: pr.headRefName,
670 state: pr.state,
671 ...(pr.isDraft === true ? { isDraft: true } : {}),
672 updatedAt: Option.match(pr.updatedAt, {
673 onNone: () => null,
674 onSome: (updatedAt) => DateTime.formatIso(updatedAt),
675 }),
676 };
677}
678
679function normalizePullRequestReference(reference: string): string {
680 const trimmed = reference.trim();
681 const hashNumber = /^#(\d+)$/.exec(trimmed);
682 return hashNumber?.[1] ?? trimmed;
683}
684
685function toResolvedPullRequest(pr: {
686 number: number;
687 title: string;
688 url: string;
689 baseRefName: string;
690 headRefName: string;
691 state?: "open" | "closed" | "merged";
692}): ResolvedPullRequest {
693 return {
694 number: pr.number,
695 title: pr.title,
696 url: pr.url,
697 baseBranch: pr.baseRefName,
698 headBranch: pr.headRefName,
699 state: pr.state ?? "open",
700 };
701}
702
703function shouldPreferSshRemote(url: string | null): boolean {
704 if (!url) return false;
705 return isSshRemoteUrl(url);
706}
707
708function toPullRequestHeadRemoteInfo(pr: {
709 isCrossRepository?: boolean | undefined;
710 headRepositoryNameWithOwner?: string | null | undefined;
711 headRepositoryOwnerLogin?: string | null | undefined;
712}): PullRequestHeadRemoteInfo {
713 return {
714 ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}),
715 ...(pr.headRepositoryNameWithOwner !== undefined
716 ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner }
717 : {}),
718 ...(pr.headRepositoryOwnerLogin !== undefined
719 ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin }
720 : {}),
721 };
722}
723
724export const make = Effect.gen(function* () {
725 const gitCore = yield* GitVcsDriver.GitVcsDriver;
726 const mergebot = yield* MergebotQueue.MergebotQueue;
727 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
728 const textGeneration = yield* TextGeneration.TextGeneration;
729 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
730 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
731 const crypto = yield* Crypto.Crypto;
732 const fileSystem = yield* FileSystem.FileSystem;
733 const path = yield* Path.Path;
734
735 const mergebotRepository = (target: string | null) =>
736 target === null
737 ? undefined
738 : mergebot.configuration?.repositories.find(
739 (repository) =>
740 canonicalRepositoryKey(`github.com/${repository}`) === canonicalRepositoryKey(target),
741 );
742
743 const mergebotPrInfo = (value: MergebotPullRequestDetails): PullRequestInfo => ({
744 number: value.number,
745 title: value.title,
746 url: value.url,
747 state: value.lifecycle,
748 baseRefName: value.baseBranch,
749 headRefName: value.headBranch,
750 headSha: value.headSha,
751 isDraft: value.draft,
752 updatedAt: DateTime.make(value.updatedAt),
753 ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }),
754 ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }),
755 });
756
757 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
758 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
759 const threads = yield* ProjectionStore.ProjectionStoreV2;
760 const projects = yield* ProjectStore.ProjectStoreV2;
761 /** Environment settings with the acting project's overrides applied. */
762 const projectSettingsFor = Effect.fnUntraced(function* (input: {
763 readonly cwd: string;
764 readonly threadId?: ThreadId | undefined;
765 }) {
766 const settings = yield* serverSettingsService.getSettings;
767 if (!hasProjectSettingsOverrides(settings))
768 return { settings, projectId: null as ProjectId | null };
769 const projectId: ProjectId | null = yield* input.threadId !== undefined
770 ? threads.getThreadShell(input.threadId).pipe(
771 Effect.map((thread) => thread?.projectId ?? null),
772 Effect.orElseSucceed(() => null),
773 )
774 : projects.findActiveByWorkspaceRoot(input.cwd).pipe(
775 Effect.map((project) => Option.getOrNull(project)?.projectId ?? null),
776 Effect.orElseSucceed(() => null),
777 );
778 return { settings: resolveProjectSettings(settings, projectId).settings, projectId };
779 });
780 // Best effort: a settings read failure falls back to the default location.
781 const readWorktreesDirectory = serverSettingsService.getSettings.pipe(
782 Effect.map((settings) => settings.worktreesDirectory),
783 Effect.orElseSucceed(() => ""),
784 );
785 const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(
786 "GitManager.createWorktree",
787 )(function* (input, options) {
788 const submodules =
789 options?.submodules !== undefined
790 ? options.submodules
791 : yield* projectSettingsFor(input).pipe(
792 Effect.map(({ settings }) => settings.worktreeSubmodules),
793 Effect.orElseSucceed(() => null),
794 );
795 const worktreesDirectory = yield* readWorktreesDirectory;
796 return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules });
797 });
798
799 const readRepositoryInstructions = (cwd: string, fileName: string) =>
800 Effect.gen(function* () {
801 const root = yield* fileSystem.realPath(cwd);
802 const instructionPath = yield* fileSystem.realPath(path.join(root, fileName));
803 // A drive root such as `D:\` already ends with a separator, so compare
804 // with path.relative instead of a `${root}${sep}` prefix.
805 const relative = path.relative(root, instructionPath);
806 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
807 return "";
808 }
809 const info = yield* fileSystem.stat(instructionPath);
810 if (info.type !== "File" || info.size > ByteSize.bytes(20_000)) {
811 return "";
812 }
813 return (yield* fileSystem.readFileString(instructionPath)).trim();
814 }).pipe(Effect.orElseSucceed(() => ""));
815
816 const readRecentCommitSubjects = (cwd: string) =>
817 gitCore
818 .execute({
819 operation: "GitManager.readRecentCommitSubjects",
820 cwd,
821 args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"],
822 })
823 .pipe(
824 Effect.map((result) =>
825 result.stdout
826 .split("\n")
827 .map((line) => line.trim())
828 .filter((line) => line.length > 0),
829 ),
830 Effect.orElseSucceed(() => []),
831 );
832
833 const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) =>
834 Effect.gen(function* () {
835 switch (settings.style.mode) {
836 case "conventional_commits":
837 return conventionalCommitsTextGenerationPolicy;
838 case "custom":
839 return customTextGenerationPolicy(
840 settings.style.customInstructions
841 ? {
842 commitInstructions: settings.style.customInstructions,
843 changeRequestInstructions: settings.style.customInstructions,
844 }
845 : {},
846 );
847 case "repo_conventions": {
848 const subjects = yield* readRecentCommitSubjects(cwd);
849 const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md");
850 const isClaudeWriter =
851 settings.modelSelection.instanceId === "claudeAgent" ||
852 (yield* providerRegistry.getProviders).some(
853 (provider) =>
854 provider.instanceId === settings.modelSelection.instanceId &&
855 provider.driver === "claudeAgent",
856 );
857 const claudeInstructions = isClaudeWriter
858 ? yield* readRepositoryInstructions(cwd, "CLAUDE.md")
859 : "";
860 const examples = [
861 ...(subjects.length > 0
862 ? [["Recent commit subjects from this repository:", ...subjects].join("\n")]
863 : []),
864 ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []),
865 ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []),
866 ].join("\n\n");
867 if (!examples) {
868 return repositoryConventionsTextGenerationPolicy;
869 }
870 return {
871 ...repositoryConventionsTextGenerationPolicy,
872 commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`,
873 changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`,
874 };
875 }
876 }
877 });
878 const randomUUIDv4 = (cwd: string) =>
879 crypto.randomUUIDv4().pipe(
880 Effect.mapError(
881 (cause) =>
882 new GitManagerError({
883 operation: "randomUUIDv4",
884 cwd,
885 detail: "Failed to generate Git operation identifier.",
886 cause,
887 }),
888 ),
889 );
890
891 const createProgressEmitter = (
892 input: { cwd: string; action: GitStackedAction },
893 options?: GitRunStackedActionOptions,
894 ) =>
895 (options?.actionId === undefined
896 ? randomUUIDv4(input.cwd)
897 : Effect.succeed(options.actionId)
898 ).pipe(
899 Effect.map((actionId) => {
900 const reporter = options?.progressReporter;
901 const emit = (event: GitActionProgressPayload) =>
902 reporter
903 ? reporter.publish({
904 actionId,
905 cwd: input.cwd,
906 action: input.action,
907 ...event,
908 } as GitActionProgressEvent)
909 : Effect.void;
910
911 return {
912 actionId,
913 emit,
914 };
915 }),
916 );
917
918 const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")(
919 function* (
920 cwd: string,
921 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
922 localBranch = pullRequest.headBranch,
923 ) {
924 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
925 if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) {
926 const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
927 yield* gitCore.fetchRemoteTrackingBranch({
928 cwd,
929 remoteName,
930 remoteBranch: pullRequest.headBranch,
931 });
932 yield* gitCore.setBranchUpstream({
933 cwd,
934 branch: localBranch,
935 remoteName,
936 remoteBranch: pullRequest.headBranch,
937 });
938 return;
939 }
940
941 if (repositoryNameWithOwner.length === 0) {
942 return;
943 }
944
945 const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({
946 cwd,
947 repository: repositoryNameWithOwner,
948 });
949 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
950 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
951 const preferredRemoteName =
952 pullRequest.headRepositoryOwnerLogin?.trim() ||
953 repositoryNameWithOwner.split("/")[0]?.trim() ||
954 "fork";
955 const remoteName = yield* gitCore.ensureRemote({
956 cwd,
957 preferredName: preferredRemoteName,
958 url: remoteUrl,
959 });
960
961 yield* gitCore.fetchRemoteTrackingBranch({
962 cwd,
963 remoteName,
964 remoteBranch: pullRequest.headBranch,
965 });
966 yield* gitCore.setBranchUpstream({
967 cwd,
968 branch: localBranch,
969 remoteName,
970 remoteBranch: pullRequest.headBranch,
971 });
972 },
973 );
974
975 const configurePullRequestHeadUpstream = (
976 cwd: string,
977 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
978 localBranch = pullRequest.headBranch,
979 ) =>
980 configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe(
981 Effect.catch((error) =>
982 Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", {
983 cwd,
984 localBranch,
985 headBranch: pullRequest.headBranch,
986 cause: error,
987 }).pipe(Effect.asVoid),
988 ),
989 );
990
991 const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")(
992 function* (
993 cwd: string,
994 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
995 localBranch = pullRequest.headBranch,
996 ) {
997 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
998
999 if (repositoryNameWithOwner.length === 0) {
1000 yield* gitCore
1001 .fetchPullRequestBranch({
1002 cwd,
1003 prNumber: pullRequest.number,
1004 branch: localBranch,
1005 })
1006 .pipe(
1007 // Azure DevOps, GitLab and Bitbucket publish no `refs/pull/<n>/head`. A head in the
1008 // same repository is a branch on the primary remote, so it is fetched by name instead,
1009 // but only when that remote is the pull request's own repository: Azure finds a pull
1010 // request by number anywhere in the organization. Only while it is open, too: the
1011 // branch of a closed one may have moved on past the head it was closed with.
1012 Effect.catch((cause) =>
1013 pullRequest.isCrossRepository === true || pullRequest.state !== "open"
1014 ? Effect.fail(cause)
1015 : Effect.gen(function* () {
1016 const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
1017 const remoteUrl = yield* gitCore.readConfigValue(
1018 cwd,
1019 `remote.${remoteName}.url`,
1020 );
1021 const pullRequestKey = pullRequestRepositoryKey(pullRequest.url);
1022 if (
1023 remoteUrl === null ||
1024 pullRequestKey === null ||
1025 canonicalRepositoryKey(pullRequestKey) !==
1026 canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl))
1027 ) {
1028 return yield* cause;
1029 }
1030 yield* gitCore.fetchRemoteBranch({
1031 cwd,
1032 remoteName,
1033 remoteBranch: pullRequest.headBranch,
1034 localBranch,
1035 });
1036 }),
1037 ),
1038 );
1039 return;
1040 }
1041
1042 const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({
1043 cwd,
1044 repository: repositoryNameWithOwner,
1045 });
1046 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
1047 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
1048 const preferredRemoteName =
1049 pullRequest.headRepositoryOwnerLogin?.trim() ||
1050 repositoryNameWithOwner.split("/")[0]?.trim() ||
1051 "fork";
1052 const remoteName = yield* gitCore.ensureRemote({
1053 cwd,
1054 preferredName: preferredRemoteName,
1055 url: remoteUrl,
1056 });
1057
1058 yield* gitCore.fetchRemoteBranch({
1059 cwd,
1060 remoteName,
1061 remoteBranch: pullRequest.headBranch,
1062 localBranch,
1063 });
1064 yield* gitCore.setBranchUpstream({
1065 cwd,
1066 branch: localBranch,
1067 remoteName,
1068 remoteBranch: pullRequest.headBranch,
1069 });
1070 },
1071 );
1072
1073 const materializePullRequestHeadBranch = (
1074 cwd: string,
1075 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
1076 localBranch = pullRequest.headBranch,
1077 ) =>
1078 materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
1079 Effect.catch((primaryCause) =>
1080 gitCore
1081 .fetchPullRequestBranch({
1082 cwd,
1083 prNumber: pullRequest.number,
1084 branch: localBranch,
1085 })
1086 .pipe(
1087 Effect.mapError(
1088 (fallbackCause) =>
1089 new GitPullRequestMaterializationError({
1090 cwd,
1091 pullRequestNumber: pullRequest.number,
1092 headRepository: resolveHeadRepositoryNameWithOwner(pullRequest),
1093 headBranch: pullRequest.headBranch,
1094 localBranch,
1095 cause: new AggregateError(
1096 [primaryCause, fallbackCause],
1097 `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`,
1098 { cause: primaryCause },
1099 ),
1100 }),
1101 ),
1102 ),
1103 ),
1104 );
1105 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
1106 const canonicalizeExistingPath = (value: string) =>
1107 fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
1108 const normalizeStatusCacheKey = canonicalizeExistingPath;
1109 const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = {
1110 isRepo: false,
1111 hasOriginRemote: false,
1112 isDefaultBranch: false,
1113 branch: null,
1114 upstreamRef: null,
1115 hasWorkingTreeChanges: false,
1116 workingTree: { files: [], insertions: 0, deletions: 0 },
1117 hasUpstream: false,
1118 aheadCount: 0,
1119 behindCount: 0,
1120 aheadOfDefaultCount: 0,
1121 };
1122 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
1123 const details = yield* gitCore
1124 .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true })
1125 .pipe(
1126 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
1127 );
1128 const hostingProvider = details.isRepo
1129 ? yield* resolveHostingProvider(cwd, details.branch)
1130 : null;
1131
1132 return {
1133 isRepo: details.isRepo,
1134 ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
1135 hasPrimaryRemote: details.hasOriginRemote,
1136 isDefaultRef: details.isDefaultBranch,
1137 refName: details.branch,
1138 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1139 workingTree: details.workingTree,
1140 ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}),
1141 } satisfies VcsStatusLocalResult;
1142 });
1143 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
1144 capacity: STATUS_RESULT_CACHE_CAPACITY,
1145 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
1146 });
1147 const invalidateLocalStatusResultCache = (cwd: string) =>
1148 normalizeStatusCacheKey(cwd).pipe(
1149 Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)),
1150 );
1151 // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh
1152 // on their own, slower cadence: ahead/behind counts stay fresh on every
1153 // status poll while the PR association is re-fetched at most once per
1154 // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump
1155 // the epoch (invalidateStatus) to bypass the cache immediately.
1156 const prLookupEpochByCwd = new Map<string, number>();
1157 const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0;
1158 const bumpPrLookupEpoch = (cwd: string) =>
1159 normalizeStatusCacheKey(cwd).pipe(
1160 Effect.map((cacheKey) => {
1161 prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1);
1162 }),
1163 );
1164 // Cache keys are NUL-joined. Automatic settlement validates repository URLs
1165 // against the cached value before it uses a pull request decision.
1166 const prLookupCacheKey = (
1167 cwd: string,
1168 details: {
1169 branch: string;
1170 upstreamRef: string | null;
1171 defaultBranch: string | null;
1172 localBranchExists?: boolean;
1173 remoteName?: string | null;
1174 },
1175 ) =>
1176 [
1177 cwd,
1178 details.branch,
1179 details.upstreamRef ?? "",
1180 details.defaultBranch ?? "",
1181 details.localBranchExists === false ? "0" : "1",
1182 details.remoteName ?? "",
1183 String(prLookupEpoch(cwd)),
1184 ].join("\u0000");
1185 // Consecutive failures per cache key, so a branch that keeps failing waits
1186 // longer before the next attempt. Cleared as soon as a lookup succeeds.
1187 const prLookupFailureStreakByKey = new Map<string, number>();
1188 const nextPrLookupFailureTtl = (key: string) => {
1189 if (
1190 !prLookupFailureStreakByKey.has(key) &&
1191 prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY
1192 ) {
1193 const oldestKey = prLookupFailureStreakByKey.keys().next().value;
1194 if (oldestKey !== undefined) {
1195 prLookupFailureStreakByKey.delete(oldestKey);
1196 }
1197 }
1198 const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1;
1199 prLookupFailureStreakByKey.set(key, streak);
1200 return prLookupFailureTtl(streak);
1201 };
1202 // The last state a branch PR lookup read per pull request. The thread details panel shows
1203 // this lookup, so a merge it sees must reach the thread's link and settlement right away.
1204 const pullRequestStateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64);
1205 const lookupStates = new Map<string, PullRequestInfo["state"]>();
1206 const noteLookupState = (pr: PullRequestInfo, reference?: ThreadPullRequestKey) =>
1207 Effect.suspend(() => {
1208 const parsed = reference ?? parseChangeRequestUrl(pr.url);
1209 if (parsed === null || parsed.number !== pr.number) return Effect.void;
1210 const key = normalizeThreadPullRequestKey(parsed);
1211 const id = threadPullRequestKeyOf(key);
1212 // An unseen pull request counts as open, so only a terminal first read announces.
1213 const previous = lookupStates.get(id) ?? "open";
1214 lookupStates.delete(id);
1215 if (lookupStates.size >= PR_LOOKUP_CACHE_CAPACITY) {
1216 const oldest = lookupStates.keys().next().value;
1217 if (oldest !== undefined) lookupStates.delete(oldest);
1218 }
1219 lookupStates.set(id, pr.state);
1220 return previous === pr.state ? Effect.void : PubSub.publish(pullRequestStateChanges, key);
1221 });
1222 const prLookupCache = yield* Cache.makeWith(
1223 (key: string) => {
1224 const [
1225 cwd = "",
1226 branch = "",
1227 upstreamRef = "",
1228 defaultBranch = "",
1229 branchExists = "1",
1230 remoteName = "",
1231 ] = key.split("\u0000");
1232 const details = {
1233 branch,
1234 upstreamRef: upstreamRef.length > 0 ? upstreamRef : null,
1235 defaultBranch: defaultBranch.length > 0 ? defaultBranch : null,
1236 localBranchExists: branchExists !== "0",
1237 ...(remoteName.length > 0 ? { remoteName } : {}),
1238 };
1239 return Effect.gen(function* () {
1240 const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details);
1241 if (!lookup) {
1242 return { latest: null, headContext };
1243 }
1244 // Only skip when the branch is untracked as well: anything carrying an
1245 // upstream keeps the old behaviour.
1246 if (
1247 details.localBranchExists &&
1248 details.upstreamRef === null &&
1249 (yield* isUnpublishedBranch(cwd, headContext))
1250 ) {
1251 return { latest: null, headContext };
1252 }
1253 const latest = yield* findLatestPrForHeadContext(cwd, headContext);
1254 if (latest !== null) yield* noteLookupState(latest);
1255 return { latest, headContext };
1256 });
1257 },
1258 {
1259 capacity: PR_LOOKUP_CACHE_CAPACITY,
1260 timeToLive: (exit, key) => {
1261 if (Exit.isSuccess(exit)) {
1262 prLookupFailureStreakByKey.delete(key);
1263 return exit.value.latest?.state === "open"
1264 ? PR_LOOKUP_CACHE_TTL
1265 : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL;
1266 }
1267 return nextPrLookupFailureTtl(key);
1268 },
1269 },
1270 );
1271 const getPrLookup = Effect.fnUntraced(function* (key: string) {
1272 if (mergebot.configuration !== null) {
1273 const [cwd = "", branch = "", upstreamRef = "", defaultBranch = "", , remoteName = ""] =
1274 key.split("\u0000");
1275 const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, {
1276 branch,
1277 upstreamRef: upstreamRef || null,
1278 defaultBranch: defaultBranch || null,
1279 ...(remoteName ? { remoteName } : {}),
1280 });
1281 const repository = mergebotRepository(headContext.targetRemoteUrlKey);
1282 if (repository !== undefined) {
1283 const latest = lookup ? yield* findLatestPrForHeadContext(cwd, headContext) : null;
1284 if (latest !== null)
1285 yield* noteLookupState(latest, { host: "github.com", repository, number: latest.number });
1286 return { latest, headContext };
1287 }
1288 if (headContext.targetRemoteUrlKey === null) return { latest: null, headContext };
1289 }
1290 return yield* detachStackFrame(Cache.get(prLookupCache, key));
1291 });
1292 // A transient lookup failure (rate limit, network blip) must not clear an
1293 // already-known PR badge, so the last successful answer per branch sticks
1294 // around as the fallback. Keep the resolved head context with it so a
1295 // branch retargeted to another remote/fork cannot inherit the old badge.
1296 interface LastKnownPr {
1297 readonly pr: ReturnType<typeof toStatusPr> | null;
1298 readonly upstreamRef: string | null;
1299 readonly headBranch: string;
1300 readonly remoteName: string | null;
1301 readonly headRemoteUrlKey: string | null;
1302 }
1303 const lastKnownPrByBranchKey = new Map<string, LastKnownPr>();
1304 const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => {
1305 if (
1306 !lastKnownPrByBranchKey.has(branchKey) &&
1307 lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY
1308 ) {
1309 const oldestKey = lastKnownPrByBranchKey.keys().next().value;
1310 if (oldestKey !== undefined) {
1311 lastKnownPrByBranchKey.delete(oldestKey);
1312 }
1313 }
1314 lastKnownPrByBranchKey.set(branchKey, entry);
1315 };
1316 const resolveLastKnownPr = (
1317 branchKey: string,
1318 current: Pick<LastKnownPr, "upstreamRef" | "headBranch" | "remoteName" | "headRemoteUrlKey">,
1319 ): ReturnType<typeof toStatusPr> | null => {
1320 const lastKnown = lastKnownPrByBranchKey.get(branchKey);
1321 if (!lastKnown) return null;
1322 if (lastKnown.headBranch !== current.headBranch) {
1323 return null;
1324 }
1325
1326 // The normalized URL catches both remote-alias changes and an existing
1327 // alias being repointed. Both sides must be resolved before treating a
1328 // mismatch as real: `readConfigValueNullable` swallows any git-config
1329 // read failure into `null`, so a transient failure to resolve the
1330 // *current* remote URL must read as "unknown", not as "no remote" — the
1331 // latter would otherwise drop an already-known PR badge on every hiccup.
1332 if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) {
1333 return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null;
1334 }
1335
1336 // If the remote URL can't be compared, fall back to the remote identity
1337 // encoded by tracked branches — same "both sides known" requirement, for
1338 // the same reason. A null-to-non-null transition (upstream/remoteName)
1339 // is allowed because that is the expected first-push case.
1340 if (
1341 lastKnown.upstreamRef !== null &&
1342 current.upstreamRef !== null &&
1343 lastKnown.remoteName !== null &&
1344 current.remoteName !== null
1345 ) {
1346 return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null;
1347 }
1348 return lastKnown.pr;
1349 };
1350 const lookupStatusPr = Effect.fn("lookupStatusPr")(function* (
1351 cwd: string,
1352 details: {
1353 branch: string;
1354 upstreamRef: string | null;
1355 defaultBranch: string | null;
1356 isDefaultBranch: boolean;
1357 },
1358 refreshMissingPullRequest = false,
1359 ) {
1360 // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first
1361 // `push -u`) must not orphan the fallback value for the same branch.
1362 const branchKey = `${cwd}\u0000${details.branch}`;
1363 const cacheKey = prLookupCacheKey(cwd, details);
1364 if (refreshMissingPullRequest) {
1365 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
1366 Effect.orElseSucceed(() => Option.none()),
1367 );
1368 if (Option.isSome(cached) && cached.value.latest === null) {
1369 yield* Cache.invalidate(prLookupCache, cacheKey);
1370 }
1371 }
1372 return yield* getPrLookup(cacheKey).pipe(
1373 Effect.map(({ latest, headContext }) => {
1374 if (!latest) return { pr: null, headContext };
1375 // On the default branch, only surface open PRs.
1376 // Merged/closed matches are usually reverse-merge history, not the thread's PR context.
1377 if (details.isDefaultBranch && latest.state !== "open") {
1378 return { pr: null, headContext };
1379 }
1380 return { pr: toStatusPr(latest), headContext };
1381 }),
1382 Effect.tap(({ pr, headContext }) =>
1383 Effect.sync(() =>
1384 rememberLastKnownPr(branchKey, {
1385 pr,
1386 upstreamRef: details.upstreamRef,
1387 headBranch: headContext.headBranch,
1388 remoteName: headContext.remoteName,
1389 headRemoteUrlKey: headContext.headRemoteUrlKey,
1390 }),
1391 ),
1392 ),
1393 Effect.map(({ pr }) => pr),
1394 Effect.catch((error) => {
1395 // No prior PR means there is no badge to preserve or remote to verify.
1396 const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr
1397 ? resolveLookupHeadContext(cwd, details).pipe(
1398 Effect.map(({ headContext }) =>
1399 resolveLastKnownPr(branchKey, {
1400 upstreamRef: details.upstreamRef,
1401 headBranch: headContext.headBranch,
1402 remoteName: headContext.remoteName,
1403 headRemoteUrlKey: headContext.headRemoteUrlKey,
1404 }),
1405 ),
1406 )
1407 : Effect.succeed(null);
1408 return Effect.logWarning("PR lookup failed.").pipe(
1409 Effect.annotateLogs({
1410 operation: "lookupStatusPr",
1411 branch: details.branch,
1412 errorTag:
1413 typeof error === "object" && error !== null && "_tag" in error
1414 ? String(error._tag)
1415 : typeof error,
1416 ...(isSourceControlProviderError(error)
1417 ? {
1418 provider: error.provider,
1419 providerOperation: error.operation,
1420 ...(error.command === undefined ? {} : { providerCommand: error.command }),
1421 errorDetail: error.detail,
1422 }
1423 : {}),
1424 }),
1425 Effect.andThen(fallback),
1426 );
1427 }),
1428 );
1429 });
1430 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (
1431 cwd: string,
1432 options?: GitRemoteStatusOptions,
1433 ) {
1434 const details = yield* gitCore
1435 .statusDetailsRemote(cwd, options)
1436 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
1437 if (details === null || !details.isRepo) {
1438 return null;
1439 }
1440
1441 const pr =
1442 details.branch !== null
1443 ? yield* lookupStatusPr(
1444 cwd,
1445 {
1446 branch: details.branch,
1447 upstreamRef: details.upstreamRef,
1448 defaultBranch: details.defaultBranch,
1449 isDefaultBranch: details.isDefaultBranch,
1450 },
1451 options?.refreshMissingPullRequest,
1452 )
1453 : null;
1454
1455 return {
1456 hasUpstream: details.hasUpstream,
1457 aheadCount: details.aheadCount,
1458 behindCount: details.behindCount,
1459 aheadOfDefaultCount: details.aheadOfDefaultCount,
1460 pr,
1461 } satisfies VcsStatusRemoteResult;
1462 });
1463 const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), {
1464 capacity: STATUS_RESULT_CACHE_CAPACITY,
1465 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
1466 });
1467 const invalidateRemoteStatusResultCache = (cwd: string) =>
1468 normalizeStatusCacheKey(cwd).pipe(
1469 Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)),
1470 );
1471
1472 const readConfigValueNullable = (cwd: string, key: string) =>
1473 gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
1474
1475 const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
1476 cwd: string,
1477 branch: string | null,
1478 ) {
1479 const preferredRemoteName =
1480 branch === null
1481 ? "origin"
1482 : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
1483 const remoteUrl =
1484 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
1485 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
1486
1487 const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
1488 if (!remoteUrl || provider?.kind !== "unknown") return provider;
1489 const handle = yield* sourceControlProviders
1490 .resolveHandle({
1491 cwd,
1492 context: { provider, remoteName: preferredRemoteName, remoteUrl },
1493 })
1494 .pipe(Effect.orElseSucceed(() => null));
1495 return handle?.context?.provider ?? provider;
1496 });
1497
1498 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
1499 cwd: string,
1500 remoteName: string | null,
1501 ) {
1502 if (!remoteName) {
1503 return {
1504 remoteUrlKey: null,
1505 repositoryNameWithOwner: null,
1506 ownerLogin: null,
1507 };
1508 }
1509
1510 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1511 let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
1512 if (
1513 remoteUrl !== null &&
1514 /^https?:\/\//iu.test(remoteUrl) &&
1515 (repositoryNameWithOwner?.split("/").length ?? 0) > 2
1516 ) {
1517 const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind;
1518 const provider =
1519 kind === undefined
1520 ? undefined
1521 : yield* (
1522 kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind)
1523 ).pipe(Effect.orElseSucceed(() => undefined));
1524 repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider);
1525 }
1526 return {
1527 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
1528 repositoryNameWithOwner,
1529 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
1530 };
1531 });
1532
1533 // Returns [head remote, origin]. Most branches track origin, so read it once.
1534 const resolveHeadAndOriginContexts = (cwd: string, remoteName: string | null) =>
1535 remoteName === "origin"
1536 ? resolveRemoteRepositoryContext(cwd, "origin").pipe(
1537 Effect.map((origin) => [origin, origin] as const),
1538 )
1539 : Effect.all(
1540 [
1541 resolveRemoteRepositoryContext(cwd, remoteName),
1542 resolveRemoteRepositoryContext(cwd, "origin"),
1543 ],
1544 { concurrency: "unbounded" },
1545 );
1546
1547 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
1548 cwd: string,
1549 details: { branch: string; upstreamRef: string | null; remoteName?: string },
1550 ) {
1551 const remoteName =
1552 details.remoteName ??
1553 (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`));
1554 const headBranchFromUpstream = details.upstreamRef
1555 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
1556 : "";
1557 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
1558 const shouldProbeLocalBranchSelector =
1559 headBranchFromUpstream.length === 0 || headBranch === details.branch;
1560
1561 const [remoteRepository, originRepository] = yield* resolveHeadAndOriginContexts(
1562 cwd,
1563 remoteName,
1564 );
1565
1566 const isCrossRepository =
1567 remoteRepository.repositoryNameWithOwner !== null &&
1568 originRepository.repositoryNameWithOwner !== null
1569 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
1570 originRepository.repositoryNameWithOwner.toLowerCase()
1571 : remoteName !== null &&
1572 remoteName !== "origin" &&
1573 remoteRepository.repositoryNameWithOwner !== null;
1574
1575 const ownerHeadSelector =
1576 remoteRepository.ownerLogin && headBranch.length > 0
1577 ? `${remoteRepository.ownerLogin}:${headBranch}`
1578 : null;
1579 const remoteAliasHeadSelector =
1580 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
1581 const shouldProbeRemoteOwnedSelectors =
1582 isCrossRepository || (remoteName !== null && remoteName !== "origin");
1583
1584 const headSelectors: string[] = [];
1585 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
1586 appendUnique(headSelectors, ownerHeadSelector);
1587 appendUnique(
1588 headSelectors,
1589 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
1590 );
1591 }
1592 if (shouldProbeLocalBranchSelector) {
1593 appendUnique(headSelectors, details.branch);
1594 }
1595 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
1596 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
1597 appendUnique(headSelectors, ownerHeadSelector);
1598 appendUnique(
1599 headSelectors,
1600 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
1601 );
1602 }
1603
1604 return {
1605 localBranch: details.branch,
1606 headBranch,
1607 headSelectors,
1608 preferredHeadSelector:
1609 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
1610 remoteName,
1611 headRemoteUrlKey:
1612 remoteRepository.remoteUrlKey ??
1613 (remoteName === null ? originRepository.remoteUrlKey : null),
1614 targetRemoteUrlKey: originRepository.remoteUrlKey,
1615 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
1616 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
1617 isCrossRepository,
1618 } satisfies BranchHeadContext;
1619 });
1620
1621 // The remote that holds a ref named after the local branch, or null when
1622 // none does. Remote names may contain slashes, so refs are matched literally
1623 // per remote instead of with a glob. When several remotes hold the name, the
1624 // preferred remote wins, then origin, then the first configured remote.
1625 const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* (
1626 cwd: string,
1627 branch: string,
1628 preferredRemoteName: string | null,
1629 ) {
1630 if (branch.length === 0) return null;
1631 return yield* Effect.gen(function* () {
1632 const remoteNames = (yield* gitCore.execute({
1633 operation: "GitManager.findRemoteTrackingRemote.remotes",
1634 cwd,
1635 args: ["remote"],
1636 timeoutMs: 5_000,
1637 })).stdout
1638 .split("\n")
1639 .map((name) => name.trim())
1640 .filter((name) => name.length > 0);
1641 if (remoteNames.length === 0) return null;
1642 const refs = new Set(
1643 (yield* gitCore.execute({
1644 operation: "GitManager.findRemoteTrackingRemote.refs",
1645 cwd,
1646 args: [
1647 "for-each-ref",
1648 "--format=%(refname)",
1649 ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`),
1650 ],
1651 timeoutMs: 5_000,
1652 })).stdout
1653 .split("\n")
1654 .map((ref) => ref.trim())
1655 .filter((ref) => ref.length > 0),
1656 );
1657 const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`));
1658 if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) {
1659 return preferredRemoteName;
1660 }
1661 if (matching.includes("origin")) return "origin";
1662 return matching[0] ?? null;
1663 });
1664 });
1665
1666 // A no-upstream branch may have local commits after its last push. Prefer a
1667 // unique exact ref, then a unique remote tip in its history; never break a
1668 // fork tie by remote order.
1669 const findPublishedBranchRemote = Effect.fn("findPublishedBranchRemote")(function* (
1670 cwd: string,
1671 branch: string,
1672 ) {
1673 const localRef = `refs/heads/${branch}`;
1674 const remoteNames = (yield* gitCore.execute({
1675 operation: "GitManager.findPublishedBranchRemote.remotes",
1676 cwd,
1677 args: ["remote"],
1678 timeoutMs: 5_000,
1679 })).stdout
1680 .split("\n")
1681 .map((name) => name.trim())
1682 .filter((name) => name.length > 0);
1683 if (remoteNames.length === 0) return null;
1684
1685 const refs = yield* gitCore.execute({
1686 operation: "GitManager.findPublishedBranchRemote",
1687 cwd,
1688 args: [
1689 "for-each-ref",
1690 "--format=%(refname)%00%(objectname)",
1691 localRef,
1692 ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`),
1693 ],
1694 timeoutMs: 5_000,
1695 });
1696 const oidByRef = new Map(
1697 refs.stdout
1698 .split("\n")
1699 .map((line) => line.trim().split("\u0000"))
1700 .filter((entry): entry is [string, string] => entry.length === 2),
1701 );
1702 const localOid = oidByRef.get(localRef);
1703 if (!localOid) return null;
1704
1705 const remoteRefs = remoteNames.flatMap((name) => {
1706 const oid = oidByRef.get(`refs/remotes/${name}/${branch}`);
1707 return oid ? [{ name, oid }] : [];
1708 });
1709 const exactRemotes = remoteRefs.filter((remote) => remote.oid === localOid);
1710 if (exactRemotes.length > 1) return null;
1711 let remoteName = exactRemotes[0]?.name;
1712 if (remoteName === undefined) {
1713 const ancestorRemotes = yield* Effect.forEach(
1714 remoteRefs,
1715 (remote) =>
1716 gitCore
1717 .execute({
1718 operation: "GitManager.findPublishedBranchRemote.ancestor",
1719 cwd,
1720 args: ["merge-base", "--is-ancestor", remote.oid, localOid],
1721 allowNonZeroExit: true,
1722 timeoutMs: 5_000,
1723 })
1724 .pipe(
1725 Effect.flatMap((result) => {
1726 // `merge-base --is-ancestor` reports 0 for ancestor and 1
1727 // for not-ancestor; any other non-zero exit is a failed
1728 // probe, not a negative result. A failed probe rejects the
1729 // whole inference instead of letting a surviving probe win
1730 // by default and attach the wrong fork's PR.
1731 if (result.exitCode === 0) return Effect.succeed(remote.name);
1732 if (result.exitCode === 1) return Effect.succeed(null);
1733 return new GitCommandError({
1734 operation: "GitManager.findPublishedBranchRemote.ancestor",
1735 command: "git merge-base --is-ancestor",
1736 cwd,
1737 exitCode: result.exitCode,
1738 detail: "Ancestry probe failed; rejecting ancestry inference.",
1739 });
1740 }),
1741 ),
1742 { concurrency: "unbounded" },
1743 );
1744 const matches = ancestorRemotes.filter((name) => name !== null);
1745 if (matches.length !== 1) return null;
1746 remoteName = matches[0];
1747 }
1748 if (remoteName === undefined) return null;
1749 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1750 return remoteUrl === null ? null : remoteName;
1751 });
1752
1753 // `git worktree add -b feature origin/main` makes the new local branch track
1754 // origin/main. That upstream is the branch's base, not its published PR
1755 // head. Looking up PRs for it can attach an old reverse merge from main and
1756 // auto-settle an unrelated feature thread.
1757 //
1758 // The branch may still have been pushed under its own name by a plain
1759 // `git push <remote> feature` that never moved the upstream. When a remote
1760 // holds a ref for the local name, look the PR up by that name on that
1761 // remote. Without such a ref there is nothing to ask the host about, so
1762 // `lookup` is false and no API call is spent. Both the cached lookup and the
1763 // failure fallback resolve through here so the last-known PR compares
1764 // against the same head branch.
1765 const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* (
1766 cwd: string,
1767 details: {
1768 branch: string;
1769 upstreamRef: string | null;
1770 defaultBranch: string | null;
1771 remoteName?: string;
1772 },
1773 ) {
1774 const headContext = yield* resolveBranchHeadContext(cwd, details);
1775 if (details.upstreamRef === null) {
1776 const remoteName = yield* findPublishedBranchRemote(cwd, details.branch).pipe(
1777 Effect.orElseSucceed(() => null),
1778 );
1779 if (remoteName !== null && remoteName !== headContext.remoteName) {
1780 return {
1781 headContext: yield* resolveBranchHeadContext(cwd, { ...details, remoteName }),
1782 lookup: true,
1783 };
1784 }
1785 }
1786 const upstreamHeadIsDefault =
1787 headContext.headBranch === details.defaultBranch ||
1788 (details.defaultBranch === null &&
1789 (headContext.headBranch === "main" || headContext.headBranch === "master"));
1790 if (
1791 headContext.headBranch === details.branch ||
1792 !upstreamHeadIsDefault ||
1793 headContext.isCrossRepository
1794 ) {
1795 return { headContext, lookup: true };
1796 }
1797 const remoteName = yield* findRemoteTrackingRemote(
1798 cwd,
1799 details.branch,
1800 headContext.remoteName,
1801 ).pipe(Effect.orElseSucceed(() => null));
1802 if (remoteName === null) {
1803 return { headContext, lookup: false };
1804 }
1805 const ownNameContext = yield* resolveBranchHeadContext(cwd, {
1806 branch: details.branch,
1807 upstreamRef: null,
1808 remoteName,
1809 });
1810 return { headContext: ownNameContext, lookup: true };
1811 });
1812
1813 /**
1814 * Whether git has no record of this branch on any remote, so a change request
1815 * cannot exist for it and asking the provider is a guaranteed-empty API call.
1816 *
1817 * `git push` writes the remote-tracking ref even without `-u` (how most
1818 * terminal and agent pushes land), and configured upstream metadata survives
1819 * when a merged change request's remote branch is deleted. Together they
1820 * distinguish branches known to have reached a host from genuinely local
1821 * branches. The remote ref check includes names containing slashes. A
1822 * repository that tracks no remotes at all cannot answer the question,
1823 * because then every branch looks unpublished; it, and any failed probe,
1824 * keeps the lookup.
1825 */
1826 const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* (
1827 cwd: string,
1828 headContext: Pick<BranchHeadContext, "headBranch" | "localBranch">,
1829 ) {
1830 if (headContext.headBranch.length === 0) {
1831 return false;
1832 }
1833 const matchesRef = (pattern: string) =>
1834 gitCore
1835 .execute({
1836 operation: "GitManager.isUnpublishedBranch",
1837 cwd,
1838 args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern],
1839 timeoutMs: 5_000,
1840 })
1841 .pipe(Effect.map((result) => result.stdout.trim().length > 0));
1842
1843 return yield* Effect.gen(function* () {
1844 const [configuredRemote, configuredMerge] = yield* Effect.all(
1845 [
1846 gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`),
1847 gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`),
1848 ],
1849 { concurrency: "unbounded" },
1850 );
1851 if (configuredRemote !== null && configuredMerge !== null) {
1852 return false;
1853 }
1854
1855 const [tracksAnyRemote, trackedRemote] = yield* Effect.all(
1856 [matchesRef("refs/remotes"), findRemoteTrackingRemote(cwd, headContext.headBranch, null)],
1857 { concurrency: "unbounded" },
1858 );
1859 return tracksAnyRemote && trackedRemote === null;
1860 }).pipe(Effect.orElseSucceed(() => false));
1861 });
1862
1863 const findOpenPr = Effect.fn("findOpenPr")(function* (
1864 cwd: string,
1865 headContext: Pick<
1866 BranchHeadContext,
1867 | "headBranch"
1868 | "headSelectors"
1869 | "headRepositoryNameWithOwner"
1870 | "headRepositoryOwnerLogin"
1871 | "isCrossRepository"
1872 >,
1873 ) {
1874 if (mergebot.configuration !== null) {
1875 const origin = yield* resolveRemoteRepositoryContext(cwd, "origin");
1876 const repository = mergebotRepository(origin.remoteUrlKey);
1877 if (repository !== undefined) {
1878 if (headContext.isCrossRepository) return null;
1879 const value = yield* mergebot.findPullRequest({
1880 repository,
1881 headBranch: headContext.headBranch,
1882 });
1883 return value === null || value.lifecycle !== "open" ? null : mergebotPrInfo(value);
1884 }
1885 }
1886 const provider = yield* sourceControlProvider(cwd);
1887 const probe = headBranchProbe(provider, headContext.headSelectors, "open");
1888 for (const headSelector of probe.headSelectors) {
1889 const pullRequests = yield* provider.listChangeRequests({
1890 cwd,
1891 headSelector,
1892 state: "open",
1893 limit: probe.limit,
1894 });
1895 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
1896
1897 const firstPullRequest = normalizedPullRequests.find((pullRequest) =>
1898 matchesBranchHeadContext(pullRequest, headContext),
1899 );
1900 if (firstPullRequest) {
1901 return {
1902 ...firstPullRequest,
1903 state: "open",
1904 updatedAt: Option.none(),
1905 } satisfies PullRequestInfo;
1906 }
1907 }
1908
1909 return null;
1910 });
1911
1912 const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* (
1913 cwd: string,
1914 headContext: BranchHeadContext,
1915 ) {
1916 const repository = mergebotRepository(headContext.targetRemoteUrlKey);
1917 if (repository !== undefined) {
1918 if (headContext.isCrossRepository) return null;
1919 const value = yield* mergebot.findPullRequest({
1920 repository,
1921 headBranch: headContext.headBranch,
1922 });
1923 return value === null ? null : mergebotPrInfo(value);
1924 }
1925 const parsedByNumber = new Map<number, PullRequestInfo>();
1926
1927 const provider = yield* sourceControlProvider(cwd);
1928 const probe = headBranchProbe(provider, headContext.headSelectors, "all");
1929 for (const headSelector of probe.headSelectors) {
1930 const pullRequests = yield* provider.listChangeRequests({
1931 cwd,
1932 headSelector,
1933 state: "all",
1934 limit: probe.limit,
1935 });
1936
1937 for (const pr of pullRequests.map(toPullRequestInfo)) {
1938 if (!matchesBranchHeadContext(pr, headContext)) {
1939 continue;
1940 }
1941 parsedByNumber.set(pr.number, pr);
1942 }
1943 }
1944
1945 const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder);
1946
1947 const latestOpenPr = parsed.find((pr) => pr.state === "open");
1948 if (latestOpenPr) {
1949 return latestOpenPr;
1950 }
1951 return parsed[0] ?? null;
1952 });
1953 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
1954 cwd: string,
1955 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
1956 ) {
1957 const terms = yield* sourceControlProvider(cwd).pipe(
1958 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
1959 Effect.orElseSucceed(() =>
1960 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
1961 ),
1962 );
1963 const summary = summarizeGitActionResult(result, terms);
1964 let latestOpenPr: PullRequestInfo | null = null;
1965 let currentBranchIsDefault = false;
1966 let finalBranchContext: {
1967 branch: string;
1968 upstreamRef: string | null;
1969 hasUpstream: boolean;
1970 } | null = null;
1971
1972 if (result.action !== "commit") {
1973 const finalStatus = yield* gitCore.statusDetails(cwd);
1974 if (finalStatus.branch) {
1975 finalBranchContext = {
1976 branch: finalStatus.branch,
1977 upstreamRef: finalStatus.upstreamRef,
1978 hasUpstream: finalStatus.hasUpstream,
1979 };
1980 currentBranchIsDefault = finalStatus.isDefaultBranch;
1981 }
1982 }
1983
1984 const explicitResultPr =
1985 (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url
1986 ? {
1987 url: result.pr.url,
1988 state: "open" as const,
1989 }
1990 : null;
1991 const shouldLookupExistingOpenPr =
1992 (result.action === "commit_push" || result.action === "push") &&
1993 result.push.status === "pushed" &&
1994 result.branch.status !== "created" &&
1995 !currentBranchIsDefault &&
1996 explicitResultPr === null &&
1997 finalBranchContext?.hasUpstream === true;
1998
1999 if (shouldLookupExistingOpenPr && finalBranchContext) {
2000 latestOpenPr = yield* resolveBranchHeadContext(cwd, {
2001 branch: finalBranchContext.branch,
2002 upstreamRef: finalBranchContext.upstreamRef,
2003 }).pipe(
2004 Effect.flatMap((headContext) => findOpenPr(cwd, headContext)),
2005 Effect.orElseSucceed(() => null),
2006 );
2007 }
2008
2009 const openPr = latestOpenPr ?? explicitResultPr;
2010
2011 const cta =
2012 result.action === "commit" && result.commit.status === "created"
2013 ? {
2014 kind: "run_action" as const,
2015 label: "Push",
2016 action: { kind: "push" as const },
2017 }
2018 : (result.action === "push" ||
2019 result.action === "create_pr" ||
2020 result.action === "commit_push" ||
2021 result.action === "commit_push_pr") &&
2022 openPr?.url &&
2023 (!currentBranchIsDefault ||
2024 result.pr.status === "created" ||
2025 result.pr.status === "opened_existing")
2026 ? {
2027 kind: "open_pr" as const,
2028 label: `View ${terms.shortLabel}`,
2029 url: openPr.url,
2030 }
2031 : (result.action === "push" || result.action === "commit_push") &&
2032 result.push.status === "pushed" &&
2033 !currentBranchIsDefault
2034 ? {
2035 kind: "run_action" as const,
2036 label: `Create ${terms.shortLabel}`,
2037 action: { kind: "create_pr" as const },
2038 }
2039 : {
2040 kind: "none" as const,
2041 };
2042
2043 return {
2044 ...summary,
2045 cta,
2046 };
2047 });
2048
2049 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
2050 cwd: string,
2051 branch: string,
2052 upstreamRef: string | null,
2053 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
2054 ) {
2055 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
2056 if (configured) {
2057 // Older worktree handoffs recorded checkout revisions as PR target branches.
2058 const configuredBranch = yield* gitCore.resolveBranchName(cwd, configured);
2059 if (configuredBranch) return configuredBranch;
2060 }
2061
2062 if (upstreamRef && !headContext.isCrossRepository) {
2063 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
2064 remoteName: headContext.remoteName,
2065 });
2066 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
2067 return upstreamBranch;
2068 }
2069 }
2070
2071 const defaultFromProvider = yield* sourceControlProvider(cwd).pipe(
2072 Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })),
2073 Effect.orElseSucceed(() => null),
2074 );
2075 if (defaultFromProvider) {
2076 return defaultFromProvider;
2077 }
2078
2079 // The provider lookup can fail for reasons unrelated to the branch, so fall
2080 // back to what the remote itself records before assuming a name. A repository
2081 // whose default branch is master would otherwise get a base branch that does
2082 // not exist.
2083 const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe(
2084 Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)),
2085 Effect.orElseSucceed(() => null),
2086 );
2087 if (defaultFromRemote) {
2088 return defaultFromRemote;
2089 }
2090
2091 return "main";
2092 });
2093
2094 const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* (
2095 cwd: string,
2096 baseBranch: string,
2097 ) {
2098 const remoteName = yield* gitCore
2099 .resolvePrimaryRemoteName(cwd)
2100 .pipe(Effect.orElseSucceed(() => null));
2101 if (!remoteName) return baseBranch;
2102
2103 return yield* gitCore
2104 .resolveRemoteTrackingCommit({
2105 cwd,
2106 refName: baseBranch,
2107 fallbackRemoteName: remoteName,
2108 })
2109 .pipe(
2110 Effect.map((resolved) => resolved.commitSha),
2111 Effect.orElseSucceed(() => baseBranch),
2112 );
2113 });
2114
2115 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
2116 function* (input: {
2117 cwd: string;
2118 branch: string | null;
2119 commitMessage?: string;
2120 /** When true, also produce a semantic feature branch name. */
2121 includeBranch?: boolean;
2122 filePaths?: readonly string[];
2123 settings: SourceControlTextGenerationSettings;
2124 }) {
2125 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
2126 if (!context) {
2127 return null;
2128 }
2129
2130 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
2131 if (customCommit) {
2132 return {
2133 subject: customCommit.subject,
2134 body: customCommit.body,
2135 ...(input.includeBranch
2136 ? { branch: sanitizeFeatureBranchName(customCommit.subject) }
2137 : {}),
2138 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
2139 };
2140 }
2141
2142 const policy = yield* resolveStylePolicy(input.cwd, input.settings);
2143
2144 const generated = yield* textGeneration
2145 .generateCommitMessage({
2146 cwd: input.cwd,
2147 branch: input.branch,
2148 stagedSummary: limitContext(context.stagedSummary, 8_000),
2149 stagedPatch: limitContext(context.stagedPatch, 50_000),
2150 ...(input.includeBranch ? { includeBranch: true } : {}),
2151 ...(policy ? { policy } : {}),
2152 modelSelection: input.settings.modelSelection,
2153 })
2154 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
2155
2156 return {
2157 subject: generated.subject,
2158 body: generated.body,
2159 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
2160 commitMessage: formatCommitMessage(generated.subject, generated.body),
2161 };
2162 },
2163 );
2164
2165 const runCommitStep = Effect.fn("runCommitStep")(function* (
2166 settings: SourceControlTextGenerationSettings,
2167 cwd: string,
2168 action: "commit" | "commit_push" | "commit_push_pr",
2169 branch: string | null,
2170 commitMessage?: string,
2171 preResolvedSuggestion?: CommitAndBranchSuggestion,
2172 filePaths?: readonly string[],
2173 progressReporter?: GitActionProgressReporter,
2174 actionId?: string,
2175 ) {
2176 const emit = (event: GitActionProgressPayload) =>
2177 progressReporter && actionId
2178 ? progressReporter.publish({
2179 actionId,
2180 cwd,
2181 action,
2182 ...event,
2183 } as GitActionProgressEvent)
2184 : Effect.void;
2185
2186 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
2187 if (!suggestion) {
2188 const needsGeneration = !commitMessage?.trim();
2189 if (needsGeneration) {
2190 yield* emit({
2191 kind: "phase_started",
2192 phase: "commit",
2193 label: "Generating commit message...",
2194 });
2195 }
2196 suggestion = yield* resolveCommitAndBranchSuggestion({
2197 cwd,
2198 branch,
2199 ...(commitMessage ? { commitMessage } : {}),
2200 ...(filePaths ? { filePaths } : {}),
2201 settings,
2202 });
2203 }
2204 if (!suggestion) {
2205 return { status: "skipped_no_changes" as const };
2206 }
2207
2208 yield* emit({
2209 kind: "phase_started",
2210 phase: "commit",
2211 label: "Committing...",
2212 });
2213
2214 let currentHookName: string | null = null;
2215 const commitProgress =
2216 progressReporter && actionId
2217 ? {
2218 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
2219 const sanitized = sanitizeProgressText(text);
2220 if (!sanitized) {
2221 return Effect.void;
2222 }
2223 return emit({
2224 kind: "hook_output",
2225 hookName: currentHookName,
2226 stream,
2227 text: sanitized,
2228 });
2229 },
2230 onHookStarted: (hookName: string) => {
2231 currentHookName = hookName;
2232 return emit({
2233 kind: "hook_started",
2234 hookName,
2235 });
2236 },
2237 onHookFinished: ({
2238 hookName,
2239 exitCode,
2240 durationMs,
2241 }: {
2242 hookName: string;
2243 exitCode: number | null;
2244 durationMs: number | null;
2245 }) => {
2246 if (currentHookName === hookName) {
2247 currentHookName = null;
2248 }
2249 return emit({
2250 kind: "hook_finished",
2251 hookName,
2252 exitCode,
2253 durationMs,
2254 });
2255 },
2256 }
2257 : null;
2258 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
2259 timeoutMs: COMMIT_TIMEOUT_MS,
2260 stage: filePaths ? { filePaths } : {},
2261 ...(commitProgress ? { progress: commitProgress } : {}),
2262 });
2263 if (currentHookName !== null) {
2264 yield* emit({
2265 kind: "hook_finished",
2266 hookName: currentHookName,
2267 exitCode: 0,
2268 durationMs: null,
2269 });
2270 currentHookName = null;
2271 }
2272 return {
2273 status: "created" as const,
2274 commitSha,
2275 subject: suggestion.subject,
2276 };
2277 });
2278
2279 const runPrStep = Effect.fn("runPrStep")(function* (
2280 settings: SourceControlTextGenerationSettings,
2281 cwd: string,
2282 fallbackBranch: string | null,
2283 emit: GitActionProgressEmitter,
2284 ) {
2285 const provider = yield* sourceControlProvider(cwd);
2286 const terms = getChangeRequestTerminologyForKind(provider.kind);
2287 const details = yield* gitCore.statusDetails(cwd);
2288 const branch = details.branch ?? fallbackBranch;
2289 if (!branch) {
2290 return yield* new GitManagerError({
2291 operation: "runPrStep",
2292 cwd,
2293 detail: "Cannot create a pull request from detached HEAD.",
2294 });
2295 }
2296 if (!details.hasUpstream) {
2297 return yield* new GitManagerError({
2298 operation: "runPrStep",
2299 cwd,
2300 detail: "Current branch has not been pushed. Push before creating a PR.",
2301 });
2302 }
2303
2304 const headContext = yield* resolveBranchHeadContext(cwd, {
2305 branch,
2306 upstreamRef: details.upstreamRef,
2307 });
2308
2309 const existing = yield* findOpenPr(cwd, headContext);
2310 if (existing) {
2311 return {
2312 status: "opened_existing" as const,
2313 url: existing.url,
2314 number: existing.number,
2315 baseBranch: existing.baseRefName,
2316 headBranch: existing.headRefName,
2317 title: existing.title,
2318 };
2319 }
2320
2321 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
2322 yield* emit({
2323 kind: "phase_started",
2324 phase: "pr",
2325 label: `Generating ${terms.shortLabel} content...`,
2326 });
2327 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
2328 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
2329 const policy = yield* resolveStylePolicy(cwd, settings);
2330 const changeRequestTemplate =
2331 settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate
2332 ? Option.getOrUndefined(
2333 yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }),
2334 )
2335 : undefined;
2336
2337 const generated = yield* textGeneration.generatePrContent({
2338 cwd,
2339 baseBranch,
2340 headBranch: headContext.headBranch,
2341 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
2342 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
2343 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
2344 ...(changeRequestTemplate ? { changeRequestTemplate } : {}),
2345 ...(policy ? { policy } : {}),
2346 modelSelection: settings.modelSelection,
2347 });
2348
2349 const bodyFile = path.join(
2350 tempDir,
2351 `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`,
2352 );
2353 yield* fileSystem.writeFileString(bodyFile, generated.body).pipe(
2354 Effect.mapError(
2355 (cause) =>
2356 new GitManagerError({
2357 operation: "runPrStep",
2358 cwd,
2359 detail: "Failed to write pull request body temp file.",
2360 cause,
2361 }),
2362 ),
2363 );
2364 yield* emit({
2365 kind: "phase_started",
2366 phase: "pr",
2367 label: `Creating ${terms.singular}...`,
2368 });
2369 yield* provider
2370 .createChangeRequest({
2371 cwd,
2372 baseRefName: baseBranch,
2373 headSelector: headContext.preferredHeadSelector,
2374 title: generated.title,
2375 bodyFile,
2376 })
2377 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore)));
2378
2379 const created = yield* findOpenPr(cwd, headContext);
2380 if (!created) {
2381 return {
2382 status: "created" as const,
2383 baseBranch,
2384 headBranch: headContext.headBranch,
2385 title: generated.title,
2386 };
2387 }
2388
2389 return {
2390 status: "created" as const,
2391 url: created.url,
2392 number: created.number,
2393 baseBranch: created.baseRefName,
2394 headBranch: created.headRefName,
2395 title: created.title,
2396 };
2397 });
2398
2399 const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
2400 function* (input) {
2401 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
2402 return yield* Cache.get(localStatusResultCache, cacheKey);
2403 },
2404 );
2405 const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
2406 function* (input, options) {
2407 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
2408 if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) {
2409 return yield* readRemoteStatus(cacheKey, options);
2410 }
2411 return yield* Cache.get(remoteStatusResultCache, cacheKey);
2412 },
2413 );
2414 const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) {
2415 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], {
2416 concurrency: "unbounded",
2417 });
2418 return mergeGitStatusParts(local, remote);
2419 });
2420 // Discovery and the settlement sweep call branchPullRequest for every thread
2421 // each minute, and most calls find the PR answer already cached. The git
2422 // state around that answer (remotes, the branch's upstream, the remote HEAD,
2423 // remote URLs) is remembered per checkout for PR_LOOKUP_GIT_STATE_TTL, so
2424 // those calls start no git process. T3's own git actions (invalidateStatus)
2425 // and `refresh` read it again, and a change made outside T3 shows within the
2426 // TTL. A merged or closed answer can settle a thread or remove its worktree,
2427 // so branchPullRequest confirms one against fresh reads, and discovery asks
2428 // for fresh reads (`freshGitState`) before it persists an answer.
2429 const prLookupGitStateByKey = new Map<
2430 string,
2431 { readonly readAtMs: number; readonly value: string | null }
2432 >();
2433 const rememberPrLookupGitState = <E>(
2434 cwd: string,
2435 key: string,
2436 fresh: boolean,
2437 read: Effect.Effect<string | null, E>,
2438 ) =>
2439 Effect.gen(function* () {
2440 const entryKey = `${cwd}\u0000${key}`;
2441 const readAtMs = yield* Clock.currentTimeMillis;
2442 const remembered = prLookupGitStateByKey.get(entryKey);
2443 if (
2444 !fresh &&
2445 remembered !== undefined &&
2446 readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL)
2447 ) {
2448 return remembered.value;
2449 }
2450 // invalidateStatus bumps the epoch. A read that started before a T3 git
2451 // action, or before a newer read finished, must not replace their state.
2452 const epoch = prLookupEpoch(cwd);
2453 const value = yield* read;
2454 const latest = prLookupGitStateByKey.get(entryKey);
2455 if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) {
2456 return value;
2457 }
2458 prLookupGitStateByKey.delete(entryKey);
2459 if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) {
2460 const oldestKey = prLookupGitStateByKey.keys().next().value;
2461 if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey);
2462 }
2463 prLookupGitStateByKey.set(entryKey, { readAtMs, value });
2464 return value;
2465 });
2466 const forgetPrLookupGitState = (cwd: string) =>
2467 normalizeStatusCacheKey(cwd).pipe(
2468 Effect.map((cacheCwd) => {
2469 const prefix = `${cacheCwd}\u0000`;
2470 for (const key of prLookupGitStateByKey.keys()) {
2471 if (key.startsWith(prefix)) prLookupGitStateByKey.delete(key);
2472 }
2473 }),
2474 );
2475 const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")(
2476 function* (
2477 cwd: string,
2478 branch: string,
2479 remoteNameOverride: string | undefined,
2480 fresh: boolean,
2481 ) {
2482 const readConfig = (key: string) =>
2483 rememberPrLookupGitState(
2484 cwd,
2485 `config\u0000${key}`,
2486 fresh,
2487 gitCore.readConfigValue(cwd, key),
2488 ).pipe(Effect.orElseSucceed(() => null));
2489 const readRemoteUrlKey = (remoteName: string | null) =>
2490 remoteName === null
2491 ? Effect.succeed(null)
2492 : readConfig(`remote.${remoteName}.url`).pipe(
2493 Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)),
2494 );
2495 const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`));
2496 const [headRemoteUrlKey, targetRemoteUrlKey] =
2497 remoteName === "origin"
2498 ? yield* readRemoteUrlKey("origin").pipe(Effect.map((key) => [key, key] as const))
2499 : yield* Effect.all([readRemoteUrlKey(remoteName), readRemoteUrlKey("origin")], {
2500 concurrency: "unbounded",
2501 });
2502 return {
2503 remoteName,
2504 headRemoteUrlKey: headRemoteUrlKey ?? (remoteName === null ? targetRemoteUrlKey : null),
2505 targetRemoteUrlKey,
2506 };
2507 },
2508 );
2509 const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* (
2510 cacheCwd: string,
2511 branch: string,
2512 options: { readonly refresh: boolean; readonly freshGitState: boolean },
2513 ) {
2514 const remember = <E>(key: string, read: Effect.Effect<string | null, E>) =>
2515 rememberPrLookupGitState(cacheCwd, key, options.freshGitState, read);
2516 const remotes = yield* remember(
2517 "remotes",
2518 gitCore
2519 .execute({
2520 operation: "GitManager.branchPullRequest.remotes",
2521 cwd: cacheCwd,
2522 args: ["remote"],
2523 })
2524 .pipe(Effect.map((result) => result.stdout)),
2525 );
2526 const remoteNames = (remotes ?? "")
2527 .split("\n")
2528 .map((remoteName) => remoteName.trim())
2529 .filter((remoteName) => remoteName.length > 0);
2530 const [firstRemoteName] = remoteNames;
2531 if (firstRemoteName === undefined) return null;
2532 const branchRef = yield* remember(
2533 `branchRef\u0000${branch}`,
2534 gitCore
2535 .execute({
2536 operation: "GitManager.branchPullRequest.branchRef",
2537 cwd: cacheCwd,
2538 args: [
2539 "for-each-ref",
2540 "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",
2541 `refs/heads/${branch}`,
2542 ],
2543 })
2544 .pipe(Effect.map((result) => result.stdout)),
2545 );
2546 const expectedRefName = `refs/heads/${branch}`;
2547 const exactBranch = (branchRef ?? "")
2548 .split("\n")
2549 .find((line) => line.split("\u0000", 1)[0] === expectedRefName);
2550 const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] =
2551 exactBranch?.split("\u0000") ?? [];
2552 const localBranchExists = refName.length > 0;
2553 let upstreamRef: string | null = null;
2554 let remoteName: string | null = null;
2555 if (savedUpstream.length > 0) {
2556 if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) {
2557 return yield* new GitManagerError({
2558 operation: "branchPullRequest",
2559 cwd: cacheCwd,
2560 detail: `Saved upstream for ${branch} is incomplete.`,
2561 });
2562 }
2563 remoteName = savedRemoteName;
2564 const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, "");
2565 upstreamRef = `${remoteName}/${upstreamBranch}`;
2566 } else if (!localBranchExists) {
2567 const trackingRemotes = yield* remember(
2568 `trackingRemotes\u0000${branch}`,
2569 gitCore
2570 .execute({
2571 operation: "GitManager.branchPullRequest.remoteTrackingRefs",
2572 cwd: cacheCwd,
2573 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
2574 })
2575 .pipe(
2576 Effect.map((trackingRefs) => {
2577 const refNames = new Set(
2578 trackingRefs.stdout
2579 .split("\n")
2580 .map((remoteRef) => remoteRef.trim())
2581 .filter((remoteRef) => remoteRef.length > 0),
2582 );
2583 return remoteNames
2584 .filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`))
2585 .join("\n");
2586 }),
2587 ),
2588 );
2589 const matchingRemoteNames = (trackingRemotes ?? "")
2590 .split("\n")
2591 .filter((candidate) => candidate.length > 0);
2592 if (matchingRemoteNames.length > 1) {
2593 return yield* new GitManagerError({
2594 operation: "branchPullRequest",
2595 cwd: cacheCwd,
2596 detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`,
2597 });
2598 }
2599 remoteName = matchingRemoteNames[0] ?? null;
2600 if (remoteName !== null) {
2601 upstreamRef = `${remoteName}/${branch}`;
2602 }
2603 }
2604 const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName;
2605 const defaultBranch = yield* remember(
2606 `defaultBranch\u0000${defaultRemoteName}`,
2607 gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName),
2608 ).pipe(Effect.orElseSucceed(() => null));
2609 const cacheKey = prLookupCacheKey(cacheCwd, {
2610 branch,
2611 upstreamRef,
2612 defaultBranch,
2613 localBranchExists,
2614 ...(localBranchExists ? {} : { remoteName }),
2615 });
2616 if (options.refresh) {
2617 // A completed turn can create a PR or reuse a merged PR's branch.
2618 // Refresh successful answers, but keep failed lookups' retry backoff.
2619 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
2620 Effect.orElseSucceed(() => Option.none()),
2621 );
2622 if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey);
2623 }
2624 let cached = yield* getPrLookup(cacheKey);
2625 // The cached head context may have resolved on a different remote than
2626 // the saved upstream: a branch tracking origin/main but pushed to a fork
2627 // is looked up on the fork. Verify against the remote the lookup used.
2628 const identityRemoteName = (headContext: BranchHeadContext) =>
2629 headContext.remoteName ?? remoteName ?? undefined;
2630 const currentIdentity = yield* resolvePrLookupRepositoryIdentity(
2631 cacheCwd,
2632 branch,
2633 identityRemoteName(cached.headContext),
2634 options.freshGitState,
2635 );
2636 const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
2637 !(
2638 (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) ||
2639 (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null)
2640 );
2641 const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
2642 headContext.headRemoteUrlKey === identity.headRemoteUrlKey &&
2643 headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey;
2644 if (!canVerifyIdentity(cached.headContext, currentIdentity)) {
2645 return yield* new GitManagerError({
2646 operation: "branchPullRequest",
2647 cwd: cacheCwd,
2648 detail: `Repository identity for ${branch} could not be verified.`,
2649 });
2650 }
2651 if (!hasSameIdentity(cached.headContext, currentIdentity)) {
2652 yield* Cache.invalidate(prLookupCache, cacheKey);
2653 cached = yield* getPrLookup(cacheKey);
2654 const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity(
2655 cacheCwd,
2656 branch,
2657 identityRemoteName(cached.headContext),
2658 true,
2659 );
2660 if (
2661 !canVerifyIdentity(cached.headContext, refreshedIdentity) ||
2662 !hasSameIdentity(cached.headContext, refreshedIdentity)
2663 ) {
2664 return yield* new GitManagerError({
2665 operation: "branchPullRequest",
2666 cwd: cacheCwd,
2667 detail: `Repository identity for ${branch} changed during pull request lookup.`,
2668 });
2669 }
2670 }
2671 const { latest } = cached;
2672 const workRepository = mergebotRepository(cached.headContext.targetRemoteUrlKey);
2673 if (latest === null) return null;
2674 if (
2675 (branch === defaultBranch ||
2676 (defaultBranch === null && (branch === "main" || branch === "master"))) &&
2677 latest.state !== "open"
2678 ) {
2679 return null;
2680 }
2681 return {
2682 ...toStatusPr(latest),
2683 closedAt: latest.closedAt ?? null,
2684 mergedAt: latest.mergedAt ?? null,
2685 headSha: latest.headSha ?? null,
2686 // Hosting CLIs can select an upstream repository instead of origin.
2687 // The returned PR URL names the repository that actually owns it.
2688 repositoryKey:
2689 workRepository === undefined
2690 ? pullRequestRepositoryKey(latest.url)
2691 : canonicalRepositoryKey(`github.com/${workRepository}`),
2692 };
2693 });
2694 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
2695 "branchPullRequest",
2696 )(function* ({ cwd, branch }, options) {
2697 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
2698 const refresh = options?.refresh === true;
2699 const freshGitState = refresh || options?.freshGitState === true;
2700 const pullRequest = yield* lookupBranchPullRequest(cacheCwd, branch, {
2701 refresh,
2702 freshGitState,
2703 });
2704 if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest;
2705 return yield* lookupBranchPullRequest(cacheCwd, branch, {
2706 refresh: false,
2707 freshGitState: true,
2708 });
2709 });
2710 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
2711 "invalidateLocalStatus",
2712 )(function* (cwd) {
2713 yield* invalidateLocalStatusResultCache(cwd);
2714 });
2715 const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn(
2716 "invalidateRemoteStatus",
2717 )(function* (cwd) {
2718 yield* invalidateRemoteStatusResultCache(cwd);
2719 });
2720 const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")(
2721 function* (cwd) {
2722 yield* invalidateLocalStatusResultCache(cwd);
2723 yield* invalidateRemoteStatusResultCache(cwd);
2724 // Full invalidation is the explicit-freshness path (git actions, user
2725 // refresh); it also bypasses the slow PR-lookup cache. The periodic
2726 // status poll only invalidates local/remote and keeps the PR cache warm.
2727 yield* bumpPrLookupEpoch(cwd);
2728 yield* forgetPrLookupGitState(cwd);
2729 },
2730 );
2731
2732 const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn(
2733 "resolvePullRequest",
2734 )(function* (input) {
2735 if (mergebot.configuration !== null) {
2736 const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin");
2737 const repository = mergebotRepository(origin.remoteUrlKey);
2738 if (repository !== undefined) {
2739 const normalized = normalizePullRequestReference(input.reference);
2740 let number = /^\d+$/.test(normalized) ? Number(normalized) : null;
2741 if (number === null) {
2742 const parsed = parseChangeRequestUrl(normalized);
2743 if (
2744 parsed?.host === "github.com" &&
2745 parsed.repository.toLowerCase() === repository.toLowerCase()
2746 )
2747 number = parsed.number;
2748 else {
2749 const parsedUrl = URL.canParse(normalized) ? new URL(normalized) : null;
2750 const parts = parsedUrl?.pathname.split("/").filter(Boolean);
2751 if (
2752 parsedUrl?.origin === mergebot.configuration.origin &&
2753 parts?.length === 4 &&
2754 `${parts[0]}/${parts[1]}`.toLowerCase() === repository.toLowerCase() &&
2755 parts[2] === "pull" &&
2756 /^\d+$/.test(parts[3]!)
2757 )
2758 number = Number(parts[3]);
2759 }
2760 }
2761 if (number === null || !Number.isSafeInteger(number) || number < 1)
2762 return yield* new GitManagerError({
2763 operation: "resolvePullRequest",
2764 cwd: input.cwd,
2765 detail: "Choose a pull request from this repository in Mergebot.",
2766 });
2767 const value = yield* mergebot.readPullRequest({ repository, number });
2768 if (value === null)
2769 return yield* new GitManagerError({
2770 operation: "resolvePullRequest",
2771 cwd: input.cwd,
2772 detail: "Mergebot is unavailable. Try again after it reconnects.",
2773 });
2774 return { pullRequest: toResolvedPullRequest(mergebotPrInfo(value)) };
2775 }
2776 }
2777 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
2778 .getChangeRequest({
2779 cwd: input.cwd,
2780 reference: normalizePullRequestReference(input.reference),
2781 })
2782 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
2783
2784 return { pullRequest };
2785 });
2786
2787 const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn(
2788 "preparePullRequestThread",
2789 )(function* (input) {
2790 if (mergebot.configuration !== null) {
2791 const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin");
2792 if (mergebotRepository(origin.remoteUrlKey) !== undefined)
2793 return yield* new GitManagerError({
2794 operation: "preparePullRequestThread",
2795 cwd: input.cwd,
2796 detail: "Open this pull request in Mergebot to choose its workspace.",
2797 });
2798 }
2799 const maybeRunSetupScript = (worktreePath: string) => {
2800 if (!input.threadId) {
2801 return Effect.void;
2802 }
2803 return projectSetupScriptRunner
2804 .runForThread({
2805 threadId: input.threadId,
2806 projectCwd: input.cwd,
2807 worktreePath,
2808 })
2809 .pipe(
2810 Effect.catch((error) =>
2811 Effect.logWarning("GitManager.preparePullRequestThread setup script failed", {
2812 threadId: input.threadId,
2813 worktreePath,
2814 cause: error,
2815 }).pipe(Effect.asVoid),
2816 ),
2817 );
2818 };
2819 return yield* Effect.gen(function* () {
2820 const normalizedReference = normalizePullRequestReference(input.reference);
2821 const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
2822 const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
2823 cwd: input.cwd,
2824 reference: normalizedReference,
2825 });
2826 const pullRequest = toResolvedPullRequest(pullRequestSummary);
2827
2828 if (input.mode === "local") {
2829 yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({
2830 cwd: input.cwd,
2831 reference: normalizedReference,
2832 force: true,
2833 });
2834 const details = yield* gitCore.statusDetails(input.cwd);
2835 yield* configurePullRequestHeadUpstream(
2836 input.cwd,
2837 {
2838 ...pullRequest,
2839 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
2840 },
2841 details.branch ?? pullRequest.headBranch,
2842 );
2843 return {
2844 pullRequest,
2845 branch: details.branch ?? pullRequest.headBranch,
2846 worktreePath: null,
2847 isOnPullRequestHead: true,
2848 };
2849 }
2850
2851 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
2852 worktreePath: string,
2853 ) {
2854 const details = yield* gitCore.statusDetails(worktreePath);
2855 yield* configurePullRequestHeadUpstream(
2856 worktreePath,
2857 {
2858 ...pullRequest,
2859 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
2860 },
2861 details.branch ?? pullRequest.headBranch,
2862 );
2863 });
2864
2865 const pullRequestWithRemoteInfo = {
2866 ...pullRequest,
2867 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
2868 } as const;
2869 const localPullRequestBranch =
2870 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
2871
2872 // Git refuses to move a branch that is checked out in a worktree, so the
2873 // reuse paths cannot go through materializePullRequestHeadBranch and instead
2874 // advance the checkout from inside the worktree. A worktree that cannot be
2875 // moved (no reachable head, local commits, dirty tree) is still handed
2876 // back, because stranding the thread is worse than reporting the staleness.
2877 const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* (
2878 worktreePath: string,
2879 checkedOutBranch: string,
2880 ) {
2881 if (checkedOutBranch !== localPullRequestBranch) {
2882 // findLocalHeadBranch also accepts a branch that merely shares the head's bare name —
2883 // a fork PR opened from "main" matches the user's own local main. That checkout is
2884 // somebody else's work, so it keeps its tracking config and nothing else.
2885 yield* ensureExistingWorktreeUpstream(worktreePath);
2886 return {
2887 pullRequest,
2888 branch: localPullRequestBranch,
2889 worktreePath,
2890 isOnPullRequestHead: false,
2891 };
2892 }
2893
2894 // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref,
2895 // and once that has jumped to a rewritten head there is no way left to tell a checkout
2896 // that holds nothing of its own from one carrying local commits.
2897 const upstreamCommitBeforeFetch = yield* gitCore
2898 .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" })
2899 .pipe(
2900 Effect.map((resolved) => resolved.commitSha),
2901 Effect.orElseSucceed(() => null),
2902 );
2903
2904 yield* ensureExistingWorktreeUpstream(worktreePath);
2905
2906 const refreshed = yield* gitCore
2907 // The pull request's own ref, because it is the only thing that certainly names its
2908 // head. The branch's upstream does not: configuring it is best-effort, so a branch cut
2909 // from `origin/main` whose head branch has since been deleted still resolves — and
2910 // following it would move the checkout onto main and call that the pull request.
2911 .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number })
2912 .pipe(
2913 // A host that publishes no `refs/pull/<n>/head` leaves the remote-tracking branch,
2914 // taken only where it is the head branch's own rather than whatever the checkout
2915 // happened to be cut from.
2916 Effect.catch(() =>
2917 Effect.gen(function* () {
2918 const details = yield* gitCore.statusDetails(worktreePath);
2919 if (
2920 details.upstreamRef === null ||
2921 !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`)
2922 ) {
2923 return yield* new GitManagerError({
2924 operation: "preparePullRequestThread",
2925 cwd: worktreePath,
2926 detail: "The pull request head could not be resolved for this checkout.",
2927 });
2928 }
2929 return yield* gitCore.resolveCommit({
2930 cwd: worktreePath,
2931 revision: details.upstreamRef,
2932 });
2933 }),
2934 ),
2935 Effect.flatMap((target) =>
2936 gitCore.refreshCheckedOutBranch({
2937 cwd: worktreePath,
2938 targetCommit: target.commitSha,
2939 resetWhenHeadCommit: upstreamCommitBeforeFetch,
2940 }),
2941 ),
2942 Effect.catch((error) =>
2943 Effect.logWarning(
2944 "GitManager.preparePullRequestThread reused worktree refresh failed",
2945 {
2946 worktreePath,
2947 localBranch: localPullRequestBranch,
2948 cause: error,
2949 },
2950 ).pipe(Effect.as({ moved: false, onTarget: false })),
2951 ),
2952 );
2953
2954 // Only when the checkout actually moved: another thread may be running in this worktree,
2955 // and re-running the setup script under it buys nothing when the code did not change.
2956 if (refreshed.moved) {
2957 yield* maybeRunSetupScript(worktreePath);
2958 }
2959
2960 return {
2961 pullRequest,
2962 branch: localPullRequestBranch,
2963 worktreePath,
2964 isOnPullRequestHead: refreshed.onTarget,
2965 };
2966 });
2967
2968 const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) {
2969 const result = yield* gitCore.listRefs({ cwd, refresh: true });
2970 const localBranch = result.refs.find(
2971 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
2972 );
2973 if (localBranch) {
2974 return localBranch;
2975 }
2976 if (localPullRequestBranch === pullRequest.headBranch) {
2977 return null;
2978 }
2979
2980 for (const branch of result.refs) {
2981 if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) {
2982 continue;
2983 }
2984
2985 const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath);
2986 if (worktreePath !== rootWorktreePath) {
2987 return branch;
2988 }
2989 }
2990
2991 return null;
2992 });
2993
2994 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
2995 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
2996 ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
2997 : null;
2998 if (
2999 existingBranchBeforeFetch?.worktreePath &&
3000 existingBranchBeforeFetchPath !== rootWorktreePath
3001 ) {
3002 return yield* reuseExistingWorktree(
3003 existingBranchBeforeFetch.worktreePath,
3004 existingBranchBeforeFetch.name,
3005 );
3006 }
3007 if (existingBranchBeforeFetchPath === rootWorktreePath) {
3008 return yield* new GitManagerError({
3009 operation: "preparePullRequestThread",
3010 cwd: input.cwd,
3011 detail:
3012 "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.",
3013 });
3014 }
3015
3016 yield* materializePullRequestHeadBranch(
3017 input.cwd,
3018 pullRequestWithRemoteInfo,
3019 localPullRequestBranch,
3020 );
3021
3022 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
3023 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
3024 ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
3025 : null;
3026 if (
3027 existingBranchAfterFetch?.worktreePath &&
3028 existingBranchAfterFetchPath !== rootWorktreePath
3029 ) {
3030 return yield* reuseExistingWorktree(
3031 existingBranchAfterFetch.worktreePath,
3032 existingBranchAfterFetch.name,
3033 );
3034 }
3035 if (existingBranchAfterFetchPath === rootWorktreePath) {
3036 return yield* new GitManagerError({
3037 operation: "preparePullRequestThread",
3038 cwd: input.cwd,
3039 detail:
3040 "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.",
3041 });
3042 }
3043
3044 const worktree = yield* gitCore.createWorktree(
3045 {
3046 cwd: input.cwd,
3047 refName: localPullRequestBranch,
3048 path: null,
3049 },
3050 {
3051 worktreesDirectory: yield* readWorktreesDirectory,
3052 // Best effort: a settings read failure falls back to the checkout's t3.json.
3053 submodules: yield* projectSettingsFor(input).pipe(
3054 Effect.map(({ settings }) => settings.worktreeSubmodules),
3055 Effect.orElseSucceed(() => null),
3056 ),
3057 },
3058 );
3059 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
3060 yield* maybeRunSetupScript(worktree.worktree.path);
3061
3062 return {
3063 pullRequest,
3064 branch: worktree.worktree.refName,
3065 worktreePath: worktree.worktree.path,
3066 isOnPullRequestHead: true,
3067 };
3068 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
3069 });
3070
3071 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
3072 settings: SourceControlTextGenerationSettings,
3073 cwd: string,
3074 branch: string | null,
3075 commitMessage?: string,
3076 filePaths?: readonly string[],
3077 ) {
3078 const suggestion = yield* resolveCommitAndBranchSuggestion({
3079 cwd,
3080 branch,
3081 ...(commitMessage ? { commitMessage } : {}),
3082 ...(filePaths ? { filePaths } : {}),
3083 includeBranch: true,
3084 settings,
3085 });
3086 if (!suggestion) {
3087 return yield* new GitManagerError({
3088 operation: "runFeatureBranchStep",
3089 cwd,
3090 detail: "Cannot create a feature branch because there are no changes to commit.",
3091 });
3092 }
3093
3094 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
3095 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
3096 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
3097
3098 yield* gitCore.createRef({ cwd, refName: resolvedBranch });
3099 yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch }));
3100
3101 return {
3102 branchStep: { status: "created" as const, name: resolvedBranch },
3103 resolvedCommitMessage: suggestion.commitMessage,
3104 resolvedCommitSuggestion: suggestion,
3105 };
3106 });
3107
3108 const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")(
3109 function* (input, options) {
3110 const progress = yield* createProgressEmitter(input, options);
3111 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
3112
3113 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
3114 GitRunStackedActionResult,
3115 GitManagerServiceError
3116 > {
3117 const initialStatus = yield* gitCore.statusDetails(input.cwd);
3118 const wantsCommit = isCommitAction(input.action);
3119 const wantsPush =
3120 input.action === "push" ||
3121 input.action === "commit_push" ||
3122 input.action === "commit_push_pr" ||
3123 (input.action === "create_pr" &&
3124 (!initialStatus.hasUpstream || initialStatus.aheadCount > 0));
3125 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";
3126
3127 if (input.featureBranch && !wantsCommit) {
3128 return yield* new GitManagerError({
3129 operation: "runStackedAction",
3130 cwd: input.cwd,
3131 detail: "Feature-branch checkout is only supported for commit actions.",
3132 });
3133 }
3134 const phases: GitActionProgressPhase[] = [
3135 ...(input.featureBranch ? (["branch"] as const) : []),
3136 ...(wantsCommit ? (["commit"] as const) : []),
3137 ...(wantsPush ? (["push"] as const) : []),
3138 ...(wantsPr ? (["pr"] as const) : []),
3139 ];
3140
3141 yield* progress.emit({
3142 kind: "action_started",
3143 phases,
3144 });
3145
3146 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
3147 return yield* new GitManagerError({
3148 operation: "runStackedAction",
3149 cwd: input.cwd,
3150 detail: "Cannot push from detached HEAD.",
3151 });
3152 }
3153 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
3154 return yield* new GitManagerError({
3155 operation: "runStackedAction",
3156 cwd: input.cwd,
3157 detail: "Cannot create a pull request from detached HEAD.",
3158 });
3159 }
3160
3161 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
3162 let commitMessageForStep = input.commitMessage;
3163 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
3164
3165 const textGenerationSettings = yield* projectSettingsFor(input).pipe(
3166 Effect.flatMap(({ settings, projectId }) =>
3167 settings.sourceControlWriterModelSelection === null
3168 ? Effect.succeed({
3169 modelSelection: settings.textGenerationModelSelection,
3170 style: settings.sourceControlWritingStyle,
3171 })
3172 : providerRegistry.getProviders.pipe(
3173 Effect.map((providers) => ({
3174 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
3175 settings,
3176 providers,
3177 projectId,
3178 ),
3179 style: settings.sourceControlWritingStyle,
3180 })),
3181 ),
3182 ),
3183 Effect.mapError(
3184 (cause) =>
3185 new GitManagerError({
3186 operation: "runStackedAction",
3187 cwd: input.cwd,
3188 detail: "Failed to get server settings.",
3189 cause,
3190 }),
3191 ),
3192 );
3193
3194 if (input.featureBranch) {
3195 yield* Ref.set(currentPhase, Option.some("branch"));
3196 yield* progress.emit({
3197 kind: "phase_started",
3198 phase: "branch",
3199 label: "Preparing feature branch...",
3200 });
3201 const result = yield* runFeatureBranchStep(
3202 textGenerationSettings,
3203 input.cwd,
3204 initialStatus.branch,
3205 input.commitMessage,
3206 input.filePaths,
3207 );
3208 branchStep = result.branchStep;
3209 commitMessageForStep = result.resolvedCommitMessage;
3210 preResolvedCommitSuggestion = result.resolvedCommitSuggestion;
3211 } else {
3212 branchStep = { status: "skipped_not_requested" as const };
3213 }
3214
3215 const currentBranch = branchStep.name ?? initialStatus.branch;
3216 const commitAction = isCommitAction(input.action) ? input.action : null;
3217 const changeRequestTerms = wantsPr
3218 ? yield* sourceControlProvider(input.cwd).pipe(
3219 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
3220 Effect.orElseSucceed(() =>
3221 getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")),
3222 ),
3223 )
3224 : null;
3225
3226 const commit = commitAction
3227 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
3228 Effect.flatMap(() =>
3229 runCommitStep(
3230 textGenerationSettings,
3231 input.cwd,
3232 commitAction,
3233 currentBranch,
3234 commitMessageForStep,
3235 preResolvedCommitSuggestion,
3236 input.filePaths,
3237 options?.progressReporter,
3238 progress.actionId,
3239 ),
3240 ),
3241 )
3242 : { status: "skipped_not_requested" as const };
3243
3244 const push = wantsPush
3245 ? yield* progress
3246 .emit({
3247 kind: "phase_started",
3248 phase: "push",
3249 label: "Pushing...",
3250 })
3251 .pipe(
3252 Effect.tap(() => Ref.set(currentPhase, Option.some("push"))),
3253 Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)),
3254 )
3255 : { status: "skipped_not_requested" as const };
3256
3257 const pr = wantsPr
3258 ? yield* progress
3259 .emit({
3260 kind: "phase_started",
3261 phase: "pr",
3262 label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`,
3263 })
3264 .pipe(
3265 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
3266 Effect.flatMap(() =>
3267 runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit),
3268 ),
3269 )
3270 : { status: "skipped_not_requested" as const };
3271
3272 const toast = yield* buildCompletionToast(input.cwd, {
3273 action: input.action,
3274 branch: branchStep,
3275 commit,
3276 push,
3277 pr,
3278 });
3279
3280 const result = {
3281 action: input.action,
3282 branch: branchStep,
3283 commit,
3284 push,
3285 pr,
3286 toast,
3287 };
3288 yield* progress.emit({
3289 kind: "action_finished",
3290 result,
3291 });
3292 return result;
3293 });
3294
3295 return yield* runAction().pipe(
3296 Effect.ensuring(invalidateStatus(input.cwd)),
3297 Effect.tapError((error) =>
3298 Effect.flatMap(Ref.get(currentPhase), (phase) =>
3299 progress.emit({
3300 kind: "action_failed",
3301 phase: Option.getOrNull(phase),
3302 message: error.message,
3303 }),
3304 ),
3305 ),
3306 );
3307 },
3308 );
3309
3310 return GitManager.of({
3311 createWorktree,
3312 localStatus,
3313 remoteStatus,
3314 status,
3315 branchPullRequest,
3316 invalidateLocalStatus,
3317 invalidateRemoteStatus,
3318 invalidateStatus,
3319 resolvePullRequest,
3320 preparePullRequestThread,
3321 runStackedAction,
3322 subscribePullRequestStateChanges: PubSub.subscribe(pullRequestStateChanges).pipe(
3323 Effect.map((subscription) => Stream.fromSubscription(subscription)),
3324 ),
3325 });
3326});
3327
3328export const layer = Layer.effect(GitManager, make);