| 1 | import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cache from "effect/Cache"; |
| 4 | import * as Clock from "effect/Clock"; |
| 5 | import * as Context from "effect/Context"; |
| 6 | import * as Crypto from "effect/Crypto"; |
| 7 | import * as ByteSize from "effect/ByteSize"; |
| 8 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Duration from "effect/Duration"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import * as Order from "effect/Order"; |
| 16 | import * as Path from "effect/Path"; |
| 17 | import * as PubSub from "effect/PubSub"; |
| 18 | import * as Ref from "effect/Ref"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Scope from "effect/Scope"; |
| 21 | import * as Stream from "effect/Stream"; |
| 22 | import { |
| 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"; |
| 46 | import { |
| 47 | hasProjectSettingsOverrides, |
| 48 | resolveProjectSettings, |
| 49 | } from "@t3tools/shared/projectSettings"; |
| 50 | import { |
| 51 | detectSourceControlProviderFromGitRemoteUrl, |
| 52 | mergeGitStatusParts, |
| 53 | normalizeGitRemoteUrl, |
| 54 | resolveAutoFeatureBranchName, |
| 55 | sanitizeBranchFragment, |
| 56 | sanitizeFeatureBranchName, |
| 57 | } from "@t3tools/shared/git"; |
| 58 | import { |
| 59 | canonicalRepositoryKey, |
| 60 | getChangeRequestTerminologyForKind, |
| 61 | isSshRemoteUrl, |
| 62 | type ChangeRequestTerminology, |
| 63 | } from "@t3tools/shared/sourceControl"; |
| 64 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 65 | import { |
| 66 | normalizeThreadPullRequestKey, |
| 67 | threadPullRequestKeyOf, |
| 68 | } from "@t3tools/shared/threadPullRequests"; |
| 69 | |
| 70 | import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; |
| 71 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; |
| 72 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 73 | import { |
| 74 | conventionalCommitsTextGenerationPolicy, |
| 75 | customTextGenerationPolicy, |
| 76 | repositoryConventionsTextGenerationPolicy, |
| 77 | } from "../textGeneration/TextGenerationPresets.ts"; |
| 78 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 79 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 80 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 81 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 82 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; |
| 83 | import { detachStackFrame } from "./detachStackFrame.ts"; |
| 84 | import * as ServerSettings from "../serverSettings.ts"; |
| 85 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 86 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 87 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 88 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 89 | import type { ChangeRequest } from "@t3tools/contracts"; |
| 90 | |
| 91 | export interface GitActionProgressReporter { |
| 92 | readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; |
| 93 | } |
| 94 | |
| 95 | export interface GitRunStackedActionOptions { |
| 96 | readonly actionId?: string; |
| 97 | readonly progressReporter?: GitActionProgressReporter; |
| 98 | } |
| 99 | |
| 100 | export 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 | |
| 105 | export 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 | |
| 114 | interface SourceControlTextGenerationSettings { |
| 115 | readonly modelSelection: ModelSelection; |
| 116 | readonly style: SourceControlWritingStyleSettings; |
| 117 | } |
| 118 | |
| 119 | export 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 | |
| 170 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 171 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| 172 | const SHORT_SHA_LENGTH = 7; |
| 173 | const TOAST_DESCRIPTION_MAX = 72; |
| 174 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| 175 | const 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. |
| 182 | const 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. |
| 188 | const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); |
| 189 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); |
| 190 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); |
| 191 | const 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. |
| 195 | const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); |
| 196 | const 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 | */ |
| 208 | export 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 | } |
| 213 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 214 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 215 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; |
| 216 | |
| 217 | function isNotGitRepositoryError(error: GitCommandError): boolean { |
| 218 | return error.message.toLowerCase().includes("not a git repository"); |
| 219 | } |
| 220 | |
| 221 | interface OpenPrInfo { |
| 222 | number: number; |
| 223 | title: string; |
| 224 | url: string; |
| 225 | baseRefName: string; |
| 226 | headRefName: string; |
| 227 | } |
| 228 | |
| 229 | interface 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 | |
| 238 | const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput( |
| 239 | Order.flip(Option.makeOrder(DateTime.Order)), |
| 240 | (pullRequest) => pullRequest.updatedAt, |
| 241 | ); |
| 242 | |
| 243 | interface ResolvedPullRequest { |
| 244 | number: number; |
| 245 | title: string; |
| 246 | url: string; |
| 247 | baseBranch: string; |
| 248 | headBranch: string; |
| 249 | state: "open" | "closed" | "merged"; |
| 250 | } |
| 251 | |
| 252 | interface PullRequestHeadRemoteInfo { |
| 253 | isCrossRepository?: boolean | undefined; |
| 254 | headRepositoryNameWithOwner?: string | null | undefined; |
| 255 | headRepositoryOwnerLogin?: string | null | undefined; |
| 256 | } |
| 257 | |
| 258 | interface 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 | |
| 271 | export 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 | |
| 288 | function 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 | |
| 295 | function 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 | |
| 316 | function 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. */ |
| 329 | export 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 | |
| 344 | function 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 | |
| 355 | function normalizeOptionalString(value: string | null | undefined): string | null { |
| 356 | const trimmed = value?.trim() ?? ""; |
| 357 | return trimmed.length > 0 ? trimmed : null; |
| 358 | } |
| 359 | |
| 360 | function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { |
| 361 | const normalized = normalizeOptionalString(value); |
| 362 | return normalized ? normalized.toLowerCase() : null; |
| 363 | } |
| 364 | |
| 365 | function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { |
| 366 | const normalized = normalizeOptionalString(value); |
| 367 | return normalized ? normalized.toLowerCase() : null; |
| 368 | } |
| 369 | |
| 370 | function 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 | |
| 391 | interface PullRequestHeadIdentity { |
| 392 | readonly repositoryNameWithOwner: string | null; |
| 393 | readonly ownerLogin: string | null; |
| 394 | } |
| 395 | |
| 396 | function 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 | |
| 410 | function 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 | |
| 422 | export 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 | |
| 481 | function 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 | |
| 506 | function limitContext(value: string, maxChars: number): string { |
| 507 | if (value.length <= maxChars) return value; |
| 508 | return `${value.slice(0, maxChars)}\n\n[truncated]`; |
| 509 | } |
| 510 | |
| 511 | function shortenSha(sha: string | undefined): string | null { |
| 512 | if (!sha) return null; |
| 513 | return sha.slice(0, SHORT_SHA_LENGTH); |
| 514 | } |
| 515 | |
| 516 | function 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 | |
| 526 | function withDescription(title: string, description: string | undefined) { |
| 527 | return description ? { title, description } : { title }; |
| 528 | } |
| 529 | |
| 530 | function 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 | |
| 563 | function 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 | |
| 582 | function 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 | |
| 593 | interface CommitAndBranchSuggestion { |
| 594 | subject: string; |
| 595 | body: string; |
| 596 | branch?: string | undefined; |
| 597 | commitMessage: string; |
| 598 | } |
| 599 | |
| 600 | function 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 | |
| 606 | function 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 | |
| 614 | function 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. */ |
| 633 | function 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 | |
| 646 | function 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 | |
| 654 | function 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 | |
| 679 | function normalizePullRequestReference(reference: string): string { |
| 680 | const trimmed = reference.trim(); |
| 681 | const hashNumber = /^#(\d+)$/.exec(trimmed); |
| 682 | return hashNumber?.[1] ?? trimmed; |
| 683 | } |
| 684 | |
| 685 | function 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 | |
| 703 | function shouldPreferSshRemote(url: string | null): boolean { |
| 704 | if (!url) return false; |
| 705 | return isSshRemoteUrl(url); |
| 706 | } |
| 707 | |
| 708 | function 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 | |
| 724 | export 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 | |
| 3328 | export const layer = Layer.effect(GitManager, make); |