| 1 | import * as Cache from "effect/Cache"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as DateTime from "effect/DateTime"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Hex from "effect/encoding/Hex"; |
| 8 | import * as Exit from "effect/Exit"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import * as PlatformError from "effect/PlatformError"; |
| 13 | import * as Ref from "effect/Ref"; |
| 14 | import * as Result from "effect/Result"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import * as Scope from "effect/Scope"; |
| 17 | import * as Semaphore from "effect/Semaphore"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 20 | |
| 21 | import { |
| 22 | GitCommandError, |
| 23 | type GitCommandFailureReason, |
| 24 | T3_PROJECT_FILE_NAME, |
| 25 | type ReviewDiffFileContentsInput, |
| 26 | type ReviewDiffPreviewInput, |
| 27 | type ReviewDiffFileStat, |
| 28 | type ReviewDiffPreviewSource, |
| 29 | type VcsRef, |
| 30 | } from "@t3tools/contracts"; |
| 31 | import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 33 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 34 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 36 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 37 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; |
| 38 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 39 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 40 | import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; |
| 41 | import { |
| 42 | parseRemoteNames, |
| 43 | parseRemoteNamesInGitOrder, |
| 44 | parseRemoteRefWithRemoteNames, |
| 45 | } from "../git/remoteRefs.ts"; |
| 46 | import * as ServerConfig from "../config.ts"; |
| 47 | |
| 48 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 49 | const gitProcesses = Semaphore.makeUnsafe(8); |
| 50 | // `git worktree add` checks out the full tree, so on large repositories it can |
| 51 | // take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle |
| 52 | // machine). Give it generous headroom while still bounding a genuinely hung git. |
| 53 | const WORKTREE_ADD_TIMEOUT_MS = 300_000; |
| 54 | const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); |
| 55 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| 56 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; |
| 57 | const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; |
| 58 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 59 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 60 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; |
| 61 | const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; |
| 62 | const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; |
| 63 | // Every status refresh adds the untracked files to a temporary index, and git add's cost grows |
| 64 | // much faster than the file count (50k files took about 30 seconds). Past this many, the |
| 65 | // totals are reported incomplete instead. |
| 66 | const REVIEW_UNTRACKED_MAX_FILES = 5_000; |
| 67 | const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; |
| 68 | // Patches the clients render are parsed against git's default a/ and b/ path |
| 69 | // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would |
| 70 | // otherwise leak into the patch and leave every parsed file unnamed. |
| 71 | export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; |
| 72 | // Shared by review previews and status totals, so the Changes row matches the Changes view. |
| 73 | const REVIEW_DIFF_ARGS = [ |
| 74 | "diff", |
| 75 | "--find-renames", |
| 76 | "--no-color", |
| 77 | "--no-ext-diff", |
| 78 | "--no-textconv", |
| 79 | "--minimal", |
| 80 | ...PATCH_RENDER_PREFIX_ARGS, |
| 81 | ]; |
| 82 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 83 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 84 | |
| 85 | const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); |
| 86 | const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); |
| 87 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 88 | const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048; |
| 89 | const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10); |
| 90 | const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5); |
| 91 | const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1); |
| 92 | const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; |
| 93 | const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); |
| 94 | const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); |
| 95 | const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); |
| 96 | const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); |
| 97 | const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); |
| 98 | const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ |
| 99 | GCM_INTERACTIVE: "never", |
| 100 | GIT_ASKPASS: "", |
| 101 | GIT_TERMINAL_PROMPT: "0", |
| 102 | SSH_ASKPASS: "", |
| 103 | SSH_ASKPASS_REQUIRE: "never", |
| 104 | } satisfies NodeJS.ProcessEnv); |
| 105 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 106 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 107 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ |
| 108 | isRepo: false, |
| 109 | hasOriginRemote: false, |
| 110 | isDefaultBranch: false, |
| 111 | branch: null, |
| 112 | upstreamRef: null, |
| 113 | hasWorkingTreeChanges: false, |
| 114 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 115 | hasUpstream: false, |
| 116 | aheadCount: 0, |
| 117 | behindCount: 0, |
| 118 | aheadOfDefaultCount: 0, |
| 119 | }); |
| 120 | const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({ |
| 121 | isRepo: false, |
| 122 | defaultBranch: null, |
| 123 | isDefaultBranch: false, |
| 124 | branch: null, |
| 125 | upstreamRef: null, |
| 126 | hasUpstream: false, |
| 127 | aheadCount: 0, |
| 128 | behindCount: 0, |
| 129 | aheadOfDefaultCount: 0, |
| 130 | }); |
| 131 | |
| 132 | type TraceTailState = { |
| 133 | processedChars: number; |
| 134 | remainder: string; |
| 135 | }; |
| 136 | |
| 137 | class StatusRemoteRefreshCacheKey extends Data.Class<{ |
| 138 | gitCommonDir: string; |
| 139 | remoteName: string; |
| 140 | }> {} |
| 141 | |
| 142 | function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { |
| 143 | const exponent = Math.max(0, consecutiveFailures - 1); |
| 144 | const cooldownMs = |
| 145 | Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); |
| 146 | return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); |
| 147 | } |
| 148 | |
| 149 | class GitRefsSnapshotCacheKey extends Data.Class<{ |
| 150 | gitCommonDir: string; |
| 151 | epoch: number; |
| 152 | }> {} |
| 153 | |
| 154 | class GitRefsRefreshCacheKey extends Data.Class<{ |
| 155 | gitCommonDir: string; |
| 156 | generation: number; |
| 157 | }> {} |
| 158 | |
| 159 | interface GitRepositoryPaths { |
| 160 | readonly gitCommonDir: string; |
| 161 | readonly worktreeRoot: string | null; |
| 162 | readonly currentBranch: string | null; |
| 163 | } |
| 164 | |
| 165 | interface GitRefsSnapshot { |
| 166 | readonly localBranches: ReadonlyArray<VcsRef>; |
| 167 | readonly remoteBranches: ReadonlyArray<VcsRef>; |
| 168 | readonly hasPrimaryRemote: boolean; |
| 169 | } |
| 170 | |
| 171 | interface ExecuteGitOptions { |
| 172 | stdin?: string | undefined; |
| 173 | timeoutMs?: number | null | undefined; |
| 174 | allowNonZeroExit?: boolean | undefined; |
| 175 | fallbackErrorDetail?: string | undefined; |
| 176 | env?: NodeJS.ProcessEnv | undefined; |
| 177 | maxOutputBytes?: number | undefined; |
| 178 | appendTruncationMarker?: boolean | undefined; |
| 179 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; |
| 180 | } |
| 181 | |
| 182 | function parseBranchAb(value: string): { ahead: number; behind: number } { |
| 183 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); |
| 184 | if (!match) return { ahead: 0, behind: 0 }; |
| 185 | return { |
| 186 | ahead: Number(match[1] ?? "0"), |
| 187 | behind: Number(match[2] ?? "0"), |
| 188 | }; |
| 189 | } |
| 190 | |
| 191 | function parseNumstatEntries( |
| 192 | stdout: string, |
| 193 | ): Array<{ path: string; insertions: number; deletions: number }> { |
| 194 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; |
| 195 | for (const line of stdout.split(/\r?\n/g)) { |
| 196 | if (line.trim().length === 0) continue; |
| 197 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); |
| 198 | const rawPath = |
| 199 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); |
| 200 | if (rawPath.length === 0) continue; |
| 201 | const added = Number.parseInt(addedRaw ?? "0", 10); |
| 202 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); |
| 203 | const renameArrowIndex = rawPath.indexOf(" => "); |
| 204 | const normalizedPath = |
| 205 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; |
| 206 | entries.push({ |
| 207 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, |
| 208 | insertions: Number.isFinite(added) ? added : 0, |
| 209 | deletions: Number.isFinite(deleted) ? deleted : 0, |
| 210 | }); |
| 211 | } |
| 212 | return entries; |
| 213 | } |
| 214 | |
| 215 | // -z preserves tabs/newlines in paths and gives renames two separate path fields. |
| 216 | function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { |
| 217 | const fields = stdout.split("\0"); |
| 218 | const files: ReviewDiffFileStat[] = []; |
| 219 | for (let index = 0; index < fields.length; index++) { |
| 220 | const field = fields[index]!; |
| 221 | const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field); |
| 222 | if (!match) continue; |
| 223 | const previousPath = match[3] === "" ? fields[++index]! : null; |
| 224 | const path = previousPath !== null ? fields[++index]! : match[3]!; |
| 225 | files.push({ |
| 226 | path, |
| 227 | previousPath, |
| 228 | additions: match[1] === "-" ? 0 : Number(match[1]), |
| 229 | deletions: match[2] === "-" ? 0 : Number(match[2]), |
| 230 | }); |
| 231 | } |
| 232 | return files; |
| 233 | } |
| 234 | |
| 235 | function parsePorcelainPath(line: string): string | null { |
| 236 | if (line.startsWith("? ") || line.startsWith("! ")) { |
| 237 | const simple = line.slice(2).trim(); |
| 238 | return simple.length > 0 ? simple : null; |
| 239 | } |
| 240 | |
| 241 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { |
| 242 | return null; |
| 243 | } |
| 244 | |
| 245 | const tabIndex = line.indexOf("\t"); |
| 246 | if (tabIndex >= 0) { |
| 247 | const fromTab = line.slice(tabIndex + 1); |
| 248 | const [filePath] = fromTab.split("\t"); |
| 249 | return filePath?.trim().length ? filePath.trim() : null; |
| 250 | } |
| 251 | |
| 252 | const parts = line.trim().split(/\s+/g); |
| 253 | const filePath = parts.at(-1) ?? ""; |
| 254 | return filePath.length > 0 ? filePath : null; |
| 255 | } |
| 256 | |
| 257 | function filterBranchesForListQuery( |
| 258 | refs: ReadonlyArray<VcsRef>, |
| 259 | query?: string, |
| 260 | ): ReadonlyArray<VcsRef> { |
| 261 | if (!query) { |
| 262 | return refs; |
| 263 | } |
| 264 | |
| 265 | const normalizedQuery = query.toLowerCase(); |
| 266 | return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); |
| 267 | } |
| 268 | |
| 269 | function paginateBranches(input: { |
| 270 | refs: ReadonlyArray<VcsRef>; |
| 271 | cursor?: number | undefined; |
| 272 | limit?: number | undefined; |
| 273 | }): { |
| 274 | refs: ReadonlyArray<VcsRef>; |
| 275 | nextCursor: number | null; |
| 276 | totalCount: number; |
| 277 | } { |
| 278 | const cursor = input.cursor ?? 0; |
| 279 | const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; |
| 280 | const totalCount = input.refs.length; |
| 281 | const refs = input.refs.slice(cursor, cursor + limit); |
| 282 | const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; |
| 283 | |
| 284 | return { |
| 285 | refs, |
| 286 | nextCursor, |
| 287 | totalCount, |
| 288 | }; |
| 289 | } |
| 290 | |
| 291 | function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> { |
| 292 | const worktreePaths = new Map<string, string>(); |
| 293 | let currentPath: string | null = null; |
| 294 | let currentBranch: string | null = null; |
| 295 | let currentPrunable = false; |
| 296 | |
| 297 | const flush = () => { |
| 298 | if (currentPath !== null && currentBranch !== null && !currentPrunable) { |
| 299 | worktreePaths.set(currentBranch, currentPath); |
| 300 | } |
| 301 | currentPath = null; |
| 302 | currentBranch = null; |
| 303 | currentPrunable = false; |
| 304 | }; |
| 305 | |
| 306 | for (const field of stdout.split("\0")) { |
| 307 | if (field === "") { |
| 308 | flush(); |
| 309 | } else if (field.startsWith("worktree ")) { |
| 310 | currentPath = field.slice("worktree ".length); |
| 311 | } else if (field.startsWith("branch refs/heads/")) { |
| 312 | currentBranch = field.slice("branch refs/heads/".length); |
| 313 | } else if (field === "prunable" || field.startsWith("prunable ")) { |
| 314 | currentPrunable = true; |
| 315 | } |
| 316 | } |
| 317 | flush(); |
| 318 | |
| 319 | return worktreePaths; |
| 320 | } |
| 321 | |
| 322 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { |
| 323 | const parts = input.split("\0"); |
| 324 | if (parts.length === 0) return []; |
| 325 | |
| 326 | if (truncated && parts[parts.length - 1]?.length) { |
| 327 | parts.pop(); |
| 328 | } |
| 329 | |
| 330 | return parts.filter((value) => value.length > 0); |
| 331 | } |
| 332 | |
| 333 | export function splitNullSeparatedGitStdoutPaths( |
| 334 | result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">, |
| 335 | ): string[] { |
| 336 | return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); |
| 337 | } |
| 338 | |
| 339 | function sanitizeRemoteName(value: string): string { |
| 340 | const sanitized = value |
| 341 | .trim() |
| 342 | .replace(/[^A-Za-z0-9._-]+/g, "-") |
| 343 | .replace(/^-+|-+$/g, ""); |
| 344 | return sanitized.length > 0 ? sanitized : "fork"; |
| 345 | } |
| 346 | |
| 347 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { |
| 348 | const remotes = new Map<string, string>(); |
| 349 | for (const line of stdout.split("\n")) { |
| 350 | const trimmed = line.trim(); |
| 351 | if (trimmed.length === 0) continue; |
| 352 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); |
| 353 | if (!match) continue; |
| 354 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; |
| 355 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { |
| 356 | continue; |
| 357 | } |
| 358 | remotes.set(remoteName, remoteUrl); |
| 359 | } |
| 360 | return remotes; |
| 361 | } |
| 362 | |
| 363 | function parseUpstreamRefWithRemoteNames( |
| 364 | upstreamRef: string, |
| 365 | remoteNames: ReadonlyArray<string>, |
| 366 | ): { upstreamRef: string; remoteName: string; branchName: string } | null { |
| 367 | const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); |
| 368 | if (!parsed) { |
| 369 | return null; |
| 370 | } |
| 371 | |
| 372 | return { |
| 373 | upstreamRef, |
| 374 | remoteName: parsed.remoteName, |
| 375 | branchName: parsed.branchName, |
| 376 | }; |
| 377 | } |
| 378 | |
| 379 | function parseUpstreamRefByFirstSeparator( |
| 380 | upstreamRef: string, |
| 381 | ): { upstreamRef: string; remoteName: string; branchName: string } | null { |
| 382 | const separatorIndex = upstreamRef.indexOf("/"); |
| 383 | if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { |
| 384 | return null; |
| 385 | } |
| 386 | |
| 387 | const remoteName = upstreamRef.slice(0, separatorIndex).trim(); |
| 388 | const branchName = upstreamRef.slice(separatorIndex + 1).trim(); |
| 389 | if (remoteName.length === 0 || branchName.length === 0) { |
| 390 | return null; |
| 391 | } |
| 392 | |
| 393 | return { |
| 394 | upstreamRef, |
| 395 | remoteName, |
| 396 | branchName, |
| 397 | }; |
| 398 | } |
| 399 | |
| 400 | function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { |
| 401 | for (const line of stdout.split("\n")) { |
| 402 | const trimmedLine = line.trim(); |
| 403 | if (trimmedLine.length === 0) { |
| 404 | continue; |
| 405 | } |
| 406 | const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); |
| 407 | const branchName = branchNameRaw?.trim() ?? ""; |
| 408 | const candidateUpstreamRef = upstreamBranchRaw.trim(); |
| 409 | if (branchName.length === 0 || candidateUpstreamRef.length === 0) { |
| 410 | continue; |
| 411 | } |
| 412 | if (candidateUpstreamRef === upstreamRef) { |
| 413 | return branchName; |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | return null; |
| 418 | } |
| 419 | |
| 420 | function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { |
| 421 | const separatorIndex = branchName.indexOf("/"); |
| 422 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { |
| 423 | return null; |
| 424 | } |
| 425 | const localBranch = branchName.slice(separatorIndex + 1).trim(); |
| 426 | return localBranch.length > 0 ? localBranch : null; |
| 427 | } |
| 428 | |
| 429 | function gitCommandContext( |
| 430 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 431 | ) { |
| 432 | return { |
| 433 | operation: input.operation, |
| 434 | command: "git", |
| 435 | cwd: input.cwd, |
| 436 | argumentCount: input.args.length, |
| 437 | } as const; |
| 438 | } |
| 439 | |
| 440 | // Git states the actual cause on stderr, but stderr never leaves this module: |
| 441 | // it echoes argv and remote URLs, which can carry credentials. Matching it |
| 442 | // against fixed patterns yields a tag the caller can act on and a message that |
| 443 | // quotes none of the matched text. Patterns run in order, specific first. |
| 444 | const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray<readonly [RegExp, GitCommandFailureReason]> = [ |
| 445 | [/would clobber existing tag/i, "tag_would_be_clobbered"], |
| 446 | [/is already (?:used by worktree at|checked out at)/i, "branch_checked_out_in_worktree"], |
| 447 | [/a branch named .+ already exists/i, "branch_already_exists"], |
| 448 | // ssh names the methods it tried, so the parenthesized list varies: |
| 449 | // `(publickey)`, `(publickey,password)`, `(keyboard-interactive)`. |
| 450 | [ |
| 451 | /(?:authentication failed|could not read Username|could not read Password|permission denied \([a-z-]+(?:,[a-z-]+)*\)|permission denied, please try again)/i, |
| 452 | "authentication_failed", |
| 453 | ], |
| 454 | // Distinct from a credential failure: the remote answered and the key it |
| 455 | // presented is untrusted, so pointing the user at credentials would misdirect. |
| 456 | [/host key verification failed/i, "host_key_unverified"], |
| 457 | [ |
| 458 | /(?:could not read from remote repository|does not appear to be a git repository|repository .+ not found)/i, |
| 459 | "remote_unreachable", |
| 460 | ], |
| 461 | // Quoted-path forms only: an unquoted `fatal: <thing> already exists` also |
| 462 | // covers tag and ref collisions, which are not path collisions. |
| 463 | [/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"], |
| 464 | ]; |
| 465 | |
| 466 | // Hooks write to the same stream git does, and nothing distinguishes their |
| 467 | // text from git's: a pre-push hook echoing "authentication failed" would |
| 468 | // otherwise be read as a credential failure. Git prefixes its own diagnostics |
| 469 | // and states a push rejection on a `! [rejected]` line, so only those are |
| 470 | // classified. `remote:` is deliberately excluded — git prefixes every byte the |
| 471 | // server sends that way, remote hook output included, so trusting it would |
| 472 | // reintroduce the same false positive from the other end of the connection. |
| 473 | const GIT_DIAGNOSTIC_LINE_PATTERN = /^(?:fatal|error):|^!\s|^\s+!\s/; |
| 474 | // ssh reports the refusal itself, unprefixed, and git only adds a generic |
| 475 | // "Could not read from remote repository" after it. Dropping ssh's line would |
| 476 | // leave that generic one to be read as an unreachable remote when the real |
| 477 | // cause is credentials, so these specific refusals are classified too. |
| 478 | const SSH_TRANSPORT_REFUSAL_PATTERN = |
| 479 | /Permission denied \((?:publickey|password|keyboard-interactive)|Permission denied, please try again|Host key verification failed/i; |
| 480 | |
| 481 | function classifyGitFailure(stderr: string): GitCommandFailureReason | null { |
| 482 | const diagnostics = stderr |
| 483 | .split(/\r?\n/) |
| 484 | .filter( |
| 485 | (line) => |
| 486 | GIT_DIAGNOSTIC_LINE_PATTERN.test(line) || |
| 487 | // A remote hook can echo ssh's wording; only the local ssh's line counts. |
| 488 | (!/^remote:/.test(line) && SSH_TRANSPORT_REFUSAL_PATTERN.test(line)), |
| 489 | ) |
| 490 | .join("\n"); |
| 491 | if (diagnostics.length === 0) return null; |
| 492 | if (isNonRepositoryGitStderr(diagnostics)) return "not_a_repository"; |
| 493 | for (const [pattern, reason] of GIT_FAILURE_REASON_PATTERNS) { |
| 494 | if (pattern.test(diagnostics)) return reason; |
| 495 | } |
| 496 | return null; |
| 497 | } |
| 498 | |
| 499 | function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { |
| 500 | const trimmed = value.trim(); |
| 501 | const prefix = `refs/remotes/${remoteName}/`; |
| 502 | if (!trimmed.startsWith(prefix)) { |
| 503 | return null; |
| 504 | } |
| 505 | const refName = trimmed.slice(prefix.length).trim(); |
| 506 | return refName.length > 0 ? refName : null; |
| 507 | } |
| 508 | |
| 509 | function isMissingGitCwdError(error: GitCommandError): boolean { |
| 510 | if (!(error.cause instanceof PlatformError.PlatformError)) { |
| 511 | return false; |
| 512 | } |
| 513 | |
| 514 | const reason = error.cause.reason; |
| 515 | if (reason._tag === "NotFound") { |
| 516 | return reason.pathOrDescriptor === error.cwd; |
| 517 | } |
| 518 | |
| 519 | return ( |
| 520 | reason._tag === "BadResource" && |
| 521 | reason.pathOrDescriptor === error.cwd && |
| 522 | typeof reason.cause === "object" && |
| 523 | reason.cause !== null && |
| 524 | "code" in reason.cause && |
| 525 | reason.cause.code === "ENOTDIR" |
| 526 | ); |
| 527 | } |
| 528 | |
| 529 | function isNonRepositoryGitStderr(stderr: string): boolean { |
| 530 | return stderr.toLowerCase().includes("not a git repository"); |
| 531 | } |
| 532 | function isUnbornHeadStderr(stderr: string): boolean { |
| 533 | const normalized = stderr.toLowerCase(); |
| 534 | return ( |
| 535 | normalized.includes("bad revision 'head'") || |
| 536 | (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) |
| 537 | ); |
| 538 | } |
| 539 | |
| 540 | // Matches `git worktree remove` on a path git no longer tracks: "is not a |
| 541 | // working tree" when the registration is gone, "cannot remove working tree" |
| 542 | // when older gits fail validation on a registered-but-deleted directory. |
| 543 | function isMissingWorktreeStderr(stderr: string): boolean { |
| 544 | const normalized = stderr.toLowerCase(); |
| 545 | return ( |
| 546 | normalized.includes("is not a working tree") || |
| 547 | normalized.includes("cannot remove working tree") |
| 548 | ); |
| 549 | } |
| 550 | |
| 551 | // Fetch stderr can contain remote credentials. Only fixed diagnoses may enter |
| 552 | // persisted errors; unrecognized output keeps the generic failure message. |
| 553 | function fetchFailureDetail(stderr: string): string | undefined { |
| 554 | const lines = stderr.split(/\r?\n/).map((line) => line.trim()); |
| 555 | if ( |
| 556 | lines.some((line) => |
| 557 | /^(?:fatal: (?:Authentication failed|could not read (?:Username|Password))\b|\S+: Permission denied \(publickey)/i.test( |
| 558 | line, |
| 559 | ), |
| 560 | ) |
| 561 | ) { |
| 562 | return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; |
| 563 | } |
| 564 | if ( |
| 565 | lines.some((line) => |
| 566 | /^(?:(?:fatal: |ssh: )?Could not resolve host(?:name)?\b|fatal: unable to access .+: (?:Could not resolve host|Failed to connect)\b|ssh: connect to host \S+ port \d+: (?:Connection timed out|Connection refused|Network is unreachable)\b)/i.test( |
| 567 | line, |
| 568 | ), |
| 569 | ) |
| 570 | ) { |
| 571 | return "Git could not reach the remote. Check the server's network connection and remote host, then retry."; |
| 572 | } |
| 573 | if ( |
| 574 | lines.some((line) => |
| 575 | /^(?:remote: Repository not found\.?$|fatal: repository .+ not found$|fatal: .+ does not appear to be a git repository$)/i.test( |
| 576 | line, |
| 577 | ), |
| 578 | ) |
| 579 | ) { |
| 580 | return "Git could not access the remote repository. Check the remote URL and repository permissions on the server."; |
| 581 | } |
| 582 | if (isRefLockFailure(stderr)) { |
| 583 | return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry."; |
| 584 | } |
| 585 | return undefined; |
| 586 | } |
| 587 | |
| 588 | // Matches a fetch that lost a ref update to another Git process. Git 2.53+ |
| 589 | // reports it as "fetching ref <ref> failed: ..."; older Git says "cannot lock ref". |
| 590 | function isRefLockFailure(stderr: string): boolean { |
| 591 | return stderr |
| 592 | .split(/\r?\n/) |
| 593 | .some((line) => |
| 594 | /^(?:(?:error|fatal): cannot lock ref\b|fatal: Unable to create ['"].+\.lock['"]:|error: fetching ref \S+ failed: (?:incorrect old value provided|reference already exists)$)/i.test( |
| 595 | line.trim(), |
| 596 | ), |
| 597 | ); |
| 598 | } |
| 599 | |
| 600 | interface Trace2Monitor { |
| 601 | readonly env: NodeJS.ProcessEnv; |
| 602 | readonly flush: Effect.Effect<void, never>; |
| 603 | } |
| 604 | |
| 605 | const nowUnixNano = DateTime.now.pipe( |
| 606 | Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), |
| 607 | ); |
| 608 | |
| 609 | const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) => |
| 610 | Effect.gen(function* () { |
| 611 | const span = yield* Effect.currentSpan; |
| 612 | const timestamp = yield* nowUnixNano; |
| 613 | yield* Effect.sync(() => { |
| 614 | span.event(name, timestamp, compactTraceAttributes(attributes)); |
| 615 | }); |
| 616 | }).pipe( |
| 617 | Effect.catchTags({ |
| 618 | NoSuchElementError: () => Effect.void, |
| 619 | }), |
| 620 | ); |
| 621 | |
| 622 | function trace2ChildKey(record: Record<string, unknown>): string | null { |
| 623 | const childId = record.child_id; |
| 624 | if (typeof childId === "number" || typeof childId === "string") { |
| 625 | return String(childId); |
| 626 | } |
| 627 | const hookName = record.hook_name; |
| 628 | return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; |
| 629 | } |
| 630 | |
| 631 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); |
| 632 | const decodeTrace2Record = decodeJsonResult(Trace2Record); |
| 633 | |
| 634 | // Untraced because it runs on every git spawn and returns at once without hook |
| 635 | // callbacks. Its errors fail the runGitCommand span. |
| 636 | const createTrace2Monitor = Effect.fnUntraced(function* ( |
| 637 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 638 | progress: GitVcsDriver.ExecuteGitProgress | undefined, |
| 639 | ): Effect.fn.Return< |
| 640 | Trace2Monitor, |
| 641 | PlatformError.PlatformError, |
| 642 | Scope.Scope | FileSystem.FileSystem | Path.Path |
| 643 | > { |
| 644 | if (!progress?.onHookStarted && !progress?.onHookFinished) { |
| 645 | return { |
| 646 | env: {}, |
| 647 | flush: Effect.void, |
| 648 | }; |
| 649 | } |
| 650 | |
| 651 | const fs = yield* FileSystem.FileSystem; |
| 652 | const path = yield* Path.Path; |
| 653 | const traceFilePath = yield* fs.makeTempFileScoped({ |
| 654 | prefix: `t3code-git-trace2-${process.pid}-`, |
| 655 | suffix: ".json", |
| 656 | }); |
| 657 | const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>(); |
| 658 | const traceTailState = yield* Ref.make<TraceTailState>({ |
| 659 | processedChars: 0, |
| 660 | remainder: "", |
| 661 | }); |
| 662 | |
| 663 | const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { |
| 664 | const trimmedLine = line.trim(); |
| 665 | if (trimmedLine.length === 0) { |
| 666 | return; |
| 667 | } |
| 668 | |
| 669 | const traceRecord = decodeTrace2Record(trimmedLine); |
| 670 | if (Result.isFailure(traceRecord)) { |
| 671 | yield* Effect.logDebug( |
| 672 | `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, |
| 673 | traceRecord.failure, |
| 674 | ); |
| 675 | return; |
| 676 | } |
| 677 | |
| 678 | if (traceRecord.success.child_class !== "hook") { |
| 679 | return; |
| 680 | } |
| 681 | |
| 682 | const event = traceRecord.success.event; |
| 683 | const childKey = trace2ChildKey(traceRecord.success); |
| 684 | if (childKey === null) { |
| 685 | return; |
| 686 | } |
| 687 | const started = hookStartByChildKey.get(childKey); |
| 688 | const hookNameFromEvent = |
| 689 | typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; |
| 690 | const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); |
| 691 | if (hookName.length === 0) { |
| 692 | return; |
| 693 | } |
| 694 | |
| 695 | if (event === "child_start") { |
| 696 | const now = yield* DateTime.now; |
| 697 | hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); |
| 698 | yield* addCurrentSpanEvent("git.hook.started", { |
| 699 | hookName, |
| 700 | }); |
| 701 | if (progress.onHookStarted) { |
| 702 | yield* progress.onHookStarted(hookName); |
| 703 | } |
| 704 | return; |
| 705 | } |
| 706 | |
| 707 | if (event === "child_exit") { |
| 708 | hookStartByChildKey.delete(childKey); |
| 709 | const code = traceRecord.success.exitCode; |
| 710 | const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; |
| 711 | const now = yield* DateTime.now; |
| 712 | const durationMs = started |
| 713 | ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) |
| 714 | : null; |
| 715 | yield* addCurrentSpanEvent("git.hook.finished", { |
| 716 | hookName: started?.hookName ?? hookName, |
| 717 | exitCode, |
| 718 | durationMs, |
| 719 | }); |
| 720 | if (progress.onHookFinished) { |
| 721 | yield* progress.onHookFinished({ |
| 722 | hookName: started?.hookName ?? hookName, |
| 723 | exitCode, |
| 724 | durationMs, |
| 725 | }); |
| 726 | } |
| 727 | } |
| 728 | }); |
| 729 | |
| 730 | const deltaMutex = yield* Semaphore.make(1); |
| 731 | const readTraceDelta = deltaMutex.withPermit( |
| 732 | fs.readFileString(traceFilePath).pipe( |
| 733 | Effect.flatMap((contents) => |
| 734 | Effect.uninterruptible( |
| 735 | Ref.modify(traceTailState, ({ processedChars, remainder }) => { |
| 736 | if (contents.length <= processedChars) { |
| 737 | return [[], { processedChars, remainder }]; |
| 738 | } |
| 739 | |
| 740 | const appended = contents.slice(processedChars); |
| 741 | const combined = remainder + appended; |
| 742 | const lines = combined.split("\n"); |
| 743 | const nextRemainder = lines.pop() ?? ""; |
| 744 | |
| 745 | return [ |
| 746 | lines.map((line) => line.replace(/\r$/, "")), |
| 747 | { |
| 748 | processedChars: contents.length, |
| 749 | remainder: nextRemainder, |
| 750 | }, |
| 751 | ]; |
| 752 | }).pipe( |
| 753 | Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), |
| 754 | ), |
| 755 | ), |
| 756 | ), |
| 757 | Effect.ignore({ log: true }), |
| 758 | ), |
| 759 | ); |
| 760 | const traceFileName = path.basename(traceFilePath); |
| 761 | yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { |
| 762 | const eventPath = event.path; |
| 763 | const isTargetTraceEvent = |
| 764 | eventPath === traceFilePath || |
| 765 | eventPath === traceFileName || |
| 766 | path.basename(eventPath) === traceFileName; |
| 767 | if (!isTargetTraceEvent) return Effect.void; |
| 768 | return readTraceDelta; |
| 769 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); |
| 770 | |
| 771 | const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { |
| 772 | yield* readTraceDelta; |
| 773 | const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ |
| 774 | remainder.trim(), |
| 775 | { |
| 776 | processedChars, |
| 777 | remainder: "", |
| 778 | }, |
| 779 | ]); |
| 780 | if (finalLine.length > 0) { |
| 781 | yield* handleTraceLine(finalLine); |
| 782 | } |
| 783 | }); |
| 784 | |
| 785 | yield* Effect.addFinalizer(finalizeTrace2Monitor); |
| 786 | |
| 787 | return { |
| 788 | env: { |
| 789 | GIT_TRACE2_EVENT: traceFilePath, |
| 790 | }, |
| 791 | flush: readTraceDelta, |
| 792 | }; |
| 793 | }); |
| 794 | |
| 795 | const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; |
| 796 | |
| 797 | /** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ |
| 798 | export function parseGitCheckoutProgressLine( |
| 799 | line: string, |
| 800 | ): { percent: number; completed: number; total: number } | null { |
| 801 | const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); |
| 802 | if (!match) return null; |
| 803 | const percent = Number(match[1]); |
| 804 | const completed = Number(match[2]); |
| 805 | const total = Number(match[3]); |
| 806 | if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { |
| 807 | return null; |
| 808 | } |
| 809 | return { percent: Math.max(0, Math.min(100, percent)), completed, total }; |
| 810 | } |
| 811 | |
| 812 | const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; |
| 813 | |
| 814 | const collectOutput = Effect.fnUntraced(function* ( |
| 815 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| 816 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, |
| 817 | maxOutputBytes: number, |
| 818 | appendTruncationMarker: boolean, |
| 819 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, |
| 820 | keepLineCallbacksAfterTruncation = false, |
| 821 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { |
| 822 | const decoder = new TextDecoder(); |
| 823 | // With callbacks continuing past the cap, lines are decoded by their own |
| 824 | // decoder from the first byte so no character is ever split at the cap. |
| 825 | const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; |
| 826 | let bytes = 0; |
| 827 | let text = ""; |
| 828 | let lineBuffer = ""; |
| 829 | let truncated = false; |
| 830 | // A separator-free stream past the cap must not grow the line buffer |
| 831 | // without bound; a line longer than this is not one the callbacks want. |
| 832 | const maxPendingLineBytes = 64 * 1024; |
| 833 | |
| 834 | // Git redraws progress with a bare `\r` between updates and only ends the |
| 835 | // line once the step is done, so `\r` has to count as a line break here. |
| 836 | const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { |
| 837 | let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); |
| 838 | while (separator) { |
| 839 | const line = lineBuffer.slice(0, separator.index); |
| 840 | lineBuffer = lineBuffer.slice(separator.index + separator[0].length); |
| 841 | if (line.length > 0 && onLine) { |
| 842 | yield* onLine(line); |
| 843 | } |
| 844 | separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); |
| 845 | } |
| 846 | |
| 847 | if (flush) { |
| 848 | const trailing = lineBuffer; |
| 849 | lineBuffer = ""; |
| 850 | if (trailing.length > 0 && onLine) { |
| 851 | yield* onLine(trailing); |
| 852 | } |
| 853 | } |
| 854 | }); |
| 855 | |
| 856 | const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { |
| 857 | if (appendTruncationMarker && truncated) { |
| 858 | if (lineDecoder) { |
| 859 | lineBuffer += lineDecoder.decode(chunk, { stream: true }); |
| 860 | yield* emitCompleteLines(false); |
| 861 | if (lineBuffer.length > maxPendingLineBytes) lineBuffer = ""; |
| 862 | } |
| 863 | return; |
| 864 | } |
| 865 | const nextBytes = bytes + chunk.byteLength; |
| 866 | if (!appendTruncationMarker && nextBytes > maxOutputBytes) { |
| 867 | return yield* new GitCommandError({ |
| 868 | ...gitCommandContext(input), |
| 869 | detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`, |
| 870 | outputLength: nextBytes, |
| 871 | }); |
| 872 | } |
| 873 | |
| 874 | const chunkToDecode = |
| 875 | appendTruncationMarker && nextBytes > maxOutputBytes |
| 876 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) |
| 877 | : chunk; |
| 878 | bytes += chunkToDecode.byteLength; |
| 879 | truncated = appendTruncationMarker && nextBytes > maxOutputBytes; |
| 880 | |
| 881 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); |
| 882 | text += decoded; |
| 883 | lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; |
| 884 | yield* emitCompleteLines(false); |
| 885 | }); |
| 886 | |
| 887 | yield* Stream.runForEach(stream, processChunk).pipe( |
| 888 | Effect.catchTags({ |
| 889 | PlatformError: (cause) => |
| 890 | new GitCommandError({ |
| 891 | ...gitCommandContext(input), |
| 892 | detail: "Failed to read Git process output.", |
| 893 | cause, |
| 894 | }), |
| 895 | }), |
| 896 | ); |
| 897 | |
| 898 | const remainder = truncated ? "" : decoder.decode(); |
| 899 | text += remainder; |
| 900 | lineBuffer += remainder; |
| 901 | if (lineDecoder) lineBuffer += lineDecoder.decode(); |
| 902 | yield* emitCompleteLines(true); |
| 903 | return { |
| 904 | text, |
| 905 | truncated, |
| 906 | }; |
| 907 | }); |
| 908 | |
| 909 | /** |
| 910 | * Git on Windows cannot create or delete a path longer than MAX_PATH (260) |
| 911 | * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not |
| 912 | * cover it, and git leaves it off by default, so deep worktrees fail to check |
| 913 | * out and fail midway through removal. |
| 914 | * |
| 915 | * Passed through `GIT_CONFIG_*` rather than `-c` so argv stays the same on every |
| 916 | * platform and nothing is written to the user's config. The entry is appended |
| 917 | * after any inherited ones. Windows env names are case-insensitive, so the |
| 918 | * inherited count is found regardless of casing and updated under its own name. |
| 919 | * A count git would reject is left alone so git still reports it. |
| 920 | */ |
| 921 | export const windowsLongPathConfigEnv = ( |
| 922 | platform: NodeJS.Platform, |
| 923 | env: NodeJS.ProcessEnv, |
| 924 | ): NodeJS.ProcessEnv => { |
| 925 | if (platform !== "win32") { |
| 926 | return {}; |
| 927 | } |
| 928 | const countKey = |
| 929 | Object.keys(env).find((key) => key.toUpperCase() === "GIT_CONFIG_COUNT") ?? "GIT_CONFIG_COUNT"; |
| 930 | const inherited = env[countKey]; |
| 931 | if ( |
| 932 | inherited !== undefined && |
| 933 | inherited !== "" && |
| 934 | // Git parses the count with strtoul: leading whitespace, an optional sign |
| 935 | // (only -0 is non-negative), digits, and nothing after them. |
| 936 | /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited |
| 937 | ) { |
| 938 | return {}; |
| 939 | } |
| 940 | const count = inherited === undefined || inherited === "" ? 0 : Number.parseInt(inherited, 10); |
| 941 | return { |
| 942 | [countKey]: String(count + 1), |
| 943 | [`GIT_CONFIG_KEY_${count}`]: "core.longpaths", |
| 944 | [`GIT_CONFIG_VALUE_${count}`]: "true", |
| 945 | }; |
| 946 | }; |
| 947 | |
| 948 | export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { |
| 949 | const fileSystem = yield* FileSystem.FileSystem; |
| 950 | const path = yield* Path.Path; |
| 951 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 952 | const { worktreesDir } = yield* ServerConfig.ServerConfig; |
| 953 | const crypto = yield* Crypto.Crypto; |
| 954 | const hostPlatform = yield* HostProcess.Platform; |
| 955 | |
| 956 | const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( |
| 957 | function* (input) { |
| 958 | const commandInput = { |
| 959 | ...input, |
| 960 | args: [...input.args], |
| 961 | } as const; |
| 962 | const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs; |
| 963 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 964 | const appendTruncationMarker = input.appendTruncationMarker ?? false; |
| 965 | |
| 966 | const runGitCommand = Effect.fn("runGitCommand")(function* () { |
| 967 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( |
| 968 | Effect.provideService(Path.Path, path), |
| 969 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 970 | Effect.mapError( |
| 971 | (cause) => |
| 972 | new GitCommandError({ |
| 973 | ...gitCommandContext(commandInput), |
| 974 | detail: "Failed to create Git trace monitor.", |
| 975 | cause, |
| 976 | }), |
| 977 | ), |
| 978 | ); |
| 979 | const env = { |
| 980 | ...process.env, |
| 981 | // Status polling runs beside the user's own git commands; without this, |
| 982 | // `git status` takes index.lock to save its refreshed index. |
| 983 | GIT_OPTIONAL_LOCKS: "0", |
| 984 | ...input.env, |
| 985 | ...trace2Monitor.env, |
| 986 | }; |
| 987 | const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; |
| 988 | const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); |
| 989 | // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit |
| 990 | // messages at the first newline; leave that case to Node's lookup. |
| 991 | const executable = resolved.shell ? "git" : resolved.command; |
| 992 | const child = yield* commandSpawner |
| 993 | .spawn( |
| 994 | ChildProcess.make(executable, commandInput.args, { |
| 995 | cwd: commandInput.cwd, |
| 996 | env: spawnEnv, |
| 997 | }), |
| 998 | ) |
| 999 | .pipe( |
| 1000 | Effect.mapError( |
| 1001 | (cause) => |
| 1002 | new GitCommandError({ |
| 1003 | ...gitCommandContext(commandInput), |
| 1004 | detail: "Failed to spawn Git process.", |
| 1005 | cause, |
| 1006 | }), |
| 1007 | ), |
| 1008 | ); |
| 1009 | |
| 1010 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 1011 | [ |
| 1012 | collectOutput( |
| 1013 | commandInput, |
| 1014 | child.stdout, |
| 1015 | maxOutputBytes, |
| 1016 | appendTruncationMarker, |
| 1017 | input.progress?.onStdoutLine, |
| 1018 | input.keepLineCallbacksAfterTruncation, |
| 1019 | ), |
| 1020 | collectOutput( |
| 1021 | commandInput, |
| 1022 | child.stderr, |
| 1023 | maxOutputBytes, |
| 1024 | appendTruncationMarker, |
| 1025 | input.progress?.onStderrLine, |
| 1026 | input.keepLineCallbacksAfterTruncation, |
| 1027 | ), |
| 1028 | child.exitCode.pipe( |
| 1029 | Effect.mapError( |
| 1030 | (cause) => |
| 1031 | new GitCommandError({ |
| 1032 | ...gitCommandContext(commandInput), |
| 1033 | detail: "Failed to read Git process exit code.", |
| 1034 | cause, |
| 1035 | }), |
| 1036 | ), |
| 1037 | ), |
| 1038 | input.stdin === undefined |
| 1039 | ? Effect.void |
| 1040 | : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( |
| 1041 | Effect.mapError( |
| 1042 | (cause) => |
| 1043 | new GitCommandError({ |
| 1044 | ...gitCommandContext(commandInput), |
| 1045 | detail: "Failed to write Git process input.", |
| 1046 | cause, |
| 1047 | }), |
| 1048 | ), |
| 1049 | ), |
| 1050 | ], |
| 1051 | { concurrency: "unbounded" }, |
| 1052 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); |
| 1053 | yield* trace2Monitor.flush; |
| 1054 | |
| 1055 | if (!input.allowNonZeroExit && exitCode !== 0) { |
| 1056 | const reason = classifyGitFailure(stderr.text); |
| 1057 | return yield* new GitCommandError({ |
| 1058 | ...gitCommandContext(commandInput), |
| 1059 | ...(reason === null ? {} : { reason }), |
| 1060 | detail: "Git command exited with a non-zero status.", |
| 1061 | exitCode, |
| 1062 | stdoutLength: stdout.text.length, |
| 1063 | stderrLength: stderr.text.length, |
| 1064 | }); |
| 1065 | } |
| 1066 | |
| 1067 | return { |
| 1068 | exitCode, |
| 1069 | stdout: stdout.text, |
| 1070 | stderr: stderr.text, |
| 1071 | stdoutTruncated: stdout.truncated, |
| 1072 | stderrTruncated: stderr.truncated, |
| 1073 | } satisfies GitVcsDriver.ExecuteGitResult; |
| 1074 | }); |
| 1075 | |
| 1076 | const execution = runGitCommand().pipe(Effect.scoped); |
| 1077 | if (timeoutMs === null) { |
| 1078 | return yield* execution; |
| 1079 | } |
| 1080 | |
| 1081 | return yield* execution.pipe( |
| 1082 | Effect.timeoutOption(timeoutMs), |
| 1083 | Effect.flatMap((result) => |
| 1084 | Effect.fromOption( |
| 1085 | result, |
| 1086 | () => |
| 1087 | new GitCommandError({ |
| 1088 | ...gitCommandContext(commandInput), |
| 1089 | detail: "Git command timed out.", |
| 1090 | }), |
| 1091 | ), |
| 1092 | ), |
| 1093 | ); |
| 1094 | }, |
| 1095 | ); |
| 1096 | |
| 1097 | const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => |
| 1098 | executeRaw(input).pipe( |
| 1099 | withMetrics({ |
| 1100 | counter: gitCommandsTotal, |
| 1101 | timer: gitCommandDuration, |
| 1102 | attributes: { |
| 1103 | operation: input.operation, |
| 1104 | }, |
| 1105 | }), |
| 1106 | (execution) => |
| 1107 | input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS |
| 1108 | ? execution |
| 1109 | : gitProcesses.withPermits(1)(execution), |
| 1110 | Effect.withSpan(input.operation, { |
| 1111 | kind: "client", |
| 1112 | attributes: { |
| 1113 | "git.operation": input.operation, |
| 1114 | "git.cwd": input.cwd, |
| 1115 | "git.args_count": input.args.length, |
| 1116 | }, |
| 1117 | }), |
| 1118 | ); |
| 1119 | |
| 1120 | const executeGit = ( |
| 1121 | operation: string, |
| 1122 | cwd: string, |
| 1123 | args: readonly string[], |
| 1124 | options: ExecuteGitOptions = {}, |
| 1125 | ): Effect.Effect<GitVcsDriver.ExecuteGitResult, GitCommandError> => |
| 1126 | execute({ |
| 1127 | operation, |
| 1128 | cwd, |
| 1129 | args, |
| 1130 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), |
| 1131 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 1132 | allowNonZeroExit: true, |
| 1133 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 1134 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| 1135 | ...(options.appendTruncationMarker !== undefined |
| 1136 | ? { appendTruncationMarker: options.appendTruncationMarker } |
| 1137 | : {}), |
| 1138 | ...(options.progress ? { progress: options.progress } : {}), |
| 1139 | }).pipe( |
| 1140 | Effect.flatMap((result) => { |
| 1141 | if (options.allowNonZeroExit || result.exitCode === 0) { |
| 1142 | return Effect.succeed(result); |
| 1143 | } |
| 1144 | const reason = classifyGitFailure(result.stderr); |
| 1145 | return Effect.fail( |
| 1146 | new GitCommandError({ |
| 1147 | ...gitCommandContext({ operation, cwd, args }), |
| 1148 | ...(reason === null ? {} : { reason }), |
| 1149 | detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", |
| 1150 | ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), |
| 1151 | stdoutLength: result.stdout.length, |
| 1152 | stderrLength: result.stderr.length, |
| 1153 | }), |
| 1154 | ); |
| 1155 | }), |
| 1156 | ); |
| 1157 | |
| 1158 | const executeGitWithStableDiagnostics = ( |
| 1159 | operation: string, |
| 1160 | cwd: string, |
| 1161 | args: readonly string[], |
| 1162 | options: ExecuteGitOptions = {}, |
| 1163 | ): Effect.Effect<GitVcsDriver.ExecuteGitResult, GitCommandError> => |
| 1164 | executeGit(operation, cwd, args, { |
| 1165 | ...options, |
| 1166 | env: { |
| 1167 | ...options.env, |
| 1168 | LC_ALL: "C", |
| 1169 | }, |
| 1170 | }); |
| 1171 | |
| 1172 | const runGit = ( |
| 1173 | operation: string, |
| 1174 | cwd: string, |
| 1175 | args: readonly string[], |
| 1176 | options: ExecuteGitOptions = {}, |
| 1177 | ): Effect.Effect<void, GitCommandError> => |
| 1178 | executeGit(operation, cwd, args, options).pipe(Effect.asVoid); |
| 1179 | |
| 1180 | const runGitStdout = ( |
| 1181 | operation: string, |
| 1182 | cwd: string, |
| 1183 | args: readonly string[], |
| 1184 | allowNonZeroExit = false, |
| 1185 | ): Effect.Effect<string, GitCommandError> => |
| 1186 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( |
| 1187 | Effect.map((result) => result.stdout), |
| 1188 | ); |
| 1189 | |
| 1190 | /** |
| 1191 | * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run |
| 1192 | * retries the line form and returns it NUL-delimited, the same shape as `-z` output. |
| 1193 | * The line form cannot carry paths that contain newlines. |
| 1194 | */ |
| 1195 | const executeWorktreeList = Effect.fn("executeWorktreeList")(function* ( |
| 1196 | operation: string, |
| 1197 | cwd: string, |
| 1198 | gitArgs: readonly string[], |
| 1199 | options: ExecuteGitOptions = {}, |
| 1200 | ) { |
| 1201 | const args = [...gitArgs, "worktree", "list", "--porcelain"]; |
| 1202 | const result = yield* executeGit(operation, cwd, [...args, "-z"], { |
| 1203 | ...options, |
| 1204 | allowNonZeroExit: true, |
| 1205 | }); |
| 1206 | if (result.exitCode === 0) return result; |
| 1207 | const lineResult = yield* executeGit(operation, cwd, args, options); |
| 1208 | return { ...lineResult, stdout: lineResult.stdout.replaceAll("\n", "\0") }; |
| 1209 | }); |
| 1210 | |
| 1211 | const runGitStdoutWithOptions = ( |
| 1212 | operation: string, |
| 1213 | cwd: string, |
| 1214 | args: readonly string[], |
| 1215 | options: ExecuteGitOptions = {}, |
| 1216 | ): Effect.Effect<string, GitCommandError> => |
| 1217 | executeGit(operation, cwd, args, options).pipe( |
| 1218 | Effect.map((result) => |
| 1219 | result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, |
| 1220 | ), |
| 1221 | ); |
| 1222 | |
| 1223 | const branchExists = (cwd: string, refName: string): Effect.Effect<boolean, GitCommandError> => |
| 1224 | executeGit( |
| 1225 | "GitVcsDriver.branchExists", |
| 1226 | cwd, |
| 1227 | ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], |
| 1228 | { |
| 1229 | allowNonZeroExit: true, |
| 1230 | timeoutMs: 5_000, |
| 1231 | }, |
| 1232 | ).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1233 | |
| 1234 | const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( |
| 1235 | cwd: string, |
| 1236 | desiredBranch: string, |
| 1237 | ) { |
| 1238 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); |
| 1239 | if (!isDesiredTaken) { |
| 1240 | return desiredBranch; |
| 1241 | } |
| 1242 | |
| 1243 | for (let suffix = 1; suffix <= 100; suffix += 1) { |
| 1244 | const candidate = `${desiredBranch}-${suffix}`; |
| 1245 | const isCandidateTaken = yield* branchExists(cwd, candidate); |
| 1246 | if (!isCandidateTaken) { |
| 1247 | return candidate; |
| 1248 | } |
| 1249 | } |
| 1250 | |
| 1251 | return yield* new GitCommandError({ |
| 1252 | ...gitCommandContext({ |
| 1253 | operation: "GitVcsDriver.renameBranch", |
| 1254 | cwd, |
| 1255 | args: ["branch", "-m", "--", desiredBranch], |
| 1256 | }), |
| 1257 | detail: `Could not find an available branch name for '${desiredBranch}'.`, |
| 1258 | }); |
| 1259 | }); |
| 1260 | |
| 1261 | const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { |
| 1262 | const upstreamRef = yield* runGitStdout( |
| 1263 | "GitVcsDriver.resolveCurrentUpstream", |
| 1264 | cwd, |
| 1265 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], |
| 1266 | true, |
| 1267 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 1268 | |
| 1269 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { |
| 1270 | return null; |
| 1271 | } |
| 1272 | |
| 1273 | const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( |
| 1274 | Effect.map(parseRemoteNames), |
| 1275 | Effect.orElseSucceed((): ReadonlyArray<string> => []), |
| 1276 | ); |
| 1277 | return ( |
| 1278 | parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? |
| 1279 | parseUpstreamRefByFirstSeparator(upstreamRef) |
| 1280 | ); |
| 1281 | }); |
| 1282 | |
| 1283 | const fetchRemoteForStatus = ( |
| 1284 | gitCommonDir: string, |
| 1285 | remoteName: string, |
| 1286 | ): Effect.Effect<void, GitCommandError> => { |
| 1287 | const fetchCwd = |
| 1288 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| 1289 | // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps |
| 1290 | // this poll from starting `git gc --auto`. When that gc fails, for example on a repository |
| 1291 | // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` |
| 1292 | // behind each time, so a background poll could fill the disk. |
| 1293 | return executeGit( |
| 1294 | "GitVcsDriver.fetchRemoteForStatus", |
| 1295 | fetchCwd, |
| 1296 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], |
| 1297 | { |
| 1298 | env: STATUS_UPSTREAM_REFRESH_ENV, |
| 1299 | fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", |
| 1300 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), |
| 1301 | }, |
| 1302 | ).pipe(Effect.asVoid); |
| 1303 | }; |
| 1304 | |
| 1305 | const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( |
| 1306 | cwd: string, |
| 1307 | ) { |
| 1308 | const commonDirResult = yield* executeGitWithStableDiagnostics( |
| 1309 | "GitVcsDriver.resolveRepositoryPaths.commonDir", |
| 1310 | cwd, |
| 1311 | ["rev-parse", "--git-common-dir"], |
| 1312 | { |
| 1313 | timeoutMs: 5_000, |
| 1314 | allowNonZeroExit: true, |
| 1315 | }, |
| 1316 | ); |
| 1317 | if (commonDirResult.exitCode !== 0) { |
| 1318 | const stderr = commonDirResult.stderr.trim(); |
| 1319 | if (isNonRepositoryGitStderr(stderr)) { |
| 1320 | return null; |
| 1321 | } |
| 1322 | return yield* new GitCommandError({ |
| 1323 | ...gitCommandContext({ |
| 1324 | operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", |
| 1325 | cwd, |
| 1326 | args: ["rev-parse", "--git-common-dir"], |
| 1327 | }), |
| 1328 | detail: "Failed to resolve the Git common directory.", |
| 1329 | exitCode: commonDirResult.exitCode, |
| 1330 | stdoutLength: commonDirResult.stdout.length, |
| 1331 | stderrLength: commonDirResult.stderr.length, |
| 1332 | }); |
| 1333 | } |
| 1334 | |
| 1335 | const commonDirOutput = commonDirResult.stdout.trim(); |
| 1336 | const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) |
| 1337 | ? path.normalize(commonDirOutput) |
| 1338 | : path.resolve(cwd, commonDirOutput); |
| 1339 | const gitCommonDir = yield* fileSystem |
| 1340 | .realPath(resolvedGitCommonDir) |
| 1341 | .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); |
| 1342 | const [worktreeRootResult, currentBranchResult] = yield* Effect.all( |
| 1343 | [ |
| 1344 | executeGit( |
| 1345 | "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", |
| 1346 | cwd, |
| 1347 | ["rev-parse", "--show-toplevel"], |
| 1348 | { |
| 1349 | timeoutMs: 5_000, |
| 1350 | allowNonZeroExit: true, |
| 1351 | }, |
| 1352 | ), |
| 1353 | executeGit( |
| 1354 | "GitVcsDriver.resolveRepositoryPaths.currentBranch", |
| 1355 | cwd, |
| 1356 | ["symbolic-ref", "--quiet", "--short", "HEAD"], |
| 1357 | { |
| 1358 | timeoutMs: 5_000, |
| 1359 | allowNonZeroExit: true, |
| 1360 | }, |
| 1361 | ), |
| 1362 | ], |
| 1363 | { concurrency: 2 }, |
| 1364 | ); |
| 1365 | const worktreeRootOutput = worktreeRootResult.stdout.trim(); |
| 1366 | const worktreeRoot = |
| 1367 | worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 |
| 1368 | ? path.normalize( |
| 1369 | path.isAbsolute(worktreeRootOutput) |
| 1370 | ? worktreeRootOutput |
| 1371 | : path.resolve(cwd, worktreeRootOutput), |
| 1372 | ) |
| 1373 | : null; |
| 1374 | const currentBranchOutput = currentBranchResult.stdout.trim(); |
| 1375 | const currentBranch = |
| 1376 | currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 |
| 1377 | ? currentBranchOutput |
| 1378 | : null; |
| 1379 | |
| 1380 | return { |
| 1381 | gitCommonDir, |
| 1382 | worktreeRoot, |
| 1383 | currentBranch, |
| 1384 | } satisfies GitRepositoryPaths; |
| 1385 | }); |
| 1386 | |
| 1387 | const repositoryPathsCache = yield* Cache.makeWith( |
| 1388 | (cwd: string) => resolveRepositoryPathsUncached(cwd), |
| 1389 | { |
| 1390 | capacity: REPOSITORY_PATHS_CACHE_CAPACITY, |
| 1391 | timeToLive: Exit.match({ |
| 1392 | onSuccess: (repositoryPaths) => |
| 1393 | repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL, |
| 1394 | onFailure: () => Duration.zero, |
| 1395 | }), |
| 1396 | }, |
| 1397 | ); |
| 1398 | const repositoryPathsRefreshCache = yield* Cache.makeWith( |
| 1399 | (cwd: string) => |
| 1400 | Cache.invalidate(repositoryPathsCache, cwd).pipe( |
| 1401 | Effect.andThen(Cache.get(repositoryPathsCache, cwd)), |
| 1402 | ), |
| 1403 | { |
| 1404 | capacity: REPOSITORY_PATHS_CACHE_CAPACITY, |
| 1405 | timeToLive: Exit.match({ |
| 1406 | onSuccess: (repositoryPaths) => |
| 1407 | repositoryPaths === null |
| 1408 | ? NON_REPOSITORY_PATHS_CACHE_TTL |
| 1409 | : REPOSITORY_PATHS_REFRESH_COALESCE_TTL, |
| 1410 | onFailure: () => Duration.zero, |
| 1411 | }), |
| 1412 | }, |
| 1413 | ); |
| 1414 | const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd)); |
| 1415 | const resolveRepositoryPaths = (cwd: string, refresh = false) => { |
| 1416 | const cacheKey = normalizeRepositoryPathsCacheKey(cwd); |
| 1417 | return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); |
| 1418 | }; |
| 1419 | |
| 1420 | const defaultBranchCache = yield* Cache.makeWith( |
| 1421 | (gitCommonDir: string) => |
| 1422 | Effect.gen(function* () { |
| 1423 | const path = yield* Path.Path; |
| 1424 | const fetchCwd = |
| 1425 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| 1426 | return yield* executeGit( |
| 1427 | "GitVcsDriver.statusDetails.defaultBranch", |
| 1428 | fetchCwd, |
| 1429 | ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], |
| 1430 | { allowNonZeroExit: true }, |
| 1431 | ).pipe( |
| 1432 | Effect.map((result) => { |
| 1433 | if (result.exitCode !== 0) return null; |
| 1434 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); |
| 1435 | }), |
| 1436 | ); |
| 1437 | }), |
| 1438 | { |
| 1439 | capacity: 2_048, |
| 1440 | timeToLive: Exit.match({ |
| 1441 | onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, |
| 1442 | onFailure: () => Duration.zero, |
| 1443 | }), |
| 1444 | }, |
| 1445 | ); |
| 1446 | const originExistsCache = yield* Cache.makeWith( |
| 1447 | (gitCommonDir: string) => |
| 1448 | Effect.gen(function* () { |
| 1449 | const path = yield* Path.Path; |
| 1450 | const fetchCwd = |
| 1451 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| 1452 | return yield* executeGit( |
| 1453 | "GitVcsDriver.statusDetails.originExists", |
| 1454 | fetchCwd, |
| 1455 | ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], |
| 1456 | { allowNonZeroExit: true }, |
| 1457 | ).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1458 | }), |
| 1459 | { |
| 1460 | capacity: 2_048, |
| 1461 | timeToLive: Exit.match({ |
| 1462 | onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, |
| 1463 | onFailure: () => Duration.zero, |
| 1464 | }), |
| 1465 | }, |
| 1466 | ); |
| 1467 | const invalidateStatusStaticCaches = (cwd: string) => |
| 1468 | Effect.gen(function* () { |
| 1469 | const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( |
| 1470 | Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), |
| 1471 | ); |
| 1472 | const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); |
| 1473 | yield* Cache.invalidate(defaultBranchCache, cacheKey); |
| 1474 | yield* Cache.invalidate(originExistsCache, cacheKey); |
| 1475 | }); |
| 1476 | |
| 1477 | const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { |
| 1478 | const repositoryPaths = yield* resolveRepositoryPaths(cwd); |
| 1479 | if (repositoryPaths !== null) { |
| 1480 | return repositoryPaths.gitCommonDir; |
| 1481 | } |
| 1482 | return yield* new GitCommandError({ |
| 1483 | ...gitCommandContext({ |
| 1484 | operation: "GitVcsDriver.resolveGitCommonDir", |
| 1485 | cwd, |
| 1486 | args: ["rev-parse", "--git-common-dir"], |
| 1487 | }), |
| 1488 | detail: "Cannot resolve a Git common directory outside a repository.", |
| 1489 | }); |
| 1490 | }); |
| 1491 | |
| 1492 | const statusRemoteRefreshFailureCounts = new Map<string, number>(); |
| 1493 | const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => |
| 1494 | `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; |
| 1495 | const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { |
| 1496 | const key = statusRemoteRefreshFailureKey(cacheKey); |
| 1497 | const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; |
| 1498 | statusRemoteRefreshFailureCounts.delete(key); |
| 1499 | statusRemoteRefreshFailureCounts.set(key, nextCount); |
| 1500 | if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { |
| 1501 | const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; |
| 1502 | if (oldestKey !== undefined) { |
| 1503 | statusRemoteRefreshFailureCounts.delete(oldestKey); |
| 1504 | } |
| 1505 | } |
| 1506 | }; |
| 1507 | const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { |
| 1508 | statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); |
| 1509 | }; |
| 1510 | const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( |
| 1511 | cacheKey: StatusRemoteRefreshCacheKey, |
| 1512 | ) { |
| 1513 | return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( |
| 1514 | Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), |
| 1515 | Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), |
| 1516 | Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), |
| 1517 | Effect.as(true as const), |
| 1518 | ); |
| 1519 | }); |
| 1520 | |
| 1521 | const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { |
| 1522 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, |
| 1523 | // A failed background fetch is intentionally cached and exponentially |
| 1524 | // backed off. Status reads swallow this failure and use the last fetched |
| 1525 | // refs, so repeated thread mounts cannot turn a slow or unavailable remote |
| 1526 | // into a repository-wide Git subprocess storm. |
| 1527 | timeToLive: (exit, cacheKey) => |
| 1528 | Exit.isSuccess(exit) |
| 1529 | ? STATUS_UPSTREAM_REFRESH_INTERVAL |
| 1530 | : statusUpstreamRefreshFailureCooldown( |
| 1531 | statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, |
| 1532 | ), |
| 1533 | }); |
| 1534 | |
| 1535 | const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( |
| 1536 | cwd: string, |
| 1537 | ) { |
| 1538 | const upstream = yield* resolveCurrentUpstream(cwd); |
| 1539 | if (!upstream) return; |
| 1540 | const gitCommonDir = yield* resolveGitCommonDir(cwd); |
| 1541 | // The cache loader logs failed attempts; cache hits keep using the last fetched refs. |
| 1542 | yield* Cache.get( |
| 1543 | statusRemoteRefreshCache, |
| 1544 | new StatusRemoteRefreshCacheKey({ |
| 1545 | gitCommonDir, |
| 1546 | remoteName: upstream.remoteName, |
| 1547 | }), |
| 1548 | ).pipe(Effect.ignore); |
| 1549 | }); |
| 1550 | |
| 1551 | const resolveDefaultBranchName = ( |
| 1552 | cwd: string, |
| 1553 | remoteName: string, |
| 1554 | ): Effect.Effect<string | null, GitCommandError> => |
| 1555 | executeGit( |
| 1556 | "GitVcsDriver.resolveDefaultBranchName", |
| 1557 | cwd, |
| 1558 | ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], |
| 1559 | { allowNonZeroExit: true }, |
| 1560 | ).pipe( |
| 1561 | Effect.map((result) => { |
| 1562 | if (result.exitCode !== 0) { |
| 1563 | return null; |
| 1564 | } |
| 1565 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); |
| 1566 | }), |
| 1567 | ); |
| 1568 | |
| 1569 | const remoteBranchExists: GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"] = (input) => |
| 1570 | executeGit( |
| 1571 | "GitVcsDriver.remoteBranchExists", |
| 1572 | input.cwd, |
| 1573 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.remoteName}/${input.refName}`], |
| 1574 | { |
| 1575 | allowNonZeroExit: true, |
| 1576 | }, |
| 1577 | ).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1578 | |
| 1579 | const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => |
| 1580 | executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { |
| 1581 | allowNonZeroExit: true, |
| 1582 | }).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1583 | |
| 1584 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => |
| 1585 | remoteExists({ cwd, remoteName: "origin" }); |
| 1586 | |
| 1587 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => |
| 1588 | runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( |
| 1589 | Effect.map(parseRemoteNamesInGitOrder), |
| 1590 | ); |
| 1591 | |
| 1592 | const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* ( |
| 1593 | cwd: string, |
| 1594 | branchName: string, |
| 1595 | ) { |
| 1596 | const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.orElseSucceed(() => [])); |
| 1597 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames); |
| 1598 | return parsedRemoteRef?.branchName ?? branchName; |
| 1599 | }); |
| 1600 | |
| 1601 | const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { |
| 1602 | if (yield* originRemoteExists(cwd)) { |
| 1603 | return "origin"; |
| 1604 | } |
| 1605 | const remotes = yield* listRemoteNames(cwd); |
| 1606 | const [firstRemote] = remotes; |
| 1607 | if (firstRemote) { |
| 1608 | return firstRemote; |
| 1609 | } |
| 1610 | return yield* new GitCommandError({ |
| 1611 | ...gitCommandContext({ |
| 1612 | operation: "GitVcsDriver.resolvePrimaryRemoteName", |
| 1613 | cwd, |
| 1614 | args: ["remote"], |
| 1615 | }), |
| 1616 | detail: "No git remote is configured for this repository.", |
| 1617 | }); |
| 1618 | }); |
| 1619 | |
| 1620 | const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( |
| 1621 | cwd: string, |
| 1622 | refName: string, |
| 1623 | ) { |
| 1624 | const branchPushRemote = yield* runGitStdout( |
| 1625 | "GitVcsDriver.resolvePushRemoteName.branchPushRemote", |
| 1626 | cwd, |
| 1627 | ["config", "--get", `branch.${refName}.pushRemote`], |
| 1628 | true, |
| 1629 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 1630 | if (branchPushRemote.length > 0) { |
| 1631 | return branchPushRemote; |
| 1632 | } |
| 1633 | |
| 1634 | const pushDefaultRemote = yield* runGitStdout( |
| 1635 | "GitVcsDriver.resolvePushRemoteName.remotePushDefault", |
| 1636 | cwd, |
| 1637 | ["config", "--get", "remote.pushDefault"], |
| 1638 | true, |
| 1639 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 1640 | if (pushDefaultRemote.length > 0) { |
| 1641 | return pushDefaultRemote; |
| 1642 | } |
| 1643 | |
| 1644 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); |
| 1645 | }); |
| 1646 | |
| 1647 | const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( |
| 1648 | "ensureRemote", |
| 1649 | )(function* (input) { |
| 1650 | const preferredName = sanitizeRemoteName(input.preferredName); |
| 1651 | const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); |
| 1652 | const remoteFetchUrls = yield* runGitStdout( |
| 1653 | "GitVcsDriver.ensureRemote.listRemoteUrls", |
| 1654 | input.cwd, |
| 1655 | ["remote", "-v"], |
| 1656 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); |
| 1657 | |
| 1658 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { |
| 1659 | if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { |
| 1660 | return remoteName; |
| 1661 | } |
| 1662 | } |
| 1663 | |
| 1664 | let remoteName = preferredName; |
| 1665 | let suffix = 1; |
| 1666 | while (remoteFetchUrls.has(remoteName)) { |
| 1667 | remoteName = `${preferredName}-${suffix}`; |
| 1668 | suffix += 1; |
| 1669 | } |
| 1670 | |
| 1671 | yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ |
| 1672 | "remote", |
| 1673 | "add", |
| 1674 | remoteName, |
| 1675 | input.url, |
| 1676 | ]); |
| 1677 | return remoteName; |
| 1678 | }); |
| 1679 | |
| 1680 | // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy. |
| 1681 | const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( |
| 1682 | cwd: string, |
| 1683 | refName: string, |
| 1684 | options?: { readonly allowRemoteOfCurrent?: boolean }, |
| 1685 | ) { |
| 1686 | const configuredBaseBranch = yield* runGitStdout( |
| 1687 | "GitVcsDriver.resolveBaseBranchForNoUpstream.config", |
| 1688 | cwd, |
| 1689 | ["config", "--get", `branch.${refName}.gh-merge-base`], |
| 1690 | true, |
| 1691 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 1692 | |
| 1693 | const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( |
| 1694 | Effect.orElseSucceed(() => null), |
| 1695 | ); |
| 1696 | const defaultBranch = |
| 1697 | primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); |
| 1698 | const candidates = [ |
| 1699 | configuredBaseBranch.length > 0 ? configuredBaseBranch : null, |
| 1700 | defaultBranch, |
| 1701 | ...DEFAULT_BASE_BRANCH_CANDIDATES, |
| 1702 | ]; |
| 1703 | |
| 1704 | for (const candidate of candidates) { |
| 1705 | if (!candidate) { |
| 1706 | continue; |
| 1707 | } |
| 1708 | |
| 1709 | const remotePrefix = |
| 1710 | primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; |
| 1711 | const normalizedCandidate = candidate.startsWith("origin/") |
| 1712 | ? candidate.slice("origin/".length) |
| 1713 | : remotePrefix && candidate.startsWith(remotePrefix) |
| 1714 | ? candidate.slice(remotePrefix.length) |
| 1715 | : candidate; |
| 1716 | if (normalizedCandidate.length === 0) { |
| 1717 | continue; |
| 1718 | } |
| 1719 | if (normalizedCandidate === refName) { |
| 1720 | if ( |
| 1721 | options?.allowRemoteOfCurrent && |
| 1722 | primaryRemoteName && |
| 1723 | (yield* remoteBranchExists({ |
| 1724 | cwd, |
| 1725 | remoteName: primaryRemoteName, |
| 1726 | refName: normalizedCandidate, |
| 1727 | })) |
| 1728 | ) { |
| 1729 | return `${primaryRemoteName}/${normalizedCandidate}`; |
| 1730 | } |
| 1731 | continue; |
| 1732 | } |
| 1733 | |
| 1734 | if ( |
| 1735 | primaryRemoteName && |
| 1736 | (yield* remoteBranchExists({ |
| 1737 | cwd, |
| 1738 | remoteName: primaryRemoteName, |
| 1739 | refName: normalizedCandidate, |
| 1740 | })) |
| 1741 | ) { |
| 1742 | return `${primaryRemoteName}/${normalizedCandidate}`; |
| 1743 | } |
| 1744 | |
| 1745 | if (yield* branchExists(cwd, normalizedCandidate)) { |
| 1746 | return normalizedCandidate; |
| 1747 | } |
| 1748 | } |
| 1749 | |
| 1750 | return null; |
| 1751 | }); |
| 1752 | |
| 1753 | const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( |
| 1754 | cwd: string, |
| 1755 | refName: string, |
| 1756 | ) { |
| 1757 | const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); |
| 1758 | if (!baseRef) { |
| 1759 | return 0; |
| 1760 | } |
| 1761 | |
| 1762 | const result = yield* executeGit( |
| 1763 | "GitVcsDriver.computeAheadCountAgainstBase", |
| 1764 | cwd, |
| 1765 | ["rev-list", "--count", `${baseRef}..HEAD`], |
| 1766 | { allowNonZeroExit: true }, |
| 1767 | ); |
| 1768 | if (result.exitCode !== 0) { |
| 1769 | return 0; |
| 1770 | } |
| 1771 | |
| 1772 | const parsed = Number.parseInt(result.stdout.trim(), 10); |
| 1773 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; |
| 1774 | }); |
| 1775 | |
| 1776 | const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { |
| 1777 | const branchResult = yield* executeGitWithStableDiagnostics( |
| 1778 | "GitVcsDriver.statusDetailsRemote.branch", |
| 1779 | cwd, |
| 1780 | ["rev-parse", "--abbrev-ref", "HEAD"], |
| 1781 | { allowNonZeroExit: true }, |
| 1782 | ).pipe( |
| 1783 | Effect.catchTags({ |
| 1784 | GitCommandError: (error) => |
| 1785 | isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), |
| 1786 | }), |
| 1787 | ); |
| 1788 | |
| 1789 | if (branchResult === null) { |
| 1790 | return NON_REPOSITORY_REMOTE_STATUS_DETAILS; |
| 1791 | } |
| 1792 | let branch: string | null; |
| 1793 | if (branchResult.exitCode !== 0) { |
| 1794 | if (isNonRepositoryGitStderr(branchResult.stderr)) { |
| 1795 | return NON_REPOSITORY_REMOTE_STATUS_DETAILS; |
| 1796 | } |
| 1797 | if (!isUnbornHeadStderr(branchResult.stderr)) { |
| 1798 | return yield* new GitCommandError({ |
| 1799 | ...gitCommandContext({ |
| 1800 | operation: "GitVcsDriver.statusDetailsRemote.branch", |
| 1801 | cwd, |
| 1802 | args: ["rev-parse", "--abbrev-ref", "HEAD"], |
| 1803 | }), |
| 1804 | detail: "Git branch lookup failed.", |
| 1805 | exitCode: branchResult.exitCode, |
| 1806 | stdoutLength: branchResult.stdout.length, |
| 1807 | stderrLength: branchResult.stderr.length, |
| 1808 | }); |
| 1809 | } |
| 1810 | |
| 1811 | const branchValue = yield* runGitStdout( |
| 1812 | "GitVcsDriver.statusDetailsRemote.unbornBranch", |
| 1813 | cwd, |
| 1814 | ["symbolic-ref", "--quiet", "--short", "HEAD"], |
| 1815 | ); |
| 1816 | branch = branchValue.trim() || null; |
| 1817 | } else { |
| 1818 | const branchValue = branchResult.stdout.trim(); |
| 1819 | branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; |
| 1820 | } |
| 1821 | const upstream = yield* resolveCurrentUpstream(cwd); |
| 1822 | const upstreamRef = upstream?.upstreamRef ?? null; |
| 1823 | let aheadCount = 0; |
| 1824 | let behindCount = 0; |
| 1825 | |
| 1826 | if (upstreamRef) { |
| 1827 | const divergence = yield* executeGit( |
| 1828 | "GitVcsDriver.statusDetailsRemote.divergence", |
| 1829 | cwd, |
| 1830 | ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`], |
| 1831 | { allowNonZeroExit: true }, |
| 1832 | ); |
| 1833 | if (divergence.exitCode === 0) { |
| 1834 | const [aheadRaw, behindRaw] = divergence.stdout.trim().split(/\s+/); |
| 1835 | const parsedAhead = Number.parseInt(aheadRaw ?? "0", 10); |
| 1836 | const parsedBehind = Number.parseInt(behindRaw ?? "0", 10); |
| 1837 | aheadCount = Number.isFinite(parsedAhead) ? Math.max(0, parsedAhead) : 0; |
| 1838 | behindCount = Number.isFinite(parsedBehind) ? Math.max(0, parsedBehind) : 0; |
| 1839 | } |
| 1840 | } else if (branch) { |
| 1841 | aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( |
| 1842 | Effect.orElseSucceed(() => 0), |
| 1843 | ); |
| 1844 | } |
| 1845 | |
| 1846 | const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin"); |
| 1847 | const isDefaultBranch = |
| 1848 | branch !== null && |
| 1849 | (branch === defaultBranch || |
| 1850 | (defaultBranch === null && (branch === "main" || branch === "master"))); |
| 1851 | const aheadOfDefaultCount = |
| 1852 | branch && !isDefaultBranch |
| 1853 | ? upstreamRef === null |
| 1854 | ? aheadCount |
| 1855 | : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) |
| 1856 | : 0; |
| 1857 | |
| 1858 | return { |
| 1859 | isRepo: true, |
| 1860 | defaultBranch, |
| 1861 | isDefaultBranch, |
| 1862 | branch, |
| 1863 | upstreamRef, |
| 1864 | hasUpstream: upstreamRef !== null, |
| 1865 | aheadCount, |
| 1866 | behindCount, |
| 1867 | aheadOfDefaultCount, |
| 1868 | }; |
| 1869 | }); |
| 1870 | |
| 1871 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* ( |
| 1872 | cwd: string, |
| 1873 | options?: GitVcsDriver.GitLocalStatusOptions, |
| 1874 | ) { |
| 1875 | const includeDivergence = options?.includeDivergence !== false; |
| 1876 | const statusArgs = [ |
| 1877 | "status", |
| 1878 | "--porcelain=2", |
| 1879 | "--branch", |
| 1880 | ...(includeDivergence ? [] : ["--no-ahead-behind"]), |
| 1881 | ]; |
| 1882 | const indexResult = yield* executeGitWithStableDiagnostics( |
| 1883 | "GitVcsDriver.statusDetails.indexPath", |
| 1884 | cwd, |
| 1885 | ["rev-parse", "--git-path", "index"], |
| 1886 | { allowNonZeroExit: true }, |
| 1887 | ).pipe( |
| 1888 | Effect.catchTags({ |
| 1889 | GitCommandError: (error) => |
| 1890 | isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), |
| 1891 | }), |
| 1892 | ); |
| 1893 | if (indexResult === null) return NON_REPOSITORY_STATUS_DETAILS; |
| 1894 | if (indexResult.exitCode === 0) { |
| 1895 | const lockPath = `${path.resolve(cwd, indexResult.stdout.trim())}.lock`; |
| 1896 | const lockError = new GitCommandError({ |
| 1897 | operation: "GitVcsDriver.statusDetails.indexPath", |
| 1898 | command: "git", |
| 1899 | cwd, |
| 1900 | detail: "Git index is locked. Status will resume when the index lock is removed.", |
| 1901 | }); |
| 1902 | // Status can succeed while locked, repeatedly running LFS clean filters without caching. |
| 1903 | if ( |
| 1904 | yield* fileSystem.exists(lockPath).pipe( |
| 1905 | Effect.mapError( |
| 1906 | (cause) => |
| 1907 | new GitCommandError({ |
| 1908 | ...lockError, |
| 1909 | detail: "Failed to check the Git index lock.", |
| 1910 | cause, |
| 1911 | }), |
| 1912 | ), |
| 1913 | ) |
| 1914 | ) { |
| 1915 | return yield* lockError; |
| 1916 | } |
| 1917 | } |
| 1918 | const statusResult = yield* executeGitWithStableDiagnostics( |
| 1919 | "GitVcsDriver.statusDetails.status", |
| 1920 | cwd, |
| 1921 | statusArgs, |
| 1922 | { |
| 1923 | allowNonZeroExit: true, |
| 1924 | }, |
| 1925 | ).pipe( |
| 1926 | Effect.catchTags({ |
| 1927 | GitCommandError: (error) => |
| 1928 | isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), |
| 1929 | }), |
| 1930 | ); |
| 1931 | |
| 1932 | if (statusResult === null) { |
| 1933 | return NON_REPOSITORY_STATUS_DETAILS; |
| 1934 | } |
| 1935 | |
| 1936 | if (statusResult.exitCode !== 0) { |
| 1937 | if (isNonRepositoryGitStderr(statusResult.stderr)) { |
| 1938 | return NON_REPOSITORY_STATUS_DETAILS; |
| 1939 | } |
| 1940 | return yield* new GitCommandError({ |
| 1941 | ...gitCommandContext({ |
| 1942 | operation: "GitVcsDriver.statusDetails.status", |
| 1943 | cwd, |
| 1944 | args: statusArgs, |
| 1945 | }), |
| 1946 | detail: "Git status failed.", |
| 1947 | exitCode: statusResult.exitCode, |
| 1948 | stdoutLength: statusResult.stdout.length, |
| 1949 | stderrLength: statusResult.stderr.length, |
| 1950 | }); |
| 1951 | } |
| 1952 | |
| 1953 | const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( |
| 1954 | Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), |
| 1955 | ); |
| 1956 | const statusCacheKey = repositoryPaths?.gitCommonDir; |
| 1957 | const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( |
| 1958 | [ |
| 1959 | // Plumbing, because porcelain `git diff` rewrites the index even with |
| 1960 | // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. |
| 1961 | executeGitWithStableDiagnostics( |
| 1962 | "GitVcsDriver.statusDetails.numstat", |
| 1963 | cwd, |
| 1964 | ["diff-index", "-M", "--numstat", "HEAD", "--"], |
| 1965 | { allowNonZeroExit: true }, |
| 1966 | ).pipe( |
| 1967 | Effect.flatMap((result) => { |
| 1968 | if (result.exitCode === 0) return Effect.succeed(result.stdout); |
| 1969 | if (isUnbornHeadStderr(result.stderr)) { |
| 1970 | return Effect.map( |
| 1971 | Effect.all([ |
| 1972 | runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ |
| 1973 | "diff-files", |
| 1974 | "--numstat", |
| 1975 | ]), |
| 1976 | runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ |
| 1977 | "diff", |
| 1978 | "--cached", |
| 1979 | "--numstat", |
| 1980 | ]), |
| 1981 | ]), |
| 1982 | ([unstagedStdout, stagedStdout]) => { |
| 1983 | const staged = parseNumstatEntries(stagedStdout); |
| 1984 | const unstaged = parseNumstatEntries(unstagedStdout); |
| 1985 | const map = new Map<string, { insertions: number; deletions: number }>(); |
| 1986 | for (const entry of [...staged, ...unstaged]) { |
| 1987 | const existing = map.get(entry.path) ?? { |
| 1988 | insertions: 0, |
| 1989 | deletions: 0, |
| 1990 | }; |
| 1991 | existing.insertions += entry.insertions; |
| 1992 | existing.deletions += entry.deletions; |
| 1993 | map.set(entry.path, existing); |
| 1994 | } |
| 1995 | return Array.from(map.entries()) |
| 1996 | .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) |
| 1997 | .join("\n"); |
| 1998 | }, |
| 1999 | ); |
| 2000 | } |
| 2001 | return Effect.fail( |
| 2002 | new GitCommandError({ |
| 2003 | ...gitCommandContext({ |
| 2004 | operation: "GitVcsDriver.statusDetails.numstat", |
| 2005 | cwd, |
| 2006 | args: ["diff-index", "-M", "--numstat", "HEAD", "--"], |
| 2007 | }), |
| 2008 | detail: "git diff-index HEAD --numstat failed.", |
| 2009 | exitCode: result.exitCode, |
| 2010 | stdoutLength: result.stdout.length, |
| 2011 | stderrLength: result.stderr.length, |
| 2012 | }), |
| 2013 | ); |
| 2014 | }), |
| 2015 | ), |
| 2016 | statusCacheKey |
| 2017 | ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) |
| 2018 | : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), |
| 2019 | statusCacheKey |
| 2020 | ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) |
| 2021 | : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), |
| 2022 | ], |
| 2023 | { concurrency: "unbounded" }, |
| 2024 | ); |
| 2025 | const statusStdout = statusResult.stdout; |
| 2026 | |
| 2027 | let refName: string | null = null; |
| 2028 | let upstreamRef: string | null = null; |
| 2029 | let aheadCount = 0; |
| 2030 | let behindCount = 0; |
| 2031 | let aheadOfDefaultCount = 0; |
| 2032 | let hasWorkingTreeChanges = false; |
| 2033 | const changedFilesWithoutNumstat = new Set<string>(); |
| 2034 | |
| 2035 | for (const line of statusStdout.split(/\r?\n/g)) { |
| 2036 | if (line.startsWith("# branch.head ")) { |
| 2037 | const value = line.slice("# branch.head ".length).trim(); |
| 2038 | refName = value.startsWith("(") ? null : value; |
| 2039 | continue; |
| 2040 | } |
| 2041 | if (line.startsWith("# branch.upstream ")) { |
| 2042 | const value = line.slice("# branch.upstream ".length).trim(); |
| 2043 | upstreamRef = value.length > 0 ? value : null; |
| 2044 | continue; |
| 2045 | } |
| 2046 | if (includeDivergence && line.startsWith("# branch.ab ")) { |
| 2047 | const value = line.slice("# branch.ab ".length).trim(); |
| 2048 | const parsed = parseBranchAb(value); |
| 2049 | aheadCount = parsed.ahead; |
| 2050 | behindCount = parsed.behind; |
| 2051 | continue; |
| 2052 | } |
| 2053 | if (line.trim().length > 0 && !line.startsWith("#")) { |
| 2054 | hasWorkingTreeChanges = true; |
| 2055 | const pathValue = parsePorcelainPath(line); |
| 2056 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); |
| 2057 | } |
| 2058 | } |
| 2059 | |
| 2060 | const fallbackAheadCount = |
| 2061 | includeDivergence && !upstreamRef && refName |
| 2062 | ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) |
| 2063 | : null; |
| 2064 | |
| 2065 | if (fallbackAheadCount !== null) { |
| 2066 | aheadCount = fallbackAheadCount; |
| 2067 | behindCount = 0; |
| 2068 | } |
| 2069 | |
| 2070 | const isDefaultBranch = |
| 2071 | refName !== null && |
| 2072 | (refName === defaultBranch || |
| 2073 | (defaultBranch === null && (refName === "main" || refName === "master"))); |
| 2074 | if (includeDivergence && refName && !isDefaultBranch) { |
| 2075 | aheadOfDefaultCount = |
| 2076 | fallbackAheadCount !== null |
| 2077 | ? fallbackAheadCount |
| 2078 | : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); |
| 2079 | } |
| 2080 | |
| 2081 | const numstatEntries = parseNumstatEntries(numstatStdout); |
| 2082 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); |
| 2083 | for (const entry of numstatEntries) { |
| 2084 | fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); |
| 2085 | } |
| 2086 | |
| 2087 | let insertions = 0; |
| 2088 | let deletions = 0; |
| 2089 | const files = Array.from(fileStatMap.entries()) |
| 2090 | .map(([filePath, stat]) => { |
| 2091 | insertions += stat.insertions; |
| 2092 | deletions += stat.deletions; |
| 2093 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; |
| 2094 | }) |
| 2095 | .toSorted((a, b) => a.path.localeCompare(b.path)); |
| 2096 | |
| 2097 | for (const filePath of changedFilesWithoutNumstat) { |
| 2098 | if (fileStatMap.has(filePath)) continue; |
| 2099 | files.push({ path: filePath, insertions: 0, deletions: 0 }); |
| 2100 | } |
| 2101 | files.sort((a, b) => a.path.localeCompare(b.path)); |
| 2102 | |
| 2103 | const branchChanges = options?.includeBranchChanges |
| 2104 | ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( |
| 2105 | Effect.orElseSucceed(() => undefined), |
| 2106 | ) |
| 2107 | : undefined; |
| 2108 | |
| 2109 | return { |
| 2110 | isRepo: true, |
| 2111 | hasOriginRemote: hasPrimaryRemote, |
| 2112 | isDefaultBranch, |
| 2113 | branch: refName, |
| 2114 | upstreamRef, |
| 2115 | hasWorkingTreeChanges, |
| 2116 | workingTree: { |
| 2117 | files, |
| 2118 | insertions, |
| 2119 | deletions, |
| 2120 | }, |
| 2121 | ...(branchChanges ? { branchChanges } : {}), |
| 2122 | hasUpstream: upstreamRef !== null, |
| 2123 | aheadCount, |
| 2124 | behindCount, |
| 2125 | aheadOfDefaultCount, |
| 2126 | }; |
| 2127 | }); |
| 2128 | |
| 2129 | const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( |
| 2130 | "statusDetailsLocal", |
| 2131 | )(function* (cwd, options) { |
| 2132 | return yield* readStatusDetailsLocal(cwd, options); |
| 2133 | }); |
| 2134 | |
| 2135 | const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( |
| 2136 | "statusDetails", |
| 2137 | )(function* (cwd) { |
| 2138 | yield* refreshStatusUpstreamIfStale(cwd).pipe( |
| 2139 | Effect.catchTags({ |
| 2140 | GitCommandError: (error) => |
| 2141 | isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), |
| 2142 | }), |
| 2143 | Effect.ignoreCause({ log: true }), |
| 2144 | ); |
| 2145 | return yield* readStatusDetailsLocal(cwd); |
| 2146 | }); |
| 2147 | |
| 2148 | const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] = |
| 2149 | Effect.fn("statusDetailsRemote")(function* (cwd, options) { |
| 2150 | if (options?.refreshUpstream !== false) { |
| 2151 | yield* refreshStatusUpstreamIfStale(cwd).pipe( |
| 2152 | Effect.catchTags({ |
| 2153 | GitCommandError: (error) => |
| 2154 | isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), |
| 2155 | }), |
| 2156 | Effect.ignoreCause({ log: true }), |
| 2157 | ); |
| 2158 | } |
| 2159 | return yield* readStatusDetailsRemote(cwd); |
| 2160 | }); |
| 2161 | |
| 2162 | const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) => |
| 2163 | statusDetails(input.cwd).pipe( |
| 2164 | Effect.map((details) => ({ |
| 2165 | isRepo: details.isRepo, |
| 2166 | hasPrimaryRemote: details.hasOriginRemote, |
| 2167 | isDefaultRef: details.isDefaultBranch, |
| 2168 | refName: details.branch, |
| 2169 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 2170 | workingTree: details.workingTree, |
| 2171 | hasUpstream: details.hasUpstream, |
| 2172 | aheadCount: details.aheadCount, |
| 2173 | behindCount: details.behindCount, |
| 2174 | aheadOfDefaultCount: details.aheadOfDefaultCount, |
| 2175 | pr: null, |
| 2176 | })), |
| 2177 | ); |
| 2178 | |
| 2179 | const stageCommitChanges = Effect.fnUntraced(function* ( |
| 2180 | cwd: string, |
| 2181 | filePaths?: readonly string[], |
| 2182 | env?: NodeJS.ProcessEnv, |
| 2183 | ) { |
| 2184 | const config = env |
| 2185 | ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] |
| 2186 | : []; |
| 2187 | if (filePaths && filePaths.length > 0) { |
| 2188 | if (env) { |
| 2189 | const head = yield* executeGit( |
| 2190 | "GitVcsDriver.prepareCommitContext.head", |
| 2191 | cwd, |
| 2192 | ["rev-parse", "--verify", "--quiet", "HEAD"], |
| 2193 | { allowNonZeroExit: true }, |
| 2194 | ); |
| 2195 | // Even with GIT_INDEX_FILE, reset clears the repository's merge state. |
| 2196 | yield* runGit( |
| 2197 | "GitVcsDriver.prepareCommitContext.readTree", |
| 2198 | cwd, |
| 2199 | [...config, "read-tree", ...(head.exitCode === 0 ? ["HEAD"] : ["--empty"])], |
| 2200 | { env }, |
| 2201 | ); |
| 2202 | } else { |
| 2203 | yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( |
| 2204 | Effect.catchTags({ GitCommandError: () => Effect.void }), |
| 2205 | ); |
| 2206 | } |
| 2207 | yield* runGit( |
| 2208 | "GitVcsDriver.commit.addSelected", |
| 2209 | cwd, |
| 2210 | [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], |
| 2211 | env ? { env } : {}, |
| 2212 | ); |
| 2213 | } else { |
| 2214 | yield* runGit( |
| 2215 | "GitVcsDriver.commit.addAll", |
| 2216 | cwd, |
| 2217 | [...config, "add", "-A"], |
| 2218 | env ? { env } : {}, |
| 2219 | ); |
| 2220 | } |
| 2221 | }); |
| 2222 | |
| 2223 | const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = |
| 2224 | Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { |
| 2225 | return yield* Effect.scoped( |
| 2226 | Effect.gen(function* () { |
| 2227 | const indexValue = yield* runGitStdout( |
| 2228 | "GitVcsDriver.prepareCommitContext.indexPath", |
| 2229 | cwd, |
| 2230 | ["rev-parse", "--git-path", "index"], |
| 2231 | ); |
| 2232 | const indexPath = path.resolve(cwd, indexValue.trim()); |
| 2233 | const directory = yield* fileSystem.makeTempDirectoryScoped({ |
| 2234 | prefix: "t3code-commit-index-", |
| 2235 | }); |
| 2236 | const tempIndexPath = path.join(directory, "index"); |
| 2237 | const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; |
| 2238 | const config = [ |
| 2239 | "-c", |
| 2240 | "core.splitIndex=false", |
| 2241 | "-c", |
| 2242 | "splitIndex.sharedIndexExpire=never", |
| 2243 | ]; |
| 2244 | if (yield* fileSystem.exists(indexPath)) { |
| 2245 | const { mtime } = yield* fileSystem.stat(indexPath); |
| 2246 | yield* fileSystem.copyFile(indexPath, tempIndexPath); |
| 2247 | const indexTime = Option.isSome(mtime) |
| 2248 | ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) |
| 2249 | : 0; |
| 2250 | yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); |
| 2251 | yield* runGit( |
| 2252 | "GitVcsDriver.prepareCommitContext.expandSplitIndex", |
| 2253 | cwd, |
| 2254 | [...config, "update-index", "--no-split-index"], |
| 2255 | { env }, |
| 2256 | ); |
| 2257 | } else { |
| 2258 | yield* runGit( |
| 2259 | "GitVcsDriver.prepareCommitContext.emptyIndex", |
| 2260 | cwd, |
| 2261 | [...config, "read-tree", "--empty"], |
| 2262 | { env }, |
| 2263 | ); |
| 2264 | } |
| 2265 | yield* stageCommitChanges(cwd, filePaths, env); |
| 2266 | |
| 2267 | const stagedSummary = yield* runGitStdoutWithOptions( |
| 2268 | "GitVcsDriver.prepareCommitContext.stagedSummary", |
| 2269 | cwd, |
| 2270 | ["diff", "--cached", "--name-status"], |
| 2271 | { env }, |
| 2272 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 2273 | if (stagedSummary.length === 0) { |
| 2274 | return null; |
| 2275 | } |
| 2276 | |
| 2277 | const stagedPatch = yield* runGitStdoutWithOptions( |
| 2278 | "GitVcsDriver.prepareCommitContext.stagedPatch", |
| 2279 | cwd, |
| 2280 | ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], |
| 2281 | { |
| 2282 | env, |
| 2283 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, |
| 2284 | appendTruncationMarker: true, |
| 2285 | }, |
| 2286 | ); |
| 2287 | |
| 2288 | return { |
| 2289 | stagedSummary, |
| 2290 | stagedPatch, |
| 2291 | }; |
| 2292 | }), |
| 2293 | ).pipe( |
| 2294 | Effect.catchTags({ |
| 2295 | PlatformError: (cause) => |
| 2296 | new GitCommandError({ |
| 2297 | operation: "GitVcsDriver.prepareCommitContext", |
| 2298 | command: "git", |
| 2299 | cwd, |
| 2300 | detail: "Failed to prepare the temporary commit index.", |
| 2301 | cause, |
| 2302 | }), |
| 2303 | }), |
| 2304 | ); |
| 2305 | }); |
| 2306 | |
| 2307 | const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( |
| 2308 | cwd, |
| 2309 | subject, |
| 2310 | body, |
| 2311 | options?: GitVcsDriver.GitCommitOptions, |
| 2312 | ) { |
| 2313 | if (options?.stage !== undefined) { |
| 2314 | yield* stageCommitChanges(cwd, options.stage.filePaths); |
| 2315 | } |
| 2316 | const args = ["commit", "-m", subject]; |
| 2317 | const trimmedBody = body.trim(); |
| 2318 | if (trimmedBody.length > 0) { |
| 2319 | args.push("-m", trimmedBody); |
| 2320 | } |
| 2321 | const progress = |
| 2322 | options?.progress?.onOutputLine === undefined |
| 2323 | ? options?.progress |
| 2324 | : { |
| 2325 | ...options.progress, |
| 2326 | onStdoutLine: (line: string) => |
| 2327 | options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, |
| 2328 | onStderrLine: (line: string) => |
| 2329 | options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, |
| 2330 | }; |
| 2331 | yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { |
| 2332 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 2333 | ...(progress ? { progress } : {}), |
| 2334 | }).pipe(Effect.asVoid); |
| 2335 | const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ |
| 2336 | "rev-parse", |
| 2337 | "HEAD", |
| 2338 | ]).pipe(Effect.map((stdout) => stdout.trim())); |
| 2339 | |
| 2340 | return { commitSha }; |
| 2341 | }); |
| 2342 | |
| 2343 | const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( |
| 2344 | "pushCurrentBranch", |
| 2345 | )(function* (cwd, fallbackBranch, options) { |
| 2346 | const details = yield* statusDetails(cwd); |
| 2347 | const branch = details.branch ?? fallbackBranch; |
| 2348 | if (!branch) { |
| 2349 | return yield* new GitCommandError({ |
| 2350 | ...gitCommandContext({ |
| 2351 | operation: "GitVcsDriver.pushCurrentBranch", |
| 2352 | cwd, |
| 2353 | args: ["push"], |
| 2354 | }), |
| 2355 | detail: "Cannot push from detached HEAD.", |
| 2356 | }); |
| 2357 | } |
| 2358 | |
| 2359 | const requestedRemoteName = options?.remoteName?.trim() || null; |
| 2360 | if (requestedRemoteName) { |
| 2361 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); |
| 2362 | yield* runGit( |
| 2363 | "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", |
| 2364 | cwd, |
| 2365 | ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`], |
| 2366 | { timeoutMs: null }, |
| 2367 | ); |
| 2368 | return { |
| 2369 | status: "pushed" as const, |
| 2370 | branch, |
| 2371 | upstreamBranch: `${requestedRemoteName}/${publishBranch}`, |
| 2372 | setUpstream: true, |
| 2373 | }; |
| 2374 | } |
| 2375 | |
| 2376 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; |
| 2377 | if (hasNoLocalDelta) { |
| 2378 | if (details.hasUpstream) { |
| 2379 | return { |
| 2380 | status: "skipped_up_to_date" as const, |
| 2381 | branch, |
| 2382 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), |
| 2383 | }; |
| 2384 | } |
| 2385 | |
| 2386 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( |
| 2387 | Effect.orElseSucceed(() => null), |
| 2388 | ); |
| 2389 | if (comparableBaseBranch) { |
| 2390 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( |
| 2391 | Effect.orElseSucceed(() => null), |
| 2392 | ); |
| 2393 | if (!publishRemoteName) { |
| 2394 | return { |
| 2395 | status: "skipped_up_to_date" as const, |
| 2396 | branch, |
| 2397 | }; |
| 2398 | } |
| 2399 | |
| 2400 | const hasRemoteBranch = yield* remoteBranchExists({ |
| 2401 | cwd, |
| 2402 | remoteName: publishRemoteName, |
| 2403 | refName: branch, |
| 2404 | }).pipe(Effect.orElseSucceed(() => false)); |
| 2405 | if (hasRemoteBranch) { |
| 2406 | return { |
| 2407 | status: "skipped_up_to_date" as const, |
| 2408 | branch, |
| 2409 | }; |
| 2410 | } |
| 2411 | } |
| 2412 | } |
| 2413 | |
| 2414 | if (!details.hasUpstream) { |
| 2415 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); |
| 2416 | if (!publishRemoteName) { |
| 2417 | return yield* new GitCommandError({ |
| 2418 | ...gitCommandContext({ |
| 2419 | operation: "GitVcsDriver.pushCurrentBranch", |
| 2420 | cwd, |
| 2421 | args: ["push"], |
| 2422 | }), |
| 2423 | detail: "Cannot push because no git remote is configured for this repository.", |
| 2424 | }); |
| 2425 | } |
| 2426 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); |
| 2427 | yield* runGit( |
| 2428 | "GitVcsDriver.pushCurrentBranch.pushWithUpstream", |
| 2429 | cwd, |
| 2430 | ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], |
| 2431 | { timeoutMs: null }, |
| 2432 | ); |
| 2433 | return { |
| 2434 | status: "pushed" as const, |
| 2435 | branch, |
| 2436 | upstreamBranch: `${publishRemoteName}/${publishBranch}`, |
| 2437 | setUpstream: true, |
| 2438 | }; |
| 2439 | } |
| 2440 | |
| 2441 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( |
| 2442 | Effect.orElseSucceed(() => null), |
| 2443 | ); |
| 2444 | if (currentUpstream) { |
| 2445 | // A branch tracking a differently named ref was cut from it, the way |
| 2446 | // `git checkout -b feature origin/dev` and our own worktree flow leave |
| 2447 | // it. That upstream is the branch's base, not its publish target, and |
| 2448 | // pushing HEAD onto it would write feature commits to a shared branch |
| 2449 | // (bare `git push` refuses this under push.default=simple). The one |
| 2450 | // same-repo tracking setup that legitimately differs is a git-mangled |
| 2451 | // alias such as local `upstream/effect-atom` for my-org/upstream's |
| 2452 | // `effect-atom`: the branch name ends in the upstream head while the |
| 2453 | // upstream ref ends in the branch name. |
| 2454 | const isAliasOfUpstreamHead = |
| 2455 | branch === currentUpstream.branchName || |
| 2456 | (branch.endsWith(`/${currentUpstream.branchName}`) && |
| 2457 | currentUpstream.upstreamRef.endsWith(`/${branch}`)); |
| 2458 | if (!isAliasOfUpstreamHead) { |
| 2459 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( |
| 2460 | Effect.orElseSucceed(() => null), |
| 2461 | ); |
| 2462 | const remoteName = publishRemoteName ?? currentUpstream.remoteName; |
| 2463 | const publishBranch = yield* resolvePublishBranchName(cwd, branch); |
| 2464 | // `-u` retargets the upstream to the published branch, so keep the |
| 2465 | // base recorded first; base resolution reads gh-merge-base before the |
| 2466 | // upstream ref. |
| 2467 | const configuredMergeBase = yield* runGitStdout( |
| 2468 | "GitVcsDriver.pushCurrentBranch.readMergeBase", |
| 2469 | cwd, |
| 2470 | ["config", "--get", `branch.${branch}.gh-merge-base`], |
| 2471 | true, |
| 2472 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 2473 | if (configuredMergeBase.length === 0) { |
| 2474 | yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ |
| 2475 | "config", |
| 2476 | `branch.${branch}.gh-merge-base`, |
| 2477 | currentUpstream.branchName, |
| 2478 | ]); |
| 2479 | } |
| 2480 | yield* runGit( |
| 2481 | "GitVcsDriver.pushCurrentBranch.pushOwnBranch", |
| 2482 | cwd, |
| 2483 | ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], |
| 2484 | { timeoutMs: null }, |
| 2485 | ); |
| 2486 | return { |
| 2487 | status: "pushed" as const, |
| 2488 | branch, |
| 2489 | upstreamBranch: `${remoteName}/${publishBranch}`, |
| 2490 | setUpstream: true, |
| 2491 | }; |
| 2492 | } |
| 2493 | |
| 2494 | yield* runGit( |
| 2495 | "GitVcsDriver.pushCurrentBranch.pushUpstream", |
| 2496 | cwd, |
| 2497 | ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], |
| 2498 | { timeoutMs: null }, |
| 2499 | ); |
| 2500 | return { |
| 2501 | status: "pushed" as const, |
| 2502 | branch, |
| 2503 | upstreamBranch: currentUpstream.upstreamRef, |
| 2504 | setUpstream: false, |
| 2505 | }; |
| 2506 | } |
| 2507 | |
| 2508 | yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], { timeoutMs: null }); |
| 2509 | return { |
| 2510 | status: "pushed" as const, |
| 2511 | branch, |
| 2512 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), |
| 2513 | setUpstream: false, |
| 2514 | }; |
| 2515 | }); |
| 2516 | |
| 2517 | const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn( |
| 2518 | "pullCurrentBranch", |
| 2519 | )(function* (cwd) { |
| 2520 | const details = yield* statusDetails(cwd); |
| 2521 | const refName = details.branch; |
| 2522 | if (!refName) { |
| 2523 | return yield* new GitCommandError({ |
| 2524 | ...gitCommandContext({ |
| 2525 | operation: "GitVcsDriver.pullCurrentBranch", |
| 2526 | cwd, |
| 2527 | args: ["pull", "--ff-only"], |
| 2528 | }), |
| 2529 | detail: "Cannot pull from detached HEAD.", |
| 2530 | }); |
| 2531 | } |
| 2532 | if (!details.hasUpstream) { |
| 2533 | return yield* new GitCommandError({ |
| 2534 | ...gitCommandContext({ |
| 2535 | operation: "GitVcsDriver.pullCurrentBranch", |
| 2536 | cwd, |
| 2537 | args: ["pull", "--ff-only"], |
| 2538 | }), |
| 2539 | detail: "Current branch has no upstream configured. Push with upstream first.", |
| 2540 | }); |
| 2541 | } |
| 2542 | const beforeSha = yield* runGitStdout( |
| 2543 | "GitVcsDriver.pullCurrentBranch.beforeSha", |
| 2544 | cwd, |
| 2545 | ["rev-parse", "HEAD"], |
| 2546 | true, |
| 2547 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 2548 | // After a successful pull, HEAD before and after decides the result, so its output is not |
| 2549 | // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a |
| 2550 | // pull Git applied. |
| 2551 | yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { |
| 2552 | timeoutMs: 30_000, |
| 2553 | fallbackErrorDetail: "git pull failed", |
| 2554 | appendTruncationMarker: true, |
| 2555 | }); |
| 2556 | const afterSha = yield* runGitStdout( |
| 2557 | "GitVcsDriver.pullCurrentBranch.afterSha", |
| 2558 | cwd, |
| 2559 | ["rev-parse", "HEAD"], |
| 2560 | true, |
| 2561 | ).pipe(Effect.map((stdout) => stdout.trim())); |
| 2562 | |
| 2563 | const refreshed = yield* statusDetails(cwd); |
| 2564 | return { |
| 2565 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 2566 | refName, |
| 2567 | upstreamRef: refreshed.upstreamRef, |
| 2568 | }; |
| 2569 | }); |
| 2570 | |
| 2571 | const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( |
| 2572 | "readRangeContext", |
| 2573 | )(function* (cwd, baseRef) { |
| 2574 | const commitRange = `${baseRef}..HEAD`; |
| 2575 | // PR diffs start at the common ancestor when the base branch has advanced. |
| 2576 | const diffRange = `${baseRef}...HEAD`; |
| 2577 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( |
| 2578 | [ |
| 2579 | runGitStdoutWithOptions( |
| 2580 | "GitVcsDriver.readRangeContext.log", |
| 2581 | cwd, |
| 2582 | ["log", "--oneline", commitRange], |
| 2583 | { |
| 2584 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, |
| 2585 | appendTruncationMarker: true, |
| 2586 | }, |
| 2587 | ), |
| 2588 | runGitStdoutWithOptions( |
| 2589 | "GitVcsDriver.readRangeContext.diffStat", |
| 2590 | cwd, |
| 2591 | ["diff", "--stat", diffRange], |
| 2592 | { |
| 2593 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, |
| 2594 | appendTruncationMarker: true, |
| 2595 | }, |
| 2596 | ), |
| 2597 | runGitStdoutWithOptions( |
| 2598 | "GitVcsDriver.readRangeContext.diffPatch", |
| 2599 | cwd, |
| 2600 | ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], |
| 2601 | { |
| 2602 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| 2603 | appendTruncationMarker: true, |
| 2604 | }, |
| 2605 | ), |
| 2606 | ], |
| 2607 | { concurrency: "unbounded" }, |
| 2608 | ); |
| 2609 | |
| 2610 | return { |
| 2611 | commitSummary, |
| 2612 | diffSummary, |
| 2613 | diffPatch, |
| 2614 | }; |
| 2615 | }); |
| 2616 | |
| 2617 | // Use the same temporary index for patch and statistics so unstaged renames agree. |
| 2618 | const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* ( |
| 2619 | cwd: string, |
| 2620 | untrackedPaths: ReadonlyArray<string>, |
| 2621 | ) { |
| 2622 | const [stagedDeletionsStdout, indexValue] = yield* Effect.all( |
| 2623 | [ |
| 2624 | runGitStdoutWithOptions( |
| 2625 | "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", |
| 2626 | cwd, |
| 2627 | ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"], |
| 2628 | { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, |
| 2629 | ), |
| 2630 | runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [ |
| 2631 | "rev-parse", |
| 2632 | "--git-path", |
| 2633 | "index", |
| 2634 | ]), |
| 2635 | ], |
| 2636 | { concurrency: 2 }, |
| 2637 | ); |
| 2638 | const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); |
| 2639 | const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); |
| 2640 | if (pathsToAdd.length === 0) return undefined; |
| 2641 | |
| 2642 | const indexPath = path.isAbsolute(indexValue.trim()) |
| 2643 | ? indexValue.trim() |
| 2644 | : path.resolve(cwd, indexValue.trim()); |
| 2645 | const tempIndexPath = yield* fileSystem.makeTempFileScoped({ |
| 2646 | prefix: `t3code-review-index-${process.pid}-`, |
| 2647 | }); |
| 2648 | const indexExists = yield* fileSystem.exists(indexPath); |
| 2649 | if (indexExists) { |
| 2650 | const { mtime } = yield* fileSystem.stat(indexPath); |
| 2651 | yield* fileSystem.copyFile(indexPath, tempIndexPath); |
| 2652 | // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. |
| 2653 | // Flooring preserves the source second without making preceding-second files racy. |
| 2654 | const indexTime = Option.isSome(mtime) |
| 2655 | ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) |
| 2656 | : 0; |
| 2657 | yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); |
| 2658 | } |
| 2659 | const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; |
| 2660 | const tempIndexConfig = [ |
| 2661 | "-c", |
| 2662 | "core.splitIndex=false", |
| 2663 | "-c", |
| 2664 | "splitIndex.sharedIndexExpire=never", |
| 2665 | ]; |
| 2666 | if (!indexExists) { |
| 2667 | yield* executeGit("GitVcsDriver.review.emptyIndex", cwd, ["read-tree", "--empty"], { env }); |
| 2668 | } |
| 2669 | yield* executeGit( |
| 2670 | "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.expandSplitIndex", |
| 2671 | cwd, |
| 2672 | [...tempIndexConfig, "update-index", "--no-split-index"], |
| 2673 | { env }, |
| 2674 | ); |
| 2675 | yield* executeGit( |
| 2676 | "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.addUntracked", |
| 2677 | cwd, |
| 2678 | [ |
| 2679 | ...tempIndexConfig, |
| 2680 | "--literal-pathspecs", |
| 2681 | "add", |
| 2682 | "--intent-to-add", |
| 2683 | "--pathspec-from-file=-", |
| 2684 | "--pathspec-file-nul", |
| 2685 | ], |
| 2686 | { env, stdin: `${pathsToAdd.join("\0")}\0` }, |
| 2687 | ); |
| 2688 | return env; |
| 2689 | }); |
| 2690 | |
| 2691 | // Before the first commit, review diffs compare with the empty tree instead of HEAD. |
| 2692 | const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) { |
| 2693 | const stdout = yield* runGitStdout("GitVcsDriver.review.emptyTree", cwd, [ |
| 2694 | "hash-object", |
| 2695 | "-t", |
| 2696 | "tree", |
| 2697 | (yield* HostProcess.Platform) === "win32" ? "NUL" : "/dev/null", |
| 2698 | ]); |
| 2699 | return stdout.trim(); |
| 2700 | }); |
| 2701 | |
| 2702 | // Lists untracked files and adds them to a temporary index, so a diff against any commit |
| 2703 | // shows them as new. Returns null when the list is too big to read or to index. Needs a Scope. |
| 2704 | const prepareUntrackedReviewIndex = Effect.fn("prepareUntrackedReviewIndex")(function* ( |
| 2705 | cwd: string, |
| 2706 | pathArgs: ReadonlyArray<string>, |
| 2707 | onlyPath?: string, |
| 2708 | ) { |
| 2709 | const untracked = yield* executeGit( |
| 2710 | "GitVcsDriver.review.listUntracked", |
| 2711 | cwd, |
| 2712 | ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], |
| 2713 | { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, |
| 2714 | ).pipe( |
| 2715 | Effect.catchIf( |
| 2716 | (error) => error.outputLength === undefined, |
| 2717 | () => Effect.succeed(null), |
| 2718 | ), |
| 2719 | ); |
| 2720 | if (untracked === null) return null; |
| 2721 | const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( |
| 2722 | (candidate) => onlyPath === undefined || candidate === onlyPath, |
| 2723 | ); |
| 2724 | if (paths.length === 0) return { env: undefined }; |
| 2725 | if (paths.length > REVIEW_UNTRACKED_MAX_FILES) return null; |
| 2726 | const env = yield* prepareReviewIndex(cwd, paths).pipe( |
| 2727 | Effect.catchTags({ |
| 2728 | PlatformError: (cause) => |
| 2729 | Effect.fail( |
| 2730 | new GitCommandError({ |
| 2731 | operation: "GitVcsDriver.prepareReviewIndex", |
| 2732 | cwd, |
| 2733 | command: "git diff", |
| 2734 | detail: "Could not prepare the review index.", |
| 2735 | cause, |
| 2736 | }), |
| 2737 | ), |
| 2738 | }), |
| 2739 | ); |
| 2740 | return { env }; |
| 2741 | }); |
| 2742 | |
| 2743 | // The diff panel's Changes view compares the working tree with merge-base(base, HEAD). |
| 2744 | // With no usable base it compares with HEAD, so Changes equals Uncommitted. |
| 2745 | const resolveReviewMergeBase = Effect.fn("resolveReviewMergeBase")(function* ( |
| 2746 | cwd: string, |
| 2747 | branch: string | null, |
| 2748 | explicitBaseRef?: string, |
| 2749 | ) { |
| 2750 | const baseRef = |
| 2751 | explicitBaseRef ?? |
| 2752 | (branch |
| 2753 | ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( |
| 2754 | Effect.orElseSucceed(() => null), |
| 2755 | ) |
| 2756 | : null); |
| 2757 | if (baseRef === null) return { baseRef, mergeBase: "HEAD" }; |
| 2758 | const args = ["merge-base", baseRef, "HEAD"]; |
| 2759 | const result = yield* executeGit("GitVcsDriver.resolveReviewMergeBase", cwd, args, { |
| 2760 | allowNonZeroExit: true, |
| 2761 | }); |
| 2762 | const mergeBase = result.stdout.trim(); |
| 2763 | if (result.exitCode !== 0 || mergeBase.length === 0) { |
| 2764 | // Before the first commit there is nothing to compare with, so Changes equals Uncommitted. |
| 2765 | const head = yield* executeGit( |
| 2766 | "GitVcsDriver.resolveReviewMergeBase.head", |
| 2767 | cwd, |
| 2768 | ["rev-parse", "--verify", "--quiet", "HEAD"], |
| 2769 | { allowNonZeroExit: true }, |
| 2770 | ); |
| 2771 | if (explicitBaseRef === undefined && head.exitCode !== 0) { |
| 2772 | return { baseRef: null, mergeBase: "HEAD" }; |
| 2773 | } |
| 2774 | return yield* new GitCommandError({ |
| 2775 | ...gitCommandContext({ operation: "GitVcsDriver.resolveReviewMergeBase", cwd, args }), |
| 2776 | detail: `Could not find a common commit between '${baseRef}' and HEAD.`, |
| 2777 | exitCode: result.exitCode, |
| 2778 | }); |
| 2779 | } |
| 2780 | return { baseRef, mergeBase }; |
| 2781 | }); |
| 2782 | |
| 2783 | // Totals for the thread panel's Changes row. Same base and untracked files as the Changes view. |
| 2784 | const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( |
| 2785 | cwd: string, |
| 2786 | branch: string | null, |
| 2787 | ) { |
| 2788 | const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); |
| 2789 | const untracked = yield* prepareUntrackedReviewIndex(cwd, []); |
| 2790 | if (untracked === null) { |
| 2791 | return yield* new GitCommandError({ |
| 2792 | operation: "GitVcsDriver.readBranchChangeTotals", |
| 2793 | command: "git ls-files", |
| 2794 | cwd, |
| 2795 | detail: "Too many untracked files to count.", |
| 2796 | }); |
| 2797 | } |
| 2798 | const readNumstat = (ref: string) => |
| 2799 | executeGit( |
| 2800 | "GitVcsDriver.readBranchChangeTotals", |
| 2801 | cwd, |
| 2802 | [...REVIEW_DIFF_ARGS, "--numstat", "-z", ref, "--"], |
| 2803 | { |
| 2804 | allowNonZeroExit: true, |
| 2805 | maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, |
| 2806 | env: untracked.env, |
| 2807 | }, |
| 2808 | ); |
| 2809 | let result = yield* readNumstat(mergeBase); |
| 2810 | if (result.exitCode !== 0 && mergeBase === "HEAD" && isUnbornHeadStderr(result.stderr)) { |
| 2811 | result = yield* readNumstat(yield* readEmptyTreeHash(cwd)); |
| 2812 | } |
| 2813 | if (result.exitCode !== 0) { |
| 2814 | return yield* new GitCommandError({ |
| 2815 | operation: "GitVcsDriver.readBranchChangeTotals", |
| 2816 | command: "git diff --numstat", |
| 2817 | cwd, |
| 2818 | detail: "Could not read Changes totals.", |
| 2819 | exitCode: result.exitCode, |
| 2820 | }); |
| 2821 | } |
| 2822 | let insertions = 0; |
| 2823 | let deletions = 0; |
| 2824 | for (const file of parseReviewNumstat(result.stdout)) { |
| 2825 | insertions += file.additions; |
| 2826 | deletions += file.deletions; |
| 2827 | } |
| 2828 | return { baseRef, insertions, deletions }; |
| 2829 | }, Effect.scoped); |
| 2830 | |
| 2831 | const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( |
| 2832 | input: ReviewDiffPreviewInput, |
| 2833 | ) { |
| 2834 | const pathArgs = input.file |
| 2835 | ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map( |
| 2836 | (path) => `:(top,literal)${path}`, |
| 2837 | ) |
| 2838 | : []; |
| 2839 | const patchLimit = input.file |
| 2840 | ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES |
| 2841 | : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; |
| 2842 | const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe( |
| 2843 | Effect.catchTags({ |
| 2844 | GitCommandError: (error) => |
| 2845 | isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), |
| 2846 | }), |
| 2847 | ); |
| 2848 | if (!repository?.worktreeRoot) { |
| 2849 | return { |
| 2850 | cwd: input.cwd, |
| 2851 | generatedAt: yield* DateTime.now, |
| 2852 | sources: [], |
| 2853 | }; |
| 2854 | } |
| 2855 | |
| 2856 | const cwd = repository.worktreeRoot; |
| 2857 | // A per-file request only reads its own source. |
| 2858 | const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD"; |
| 2859 | const review = |
| 2860 | input.file?.sourceKind === "working-tree" |
| 2861 | ? { baseRef: input.baseRef ?? null, mergeBase: null } |
| 2862 | : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef); |
| 2863 | |
| 2864 | const diffArgs = [ |
| 2865 | ...REVIEW_DIFF_ARGS, |
| 2866 | ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), |
| 2867 | ]; |
| 2868 | const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( |
| 2869 | ref: string, |
| 2870 | env?: NodeJS.ProcessEnv, |
| 2871 | ) { |
| 2872 | const args = [...diffArgs, "--numstat", "-z"]; |
| 2873 | const result = yield* executeGit( |
| 2874 | "GitVcsDriver.getReviewDiffPreview.stat", |
| 2875 | cwd, |
| 2876 | [...args, ref, "--", ...pathArgs], |
| 2877 | { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env }, |
| 2878 | ); |
| 2879 | if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; |
| 2880 | if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { |
| 2881 | const emptyTree = yield* readEmptyTreeHash(cwd); |
| 2882 | const stdout = yield* runGitStdoutWithOptions( |
| 2883 | "GitVcsDriver.getReviewDiffPreview.unbornStat", |
| 2884 | cwd, |
| 2885 | [...args, emptyTree, "--", ...pathArgs], |
| 2886 | { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env }, |
| 2887 | ); |
| 2888 | return { ref: emptyTree, files: parseReviewNumstat(stdout) }; |
| 2889 | } |
| 2890 | return yield* new GitCommandError({ |
| 2891 | operation: "GitVcsDriver.getReviewDiffPreview.stat", |
| 2892 | cwd, |
| 2893 | command: "git diff --numstat", |
| 2894 | detail: "Could not read complete diff statistics.", |
| 2895 | exitCode: result.exitCode, |
| 2896 | }); |
| 2897 | }); |
| 2898 | // One commit argument diffs that commit against the working tree. |
| 2899 | const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( |
| 2900 | ref: string | null, |
| 2901 | env?: NodeJS.ProcessEnv, |
| 2902 | ) { |
| 2903 | if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] }; |
| 2904 | const stat = yield* readStats(ref, env); |
| 2905 | if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] }; |
| 2906 | const patch = yield* executeGit( |
| 2907 | "GitVcsDriver.getReviewDiffPreview.patch", |
| 2908 | cwd, |
| 2909 | [...diffArgs, "--patch", stat.ref, "--", ...pathArgs], |
| 2910 | { maxOutputBytes: patchLimit, appendTruncationMarker: true, env }, |
| 2911 | ); |
| 2912 | return { ...patch, files: stat.files }; |
| 2913 | }); |
| 2914 | const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () { |
| 2915 | const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); |
| 2916 | // With no base both sources diff HEAD, so read it once. |
| 2917 | const [dirty, base] = |
| 2918 | review.mergeBase === dirtyRef |
| 2919 | ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe( |
| 2920 | Effect.map((result) => [result, result] as const), |
| 2921 | ) |
| 2922 | : yield* Effect.all( |
| 2923 | [ |
| 2924 | readTrackedDiff(dirtyRef, untracked?.env), |
| 2925 | readTrackedDiff(review.mergeBase, untracked?.env), |
| 2926 | ], |
| 2927 | { concurrency: 2 }, |
| 2928 | ); |
| 2929 | if (untracked !== null) return [dirty, base] as const; |
| 2930 | // Too many untracked files to list: show tracked changes and mark totals incomplete. |
| 2931 | const incomplete = (ref: string | null, result: typeof dirty) => |
| 2932 | ref === null ? result : { ...result, files: undefined, stdoutTruncated: true }; |
| 2933 | return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const; |
| 2934 | }).pipe(Effect.scoped); |
| 2935 | const dirtyFiles = dirtyTrackedResult.files; |
| 2936 | const baseFiles = baseResult.files; |
| 2937 | const dirtyDiff = dirtyTrackedResult.stdout; |
| 2938 | const baseDiff = baseResult.stdout; |
| 2939 | const hashDiff = (diff: string, files: ReadonlyArray<ReviewDiffFileStat>) => |
| 2940 | crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( |
| 2941 | Effect.map(Hex.encode), |
| 2942 | Effect.mapError( |
| 2943 | (cause) => |
| 2944 | new GitCommandError({ |
| 2945 | operation: "GitVcsDriver.getReviewDiffPreview.hash", |
| 2946 | command: "crypto.digest SHA-256", |
| 2947 | cwd, |
| 2948 | detail: "Failed to hash review diff.", |
| 2949 | cause, |
| 2950 | }), |
| 2951 | ), |
| 2952 | ); |
| 2953 | const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ |
| 2954 | hashDiff(dirtyDiff, dirtyFiles ?? []), |
| 2955 | hashDiff(baseDiff, baseFiles ?? []), |
| 2956 | ]); |
| 2957 | |
| 2958 | const sources: ReviewDiffPreviewSource[] = [ |
| 2959 | { |
| 2960 | id: "working-tree", |
| 2961 | kind: "working-tree", |
| 2962 | title: "Uncommitted", |
| 2963 | baseRef: "HEAD", |
| 2964 | headRef: null, |
| 2965 | diff: dirtyDiff, |
| 2966 | ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }), |
| 2967 | diffHash: dirtyDiffHash, |
| 2968 | truncated: dirtyTrackedResult.stdoutTruncated, |
| 2969 | }, |
| 2970 | { |
| 2971 | id: "branch-range", |
| 2972 | kind: "branch-range", |
| 2973 | title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes", |
| 2974 | baseRef: review.baseRef, |
| 2975 | // For display only. The new side is the working tree. |
| 2976 | headRef: repository.currentBranch ?? "HEAD", |
| 2977 | diff: baseDiff, |
| 2978 | ...(baseFiles === undefined ? {} : { files: baseFiles }), |
| 2979 | diffHash: baseDiffHash, |
| 2980 | truncated: baseResult.stdoutTruncated, |
| 2981 | }, |
| 2982 | ]; |
| 2983 | |
| 2984 | return { |
| 2985 | cwd: input.cwd, |
| 2986 | generatedAt: yield* DateTime.now, |
| 2987 | sources, |
| 2988 | }; |
| 2989 | }); |
| 2990 | |
| 2991 | const reviewDiffFileError = ( |
| 2992 | input: ReviewDiffFileContentsInput, |
| 2993 | detail: string, |
| 2994 | cause?: unknown, |
| 2995 | ) => |
| 2996 | new GitCommandError({ |
| 2997 | operation: "GitVcsDriver.getReviewDiffFileContents", |
| 2998 | command: "git", |
| 2999 | cwd: input.cwd, |
| 3000 | detail, |
| 3001 | ...(cause === undefined ? {} : { cause }), |
| 3002 | }); |
| 3003 | |
| 3004 | const isPathWithinRoot = (root: string, candidate: string) => { |
| 3005 | const relative = path.relative(root, candidate); |
| 3006 | return ( |
| 3007 | relative === "" || |
| 3008 | (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) |
| 3009 | ); |
| 3010 | }; |
| 3011 | |
| 3012 | const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* ( |
| 3013 | input: ReviewDiffFileContentsInput, |
| 3014 | revision: string, |
| 3015 | relativePath: string, |
| 3016 | ) { |
| 3017 | const result = yield* executeGit( |
| 3018 | "GitVcsDriver.getReviewDiffFileContents.revision", |
| 3019 | input.cwd, |
| 3020 | ["show", `${revision}:${relativePath}`], |
| 3021 | { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES }, |
| 3022 | ); |
| 3023 | if (result.stdout.includes("\0")) { |
| 3024 | return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`); |
| 3025 | } |
| 3026 | return result.stdout; |
| 3027 | }); |
| 3028 | |
| 3029 | const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* ( |
| 3030 | input: ReviewDiffFileContentsInput, |
| 3031 | repositoryRoot: string, |
| 3032 | ) { |
| 3033 | const fileError = (stage: string, detail: string, cause?: unknown) => |
| 3034 | new GitCommandError({ |
| 3035 | operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`, |
| 3036 | command: stage, |
| 3037 | cwd: input.cwd, |
| 3038 | detail, |
| 3039 | ...(cause === undefined ? {} : { cause }), |
| 3040 | }); |
| 3041 | const requestedPath = path.resolve(repositoryRoot, input.newPath); |
| 3042 | if (!isPathWithinRoot(repositoryRoot, requestedPath)) { |
| 3043 | return yield* fileError( |
| 3044 | "path.resolve", |
| 3045 | `Diff file '${input.newPath}' resolves outside the review workspace.`, |
| 3046 | ); |
| 3047 | } |
| 3048 | |
| 3049 | const [realRepositoryRoot, realTarget] = yield* Effect.all([ |
| 3050 | fileSystem.realPath(repositoryRoot), |
| 3051 | fileSystem.realPath(requestedPath), |
| 3052 | ]).pipe( |
| 3053 | Effect.mapError((cause) => |
| 3054 | fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause), |
| 3055 | ), |
| 3056 | ); |
| 3057 | if (!isPathWithinRoot(realRepositoryRoot, realTarget)) { |
| 3058 | return yield* fileError( |
| 3059 | "fs.realPath", |
| 3060 | `Diff file '${input.newPath}' resolves outside the review workspace.`, |
| 3061 | ); |
| 3062 | } |
| 3063 | |
| 3064 | const info = yield* fileSystem |
| 3065 | .stat(realTarget) |
| 3066 | .pipe( |
| 3067 | Effect.mapError((cause) => |
| 3068 | fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause), |
| 3069 | ), |
| 3070 | ); |
| 3071 | if (info.type !== "File") { |
| 3072 | return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`); |
| 3073 | } |
| 3074 | if (info.size > BigInt(REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES)) { |
| 3075 | return yield* fileError( |
| 3076 | "fs.stat", |
| 3077 | `Diff file '${input.newPath}' exceeds the 1 MB expansion limit.`, |
| 3078 | ); |
| 3079 | } |
| 3080 | |
| 3081 | const bytes = yield* fileSystem |
| 3082 | .readFile(realTarget) |
| 3083 | .pipe( |
| 3084 | Effect.mapError((cause) => |
| 3085 | fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause), |
| 3086 | ), |
| 3087 | ); |
| 3088 | if (bytes.includes(0)) { |
| 3089 | return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); |
| 3090 | } |
| 3091 | return new TextDecoder("utf-8").decode(bytes); |
| 3092 | }); |
| 3093 | |
| 3094 | // Both views compare a commit with the working tree: HEAD for Uncommitted, |
| 3095 | // merge-base(base, HEAD) for Changes. The new side always comes from disk. |
| 3096 | const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( |
| 3097 | input: ReviewDiffFileContentsInput, |
| 3098 | ) { |
| 3099 | const repositoryRoot = yield* runGitStdout( |
| 3100 | "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", |
| 3101 | input.cwd, |
| 3102 | ["rev-parse", "--show-toplevel"], |
| 3103 | ).pipe(Effect.map((value) => value.trim())); |
| 3104 | if (repositoryRoot.length === 0) { |
| 3105 | return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); |
| 3106 | } |
| 3107 | const oldRevision = |
| 3108 | input.sourceKind === "working-tree" |
| 3109 | ? (input.baseRef ?? "HEAD") |
| 3110 | : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase; |
| 3111 | const [oldContents, newContents] = yield* Effect.all( |
| 3112 | [ |
| 3113 | input.changeType === "new" |
| 3114 | ? Effect.succeed("") |
| 3115 | : readReviewFileAtRevision(input, oldRevision, input.oldPath), |
| 3116 | input.changeType === "deleted" |
| 3117 | ? Effect.succeed("") |
| 3118 | : readWorkingTreeReviewFile(input, repositoryRoot), |
| 3119 | ], |
| 3120 | { concurrency: 2 }, |
| 3121 | ); |
| 3122 | return { oldContents, newContents }; |
| 3123 | }); |
| 3124 | |
| 3125 | const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => |
| 3126 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( |
| 3127 | Effect.map((stdout) => stdout.trim()), |
| 3128 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), |
| 3129 | ); |
| 3130 | |
| 3131 | const resolveBranchName = Effect.fn("GitVcsDriver.resolveBranchName")(function* ( |
| 3132 | cwd: string, |
| 3133 | refName: string, |
| 3134 | ) { |
| 3135 | const result = yield* executeGit( |
| 3136 | "GitVcsDriver.resolveBranchName.resolveRef", |
| 3137 | cwd, |
| 3138 | ["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", refName], |
| 3139 | { allowNonZeroExit: true }, |
| 3140 | ); |
| 3141 | if (result.exitCode !== 0) { |
| 3142 | // Remote-only bases are often recorded by their unqualified branch name. |
| 3143 | const remoteName = yield* resolvePrimaryRemoteName(cwd).pipe( |
| 3144 | Effect.orElseSucceed(() => null), |
| 3145 | ); |
| 3146 | return remoteName && (yield* remoteBranchExists({ cwd, remoteName, refName })) |
| 3147 | ? refName |
| 3148 | : null; |
| 3149 | } |
| 3150 | const ref = result.stdout.trim(); |
| 3151 | if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length); |
| 3152 | if (!ref.startsWith("refs/remotes/")) return null; |
| 3153 | const remoteNames = yield* listRemoteNames(cwd); |
| 3154 | return ( |
| 3155 | parseRemoteRefWithRemoteNames( |
| 3156 | ref.slice("refs/remotes/".length), |
| 3157 | remoteNames.toSorted((left, right) => right.length - left.length), |
| 3158 | )?.branchName ?? null |
| 3159 | ); |
| 3160 | }); |
| 3161 | |
| 3162 | const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { |
| 3163 | const fetchCwd = |
| 3164 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| 3165 | const gitDirArgs = ["--git-dir", gitCommonDir] as const; |
| 3166 | const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( |
| 3167 | [ |
| 3168 | executeGitWithStableDiagnostics( |
| 3169 | "GitVcsDriver.listRefs.snapshotRefs", |
| 3170 | fetchCwd, |
| 3171 | [ |
| 3172 | ...gitDirArgs, |
| 3173 | "for-each-ref", |
| 3174 | "--format=%(refname)%09%(committerdate:unix)%09%(symref)", |
| 3175 | "refs/heads", |
| 3176 | "refs/remotes", |
| 3177 | ], |
| 3178 | { |
| 3179 | timeoutMs: 30_000, |
| 3180 | maxOutputBytes: 16 * 1024 * 1024, |
| 3181 | fallbackErrorDetail: "Git ref snapshot enumeration failed.", |
| 3182 | }, |
| 3183 | ), |
| 3184 | executeGit( |
| 3185 | "GitVcsDriver.listRefs.defaultRef", |
| 3186 | fetchCwd, |
| 3187 | [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"], |
| 3188 | { |
| 3189 | timeoutMs: 5_000, |
| 3190 | allowNonZeroExit: true, |
| 3191 | }, |
| 3192 | ), |
| 3193 | executeWorktreeList("GitVcsDriver.listRefs.worktreeList", fetchCwd, gitDirArgs, { |
| 3194 | timeoutMs: 30_000, |
| 3195 | allowNonZeroExit: true, |
| 3196 | maxOutputBytes: 16 * 1024 * 1024, |
| 3197 | }), |
| 3198 | executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { |
| 3199 | timeoutMs: 5_000, |
| 3200 | allowNonZeroExit: true, |
| 3201 | }), |
| 3202 | ], |
| 3203 | { concurrency: 2 }, |
| 3204 | ); |
| 3205 | |
| 3206 | const remoteNames = |
| 3207 | remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; |
| 3208 | if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { |
| 3209 | yield* Effect.logWarning( |
| 3210 | `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, |
| 3211 | ); |
| 3212 | } |
| 3213 | const defaultBranch = |
| 3214 | defaultRefResult.exitCode === 0 |
| 3215 | ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") |
| 3216 | : null; |
| 3217 | const parsedWorktreeEntries = |
| 3218 | worktreeListResult.exitCode === 0 |
| 3219 | ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( |
| 3220 | ([branchName, worktreePath]) => |
| 3221 | [branchName, path.normalize(path.resolve(worktreePath))] as const, |
| 3222 | ) |
| 3223 | : []; |
| 3224 | const existingWorktreeEntries = yield* Effect.filter( |
| 3225 | parsedWorktreeEntries, |
| 3226 | ([, worktreePath]) => |
| 3227 | fileSystem.stat(worktreePath).pipe( |
| 3228 | Effect.as(true), |
| 3229 | Effect.orElseSucceed(() => false), |
| 3230 | ), |
| 3231 | { concurrency: 16 }, |
| 3232 | ); |
| 3233 | const worktreeMap = new Map(existingWorktreeEntries); |
| 3234 | const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; |
| 3235 | const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; |
| 3236 | |
| 3237 | for (const line of refsResult.stdout.split("\n")) { |
| 3238 | if (line.length === 0) continue; |
| 3239 | const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); |
| 3240 | if (!fullRefName || symbolicTarget) continue; |
| 3241 | const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); |
| 3242 | const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; |
| 3243 | |
| 3244 | if (fullRefName.startsWith("refs/heads/")) { |
| 3245 | const name = fullRefName.slice("refs/heads/".length); |
| 3246 | localBranches.push({ |
| 3247 | ref: { |
| 3248 | name, |
| 3249 | current: false, |
| 3250 | isRemote: false, |
| 3251 | isDefault: name === defaultBranch, |
| 3252 | worktreePath: worktreeMap.get(name) ?? null, |
| 3253 | }, |
| 3254 | lastCommit, |
| 3255 | }); |
| 3256 | continue; |
| 3257 | } |
| 3258 | if (!fullRefName.startsWith("refs/remotes/")) continue; |
| 3259 | |
| 3260 | const name = fullRefName.slice("refs/remotes/".length); |
| 3261 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames); |
| 3262 | const remoteBranch: VcsRef = { |
| 3263 | name, |
| 3264 | current: false, |
| 3265 | isRemote: true, |
| 3266 | isDefault: |
| 3267 | defaultBranch !== null && |
| 3268 | parsedRemoteRef?.remoteName === "origin" && |
| 3269 | parsedRemoteRef.branchName === defaultBranch, |
| 3270 | worktreePath: null, |
| 3271 | ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}), |
| 3272 | }; |
| 3273 | remoteBranches.push({ ref: remoteBranch, lastCommit }); |
| 3274 | } |
| 3275 | |
| 3276 | const byRecencyThenName = ( |
| 3277 | left: { readonly ref: VcsRef; readonly lastCommit: number }, |
| 3278 | right: { readonly ref: VcsRef; readonly lastCommit: number }, |
| 3279 | ) => |
| 3280 | left.lastCommit !== right.lastCommit |
| 3281 | ? right.lastCommit - left.lastCommit |
| 3282 | : left.ref.name.localeCompare(right.ref.name); |
| 3283 | |
| 3284 | return { |
| 3285 | localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), |
| 3286 | remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), |
| 3287 | hasPrimaryRemote: remoteNames.includes("origin"), |
| 3288 | } satisfies GitRefsSnapshot; |
| 3289 | }); |
| 3290 | |
| 3291 | const listRefsEpochByCommonDir = new Map<string, number>(); |
| 3292 | let listRefsEpochSequence = 0; |
| 3293 | const bumpListRefsEpoch = (gitCommonDir: string): number => { |
| 3294 | const nextEpoch = ++listRefsEpochSequence; |
| 3295 | listRefsEpochByCommonDir.delete(gitCommonDir); |
| 3296 | listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch); |
| 3297 | if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { |
| 3298 | const oldestKey = listRefsEpochByCommonDir.keys().next().value; |
| 3299 | if (oldestKey !== undefined) { |
| 3300 | listRefsEpochByCommonDir.delete(oldestKey); |
| 3301 | } |
| 3302 | } |
| 3303 | return nextEpoch; |
| 3304 | }; |
| 3305 | const listRefsGenerationByCommonDir = new Map<string, number>(); |
| 3306 | let listRefsGenerationSequence = 0; |
| 3307 | const setListRefsGeneration = (gitCommonDir: string, generation: number): number => { |
| 3308 | listRefsGenerationByCommonDir.delete(gitCommonDir); |
| 3309 | listRefsGenerationByCommonDir.set(gitCommonDir, generation); |
| 3310 | if (listRefsGenerationByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { |
| 3311 | const oldestKey = listRefsGenerationByCommonDir.keys().next().value; |
| 3312 | if (oldestKey !== undefined) { |
| 3313 | listRefsGenerationByCommonDir.delete(oldestKey); |
| 3314 | } |
| 3315 | } |
| 3316 | return generation; |
| 3317 | }; |
| 3318 | const currentListRefsGeneration = (gitCommonDir: string): number => { |
| 3319 | const current = listRefsGenerationByCommonDir.get(gitCommonDir); |
| 3320 | return current === undefined |
| 3321 | ? setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence) |
| 3322 | : setListRefsGeneration(gitCommonDir, current); |
| 3323 | }; |
| 3324 | const bumpListRefsGeneration = (gitCommonDir: string): number => |
| 3325 | setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence); |
| 3326 | const listRefsSnapshotCache = yield* Cache.makeWith( |
| 3327 | (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir), |
| 3328 | { |
| 3329 | capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, |
| 3330 | timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero), |
| 3331 | }, |
| 3332 | ); |
| 3333 | const listRefsRefreshSnapshotCache = yield* Cache.makeWith( |
| 3334 | (cacheKey: GitRefsRefreshCacheKey) => |
| 3335 | Effect.suspend(() => { |
| 3336 | const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir); |
| 3337 | return Cache.get( |
| 3338 | listRefsSnapshotCache, |
| 3339 | new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }), |
| 3340 | ); |
| 3341 | }), |
| 3342 | { |
| 3343 | capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, |
| 3344 | timeToLive: (exit) => |
| 3345 | Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN, |
| 3346 | }, |
| 3347 | ); |
| 3348 | const resolveListRefsSnapshot = Effect.fn("resolveListRefsSnapshot")(function* ( |
| 3349 | gitCommonDir: string, |
| 3350 | refresh: boolean, |
| 3351 | ) { |
| 3352 | while (true) { |
| 3353 | const generation = currentListRefsGeneration(gitCommonDir); |
| 3354 | const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); |
| 3355 | const snapshot = |
| 3356 | refresh || currentEpoch === undefined |
| 3357 | ? // The refresh cache owns the complete snapshot read, rather than only the |
| 3358 | // epoch bump. Slow repositories therefore remain singleflight for the |
| 3359 | // entire Git scan even when more refresh requests arrive after the |
| 3360 | // coalescing TTL would otherwise have elapsed. |
| 3361 | yield* Cache.get( |
| 3362 | listRefsRefreshSnapshotCache, |
| 3363 | new GitRefsRefreshCacheKey({ gitCommonDir, generation }), |
| 3364 | ) |
| 3365 | : yield* Cache.get( |
| 3366 | listRefsSnapshotCache, |
| 3367 | new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }), |
| 3368 | ); |
| 3369 | if (currentListRefsGeneration(gitCommonDir) === generation) { |
| 3370 | return snapshot; |
| 3371 | } |
| 3372 | } |
| 3373 | }); |
| 3374 | const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( |
| 3375 | cwd: string, |
| 3376 | ) { |
| 3377 | const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); |
| 3378 | const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); |
| 3379 | if (repositoryPaths === null) return; |
| 3380 | const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); |
| 3381 | bumpListRefsGeneration(repositoryPaths.gitCommonDir); |
| 3382 | bumpListRefsEpoch(repositoryPaths.gitCommonDir); |
| 3383 | yield* Cache.invalidate( |
| 3384 | listRefsRefreshSnapshotCache, |
| 3385 | new GitRefsRefreshCacheKey({ |
| 3386 | gitCommonDir: repositoryPaths.gitCommonDir, |
| 3387 | generation: previousGeneration, |
| 3388 | }), |
| 3389 | ); |
| 3390 | yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey); |
| 3391 | yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey); |
| 3392 | }); |
| 3393 | |
| 3394 | const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( |
| 3395 | function* (input) { |
| 3396 | const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( |
| 3397 | Effect.catchTags({ |
| 3398 | GitCommandError: (error) => |
| 3399 | isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), |
| 3400 | }), |
| 3401 | ); |
| 3402 | if (repositoryPaths === null) { |
| 3403 | return { |
| 3404 | refs: [], |
| 3405 | isRepo: false, |
| 3406 | hasPrimaryRemote: false, |
| 3407 | nextCursor: null, |
| 3408 | totalCount: 0, |
| 3409 | }; |
| 3410 | } |
| 3411 | |
| 3412 | const snapshot = yield* resolveListRefsSnapshot( |
| 3413 | repositoryPaths.gitCommonDir, |
| 3414 | input.refresh === true, |
| 3415 | ); |
| 3416 | const hasCurrentWorktreeBranch = |
| 3417 | repositoryPaths.worktreeRoot !== null && |
| 3418 | snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); |
| 3419 | const localBranches = snapshot.localBranches.map((ref) => ({ |
| 3420 | ...ref, |
| 3421 | current: hasCurrentWorktreeBranch |
| 3422 | ? ref.worktreePath === repositoryPaths.worktreeRoot |
| 3423 | : ref.name === repositoryPaths.currentBranch, |
| 3424 | })); |
| 3425 | const combinedBranches = input.includeMatchingRemoteRefs |
| 3426 | ? [...localBranches, ...snapshot.remoteBranches] |
| 3427 | : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); |
| 3428 | // Keep current/default refs on the first page even when the default |
| 3429 | // only exists as origin/<default> (remote refs sort after all locals). |
| 3430 | const allBranches = combinedBranches.toSorted((left, right) => { |
| 3431 | const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; |
| 3432 | const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; |
| 3433 | return leftPriority - rightPriority; |
| 3434 | }); |
| 3435 | const branchesForKind = |
| 3436 | input.refKind === "local" |
| 3437 | ? allBranches.filter((ref) => !ref.isRemote) |
| 3438 | : input.refKind === "remote" |
| 3439 | ? allBranches.filter((ref) => ref.isRemote) |
| 3440 | : allBranches; |
| 3441 | const refs = paginateBranches({ |
| 3442 | refs: filterBranchesForListQuery(branchesForKind, input.query), |
| 3443 | cursor: input.cursor, |
| 3444 | limit: input.limit, |
| 3445 | }); |
| 3446 | |
| 3447 | return { |
| 3448 | refs: [...refs.refs], |
| 3449 | isRepo: true, |
| 3450 | hasPrimaryRemote: snapshot.hasPrimaryRemote, |
| 3451 | nextCursor: refs.nextCursor, |
| 3452 | totalCount: refs.totalCount, |
| 3453 | }; |
| 3454 | }, |
| 3455 | ); |
| 3456 | |
| 3457 | const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( |
| 3458 | "createWorktree", |
| 3459 | )(function* (input, options) { |
| 3460 | const targetBranch = input.newRefName ?? input.refName; |
| 3461 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); |
| 3462 | const repoName = path.basename(input.cwd); |
| 3463 | let worktreePath = input.path; |
| 3464 | if (worktreePath == null) { |
| 3465 | const parentDir = resolveWorktreesDirectory( |
| 3466 | options?.worktreesDirectory ?? "", |
| 3467 | worktreesDir, |
| 3468 | path, |
| 3469 | yield* HostProcess.HomeDirectory, |
| 3470 | ); |
| 3471 | if (parentDir === null) { |
| 3472 | return yield* new GitCommandError({ |
| 3473 | operation: "GitVcsDriver.createWorktree", |
| 3474 | command: "git worktree add", |
| 3475 | cwd: input.cwd, |
| 3476 | detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Storage.`, |
| 3477 | }); |
| 3478 | } |
| 3479 | worktreePath = path.join(parentDir, repoName, sanitizedBranch); |
| 3480 | } |
| 3481 | const args = input.newRefName |
| 3482 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] |
| 3483 | : ["worktree", "add", worktreePath, input.refName]; |
| 3484 | const progress = options?.progress; |
| 3485 | const onCheckoutProgress = progress?.onCheckoutProgress; |
| 3486 | |
| 3487 | const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; |
| 3488 | yield* executeGit( |
| 3489 | "GitVcsDriver.createWorktree", |
| 3490 | input.cwd, |
| 3491 | ["-c", `checkout.workers=${checkoutWorkers}`, ...args], |
| 3492 | { |
| 3493 | fallbackErrorDetail: "git worktree add failed", |
| 3494 | timeoutMs: WORKTREE_ADD_TIMEOUT_MS, |
| 3495 | ...(onCheckoutProgress |
| 3496 | ? { |
| 3497 | // Git only prints checkout progress when stderr is a tty or the |
| 3498 | // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. |
| 3499 | env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, |
| 3500 | progress: { |
| 3501 | onStderrLine: (line) => { |
| 3502 | const parsed = parseGitCheckoutProgressLine(line); |
| 3503 | return parsed ? onCheckoutProgress(parsed) : Effect.void; |
| 3504 | }, |
| 3505 | }, |
| 3506 | } |
| 3507 | : {}), |
| 3508 | }, |
| 3509 | ); |
| 3510 | |
| 3511 | if (progress?.onWorktreeClaimed) { |
| 3512 | yield* progress.onWorktreeClaimed(worktreePath); |
| 3513 | } |
| 3514 | |
| 3515 | // `git worktree add` leaves submodules empty, so a repo that keeps agent |
| 3516 | // skills, tooling or source in one gets a worktree that is quietly missing |
| 3517 | // them. Best-effort: the objects are usually already in the parent's |
| 3518 | // `.git/modules`, but a first-ever clone needs the network, and failing to |
| 3519 | // populate a submodule must not roll back the caller's thread. Repos with |
| 3520 | // hundreds of nested submodules opt out or stop at the top level; the |
| 3521 | // caller resolves that from settings, or the checkout's t3.json decides. |
| 3522 | const hasSubmodules = yield* fileSystem |
| 3523 | .exists(path.join(worktreePath, ".gitmodules")) |
| 3524 | .pipe(Effect.orElseSucceed(() => false)); |
| 3525 | const submoduleMode = !hasSubmodules |
| 3526 | ? { value: "none" as const, source: "environment" as const } |
| 3527 | : resolveProjectFileBackedSetting( |
| 3528 | "worktreeSubmodules", |
| 3529 | options?.submodules ?? null, |
| 3530 | options?.submodules != null |
| 3531 | ? null |
| 3532 | : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( |
| 3533 | Effect.flatMap((contents) => { |
| 3534 | const file = parseT3ProjectFile(contents); |
| 3535 | return file === null |
| 3536 | ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { |
| 3537 | worktreePath, |
| 3538 | }).pipe(Effect.as(null)) |
| 3539 | : Effect.succeed(file); |
| 3540 | }), |
| 3541 | Effect.orElseSucceed(() => null), |
| 3542 | ), |
| 3543 | ); |
| 3544 | if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { |
| 3545 | yield* progress.onSubmodulesDisabled({ |
| 3546 | source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", |
| 3547 | }); |
| 3548 | } |
| 3549 | if (submoduleMode.value !== "none") { |
| 3550 | if (progress?.onSubmodulesStarted) { |
| 3551 | yield* progress.onSubmodulesStarted(); |
| 3552 | } |
| 3553 | const onSubmoduleLine = progress?.onSubmoduleLine; |
| 3554 | yield* runGit( |
| 3555 | "GitVcsDriver.createWorktree.updateSubmodules", |
| 3556 | worktreePath, |
| 3557 | submoduleMode.value === "recursive" |
| 3558 | ? ["submodule", "update", "--init", "--recursive"] |
| 3559 | : ["submodule", "update", "--init"], |
| 3560 | onSubmoduleLine |
| 3561 | ? { |
| 3562 | env: { LC_ALL: "C" }, |
| 3563 | progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, |
| 3564 | } |
| 3565 | : {}, |
| 3566 | ).pipe( |
| 3567 | Effect.matchEffect({ |
| 3568 | onFailure: (cause) => |
| 3569 | Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { |
| 3570 | worktreePath, |
| 3571 | cause, |
| 3572 | }).pipe( |
| 3573 | Effect.andThen( |
| 3574 | progress?.onSubmodulesFinished |
| 3575 | ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) |
| 3576 | : Effect.void, |
| 3577 | ), |
| 3578 | ), |
| 3579 | onSuccess: () => |
| 3580 | progress?.onSubmodulesFinished |
| 3581 | ? progress.onSubmodulesFinished({ ok: true, detail: null }) |
| 3582 | : Effect.void, |
| 3583 | }), |
| 3584 | ); |
| 3585 | } |
| 3586 | |
| 3587 | if (input.newRefName && input.baseRefName) { |
| 3588 | // A checkout can start at a commit or tag, but a PR must target a branch. |
| 3589 | const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName); |
| 3590 | if (baseBranch) { |
| 3591 | yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ |
| 3592 | "config", |
| 3593 | `branch.${input.newRefName}.gh-merge-base`, |
| 3594 | baseBranch, |
| 3595 | ]); |
| 3596 | } |
| 3597 | } |
| 3598 | |
| 3599 | return { |
| 3600 | worktree: { |
| 3601 | path: worktreePath, |
| 3602 | refName: targetBranch, |
| 3603 | }, |
| 3604 | }; |
| 3605 | }); |
| 3606 | |
| 3607 | const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = |
| 3608 | Effect.fn("fetchPullRequestBranch")(function* (input) { |
| 3609 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); |
| 3610 | yield* executeGit( |
| 3611 | "GitVcsDriver.fetchPullRequestBranch", |
| 3612 | input.cwd, |
| 3613 | [ |
| 3614 | "fetch", |
| 3615 | "--quiet", |
| 3616 | "--no-tags", |
| 3617 | remoteName, |
| 3618 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, |
| 3619 | ], |
| 3620 | { |
| 3621 | fallbackErrorDetail: "git fetch pull request branch failed", |
| 3622 | }, |
| 3623 | ); |
| 3624 | }); |
| 3625 | |
| 3626 | const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( |
| 3627 | "resolveCommit", |
| 3628 | )(function* (input) { |
| 3629 | const commitSha = yield* runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [ |
| 3630 | "rev-parse", |
| 3631 | "--verify", |
| 3632 | `${input.revision}^{commit}`, |
| 3633 | ]).pipe(Effect.map((stdout) => stdout.trim())); |
| 3634 | |
| 3635 | return { commitSha }; |
| 3636 | }); |
| 3637 | |
| 3638 | const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = |
| 3639 | Effect.fn("fetchPullRequestHeadCommit")(function* (input) { |
| 3640 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); |
| 3641 | // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a |
| 3642 | // branch, which is the only way to read it while that branch is checked out somewhere. |
| 3643 | yield* executeGit( |
| 3644 | "GitVcsDriver.fetchPullRequestHeadCommit", |
| 3645 | input.cwd, |
| 3646 | ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`], |
| 3647 | { |
| 3648 | fallbackErrorDetail: "git fetch pull request head failed", |
| 3649 | }, |
| 3650 | ); |
| 3651 | |
| 3652 | return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); |
| 3653 | }); |
| 3654 | |
| 3655 | const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = |
| 3656 | Effect.fn("refreshCheckedOutBranch")(function* (input) { |
| 3657 | const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" }); |
| 3658 | if (headCommit === input.targetCommit) { |
| 3659 | return { headCommit, moved: false, onTarget: true }; |
| 3660 | } |
| 3661 | |
| 3662 | const worktreeChanges = yield* runGitStdout( |
| 3663 | "GitVcsDriver.refreshCheckedOutBranch.status", |
| 3664 | input.cwd, |
| 3665 | ["status", "--porcelain"], |
| 3666 | ); |
| 3667 | if (worktreeChanges.trim().length > 0) { |
| 3668 | return { headCommit, moved: false, onTarget: false }; |
| 3669 | } |
| 3670 | |
| 3671 | const isAncestor = yield* executeGit( |
| 3672 | "GitVcsDriver.refreshCheckedOutBranch.isAncestor", |
| 3673 | input.cwd, |
| 3674 | ["merge-base", "--is-ancestor", headCommit, input.targetCommit], |
| 3675 | { allowNonZeroExit: true }, |
| 3676 | ).pipe(Effect.map((result) => result.exitCode === 0)); |
| 3677 | // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can |
| 3678 | // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD |
| 3679 | // never left the commit the upstream held before the fetch. |
| 3680 | if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { |
| 3681 | return { headCommit, moved: false, onTarget: false }; |
| 3682 | } |
| 3683 | |
| 3684 | if (!isAncestor) { |
| 3685 | // The commit being reset away is about to be reachable from nothing. It is only ever a |
| 3686 | // commit the remote already held, but "the remote held it" stops being a way back once |
| 3687 | // the head it belonged to has been rewritten, so a ref keeps it findable. |
| 3688 | yield* executeGit( |
| 3689 | "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", |
| 3690 | input.cwd, |
| 3691 | ["update-ref", "refs/t3code/pre-refresh", headCommit], |
| 3692 | { fallbackErrorDetail: "git failed to record the previous checkout commit" }, |
| 3693 | ); |
| 3694 | } |
| 3695 | |
| 3696 | yield* executeGit( |
| 3697 | "GitVcsDriver.refreshCheckedOutBranch.move", |
| 3698 | input.cwd, |
| 3699 | // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another |
| 3700 | // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` |
| 3701 | // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the |
| 3702 | // other branch — so a race loses nothing; the refresh fails and is reported instead. |
| 3703 | isAncestor |
| 3704 | ? ["merge", "--ff-only", input.targetCommit] |
| 3705 | : ["reset", "--merge", input.targetCommit], |
| 3706 | { |
| 3707 | timeoutMs: 30_000, |
| 3708 | fallbackErrorDetail: "git failed to move the checkout onto the pull request head", |
| 3709 | }, |
| 3710 | ); |
| 3711 | |
| 3712 | return { headCommit: input.targetCommit, moved: true, onTarget: true }; |
| 3713 | }); |
| 3714 | |
| 3715 | const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( |
| 3716 | function* (input) { |
| 3717 | const args = ["fetch", "--quiet", input.remoteName]; |
| 3718 | const options = { |
| 3719 | env: STATUS_UPSTREAM_REFRESH_ENV, |
| 3720 | fallbackErrorDetail: `git fetch ${input.remoteName} failed`, |
| 3721 | }; |
| 3722 | // Another fetch in the same repository, such as the background status |
| 3723 | // poll, can win the race for a remote-tracking ref. The loser fails, but |
| 3724 | // the ref is free again by the time a rerun needs it, so retry once. |
| 3725 | const runFetch = (fetchArgs: readonly string[]) => { |
| 3726 | const run = executeGitWithStableDiagnostics( |
| 3727 | "GitVcsDriver.fetchRemote", |
| 3728 | input.cwd, |
| 3729 | fetchArgs, |
| 3730 | { |
| 3731 | ...options, |
| 3732 | allowNonZeroExit: true, |
| 3733 | }, |
| 3734 | ); |
| 3735 | return run.pipe( |
| 3736 | Effect.flatMap((result) => |
| 3737 | result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), |
| 3738 | ), |
| 3739 | ); |
| 3740 | }; |
| 3741 | const fetchAll = runFetch(args).pipe( |
| 3742 | Effect.flatMap((result) => |
| 3743 | result.exitCode === 0 |
| 3744 | ? Effect.void |
| 3745 | : Effect.fail( |
| 3746 | new GitCommandError({ |
| 3747 | ...gitCommandContext({ |
| 3748 | operation: "GitVcsDriver.fetchRemote", |
| 3749 | cwd: input.cwd, |
| 3750 | args, |
| 3751 | }), |
| 3752 | detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, |
| 3753 | ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), |
| 3754 | stdoutLength: result.stdout.length, |
| 3755 | stderrLength: result.stderr.length, |
| 3756 | }), |
| 3757 | ), |
| 3758 | ), |
| 3759 | ); |
| 3760 | if (input.refName === undefined) { |
| 3761 | return yield* fetchAll.pipe(Effect.asVoid); |
| 3762 | } |
| 3763 | const branch = |
| 3764 | parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? |
| 3765 | input.refName; |
| 3766 | const scopedArgs = [ |
| 3767 | ...args, |
| 3768 | `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, |
| 3769 | ]; |
| 3770 | const result = yield* runFetch(scopedArgs); |
| 3771 | if (result.exitCode === 0) return; |
| 3772 | if ( |
| 3773 | result.stderr |
| 3774 | .split(/\r?\n/) |
| 3775 | .includes(`fatal: couldn't find remote ref refs/heads/${branch}`) |
| 3776 | ) { |
| 3777 | return yield* fetchAll.pipe(Effect.asVoid); |
| 3778 | } |
| 3779 | return yield* new GitCommandError({ |
| 3780 | ...gitCommandContext({ |
| 3781 | operation: "GitVcsDriver.fetchRemote", |
| 3782 | cwd: input.cwd, |
| 3783 | args: scopedArgs, |
| 3784 | }), |
| 3785 | detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, |
| 3786 | ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), |
| 3787 | stdoutLength: result.stdout.length, |
| 3788 | stderrLength: result.stderr.length, |
| 3789 | }); |
| 3790 | }, |
| 3791 | ); |
| 3792 | |
| 3793 | const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = |
| 3794 | Effect.fn("resolveRemoteTrackingCommit")(function* (input) { |
| 3795 | const remoteNames = yield* listRemoteNames(input.cwd); |
| 3796 | const parsedRemoteRef = parseRemoteRefWithRemoteNames( |
| 3797 | input.refName, |
| 3798 | remoteNames.toSorted((left, right) => right.length - left.length), |
| 3799 | ); |
| 3800 | const remoteRefName = |
| 3801 | parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; |
| 3802 | const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ |
| 3803 | "rev-parse", |
| 3804 | "--verify", |
| 3805 | `refs/remotes/${remoteRefName}^{commit}`, |
| 3806 | ]).pipe(Effect.map((stdout) => stdout.trim())); |
| 3807 | |
| 3808 | return { commitSha, remoteRefName }; |
| 3809 | }); |
| 3810 | |
| 3811 | const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( |
| 3812 | "fetchRemoteBranch", |
| 3813 | )(function* (input) { |
| 3814 | yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ |
| 3815 | "fetch", |
| 3816 | "--quiet", |
| 3817 | "--no-tags", |
| 3818 | input.remoteName, |
| 3819 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, |
| 3820 | ]); |
| 3821 | |
| 3822 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); |
| 3823 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; |
| 3824 | yield* runGit( |
| 3825 | "GitVcsDriver.fetchRemoteBranch.materialize", |
| 3826 | input.cwd, |
| 3827 | localBranchAlreadyExists |
| 3828 | ? ["branch", "--force", input.localBranch, targetRef] |
| 3829 | : ["branch", input.localBranch, targetRef], |
| 3830 | ); |
| 3831 | }); |
| 3832 | |
| 3833 | const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = |
| 3834 | Effect.fn("fetchRemoteTrackingBranch")(function* (input) { |
| 3835 | yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ |
| 3836 | "fetch", |
| 3837 | "--quiet", |
| 3838 | "--no-tags", |
| 3839 | input.remoteName, |
| 3840 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, |
| 3841 | ]); |
| 3842 | }); |
| 3843 | |
| 3844 | const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => |
| 3845 | runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ |
| 3846 | "branch", |
| 3847 | "--set-upstream-to", |
| 3848 | `${input.remoteName}/${input.remoteBranch}`, |
| 3849 | input.branch, |
| 3850 | ]); |
| 3851 | |
| 3852 | const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( |
| 3853 | "removeWorktree", |
| 3854 | )(function* (input) { |
| 3855 | // Git refuses to remove a worktree with untracked files unless forced, but |
| 3856 | // its check honors `status.showUntrackedFiles=no` and would delete them. |
| 3857 | const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; |
| 3858 | if (input.force) { |
| 3859 | args.push("--force"); |
| 3860 | } |
| 3861 | args.push(input.path); |
| 3862 | const result = yield* executeGitWithStableDiagnostics( |
| 3863 | "GitVcsDriver.removeWorktree", |
| 3864 | input.cwd, |
| 3865 | args, |
| 3866 | { |
| 3867 | // Removing dependency-heavy worktrees is filesystem-bound and can take |
| 3868 | // minutes, especially on Windows. Keep it bounded without interrupting |
| 3869 | // git midway through cleanup. |
| 3870 | timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, |
| 3871 | allowNonZeroExit: true, |
| 3872 | }, |
| 3873 | ); |
| 3874 | if (result.exitCode === 0) { |
| 3875 | return; |
| 3876 | } |
| 3877 | // Threads can share a worktree path, and worktrees get removed or pruned |
| 3878 | // outside the app, so a worktree that is already gone is a no-op rather |
| 3879 | // than an error. Prune so no stale registration lingers to block a later |
| 3880 | // `worktree add` at the same path. |
| 3881 | const alreadyGone = |
| 3882 | isMissingWorktreeStderr(result.stderr) && |
| 3883 | !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); |
| 3884 | if (alreadyGone) { |
| 3885 | yield* pruneWorktrees({ cwd: input.cwd }); |
| 3886 | return; |
| 3887 | } |
| 3888 | // Raw stderr stays out of both the wire error and the log (it can carry |
| 3889 | // secrets); log bounded diagnostics so a genuine failure is visible |
| 3890 | // server-side. |
| 3891 | yield* Effect.logWarning( |
| 3892 | `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, |
| 3893 | ); |
| 3894 | return yield* new GitCommandError({ |
| 3895 | ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), |
| 3896 | detail: "git worktree remove failed", |
| 3897 | ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), |
| 3898 | stdoutLength: result.stdout.length, |
| 3899 | stderrLength: result.stderr.length, |
| 3900 | }); |
| 3901 | }); |
| 3902 | |
| 3903 | const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( |
| 3904 | "pruneWorktrees", |
| 3905 | )(function* (input) { |
| 3906 | yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { |
| 3907 | timeoutMs: 15_000, |
| 3908 | fallbackErrorDetail: "git worktree prune failed", |
| 3909 | }); |
| 3910 | }); |
| 3911 | |
| 3912 | const deleteLocalBranch: GitVcsDriver.GitVcsDriver["Service"]["deleteLocalBranch"] = Effect.fn( |
| 3913 | "deleteLocalBranch", |
| 3914 | )(function* (input) { |
| 3915 | yield* executeGit( |
| 3916 | "GitVcsDriver.deleteLocalBranch", |
| 3917 | input.cwd, |
| 3918 | ["branch", input.force === true ? "-D" : "-d", "--", input.refName], |
| 3919 | { |
| 3920 | timeoutMs: 10_000, |
| 3921 | fallbackErrorDetail: "git branch delete failed", |
| 3922 | }, |
| 3923 | ); |
| 3924 | }); |
| 3925 | |
| 3926 | const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( |
| 3927 | "renameBranch", |
| 3928 | )(function* (input) { |
| 3929 | if (input.oldBranch === input.newBranch) { |
| 3930 | return { branch: input.newBranch }; |
| 3931 | } |
| 3932 | const targetBranch = input.exactName |
| 3933 | ? input.newBranch |
| 3934 | : yield* resolveAvailableBranchName(input.cwd, input.newBranch); |
| 3935 | |
| 3936 | yield* executeGit( |
| 3937 | "GitVcsDriver.renameBranch", |
| 3938 | input.cwd, |
| 3939 | ["branch", "-m", "--", input.oldBranch, targetBranch], |
| 3940 | { |
| 3941 | timeoutMs: 10_000, |
| 3942 | fallbackErrorDetail: "git branch rename failed", |
| 3943 | }, |
| 3944 | ); |
| 3945 | |
| 3946 | return { branch: targetBranch }; |
| 3947 | }); |
| 3948 | |
| 3949 | const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")( |
| 3950 | function* (input) { |
| 3951 | const [localInputExists, remoteExists] = yield* Effect.all( |
| 3952 | [ |
| 3953 | executeGit( |
| 3954 | "GitVcsDriver.switchRef.localInputExists", |
| 3955 | input.cwd, |
| 3956 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], |
| 3957 | { |
| 3958 | timeoutMs: 5_000, |
| 3959 | allowNonZeroExit: true, |
| 3960 | }, |
| 3961 | ).pipe(Effect.map((result) => result.exitCode === 0)), |
| 3962 | executeGit( |
| 3963 | "GitVcsDriver.switchRef.remoteExists", |
| 3964 | input.cwd, |
| 3965 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], |
| 3966 | { |
| 3967 | timeoutMs: 5_000, |
| 3968 | allowNonZeroExit: true, |
| 3969 | }, |
| 3970 | ).pipe(Effect.map((result) => result.exitCode === 0)), |
| 3971 | ], |
| 3972 | { concurrency: "unbounded" }, |
| 3973 | ); |
| 3974 | |
| 3975 | const localTrackingBranch = remoteExists |
| 3976 | ? yield* executeGit( |
| 3977 | "GitVcsDriver.switchRef.localTrackingBranch", |
| 3978 | input.cwd, |
| 3979 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], |
| 3980 | { |
| 3981 | timeoutMs: 5_000, |
| 3982 | allowNonZeroExit: true, |
| 3983 | }, |
| 3984 | ).pipe( |
| 3985 | Effect.map((result) => |
| 3986 | result.exitCode === 0 |
| 3987 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) |
| 3988 | : null, |
| 3989 | ), |
| 3990 | ) |
| 3991 | : null; |
| 3992 | |
| 3993 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); |
| 3994 | const localTrackedBranchTargetExists = |
| 3995 | remoteExists && localTrackedBranchCandidate |
| 3996 | ? yield* executeGit( |
| 3997 | "GitVcsDriver.switchRef.localTrackedBranchTargetExists", |
| 3998 | input.cwd, |
| 3999 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], |
| 4000 | { |
| 4001 | timeoutMs: 5_000, |
| 4002 | allowNonZeroExit: true, |
| 4003 | }, |
| 4004 | ).pipe(Effect.map((result) => result.exitCode === 0)) |
| 4005 | : false; |
| 4006 | |
| 4007 | const checkoutArgs = localInputExists |
| 4008 | ? ["checkout", input.refName] |
| 4009 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists |
| 4010 | ? ["checkout", input.refName] |
| 4011 | : remoteExists && !localTrackingBranch |
| 4012 | ? ["checkout", "--track", input.refName] |
| 4013 | : remoteExists && localTrackingBranch |
| 4014 | ? ["checkout", localTrackingBranch] |
| 4015 | : ["checkout", input.refName]; |
| 4016 | |
| 4017 | // A stale ref must not turn into a path checkout that discards local edits. |
| 4018 | yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { |
| 4019 | timeoutMs: 10_000, |
| 4020 | fallbackErrorDetail: "git checkout failed", |
| 4021 | }); |
| 4022 | |
| 4023 | const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ |
| 4024 | "branch", |
| 4025 | "--show-current", |
| 4026 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); |
| 4027 | |
| 4028 | return { refName }; |
| 4029 | }, |
| 4030 | ); |
| 4031 | |
| 4032 | const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")( |
| 4033 | function* (input) { |
| 4034 | yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { |
| 4035 | timeoutMs: 10_000, |
| 4036 | fallbackErrorDetail: "git branch create failed", |
| 4037 | }); |
| 4038 | if (input.switchRef) { |
| 4039 | yield* switchRef({ cwd: input.cwd, refName: input.refName }); |
| 4040 | } |
| 4041 | |
| 4042 | return { refName: input.refName }; |
| 4043 | }, |
| 4044 | ); |
| 4045 | |
| 4046 | const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => |
| 4047 | executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { |
| 4048 | timeoutMs: 10_000, |
| 4049 | fallbackErrorDetail: "git init failed", |
| 4050 | }).pipe(Effect.asVoid); |
| 4051 | |
| 4052 | const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( |
| 4053 | cwd, |
| 4054 | ) => |
| 4055 | runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ |
| 4056 | "branch", |
| 4057 | "--list", |
| 4058 | "--no-column", |
| 4059 | "--format=%(refname:short)", |
| 4060 | ]).pipe( |
| 4061 | Effect.map((stdout) => { |
| 4062 | const branchNames: Array<string> = []; |
| 4063 | for (const line of stdout.split("\n")) { |
| 4064 | const branchName = line.trim(); |
| 4065 | if (branchName.length > 0) { |
| 4066 | branchNames.push(branchName); |
| 4067 | } |
| 4068 | } |
| 4069 | return branchNames; |
| 4070 | }), |
| 4071 | ); |
| 4072 | |
| 4073 | const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => |
| 4074 | executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe( |
| 4075 | // One record per worktree, each ended by an empty field. A `prunable` |
| 4076 | // record's directory is gone, and another checkout may now sit there. |
| 4077 | Effect.map(({ stdout }) => |
| 4078 | stdout.split("\0\0").flatMap((record) => { |
| 4079 | const fields = record.split("\0"); |
| 4080 | const worktree = fields.find((field) => field.startsWith("worktree ")); |
| 4081 | return worktree === undefined || fields.some((field) => field.startsWith("prunable")) |
| 4082 | ? [] |
| 4083 | : [path.resolve(cwd, worktree.slice("worktree ".length))]; |
| 4084 | }), |
| 4085 | ), |
| 4086 | ); |
| 4087 | |
| 4088 | const withListRefsInvalidation = <A, E>( |
| 4089 | cwd: string, |
| 4090 | effect: Effect.Effect<A, E>, |
| 4091 | ): Effect.Effect<A, E> => |
| 4092 | effect.pipe( |
| 4093 | Effect.ensuring( |
| 4094 | Effect.all([ |
| 4095 | invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), |
| 4096 | invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), |
| 4097 | ]), |
| 4098 | ), |
| 4099 | ); |
| 4100 | const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( |
| 4101 | input, |
| 4102 | ) => |
| 4103 | initRepo(input).pipe( |
| 4104 | Effect.ensuring( |
| 4105 | Effect.gen(function* () { |
| 4106 | const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd); |
| 4107 | yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); |
| 4108 | yield* Cache.invalidate(repositoryPathsCache, cacheKey); |
| 4109 | yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore); |
| 4110 | }), |
| 4111 | ), |
| 4112 | ); |
| 4113 | |
| 4114 | return GitVcsDriver.GitVcsDriver.of({ |
| 4115 | execute, |
| 4116 | status, |
| 4117 | statusDetails, |
| 4118 | statusDetailsLocal, |
| 4119 | statusDetailsRemote, |
| 4120 | prepareCommitContext, |
| 4121 | commit: (cwd, subject, body, options) => |
| 4122 | withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), |
| 4123 | pushCurrentBranch: (cwd, fallbackBranch, options) => |
| 4124 | withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), |
| 4125 | pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), |
| 4126 | readRangeContext, |
| 4127 | getReviewDiffPreview, |
| 4128 | getReviewDiffFileContents, |
| 4129 | readConfigValue, |
| 4130 | resolveBranchName, |
| 4131 | listRefs, |
| 4132 | createWorktree: (input, options) => |
| 4133 | withListRefsInvalidation(input.cwd, createWorktree(input, options)), |
| 4134 | fetchPullRequestBranch: (input) => |
| 4135 | withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), |
| 4136 | fetchPullRequestHeadCommit, |
| 4137 | resolveCommit, |
| 4138 | refreshCheckedOutBranch: (input) => |
| 4139 | withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), |
| 4140 | ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), |
| 4141 | resolvePrimaryRemoteName, |
| 4142 | resolveDefaultBranchName, |
| 4143 | fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), |
| 4144 | remoteExists, |
| 4145 | remoteBranchExists, |
| 4146 | resolveRemoteTrackingCommit, |
| 4147 | fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), |
| 4148 | fetchRemoteTrackingBranch: (input) => |
| 4149 | withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), |
| 4150 | setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), |
| 4151 | removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), |
| 4152 | pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), |
| 4153 | deleteLocalBranch: (input) => withListRefsInvalidation(input.cwd, deleteLocalBranch(input)), |
| 4154 | renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), |
| 4155 | createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), |
| 4156 | switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), |
| 4157 | initRepo: initRepoWithListRefsInvalidation, |
| 4158 | listLocalBranchNames, |
| 4159 | listWorktreePaths, |
| 4160 | }); |
| 4161 | }); |