diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index b530b83184ee..07e40a4718fb 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -110,7 +110,7 @@ export interface WithMetricsOptions { ) => Readonly>; } -const recordMetrics = ( +export const recordMetrics = ( options: WithMetricsOptions, startedAt: bigint, exit: Exit.Exit, diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 582f452bf12a..5118544796b7 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -9,8 +9,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as ProcessRunner from "../processRunner.ts"; +import * as GitMetadataFastPath from "../vcs/GitMetadataFastPath.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; // Background sweeps resolve every project each minute. A long TTL keeps them @@ -115,19 +117,26 @@ function buildRepositoryIdentity(input: { }; } -const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( - function* (cwd: string) { - const processRunner = yield* ProcessRunner.ProcessRunner; +/** Reads the answer from the repository files when possible, otherwise spawns git. */ +const runGitMetadataCommand = Effect.fn("RepositoryIdentityResolver.runGitMetadataCommand")( + function* (cwd: string, args: ReadonlyArray) { + const answer = yield* Effect.promise(() => + GitMetadataFastPath.tryAnswerGitCommand({ cwd, args }), + ); + if (answer !== null) return Option.some({ code: answer.exitCode, stdout: answer.stdout }); + const processRunner = yield* ProcessRunner.ProcessRunner; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. - const topLevelResult = yield* processRunner - .run({ - command: "git", - args: ["-C", cwd, "rev-parse", "--show-toplevel"], - timeoutBehavior: "timedOutResult", - }) + return yield* processRunner + .run({ command: "git", args: ["-C", cwd, ...args], timeoutBehavior: "timedOutResult" }) .pipe(Effect.option); + }, +); + +const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( + function* (cwd: string) { + const topLevelResult = yield* runGitMetadataCommand(cwd, ["rev-parse", "--show-toplevel"]); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { return null; } @@ -142,14 +151,7 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( )(function* ( cacheKey: string, ): Effect.fn.Return { - const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner - .run({ - command: "git", - args: ["-C", cacheKey, "remote", "-v"], - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); + const remoteResult = yield* runGitMetadataCommand(cacheKey, ["remote", "-v"]); if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { return null; } diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts new file mode 100644 index 000000000000..3764e4f37885 --- /dev/null +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -0,0 +1,913 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - the module under test is plain Node; fixtures are built with real git. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; + +import { + gitAnswerMemoKey, + makeTaskLimiter, + parseGitConfig, + rememberGitAnswer, + resetGitFastPathCaches, + tryAnswerGitCommand, + withinReadBudget, +} from "./GitMetadataFastPath.ts"; + +const git = (cwd: string, ...args: ReadonlyArray) => + NodeChildProcess.execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + +const UPSTREAM_FORMAT = + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)"; + +const COMMANDS: ReadonlyArray> = [ + ["for-each-ref", "--format=%(refname)", "refs/remotes"], + ["for-each-ref", "--format=%(refname)", "refs/heads/feature"], + ["for-each-ref", "--format=%(refname)", "refs/heads/feat"], + ["for-each-ref", "--format=%(refname)", "refs/remotes/origin/main", "refs/remotes/Upstream/main"], + ["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/*/main"], + ["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/*/missing"], + ["for-each-ref", UPSTREAM_FORMAT, "refs/heads/main"], + ["for-each-ref", UPSTREAM_FORMAT, "refs/heads/packed"], + ["for-each-ref", UPSTREAM_FORMAT, "refs/heads/missing"], + ["rev-parse", "--is-inside-work-tree"], + ["rev-parse", "--show-toplevel"], + ["rev-parse", "--git-common-dir"], + ["rev-parse", "--abbrev-ref", "HEAD"], + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + ["rev-list", "--count", "origin/main..HEAD"], + ["rev-list", "--left-right", "--count", "HEAD...origin/main"], + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "refs/remotes/origin/HEAD"], + ["remote"], + ["remote", "-v"], + ["remote", "get-url", "origin"], + ["remote", "get-url", "missing"], + ["config", "--get", "branch.main.remote"], + ["config", "--get", "Branch.main.Merge"], + ["config", "--get", "branch.missing.remote"], + ["config", "--get", "remote.origin.url"], + ["show-ref", "--verify", "--quiet", "refs/heads/main"], + ["show-ref", "--verify", "--quiet", "refs/heads/packed"], + ["show-ref", "--verify", "--quiet", "refs/heads/feature/nested"], + ["show-ref", "--verify", "--quiet", "refs/heads/missing"], +]; + +let root: string; +const repos: Record = {}; + +function makeRepo(name: string, setup: (dir: string) => void = () => {}) { + const dir = NodePath.join(root, name); + NodeFS.mkdirSync(dir, { recursive: true }); + git(dir, "init", "-q", "-b", "main"); + NodeFS.writeFileSync(NodePath.join(dir, "file.txt"), "content\n"); + git(dir, "add", "."); + git( + dir, + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "init", + ); + setup(dir); + repos[name] = dir; + return dir; +} + +// The suite pins git config through GIT_CONFIG_* variables, and those make the fast +// path decline everything. The fixtures here do not depend on the pinned values. +const pinnedGitConfig = Object.entries(process.env).filter(([key]) => + key.startsWith("GIT_CONFIG_"), +); + +beforeAll(() => { + for (const [key] of pinnedGitConfig) delete process.env[key]; + root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-git-fast-path-")); + makeRepo("plain", (dir) => { + git(dir, "remote", "add", "origin", "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/acme/widgets.git"); + git(dir, "remote", "add", "Upstream", "git@github.com:other/widgets.git"); + git(dir, "remote", "set-url", "--push", "Upstream", "https://example.com/push.git"); + git(dir, "config", "branch.main.remote", "origin"); + git(dir, "config", "branch.main.merge", "refs/heads/main"); + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + git(dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"); + git(dir, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"); + git(dir, "branch", "--set-upstream-to=origin/main", "main"); + git(dir, "branch", "packed"); + git(dir, "pack-refs", "--all"); + git(dir, "branch", "feature/nested"); + NodeFS.mkdirSync(NodePath.join(dir, "nested", "deeper"), { recursive: true }); + }); + repos.nested = NodePath.join(repos.plain!, "nested", "deeper"); + repos.linkedWorktree = NodePath.join(root, "linked"); + git(repos.plain!, "worktree", "add", "-q", repos.linkedWorktree, "feature/nested"); + makeRepo("detached", (dir) => git(dir, "checkout", "-q", "--detach")); + makeRepo("noRemotes"); + makeRepo("worktreeConfig", (dir) => { + git(dir, "remote", "add", "origin", "https://example.com/shared.git"); + git(dir, "config", "extensions.worktreeConfig", "true"); + git(dir, "config", "--worktree", "remote.origin.url", "https://example.com/per-worktree.git"); + git(dir, "config", "--worktree", "branch.main.remote", "origin"); + }); + repos.notARepository = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-git-fast-path-none-"), + ); +}); + +afterAll(() => { + NodeFS.rmSync(root, { recursive: true, force: true }); + NodeFS.rmSync(repos.notARepository!, { recursive: true, force: true }); + for (const [key, value] of pinnedGitConfig) process.env[key] = value; +}); +afterEach(() => { + delete process.env.GIT_DIR; + delete process.env.T3CODE_GIT_FAST_PATH; + resetGitFastPathCaches(); +}); + +describe("GitMetadataFastPath", () => { + it("prints exactly what git prints, or declines", async () => { + let answered = 0; + for (const [name, cwd] of Object.entries(repos)) { + for (const args of COMMANDS) { + const fast = await tryAnswerGitCommand({ cwd, args }); + if (fast === null) continue; + answered++; + const real = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8" }); + expect({ name, args, exitCode: fast.exitCode, stdout: fast.stdout }).toEqual({ + name, + args, + exitCode: real.status, + stdout: real.stdout, + }); + } + } + // Guards against the fast path silently declining everything. + expect(answered).toBeGreaterThan(60); + }); + + it("accepts the -C and --git-dir forms the drivers use", async () => { + const cwd = repos.plain!; + expect(await tryAnswerGitCommand({ cwd: root, args: ["-C", cwd, "remote"] })).toEqual({ + exitCode: 0, + stdout: "Upstream\norigin\n", + stderr: "", + }); + const gitDir = NodePath.join(cwd, ".git"); + expect( + await tryAnswerGitCommand({ + cwd, + args: ["--git-dir", gitDir, "remote", "get-url", "origin"], + }), + ).toMatchObject({ stdout: "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/acme/widgets.git\n" }); + }); + + it.each([ + ["commands it does not know", (dir: string) => dir, ["status", "--porcelain"]], + ["extra arguments", (dir: string) => dir, ["remote", "-v", "show"]], + [ + "ref listings that need object data", + (dir: string) => dir, + ["for-each-ref", "--format=%(refname)%09%(committerdate:unix)", "refs/heads"], + ], + [ + "ref listings outside branches", + (dir: string) => dir, + ["for-each-ref", "--format=%(refname)", "refs/tags"], + ], + [ + "ref names that escape the git directory", + (dir: string) => dir, + ["show-ref", "--verify", "--quiet", "refs/heads/../../config"], + ], + ["a missing remote, so git reports it", (dir: string) => dir, ["remote", "get-url", "missing"]], + [ + "the inside of a git directory", + (dir: string) => NodePath.join(dir, ".git"), + ["rev-parse", "--show-toplevel"], + ], + ] as const)("declines %s", async (_label, cwdOf, args) => { + expect(await tryAnswerGitCommand({ cwd: cwdOf(repos.plain!), args })).toBeNull(); + }); + + it("declines repositories whose config it cannot fully account for", async () => { + const rewritten = makeRepo("rewritten", (dir) => { + git(dir, "remote", "add", "origin", "gh:acme/widgets.git"); + git(dir, "config", "url.https://github.com/.insteadOf", "gh:"); + }); + expect(await tryAnswerGitCommand({ cwd: rewritten, args: ["remote", "-v"] })).toBeNull(); + + const included = makeRepo("included", (dir) => + git(dir, "config", "include.path", "../extra.cfg"), + ); + expect( + await tryAnswerGitCommand({ cwd: included, args: ["rev-parse", "--show-toplevel"] }), + ).toBeNull(); + + const ambiguous = makeRepo("ambiguous", (dir) => git(dir, "tag", "main")); + expect( + await tryAnswerGitCommand({ cwd: ambiguous, args: ["rev-parse", "--abbrev-ref", "HEAD"] }), + ).toBeNull(); + + const localUpstream = makeRepo("localUpstream", (dir) => { + git(dir, "branch", "topic"); + git(dir, "branch", "--set-upstream-to=main", "topic"); + }); + expect( + await tryAnswerGitCommand({ + cwd: localUpstream, + args: ["for-each-ref", UPSTREAM_FORMAT, "refs/heads/topic"], + }), + ).toBeNull(); + + const unborn = NodePath.join(root, "unborn"); + NodeFS.mkdirSync(unborn); + git(unborn, "init", "-q", "-b", "trunk"); + expect( + await tryAnswerGitCommand({ cwd: unborn, args: ["rev-parse", "--abbrev-ref", "HEAD"] }), + ).toBeNull(); + }); + + it("declines when the environment redirects git or the switch is off", async () => { + const input = { cwd: repos.plain!, args: ["remote"] }; + expect( + await tryAnswerGitCommand({ ...input, env: { GIT_CONFIG_GLOBAL: "/dev/null" } }), + ).toBeNull(); + process.env.GIT_DIR = "elsewhere"; + expect(await tryAnswerGitCommand(input)).toBeNull(); + delete process.env.GIT_DIR; + process.env.T3CODE_GIT_FAST_PATH = "0"; + expect(await tryAnswerGitCommand(input)).toBeNull(); + delete process.env.T3CODE_GIT_FAST_PATH; + expect(await tryAnswerGitCommand(input)).not.toBeNull(); + }); + + it("reports a missing upstream the way git does", async () => { + const args = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]; + const cwd = repos.noRemotes!; + const real = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8" }); + expect(await tryAnswerGitCommand({ cwd, args })).toEqual({ + exitCode: real.status, + stdout: real.stdout, + stderr: real.stderr, + }); + }); + + it("counts commits only for a pair of commits git already counted", async () => { + const commit = (cwd: string, message: string) => + git( + cwd, + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--allow-empty", + "-m", + message, + ); + const cwd = makeRepo("diverged", (dir) => { + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + commit(dir, "local"); + }); + const input = { cwd, args: ["rev-list", "--left-right", "--count", "HEAD...origin/main"] }; + const ask = () => + NodeChildProcess.spawnSync("git", input.args, { cwd, encoding: "utf8" }).stdout; + + expect(await tryAnswerGitCommand(input)).toBeNull(); + const key = await gitAnswerMemoKey(input); + expect(key).not.toBeNull(); + await rememberGitAnswer(input, key!, ask()); + expect(await tryAnswerGitCommand(input)).toMatchObject({ exitCode: 0, stdout: "1\t0\n" }); + // The key lookup shares the caller's budget: git is about to run either way. + expect(await gitAnswerMemoKey({ ...input, timeoutMs: 0 })).toBeNull(); + + // A moved ref is a different question. + commit(cwd, "local 2"); + expect(await tryAnswerGitCommand(input)).toBeNull(); + + // An answer that raced with a ref update is not stored. + const staleKey = await gitAnswerMemoKey(input); + const staleAnswer = ask(); + commit(cwd, "local 3"); + await rememberGitAnswer(input, staleKey!, staleAnswer); + expect(await tryAnswerGitCommand(input)).toBeNull(); + + // Shallow history changes counts without changing the commits. + NodeFS.writeFileSync(NodePath.join(cwd, ".git", "shallow"), ""); + expect(await gitAnswerMemoKey(input)).toBeNull(); + }); + + it("sees changes immediately, without a cache to go stale", async () => { + const cwd = makeRepo("changing"); + expect(await tryAnswerGitCommand({ cwd, args: ["remote"] })).toMatchObject({ stdout: "" }); + git(cwd, "remote", "add", "origin", "https://example.com/one.git"); + git(cwd, "checkout", "-q", "-b", "next"); + git(cwd, "pack-refs", "--all"); + expect(await tryAnswerGitCommand({ cwd, args: ["remote", "get-url", "origin"] })).toMatchObject( + { + stdout: "https://example.com/one.git\n", + }, + ); + expect( + await tryAnswerGitCommand({ cwd, args: ["rev-parse", "--abbrev-ref", "HEAD"] }), + ).toMatchObject({ + stdout: "next\n", + }); + }); +}); + +describe("GitMetadataFastPath on repositories git treats differently", () => { + const savedEnv = new Map(); + const setEnv = (key: string, value: string) => { + if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); + process.env[key] = value; + resetGitFastPathCaches(); + }; + afterEach(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); + }); + + /** A home directory whose `.gitconfig` is the global config for git and the fast path alike. */ + const useGlobalConfig = (name: string, body: string) => { + const home = NodePath.join(root, name); + NodeFS.mkdirSync(home, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(home, ".gitconfig"), body); + setEnv("HOME", home); + setEnv("USERPROFILE", home); + setEnv("XDG_CONFIG_HOME", NodePath.join(home, "xdg")); + return home; + }; + + const declines = async (cwd: string, ...args: ReadonlyArray) => + expect({ args, answer: await tryAnswerGitCommand({ cwd, args }) }).toEqual({ + args, + answer: null, + }); + + /** For cases where answering is fine as long as the answer is git's. */ + const agreesWithGit = async (cwd: string, ...args: ReadonlyArray) => { + const fast = await tryAnswerGitCommand({ cwd, args }); + if (fast === null) return; + const real = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8" }); + expect({ args, ...fast }).toEqual({ + args, + exitCode: real.status, + stdout: real.stdout, + stderr: real.stderr, + }); + }; + + const headOf = (cwd: string) => git(cwd, "rev-parse", "HEAD").trim(); + + it("says what git says outside a repository, stderr included", async () => { + const cwd = repos.notARepository!; + const args = ["rev-parse", "--show-toplevel"]; + const real = NodeChildProcess.spawnSync("git", args, { + cwd, + encoding: "utf8", + env: { ...process.env, LC_ALL: "C" }, + }); + expect(await tryAnswerGitCommand({ cwd, args, env: { LC_ALL: "C" } })).toEqual({ + exitCode: real.status, + stdout: real.stdout, + stderr: real.stderr, + }); + // These work without a repository, from system and global config or from nothing at all. + await declines(cwd, "config", "--get", "remote.origin.url"); + await declines(cwd, "config", "--get", "branch.main.remote"); + await declines(cwd, "rev-parse", "--sq-quote", "x"); + await declines(cwd, "rev-parse", "--git-dir"); + }); + + it("keeps English error text away from a git that would translate it", async () => { + const upstream = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]; + const translated = { LC_ALL: "de_DE.UTF-8" }; + expect( + await tryAnswerGitCommand({ cwd: repos.noRemotes!, args: upstream, env: translated }), + ).toBeNull(); + expect( + await tryAnswerGitCommand({ + cwd: repos.notARepository!, + args: ["rev-parse", "--show-toplevel"], + env: translated, + }), + ).toBeNull(); + setEnv("LANG", "de_DE.UTF-8"); + setEnv("LC_ALL", ""); + expect(await tryAnswerGitCommand({ cwd: repos.noRemotes!, args: upstream })).toBeNull(); + }); + + it("leaves remotes that git lists differently to git", async () => { + const urlLess = makeRepo("urlLess", (dir) => { + git(dir, "remote", "add", "origin", "https://example.com/origin.git"); + git(dir, "config", "remote.old.fetch", "+refs/heads/*:refs/remotes/old/*"); + }); + await declines(urlLess, "remote"); + await declines(urlLess, "remote", "-v"); + + useGlobalConfig("home-global-remote", '[remote "shared"]\n\turl = https://example.com/s.git\n'); + // `get-url` wants the remote in the repository and exits 2 otherwise. + await declines(repos.noRemotes!, "remote", "get-url", "shared"); + await agreesWithGit(repos.noRemotes!, "remote"); + await agreesWithGit(repos.noRemotes!, "remote", "-v"); + await agreesWithGit(repos.noRemotes!, "config", "--get", "remote.shared.url"); + }); + + it("notices a changed global config under an unusual path", async () => { + const home = useGlobalConfig("ho#me dir", '[remote "shared"]\n\turl = https://one\n'); + const args = ["config", "--get", "remote.shared.url"]; + await agreesWithGit(repos.noRemotes!, ...args); + NodeFS.writeFileSync( + NodePath.join(home, ".gitconfig"), + '[remote "shared"]\n\turl = https://another.example\n', + ); + await agreesWithGit(repos.noRemotes!, ...args); + }); + + it("declines when git cannot be run", async () => { + setEnv("PATH", NodePath.join(root, "no-git-here")); + await declines(repos.plain!, "rev-parse", "--show-toplevel"); + await declines(repos.notARepository!, "rev-parse", "--show-toplevel"); + }); + + it("honours the switch in the command's own environment", async () => { + expect( + await tryAnswerGitCommand({ + cwd: repos.plain!, + args: ["remote"], + env: { T3CODE_GIT_FAST_PATH: "0" }, + }), + ).toBeNull(); + }); + + it("leaves the command to git when its environment moves git or its config", async () => { + const home = useGlobalConfig( + "home-ambient", + '[remote "shared"]\n\turl = https://ambient.example\n', + ); + const otherHome = NodePath.join(root, "home-of-the-command"); + NodeFS.mkdirSync(otherHome, { recursive: true }); + const args = ["config", "--get", "remote.shared.url"]; + const cwd = repos.noRemotes!; + // The spawned git would read the other home's config, which has no such remote. + for (const key of ["HOME", "USERPROFILE", "XDG_CONFIG_HOME"]) { + expect(await tryAnswerGitCommand({ cwd, args, env: { [key]: otherHome } })).toBeNull(); + } + expect( + await tryAnswerGitCommand({ cwd, args, env: { PATH: NodePath.join(root, "other-git") } }), + ).toBeNull(); + // Restating the server's own value changes nothing. + expect(await tryAnswerGitCommand({ cwd, args, env: { HOME: home } })).toEqual({ + exitCode: 0, + stdout: "https://ambient.example\n", + stderr: "", + }); + }); + + it("notices a global include that appears after the config was listed", async () => { + const home = useGlobalConfig( + "home-include", + "[include]\n\tpath = later.inc\n\tpath = ~/tilde.inc\n", + ); + const cwd = repos.noRemotes!; + const get = (name: string) => ["config", "--get", `remote.${name}.url`]; + expect(await tryAnswerGitCommand({ cwd, args: get("later") })).toEqual({ + exitCode: 1, + stdout: "", + stderr: "", + }); + const answered = async (file: string, name: string) => { + NodeFS.writeFileSync( + NodePath.join(home, file), + `[remote "${name}"]\n\turl = https://${name}.example\n`, + ); + expect(await tryAnswerGitCommand({ cwd, args: get(name) })).toEqual({ + exitCode: 0, + stdout: `https://${name}.example\n`, + stderr: "", + }); + }; + await answered("later.inc", "later"); + await answered("tilde.inc", "tilde"); + }); + + it("starts no reads while an abandoned one is still stuck", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + let releaseStuck!: () => void; + const stuck = withinReadBudget( + 10, + () => + new Promise((resolve) => { + releaseStuck = () => resolve("late"); + }), + ); + await vi.advanceTimersByTimeAsync(10); + expect(await stuck).toBeNull(); + + const work = vi.fn(async () => "fresh"); + expect(await withinReadBudget(10, work)).toBeNull(); + expect(work).not.toHaveBeenCalled(); + + releaseStuck(); + await vi.advanceTimersByTimeAsync(0); + expect(await withinReadBudget(10, work)).toBe("fresh"); + } finally { + vi.useRealTimers(); + } + }); + + it("stays inside the caller's time and output budget", async () => { + const cwd = repos.plain!; + expect(await tryAnswerGitCommand({ cwd, args: ["remote", "-v"], timeoutMs: 0 })).toBeNull(); + // Same with everything already cached, when the reads alone might beat a zero timer. + expect(await tryAnswerGitCommand({ cwd, args: ["remote", "-v"] })).not.toBeNull(); + expect(await tryAnswerGitCommand({ cwd, args: ["remote", "-v"], timeoutMs: 0 })).toBeNull(); + expect( + await tryAnswerGitCommand({ cwd, args: ["remote", "-v"], timeoutMs: null }), + ).not.toBeNull(); + + const listing = git(cwd, "remote", "-v"); + const fits = Buffer.byteLength(listing); + expect( + await tryAnswerGitCommand({ cwd, args: ["remote", "-v"], maxOutputBytes: fits }), + ).toEqual({ exitCode: 0, stdout: listing, stderr: "" }); + expect( + await tryAnswerGitCommand({ cwd, args: ["remote", "-v"], maxOutputBytes: fits - 1 }), + ).toBeNull(); + // stderr counts too: the missing-upstream message is longer than this cap. + expect( + await tryAnswerGitCommand({ + cwd: repos.noRemotes!, + args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + env: { LC_ALL: "C" }, + maxOutputBytes: 8, + }), + ).toBeNull(); + }); + + it.skipIf(NodePath.sep === "\\")( + "does not wait on a FIFO standing where a file would be", + async () => { + const fifoRepo = makeRepo("fifo"); + NodeChildProcess.execFileSync("mkfifo", [NodePath.join(fifoRepo, ".git", "packed-refs")]); + await declines(fifoRepo, "show-ref", "--verify", "--quiet", "refs/heads/missing"); + }, + ); + + it("remembers that the global config could not be used instead of asking git every time", async () => { + // A key without a value is legal to git; this reader leaves such a config to git. + const home = useGlobalConfig("home-valueless", "[core]\n\tvalueless\n"); + const args = ["remote", "get-url", "origin"]; + await declines(repos.plain!, ...args); + + NodeFS.writeFileSync(NodePath.join(home, ".gitconfig"), ""); + const now = Date.now(); + vi.useFakeTimers({ toFake: ["Date"], now }); + try { + // Still inside the retry pause: the listing is not repeated for this command. + await declines(repos.plain!, ...args); + vi.setSystemTime(now + 31_000); + expect(await tryAnswerGitCommand({ cwd: repos.plain!, args })).toEqual({ + exitCode: 0, + stdout: "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/acme/widgets.git\n", + stderr: "", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves a file given as the working directory to git", async () => { + const file = NodePath.join(repos.plain!, "not-a-directory.txt"); + NodeFS.writeFileSync(file, ""); + try { + await declines(file, "rev-parse", "--abbrev-ref", "HEAD"); + await declines(file, "remote"); + } finally { + NodeFS.rmSync(file); + } + }); + + it("leaves refs that belong to one worktree to git", async () => { + const linked = repos.linkedWorktree!; + git(linked, "update-ref", "refs/bisect/bad", "HEAD"); + git(linked, "update-ref", "refs/worktree/mark", "HEAD"); + for (const cwd of [linked, repos.plain!]) { + await declines(cwd, "show-ref", "--verify", "--quiet", "refs/bisect/bad"); + await declines(cwd, "show-ref", "--verify", "--quiet", "refs/worktree/mark"); + await declines(cwd, "show-ref", "--verify", "--quiet", "refs/rewritten/onto"); + } + }); + + it("walks past a directory with a broken HEAD, as git does", async () => { + const outer = makeRepo("outerOfBroken", (dir) => + git(dir, "remote", "add", "origin", "https://example.com/outer.git"), + ); + for (const [name, head] of [ + ["empty", ""], + ["garbage", "garbage\n"], + ["huge", `ref: refs/heads/main${" ".repeat(8 * 1024)}\n`], + ] as const) { + const inner = NodePath.join(outer, name); + NodeFS.mkdirSync(inner); + git(inner, "init", "-q"); + NodeFS.writeFileSync(NodePath.join(inner, ".git", "HEAD"), head); + await agreesWithGit(inner, "rev-parse", "--show-toplevel"); + if (name === "empty") { + expect( + await tryAnswerGitCommand({ cwd: inner, args: ["rev-parse", "--show-toplevel"] }), + ).toMatchObject({ stdout: git(outer, "rev-parse", "--show-toplevel") }); + } + await agreesWithGit(inner, "remote", "get-url", "origin"); + await declines(outer, "--git-dir", NodePath.join(inner, ".git"), "remote"); + } + }); + + it("never follows a repository pointer to another machine", async () => { + const pointer = NodePath.join(root, "uncPointer"); + NodeFS.mkdirSync(pointer); + // TEST-NET address: nothing may try to reach it. + NodeFS.writeFileSync(NodePath.join(pointer, ".git"), "gitdir: //203.0.113.1/share/repo.git\n"); + await declines(pointer, "remote"); + await declines(root, "--git-dir", "//203.0.113.1/share/repo.git", "remote"); + await declines(root, "--git-dir", "\\\\203.0.113.1\\share\\repo.git", "remote"); + + const main = makeRepo("uncCommonDirMain"); + const linked = NodePath.join(root, "uncCommonDirLinked"); + git(main, "worktree", "add", "-q", "-b", "side", linked); + const linkedGitDir = NodePath.join(main, ".git", "worktrees", "uncCommonDirLinked"); + NodeFS.writeFileSync( + NodePath.join(linkedGitDir, "commondir"), + "//203.0.113.1/share/repo.git\n", + ); + await declines(linked, "remote"); + }); + + it("leaves a refs directory swapped for a symlink to git, even after reading it", async () => { + const cwd = makeRepo("symlinkedRefs", (dir) => git(dir, "branch", "feature/linked")); + const heads = NodePath.join(cwd, ".git", "refs", "heads"); + expect( + await tryAnswerGitCommand({ + cwd, + args: ["show-ref", "--verify", "--quiet", "refs/heads/feature/linked"], + }), + ).toMatchObject({ exitCode: 0 }); + const moved = NodePath.join(root, "symlinkedRefsTarget"); + NodeFS.renameSync(NodePath.join(heads, "feature"), moved); + // A junction needs no privilege on Windows and is a symlink everywhere else. + NodeFS.symlinkSync(moved, NodePath.join(heads, "feature"), "junction"); + await declines(cwd, "show-ref", "--verify", "--quiet", "refs/heads/feature/linked"); + await declines(cwd, "for-each-ref", "--format=%(refname)", "refs/heads"); + }); + + it("leaves a symlinked HEAD to git", async (context) => { + const cwd = makeRepo("symlinkedHead"); + const head = NodePath.join(cwd, ".git", "HEAD"); + const target = NodePath.join(root, "symlinkedHeadTarget"); + NodeFS.writeFileSync(target, "ref: refs/heads/main\n"); + NodeFS.rmSync(head); + try { + NodeFS.symlinkSync(target, head, "file"); + } catch { + // File symlinks need a privilege on Windows. + context.skip(); + } + await declines(cwd, "symbolic-ref", "--quiet", "--short", "HEAD"); + await declines(cwd, "remote"); + }); + + it("asks git again when another repository takes the path", async () => { + const cwd = makeRepo("replaced", (dir) => + git(dir, "remote", "add", "origin", "https://example.com/first.git"), + ); + const remote = ["remote", "get-url", "origin"]; + expect(await tryAnswerGitCommand({ cwd, args: remote })).toMatchObject({ exitCode: 0 }); + const savedPath = process.env.PATH; + try { + // Without git only a remembered verdict can answer. + process.env.PATH = NodePath.join(root, "no-git-here"); + expect(await tryAnswerGitCommand({ cwd, args: remote })).toMatchObject({ exitCode: 0 }); + + process.env.PATH = savedPath; + NodeFS.rmSync(cwd, { recursive: true, force: true }); + makeRepo("replaced", (dir) => + git(dir, "remote", "add", "origin", "https://example.com/second.git"), + ); + process.env.PATH = NodePath.join(root, "no-git-here"); + await declines(cwd, ...remote); + } finally { + process.env.PATH = savedPath; + } + }); + + it("reads config the way git does, or not at all", async () => { + const bare = makeRepo("numericBare", (dir) => git(dir, "config", "core.bare", "2")); + await declines(bare, "rev-parse", "--is-inside-work-tree"); + + const versionless = makeRepo("versionless", (dir) => { + git(dir, "remote", "add", "origin", "https://example.com/shared.git"); + git(dir, "config", "extensions.worktreeConfig", "true"); + git(dir, "config", "--worktree", "remote.origin.url", "https://example.com/ignored.git"); + git(dir, "config", "--unset", "core.repositoryformatversion"); + }); + await agreesWithGit(versionless, "remote", "get-url", "origin"); + + const huge = makeRepo("hugeConfig", (dir) => + NodeFS.appendFileSync(NodePath.join(dir, ".git", "config"), `# ${"x".repeat(2_000_000)}\n`), + ); + await declines(huge, "remote"); + + const binary = makeRepo("binaryConfig", (dir) => + NodeFS.appendFileSync( + NodePath.join(dir, ".git", "config"), + Buffer.concat([ + Buffer.from('[remote "origin"]\n\turl = https://example.com/'), + Buffer.from([0xff, 0xfe]), + Buffer.from("\n"), + ]), + ), + ); + await agreesWithGit(binary, "remote", "get-url", "origin"); + await agreesWithGit(binary, "remote", "-v"); + + const loneCarriageReturn = makeRepo("loneCr", (dir) => + NodeFS.appendFileSync( + NodePath.join(dir, ".git", "config"), + '[remote "origin"]\n\turl = https://example.com/a\rb\n', + ), + ); + await agreesWithGit(loneCarriageReturn, "remote", "get-url", "origin"); + }); + + it("refuses packed refs that git would die on", async () => { + for (const [name, line] of [ + ["escaping", "refs/heads/../../evil"], + ["nul", "refs/heads/ev\0il"], + ["device", "refs/heads/NUL"], + ] as const) { + const cwd = makeRepo(`packed-${name}`, (dir) => { + git(dir, "pack-refs", "--all"); + NodeFS.appendFileSync( + NodePath.join(dir, ".git", "packed-refs"), + `${headOf(dir)} ${line}\n`, + ); + }); + await declines(cwd, "for-each-ref", "--format=%(refname)", "refs/heads"); + await declines(cwd, "show-ref", "--verify", "--quiet", "refs/heads/main"); + } + + const oversized = makeRepo("packed-oversized", (dir) => { + const line = `${headOf(dir)} refs/heads/filler-${"x".repeat(200)}\n`; + NodeFS.writeFileSync( + NodePath.join(dir, ".git", "packed-refs"), + `# pack-refs with: peeled fully-peeled sorted \n${line.repeat(140_000)}`, + ); + }); + // `main` is a loose ref here, which neither git nor the fast path looks up in packed-refs. + await declines(oversized, "show-ref", "--verify", "--quiet", "refs/heads/missing"); + }); + + it("does not open ref names that Windows maps onto something else", async () => { + for (const name of ["CON", "nul", "COM1", "aux.txt", "trailing.", "a/prn/b"]) { + await declines(repos.plain!, "show-ref", "--verify", "--quiet", `refs/heads/${name}`); + } + }); + + it("reports a ref whose name is only a directory as missing", async () => { + const args = ["show-ref", "--verify", "--quiet", "refs/heads/feature"]; + const real = NodeChildProcess.spawnSync("git", args, { cwd: repos.plain!, encoding: "utf8" }); + expect(await tryAnswerGitCommand({ cwd: repos.plain!, args })).toMatchObject({ + exitCode: real.status, + stdout: real.stdout, + }); + }); + + it("leaves symbolic ref chains and remote HEAD upstreams to git", async () => { + const chained = makeRepo("symrefChain", (dir) => { + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + git(dir, "symbolic-ref", "refs/remotes/origin/alias", "refs/remotes/origin/main"); + git(dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/alias"); + }); + await declines(chained, "symbolic-ref", "refs/remotes/origin/HEAD"); + + const headUpstream = makeRepo("headUpstream", (dir) => { + git(dir, "remote", "add", "origin", "https://example.com/origin.git"); + git(dir, "update-ref", "refs/remotes/origin/HEAD", "HEAD"); + git(dir, "config", "branch.main.remote", "origin"); + git(dir, "config", "branch.main.merge", "refs/heads/HEAD"); + }); + await declines( + headUpstream, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ); + await declines(headUpstream, "for-each-ref", UPSTREAM_FORMAT, "refs/heads/main"); + }); + + it("checks both git directories of a linked worktree for a rival short name", async () => { + const main = makeRepo("rivalMain"); + const linked = NodePath.join(root, "rivalLinked"); + git(main, "worktree", "add", "-q", "-b", "topic", linked); + NodeFS.writeFileSync(NodePath.join(main, ".git", "topic"), `${headOf(main)}\n`); + await agreesWithGit(linked, "rev-parse", "--abbrev-ref", "HEAD"); + await agreesWithGit(linked, "symbolic-ref", "--quiet", "--short", "HEAD"); + NodeFS.writeFileSync( + NodePath.join(main, ".git", "worktrees", "rivalLinked", "topic"), + `${headOf(main)}\n`, + ); + await agreesWithGit(linked, "rev-parse", "--abbrev-ref", "HEAD"); + }); +}); + +describe("makeTaskLimiter", () => { + it("runs a bounded number of tasks at once and frees the slot of a failed one", async () => { + const limit = makeTaskLimiter(3); + const release: Array<(fail: boolean) => void> = []; + let running = 0; + let mostRunning = 0; + const results = Array.from({ length: 10 }, (_, index) => + limit(async () => { + mostRunning = Math.max(mostRunning, ++running); + const failed = await new Promise((resolve) => release.push(resolve)); + running--; + if (failed) throw new Error(`task ${index}`); + return index; + }).catch((error: Error) => error.message), + ); + // Release in start order; every third task fails. + for (let done = 0; done < 10; done++) { + while (release.length <= done) await new Promise((resolve) => setImmediate(resolve)); + expect(running).toBeLessThanOrEqual(3); + release[done]!(done % 3 === 0); + } + expect(await Promise.all(results)).toEqual([ + "task 0", + 1, + 2, + "task 3", + 4, + 5, + "task 6", + 7, + 8, + "task 9", + ]); + expect(mostRunning).toBe(3); + }); +}); + +describe("parseGitConfig", () => { + it("handles quoting, escapes, comments and continuations like git", () => { + const entries = parseGitConfig( + [ + "; comment", + '[Remote "Origin"] # trailing', + '\turl = "https://example.com/a b.git" ; note', + "\tfetch = +refs/heads/*:\\", + "refs/remotes/origin/*", + '[branch "we\\"ird"]', + "\tReMoTe = a\\\\b\\tc", + "[core]", + "\tbare=false", + ].join("\r\n"), + ); + expect(entries).toEqual([ + { key: "remote.Origin.url", value: "https://example.com/a b.git" }, + { key: "remote.Origin.fetch", value: "+refs/heads/*:refs/remotes/origin/*" }, + { key: 'branch.we"ird.remote', value: "a\\b\tc" }, + { key: "core.bare", value: "false" }, + ]); + }); + + it.each([ + ["value-less keys", "[core]\n\tbare\n"], + ["keys before a section", "name = value\n"], + ["unterminated quotes", '[a]\n\tb = "open\n'], + ["unknown escapes", "[a]\n\tb = \\q\n"], + ["malformed headers", "[a b]\n"], + ])("refuses %s instead of guessing", (_label, text) => { + expect(() => parseGitConfig(text)).toThrow(); + }); +}); diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts new file mode 100644 index 000000000000..c6f5ef4b4ce2 --- /dev/null +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -0,0 +1,1438 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - plain Node on purpose: this runs before and instead of a process spawn. +/** + * Answers a small set of read-only git metadata commands by reading the + * repository files directly, without spawning `git`. + * + * Background loops ask git the same cheap questions (toplevel, remotes, HEAD, + * a config value) many times a minute per project. On Windows every spawn costs + * a launcher, a `conhost.exe` and a slot on the session-wide win32k lock, so the + * volume stalls the whole desktop. Reading a few small files costs microseconds. + * + * Contract: `tryAnswerGitCommand` returns exactly what `git` would have printed, + * or `null`. `null` means "not sure" and the caller must spawn git. Anything + * unusual (unknown arguments, `GIT_*` overrides, includes, extensions, bare + * repositories, reftable, ambiguous names, unreadable files) returns `null`. + * Never guess here: a wrong answer is worse than a spawn. + * + * Whether a directory is a repository git is willing to open at all (ownership + * and `safe.directory`, format version, filesystem boundaries) is git's call: + * git is asked once per repository and the verdict is reused for a few minutes. + * This module only ever reads text and never runs anything a repository configures. + */ +import * as NodeAsyncHooks from "node:async_hooks"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface GitFastPathInput { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv | undefined; + /** The caller's budget for the command; the answer never takes longer than this. */ + readonly timeoutMs?: number | null | undefined; + /** The caller's output cap. A larger answer is left to git, which truncates or fails as asked. */ + readonly maxOutputBytes?: number | undefined; +} + +export interface GitFastPathAnswer { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +/** `T3CODE_GIT_FAST_PATH=0` turns the fast path off; every command spawns git again. */ +const isGitFastPathEnabled = (env: NodeJS.ProcessEnv = process.env) => + env.T3CODE_GIT_FAST_PATH !== "0"; + +const ANSWER_TIMEOUT_MS = 2_000; +// Attempts that ran out of budget while their reads were still pending. +let abandonedReads = 0; +// Same default as the process runners that would otherwise spawn git. +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +class Unsure extends Error {} +/** Discovery reached the filesystem root, and git agreed: exit 128 with this stderr. */ +class NotARepository extends Error { + readonly stderr: string; + constructor(stderr: string) { + super("not a repository"); + this.stderr = stderr; + } +} +const unsure = (reason: string): never => { + throw new Unsure(reason); +}; + +// GIT_* variables that cannot change discovery, config or ref resolution. +const HARMLESS_GIT_ENV = new Set([ + "GIT_TERMINAL_PROMPT", + "GIT_ASKPASS", + "GIT_EDITOR", + "GIT_SEQUENCE_EDITOR", + "GIT_PAGER", + "GIT_OPTIONAL_LOCKS", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_SSH_VARIANT", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_DATE", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_DATE", + "GIT_LFS_SKIP_SMUDGE", + "GIT_MERGE_AUTOEDIT", + "GIT_EXEC_PATH", + "GIT_INSTALL_ROOT", +]); + +function hasGitEnvOverride(env: NodeJS.ProcessEnv | undefined): boolean { + if (!env) return false; + for (const key of Object.keys(env)) { + if (env[key] === undefined) continue; + const upper = key.toUpperCase(); + if (upper.startsWith("GIT_") && !HARMLESS_GIT_ENV.has(upper)) return true; + } + return false; +} + +// Where git finds itself and its system/global config. The cached listing and +// verdicts come from git run with the server's own environment. +const GIT_LOCATION_ENV = new Set([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "XDG_CONFIG_HOME", + "PATH", +]); + +function movesGitOrItsConfig(env: NodeJS.ProcessEnv | undefined): boolean { + if (!env) return false; + for (const [key, value] of Object.entries(env)) { + // Windows environment names ignore case; `process.env` lookups there do too. + const name = NodePath.sep === "\\" ? key.toUpperCase() : key; + if (GIT_LOCATION_ENV.has(name) && value !== process.env[key]) return true; + } + return false; +} + +/** + * Runs at most `max` of the given tasks at once, the rest in arrival order. + * Exported for tests. + */ +export function makeTaskLimiter(max: number) { + let running = 0; + const waiting: Array<() => void> = []; + return async (task: () => Promise): Promise => { + // A finishing task hands its slot to the next one, so `running` stays put. + if (running >= max) await new Promise((resolve) => waiting.push(resolve)); + else running++; + try { + return await task(); + } finally { + const next = waiting.shift(); + if (next) next(); + else running--; + } + }; +} + +// The git processes started here run outside the drivers' process permits. A +// sweep over many repositories asks for one verdict each, all at once. +const withOwnGitProcess = makeTaskLimiter(4); + +const toGitPath = (value: string) => (NodePath.sep === "\\" ? value.replaceAll("\\", "/") : value); + +// Symlinks are git's to follow: one inside repository metadata can point at a +// `\\server\share` path, and touching that makes Windows authenticate to the server. +// Directories found free of symlinks, with their ancestors, are remembered only +// for the rest of one query: a later one checks them again, since any may have +// been swapped for a symlink in between. +const symlinkFreeDirs = new NodeAsyncHooks.AsyncLocalStorage>(); + +/** Runs one query with its own memory of checked directories. */ +const withFreshSymlinkChecks = (work: () => Promise): Promise => + symlinkFreeDirs.run(new Set(), work); + +function isKnownSymlinkFree(dir: string): boolean { + const prefix = dir.endsWith(NodePath.sep) ? dir : dir + NodePath.sep; + for (const known of symlinkFreeDirs.getStore() ?? []) { + if (known === dir || known.startsWith(prefix)) return true; + } + return false; +} + +function rememberSymlinkFree(dir: string): void { + symlinkFreeDirs.getStore()?.add(dir); +} + +/** Declines when `target` or a directory above it is a symlink. */ +async function assertNoSymlinks(target: string): Promise { + const parent = NodePath.dirname(target); + let parentChecked = false; + for (let dir = parent; !isKnownSymlinkFree(dir);) { + const stat = await NodeFSP.lstat(dir).catch(() => null); + if (stat?.isSymbolicLink()) unsure("symlink in repository path"); + if (dir === parent) parentChecked = stat !== null; + const above = NodePath.dirname(dir); + if (above === dir) break; + dir = above; + } + if (parentChecked) rememberSymlinkFree(parent); + const stat = await NodeFSP.lstat(target).catch(() => null); + if (stat?.isSymbolicLink()) unsure("symlink in repository path"); +} + +async function statOrNull(target: string) { + await assertNoSymlinks(target); + try { + return await NodeFSP.lstat(target); + } catch { + return null; + } +} + +// git bounds what it reads from a repository too (HEAD, the `.git` file). The +// server opens folders it did not create, so a huge or special file is git's to refuse. +const SMALL_FILE_BYTES = 4 * 1024; +const CONFIG_FILE_BYTES = 1024 * 1024; +const PACKED_REFS_BYTES = 32 * 1024 * 1024; + +/** Text of a regular file no larger than `maxBytes`, `null` when it does not exist. */ +async function readBoundedFile(file: string, maxBytes: number): Promise { + // One handle for the check and the read: a path checked first and opened later + // can be swapped for a FIFO or a huge file in between. O_NONBLOCK keeps the + // open itself from waiting on a FIFO, O_NOFOLLOW from following a swapped-in + // symlink; Windows has neither. + await assertNoSymlinks(file); + const handle = await NodeFSP.open( + file, + NodeFSP.constants.O_RDONLY | + (NodeFSP.constants.O_NONBLOCK ?? 0) | + (NodeFSP.constants.O_NOFOLLOW ?? 0), + ).catch((error: NodeJS.ErrnoException) => + error.code === "ENOENT" || error.code === "ENOTDIR" ? null : unsure("unreadable file"), + ); + if (handle === null) return null; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > maxBytes) unsure("not a small regular file"); + // One spare byte shows a file that grew after the size was taken. + const buffer = Buffer.alloc(stat.size + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > stat.size) unsure("file grew while it was read"); + const text = buffer.toString("utf8", 0, length); + // Lossy decoding or a NUL would make the answer differ from git's bytes. + if (text.includes("\0") || text.includes("\ufffd")) unsure("binary content"); + return text; + } finally { + await handle.close(); + } +} + +/** + * git refuses `\\server\share` targets in `.git` files and `commondir`: touching + * one makes Windows authenticate to that server. Checked before any filesystem call. + */ +function assertLocalPath(target: string): void { + if (/^[\\/]{2}/.test(target)) unsure("UNC path"); +} + +// --------------------------------------------------------------------------- +// Config parsing + +export interface GitConfigEntry { + /** Canonical key: lowercase section and variable, subsection as written. */ + readonly key: string; + readonly value: string; +} + +/** + * Strict parser for git's config file syntax. Throws on anything it does not + * fully understand, including value-less keys, so the caller falls back to git. + */ +export function parseGitConfig(text: string): ReadonlyArray { + const entries: Array = []; + let section: string | null = null; + let index = text.charCodeAt(0) === 0xfeff ? 1 : 0; + const length = text.length; + const isSpace = (char: string) => char === " " || char === "\t" || char === "\r"; + + while (index < length) { + const char = text[index]!; + if (char === "\n" || isSpace(char)) { + index++; + continue; + } + if (char === "#" || char === ";") { + while (index < length && text[index] !== "\n") index++; + continue; + } + if (char === "[") { + const close = text.indexOf("]", index); + const lineEnd = text.indexOf("\n", index); + if (close === -1) unsure("config: unterminated section header"); + const header = text.slice(index + 1, close); + const quoted = /^([A-Za-z0-9.-]+)[ \t]+"((?:[^"\\\n]|\\.)*)"$/.exec(header); + if (quoted) { + section = `${quoted[1]!.toLowerCase()}.${quoted[2]!.replace(/\\(.)/g, "$1")}`; + } else if (/^[A-Za-z0-9.-]+$/.test(header)) { + section = header.toLowerCase(); + } else { + unsure("config: unsupported section header"); + } + if (lineEnd !== -1 && close > lineEnd) unsure("config: section header spans lines"); + index = close + 1; + continue; + } + + if (section === null) unsure("config: key before any section"); + const nameMatch = /^[A-Za-z][A-Za-z0-9-]*/.exec(text.slice(index, index + 256)); + if (!nameMatch) unsure("config: invalid variable name"); + const name = nameMatch![0].toLowerCase(); + index += nameMatch![0].length; + while (index < length && isSpace(text[index]!)) index++; + if (text[index] !== "=") unsure("config: value-less key"); + index++; + while (index < length && isSpace(text[index]!)) index++; + + let value = ""; + let inQuotes = false; + let pendingSpace = ""; + for (; index < length; index++) { + const current = text[index]!; + if (current === "\n") break; + if (current === "\r" && text[index + 1] === "\n") continue; + if (!inQuotes && (current === "#" || current === ";")) { + while (index < length && text[index] !== "\n") index++; + break; + } + if (!inQuotes && (current === " " || current === "\t")) { + pendingSpace += current; + continue; + } + value += pendingSpace; + pendingSpace = ""; + if (current === '"') { + inQuotes = !inQuotes; + continue; + } + if (current === "\\") { + let next = text[++index]; + if (next === "\r" && text[index + 1] === "\n") next = text[++index]; + if (next === "\n") continue; + if (next === "n") value += "\n"; + else if (next === "t") value += "\t"; + else if (next === "b") value += "\b"; + else if (next === "\\" || next === '"') value += next; + else unsure("config: unsupported escape"); + continue; + } + value += current; + } + if (inQuotes) unsure("config: unterminated quote"); + entries.push({ key: `${section}.${name}`, value }); + } + return entries; +} + +/** `Section.Sub.Name` -> `section.Sub.name`, the form git prints and compares. */ +function canonicalConfigKey(key: string): string | null { + const first = key.indexOf("."); + const last = key.lastIndexOf("."); + if (first <= 0 || last === key.length - 1) return null; + if (/[\n\0]/.test(key)) return null; + const section = key.slice(0, first).toLowerCase(); + const name = key.slice(last + 1).toLowerCase(); + if (!/^[a-z0-9-]+$/.test(section) || !/^[a-z][a-z0-9-]*$/.test(name)) return null; + return first === last ? `${section}.${name}` : `${section}.${key.slice(first + 1, last)}.${name}`; +} + +const lastValue = (entries: ReadonlyArray, key: string) => + entries.findLast((entry) => entry.key === key)?.value; + +/** git's boolean: a word, or any non-zero integer (`core.bare = 2` is true). */ +const isGitTrue = (value: string | undefined) => { + const text = value?.trim() ?? ""; + if (/^(true|yes|on)$/i.test(text)) return true; + return /^[-+]?\d+[kmg]?$/i.test(text) && Number.parseInt(text, 10) !== 0; +}; + +// --------------------------------------------------------------------------- +// System and global config +// +// Their locations depend on the git installation, so git lists them once and +// the result is reused until one of the files it came from changes. + +interface OuterConfig { + readonly entries: ReadonlyArray; + readonly files: ReadonlyArray; + readonly fingerprint: string; + readonly loadedAtMs: number; +} + +const OUTER_CONFIG_MAX_AGE_MS = 5 * 60_000; +const OUTER_CONFIG_RETRY_MS = 30_000; +let outerConfig: Promise | null = null; +let outerConfigFailedAtMs: number | null = null; + +function outerConfigCandidates(origins: ReadonlyArray): ReadonlyArray { + const home = NodeOS.homedir(); + const xdg = process.env.XDG_CONFIG_HOME?.trim() || NodePath.join(home, ".config"); + return [ + ...new Set([ + ...origins, + NodePath.join(home, ".gitconfig"), + NodePath.join(xdg, "git", "config"), + ]), + ]; +} + +async function fingerprintFiles(files: ReadonlyArray): Promise { + const stats = await Promise.all(files.map(statOrNull)); + return stats.map((stat) => (stat ? `${stat.mtimeMs}:${stat.size}:${stat.ino}` : "-")).join("|"); +} + +function listOuterConfig(): Promise { + return withOwnGitProcess( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + "git", + ["config", "--list", "--show-scope", "--show-origin", "-z"], + { cwd: NodeOS.tmpdir(), windowsHide: true, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + }), + ); +} + +/** Where git looks for an `include.path` value found in `originFile`. */ +function includedConfigFile(originFile: string, value: string): string { + if (value === "~" || value.startsWith("~/")) + return NodePath.join(NodeOS.homedir(), value.slice(1)); + // `~user/` needs the account database; `%(prefix)/` needs git's install location. + if (value.startsWith("~") || value.startsWith("%(")) unsure("outer config: include location"); + return NodePath.resolve(NodePath.dirname(originFile), value); +} + +async function loadOuterConfig(): Promise { + // Records: scope NUL origin NUL key LF value NUL + const fields = (await listOuterConfig()).split("\0"); + const entries: Array = []; + const origins: Array = []; + for (let index = 0; index + 2 < fields.length; index += 3) { + const scope = fields[index]!; + const origin = fields[index + 1]!; + const record = fields[index + 2]!; + if (scope !== "system" && scope !== "global") continue; + if (!origin.startsWith("file:")) unsure("outer config: non-file origin"); + const originFile = NodePath.resolve(origin.slice("file:".length)); + origins.push(originFile); + const separator = record.indexOf("\n"); + if (separator === -1) unsure("outer config: value-less key"); + const entry = { key: record.slice(0, separator), value: record.slice(separator + 1) }; + entries.push(entry); + // An included file that is missing or empty lists no entries of its own, so + // it is watched by name: the listing is stale once it gains content. + if (entry.key === "include.path") origins.push(includedConfigFile(originFile, entry.value)); + } + const files = outerConfigCandidates(origins); + return { entries, files, fingerprint: await fingerprintFiles(files), loadedAtMs: Date.now() }; +} + +/** + * Also the proof that git runs at all: every answer asks for it first, so a + * missing or broken git stays as visible as it was when each question spawned it. + */ +async function getOuterConfig(): Promise> { + const pending = outerConfig; + const current = pending ? await pending.catch(() => null) : null; + if ( + current && + Date.now() - current.loadedAtMs < OUTER_CONFIG_MAX_AGE_MS && + (await fingerprintFiles(current.files)) === current.fingerprint + ) { + return current.entries; + } + // Concurrent callers share one listing. + if (outerConfig === pending) { + // A listing that failed (no git, a config this reader refuses) fails again; + // retrying it for every command would add a spawn to each one. + if ( + outerConfigFailedAtMs !== null && + Date.now() - outerConfigFailedAtMs < OUTER_CONFIG_RETRY_MS + ) + unsure("outer config unavailable"); + const loading = loadOuterConfig(); + loading.then( + () => (outerConfigFailedAtMs = null), + () => (outerConfigFailedAtMs = Date.now()), + ); + outerConfig = loading; + } + return (await outerConfig!).entries; +} + +/** Test seam: forget the cached system/global config. */ +export const resetGitFastPathCaches = () => { + outerConfig = null; + outerConfigFailedAtMs = null; + verdicts.clear(); + packedRefsCache.clear(); + revListMemo.clear(); +}; + +// --------------------------------------------------------------------------- +// Repository discovery + +interface Repository { + /** Real path of the directory that holds `.git`. */ + readonly workTree: string; + readonly gitDir: string; + readonly commonDir: string; + /** `.git` is a directory (main worktree), not a `gitdir:` file. */ + readonly dotGitIsDirectory: boolean; + readonly localConfig: ReadonlyArray; +} + +const HEAD_CONTENT = /^(?:ref:[ \t]*refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})\s*$/; + +/** + * git's `is_git_directory`. A directory whose HEAD is empty or garbage (an + * interrupted clone) is not a repository to git, which then keeps looking in the + * parents; callers here decline instead. + */ +async function looksLikeGitDir(dir: string): Promise { + const [head, objects, refs, commondir] = await Promise.all([ + statOrNull(NodePath.join(dir, "HEAD")), + statOrNull(NodePath.join(dir, "objects")), + statOrNull(NodePath.join(dir, "refs")), + statOrNull(NodePath.join(dir, "commondir")), + ]); + if (!head?.isFile() || !(commondir?.isFile() || (objects?.isDirectory() && refs?.isDirectory()))) + return false; + const content = await readBoundedFile(NodePath.join(dir, "HEAD"), SMALL_FILE_BYTES); + return content !== null && HEAD_CONTENT.test(content); +} + +const SUPPORTED_EXTENSIONS = new Set([ + "extensions.noop", + "extensions.objectformat", + "extensions.partialclone", + "extensions.preciousobjects", + "extensions.worktreeconfig", +]); + +async function openRepository(workTree: string, gitDir: string, dotGitIsDirectory: boolean) { + assertLocalPath(gitDir); + if (!(await looksLikeGitDir(gitDir))) unsure("not a git directory"); + let commonDir = gitDir; + const commonDirFile = await readBoundedFile(NodePath.join(gitDir, "commondir"), SMALL_FILE_BYTES); + if (commonDirFile !== null) { + const relative = commonDirFile.replace(/\r?\n$/, ""); + if (relative.length === 0 || relative.includes("\n")) unsure("malformed commondir"); + assertLocalPath(relative); + commonDir = NodePath.resolve(gitDir, relative); + assertLocalPath(commonDir); + const [objects, refs] = await Promise.all([ + statOrNull(NodePath.join(commonDir, "objects")), + statOrNull(NodePath.join(commonDir, "refs")), + ]); + if (!objects?.isDirectory() || !refs?.isDirectory()) unsure("common dir is incomplete"); + } + + const sharedConfig = parseGitConfig( + (await readBoundedFile(NodePath.join(commonDir, "config"), CONFIG_FILE_BYTES)) ?? + unsure("no repository config"), + ); + // Without a format version git ignores every extension, worktreeConfig included. + const versionText = lastValue(sharedConfig, "core.repositoryformatversion"); + if (versionText === undefined && sharedConfig.some(({ key }) => key.startsWith("extensions."))) + unsure("extensions without a format version"); + // extensions.worktreeConfig adds a per-worktree file that outranks the shared one. + const worktreeConfigFile = isGitTrue(lastValue(sharedConfig, "extensions.worktreeconfig")) + ? ((await readBoundedFile(NodePath.join(gitDir, "config.worktree"), CONFIG_FILE_BYTES)) ?? "") + : ""; + const localConfig = [...sharedConfig, ...parseGitConfig(worktreeConfigFile)]; + const version = Number(versionText ?? "0"); + if (version !== 0 && version !== 1) unsure("unknown repository format"); + for (const { key } of localConfig) { + if (key.startsWith("include.") || key.startsWith("includeif.")) unsure("config includes"); + if (key.startsWith("extensions.") && !SUPPORTED_EXTENSIONS.has(key)) unsure("extension"); + } + if (isGitTrue(lastValue(localConfig, "core.bare"))) unsure("bare repository"); + if (lastValue(localConfig, "core.worktree") !== undefined) unsure("core.worktree"); + + return { workTree, gitDir, commonDir, dotGitIsDirectory, localConfig } satisfies Repository; +} + +async function discoverRepository(cwd: string): Promise<{ repo: Repository; realCwd: string }> { + const realCwd = await NodeFSP.realpath(cwd); + if (realCwd.startsWith("\\\\")) unsure("UNC path"); + const startStat = await NodeFSP.stat(realCwd); + // git cannot even start in a file; walking up from it would answer for the parent repository. + if (!startStat.isDirectory()) unsure("cwd is not a directory"); + rememberSymlinkFree(realCwd); + // Windows has no device ids worth comparing, and git for Windows does not compare them either. + const checkBoundaries = NodePath.sep !== "\\"; + + for (let dir = realCwd; ;) { + const dotGit = NodePath.join(dir, ".git"); + const dotGitStat = await statOrNull(dotGit); + // git walks past a `.git` directory that is not a repository (an empty one, a broken HEAD). + if (dotGitStat?.isDirectory() && (await looksLikeGitDir(dotGit))) { + return { repo: await openRepository(dir, dotGit, true), realCwd }; + } + if (dotGitStat && !dotGitStat.isDirectory()) { + const pointer = /^gitdir: (.+?)\r?\n?$/.exec( + (await readBoundedFile(dotGit, SMALL_FILE_BYTES)) ?? "", + ); + if (!pointer) unsure("malformed .git file"); + assertLocalPath(pointer![1]!); + return { + repo: await openRepository(dir, NodePath.resolve(dir, pointer![1]!), false), + realCwd, + }; + } + // Inside a git directory or a bare repository: git has rules this does not replicate. + if (await looksLikeGitDir(dir)) unsure("inside a git directory"); + + const parent = NodePath.dirname(dir); + if (parent === dir) return notARepository(realCwd); + // git stops at filesystem boundaries unless told otherwise. + if (checkBoundaries && (await NodeFSP.stat(parent)).dev !== startStat.dev) + unsure("filesystem boundary"); + dir = parent; + } +} + +// --------------------------------------------------------------------------- +// git's verdict on the repository +// +// Discovery above finds the files. Whether git accepts them (owner and +// `safe.directory`, format version and extensions, filesystem boundaries) has +// too many rules, and too much security history, to mirror here. One spawn per +// repository per few minutes settles it, against hundreds of answered questions. + +const VERDICT_MAX_AGE_MS = 5 * 60_000; +const VERDICT_CAPACITY = 256; + +interface GitVerdict { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +const verdicts = new Map }>(); + +function askGit(args: ReadonlyArray): Promise { + return withOwnGitProcess( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + "git", + [...args], + { + cwd: NodeOS.tmpdir(), + env: { ...process.env, LC_ALL: "C" }, + windowsHide: true, + timeout: 10_000, + maxBuffer: 64 * 1024, + }, + (error, stdout, stderr) => + error && typeof error.code !== "number" + ? reject(error) + : resolve({ + exitCode: typeof error?.code === "number" ? error.code : 0, + stdout, + stderr, + }), + ); + }), + ); +} + +/** + * What git's acceptance depends on beyond the path: which directories these are + * and who owns them, the repository config, and the system and global config + * that hold `safe.directory`. A replaced repository or changed settings get a new verdict. + */ +async function repositoryIdentity(repo: Repository): Promise { + const directories = await Promise.all( + [repo.workTree, repo.gitDir, repo.commonDir].map((dir) => NodeFSP.stat(dir)), + ); + const outer = await outerConfig?.catch(() => null); + return [ + ...directories.map((stat) => `${stat.dev}:${stat.ino}:${stat.uid}:${stat.birthtimeMs}`), + await fingerprintFiles([NodePath.join(repo.commonDir, "config")]), + outer?.fingerprint ?? "", + ].join("|"); +} + +function gitVerdict(args: ReadonlyArray, identity = ""): Promise { + const key = [...args, identity].join("\0"); + const known = verdicts.get(key); + if (known && Date.now() - known.at < VERDICT_MAX_AGE_MS) return known.verdict; + if (verdicts.size >= VERDICT_CAPACITY) verdicts.clear(); + const verdict = askGit(args); + verdicts.set(key, { at: Date.now(), verdict }); + // A failed spawn is not a verdict. + verdict.catch(() => verdicts.delete(key)); + return verdict; +} + +/** Declines unless git opens the same repository from the same place. */ +async function requireGitAgrees(repo: Repository, explicitGitDir: boolean): Promise { + const identity = await repositoryIdentity(repo); + const verdict = explicitGitDir + ? await gitVerdict(["--git-dir", repo.gitDir, "rev-parse", "--git-dir"], identity) + : await gitVerdict(["-C", repo.workTree, "rev-parse", "--show-toplevel"], identity); + if (verdict.exitCode !== 0) unsure("git refuses this repository"); + if (!explicitGitDir && verdict.stdout !== `${toGitPath(repo.workTree)}\n`) + unsure("git opens a different repository"); +} + +/** Discovery found no repository; answers 128 only with git's own words for it. */ +async function notARepository(cwd: string): Promise { + const verdict = await gitVerdict(["-C", cwd, "rev-parse", "--show-toplevel"]); + if (verdict.exitCode !== 128 || verdict.stdout !== "") unsure("git found a repository"); + throw new NotARepository(verdict.stderr); +} + +// --------------------------------------------------------------------------- +// Refs + +const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; + +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9]|conin\$|conout\$)(?:\..*)?$/i; + +/** Accepts only names that are safe to join onto the git directory. */ +function isSafeRefName(ref: string): boolean { + if (!ref.startsWith("refs/") || ref.endsWith("/") || ref.endsWith(".")) return false; + // eslint-disable-next-line no-control-regex -- git's check_refname_format rejects control characters + if (/[\x00-\x20\x7f~^:?*[\\]|\.\.|@\{|\/\//.test(ref)) return false; + return ref.split("/").every( + (part) => + part.length > 0 && + !part.startsWith(".") && + !part.endsWith(".lock") && + // Legal to git, but Windows maps these onto other files or onto devices. + !part.endsWith(".") && + !WINDOWS_DEVICE_NAME.test(part), + ); +} + +const packedRefsCache = new Map< + string, + { fingerprint: string; refs: ReadonlyMap } +>(); + +/** Packed ref name -> object id. */ +async function readPackedRefs(commonDir: string): Promise> { + const file = NodePath.join(commonDir, "packed-refs"); + const stat = await statOrNull(file); + if (!stat) return new Map(); + const fingerprint = `${stat.mtimeMs}:${stat.size}:${stat.ino}`; + const cached = packedRefsCache.get(file); + if (cached?.fingerprint === fingerprint) return cached.refs; + + const refs = new Map(); + const text = (await readBoundedFile(file, PACKED_REFS_BYTES)) ?? ""; + for (const line of text.split("\n")) { + if (line.length === 0 || line.startsWith("#") || line.startsWith("^")) continue; + const space = line.indexOf(" "); + if (space === -1 || !OBJECT_ID.test(line.slice(0, space))) unsure("malformed packed-refs"); + // git dies on an unsafe name here; these names reach NUL-delimited output. + const name = line.slice(space + 1); + if (!isSafeRefName(name)) unsure("unsafe packed ref name"); + refs.set(name, line.slice(0, space)); + } + // Without an inode a same-size rewrite inside one mtime tick would go unseen. + if (stat.ino === 0) return refs; + if (packedRefsCache.size >= 64) packedRefsCache.clear(); + packedRefsCache.set(file, { fingerprint, refs }); + return refs; +} + +type RefState = "exists" | "missing"; + +/** Object id a ref points at, `null` when the ref does not exist. */ +async function refObjectId(repo: Repository, ref: string): Promise { + if (!isSafeRefName(ref)) unsure("unsafe ref name"); + // These live in each worktree's own git directory, not in the common one read below. + if (/^refs\/(?:bisect|worktree|rewritten)\//.test(ref)) unsure("per-worktree ref"); + const looseFile = NodePath.join(repo.commonDir, ...ref.split("/")); + // A directory here means deeper refs exist (`refs/heads/a` vs `refs/heads/a/b`), not this one. + const loose = (await statOrNull(looseFile))?.isDirectory() + ? null + : await readBoundedFile(looseFile, SMALL_FILE_BYTES); + if (loose !== null) { + // Symbolic or damaged loose refs need git's full resolution. + return OBJECT_ID.test(loose.trim()) ? loose.trim() : unsure("loose ref is not an object id"); + } + return (await readPackedRefs(repo.commonDir)).get(ref) ?? null; +} + +const refState = async (repo: Repository, ref: string): Promise => + (await refObjectId(repo, ref)) === null ? "missing" : "exists"; + +type Head = + | { /** Full ref name HEAD points at. */ readonly ref: string } + | { /** Detached. */ readonly ref: null; readonly objectId: string }; + +async function readHead(repo: Repository): Promise { + const content = ( + (await readBoundedFile(NodePath.join(repo.gitDir, "HEAD"), SMALL_FILE_BYTES)) ?? + unsure("no HEAD") + ).trim(); + if (OBJECT_ID.test(content)) return { ref: null, objectId: content }; + const symbolic = /^ref: (refs\/\S+)$/.exec(content); + if (!symbolic || !isSafeRefName(symbolic[1]!)) unsure("unexpected HEAD"); + return { ref: symbolic![1]! }; +} + +/** `refs/heads/x` -> `x`, only when no other ref namespace could claim `x`. */ +async function shortBranchName(repo: Repository, ref: string): Promise { + if (!ref.startsWith("refs/heads/")) unsure("HEAD outside refs/heads"); + const short = ref.slice("refs/heads/".length); + const rivals = [ + `refs/${short}`, + `refs/tags/${short}`, + `refs/remotes/${short}`, + `refs/remotes/${short}/HEAD`, + ]; + for (const rival of rivals) { + if (!isSafeRefName(rival)) unsure("unsafe rival ref"); + if (await statOrNull(NodePath.join(repo.commonDir, ...rival.split("/")))) + unsure("ambiguous short name"); + } + for (const dir of new Set([repo.gitDir, repo.commonDir])) { + if (await statOrNull(NodePath.join(dir, ...short.split("/")))) unsure("ambiguous short name"); + } + const packed = await readPackedRefs(repo.commonDir); + if (rivals.some((rival) => packed.has(rival))) unsure("ambiguous short name"); + return short; +} + +// --------------------------------------------------------------------------- +// Remotes + +async function mergedConfig(repo: Repository): Promise> { + const outer = await getOuterConfig(); + // Conditional includes depend on the repository; the outer listing cannot show them. + if (outer.some(({ key }) => key.startsWith("includeif."))) unsure("conditional includes"); + return [...outer, ...repo.localConfig]; +} + +interface Remote { + readonly name: string; + readonly urls: ReadonlyArray; + readonly pushUrls: ReadonlyArray; +} + +async function readRemotes(repo: Repository): Promise> { + const config = await mergedConfig(repo); + // URL rewriting changes what git prints for every remote. + if (config.some(({ key }) => key.startsWith("url."))) unsure("url rewriting"); + for (const legacy of ["remotes", "branches"]) { + const dir = NodePath.join(repo.commonDir, legacy); + await assertNoSymlinks(dir); + const names = await NodeFSP.readdir(dir).catch(() => []); + if (names.length > 0) unsure("legacy remote files"); + } + + const remotes = new Map; pushUrls: Array }>(); + for (const { key, value } of config) { + const match = /^remote\.(.+)\.([a-z][a-z0-9-]*)$/.exec(key); + if (!match) continue; + const [, name, variable] = match as unknown as [string, string, string]; + if (variable === "partialclonefilter" || variable === "vcs") unsure("remote variable"); + const remote = remotes.get(name) ?? { urls: [], pushUrls: [] }; + remotes.set(name, remote); + if (variable !== "url" && variable !== "pushurl") continue; + // An empty value resets the list in git; rare enough to leave to git. + if (value.length === 0) unsure("empty remote url"); + (variable === "url" ? remote.urls : remote.pushUrls).push(value); + } + + const listed: Array = []; + for (const [name, remote] of remotes) { + // git lists a remote for any `remote..*` key; without a url its lines differ. + if (remote.urls.length === 0) unsure("remote without url"); + listed.push({ name, ...remote }); + } + const byteOrder = (left: Remote, right: Remote) => + Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)); + return listed.toSorted(byteOrder); +} + +// --------------------------------------------------------------------------- +// Commands + +const ok = (stdout: string): GitFastPathAnswer => ({ exitCode: 0, stdout, stderr: "" }); +const silentFailure: GitFastPathAnswer = { exitCode: 1, stdout: "", stderr: "" }; + +/** + * git translates its messages when the locale asks for it. Error text answered + * here is English, so it is only given to a caller whose git would speak English. + */ +function gitMessagesMayBeTranslated(env: NodeJS.ProcessEnv | undefined): boolean { + const merged = { ...process.env, ...env }; + const locale = merged.LC_ALL || merged.LC_MESSAGES || merged.LANG || ""; + if (locale === "" || /^(?:C|POSIX)(?:[.@]|$)/.test(locale)) return false; + return !(merged.LANGUAGE || locale).split(":").every((name) => /^en(?:[_.@]|$)/.test(name)); +} + +const sameArgs = (args: ReadonlyArray, expected: ReadonlyArray) => + args.length === expected.length && expected.every((value, index) => args[index] === value); + +const REV_PARSE_FORMS: ReadonlyArray> = [ + ["--is-inside-work-tree"], + ["--show-toplevel"], + ["--git-common-dir"], + ["--abbrev-ref", "HEAD"], + ["--abbrev-ref", "--symbolic-full-name", "@{upstream}"], +]; + +async function answerRevParse( + cwd: string, + args: ReadonlyArray, + env: NodeJS.ProcessEnv | undefined, +) { + // Other forms (`--sq-quote`, `--parseopt`, ...) work outside a repository. + if (!REV_PARSE_FORMS.some((form) => sameArgs(args, form))) unsure("rev-parse arguments"); + const { repo, realCwd } = await discoverRepository(cwd); + await requireGitAgrees(repo, false); + if (sameArgs(args, ["--is-inside-work-tree"])) return ok("true\n"); + if (sameArgs(args, ["--show-toplevel"])) return ok(`${toGitPath(repo.workTree)}\n`); + if (sameArgs(args, ["--git-common-dir"])) { + // Below the worktree root git prints a relative path; leave that form to git. + if (realCwd !== repo.workTree) unsure("common dir from a subdirectory"); + return ok(repo.dotGitIsDirectory ? ".git\n" : `${toGitPath(repo.commonDir)}\n`); + } + if (sameArgs(args, ["--abbrev-ref", "HEAD"])) { + const head = await readHead(repo); + if (head.ref === null) return ok("HEAD\n"); + // An unborn branch is an error in git, with a message worth keeping. + if ((await refState(repo, head.ref)) === "missing") unsure("unborn branch"); + return ok(`${await shortBranchName(repo, head.ref)}\n`); + } + if (sameArgs(args, ["--abbrev-ref", "--symbolic-full-name", "@{upstream}"])) { + const head = await readHead(repo); + // Detached and unborn HEADs fail with messages of their own. + if (head.ref === null || !head.ref.startsWith("refs/heads/")) unsure("detached HEAD"); + if ((await refState(repo, head.ref!)) === "missing") unsure("unborn branch"); + const branch = head.ref!.slice("refs/heads/".length); + const upstream = await resolveUpstream(repo, await mergedConfig(repo), branch); + if (!upstream && gitMessagesMayBeTranslated(env)) unsure("translated error message"); + return upstream + ? ok(`${upstream.short}\n`) + : { + exitCode: 128, + stdout: "", + stderr: `fatal: no upstream configured for branch '${branch}'\n`, + }; + } + return unsure("rev-parse arguments"); +} + +async function answerSymbolicRef(repo: Repository, args: ReadonlyArray) { + if (sameArgs(args, ["--quiet", "--short", "HEAD"])) { + const head = await readHead(repo); + return head.ref === null ? silentFailure : ok(`${await shortBranchName(repo, head.ref)}\n`); + } + if ( + args.length === 1 && + /^refs\/remotes\/[^/]+\/HEAD$/.test(args[0]!) && + isSafeRefName(args[0]!) + ) { + const content = + (await readBoundedFile( + NodePath.join(repo.commonDir, ...args[0]!.split("/")), + SMALL_FILE_BYTES, + )) ?? unsure("no such ref"); + const symbolic = /^ref: (refs\/\S+)\r?\n?$/.exec(content); + if (!symbolic || !isSafeRefName(symbolic[1]!)) unsure("not a symbolic ref"); + // git prints the end of a chain; `refObjectId` declines when the target is symbolic too. + await refObjectId(repo, symbolic![1]!); + return ok(`${symbolic![1]!}\n`); + } + return unsure("symbolic-ref arguments"); +} + +async function answerRemote(repo: Repository, args: ReadonlyArray) { + const remotes = await readRemotes(repo); + if (args.length === 0) return ok(remotes.map(({ name }) => `${name}\n`).join("")); + if (sameArgs(args, ["-v"])) { + return ok( + remotes + .flatMap(({ name, urls, pushUrls }) => [ + `${name}\t${urls[0]!} (fetch)\n`, + ...(pushUrls.length > 0 ? pushUrls : urls).map((url) => `${name}\t${url} (push)\n`), + ]) + .join(""), + ); + } + if (args.length === 2 && args[0] === "get-url") { + // A missing remote has a specific exit code and message; git reports it. + const remote = remotes.find(({ name }) => name === args[1]) ?? unsure("no such remote"); + // So does one that only system or global config defines: `get-url` wants it in the repository. + if (!repo.localConfig.some(({ key }) => key.startsWith(`remote.${remote.name}.`))) + unsure("remote defined outside the repository"); + return ok(`${remote.urls[0]!}\n`); + } + return unsure("remote arguments"); +} + +// --------------------------------------------------------------------------- +// for-each-ref + +const REFNAME_FORMAT = "--format=%(refname)"; +const UPSTREAM_FORMAT = + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)"; + +const byteOrder = (left: string, right: string) => + Buffer.compare(Buffer.from(left), Buffer.from(right)); + +/** Every ref below `refs/heads` or `refs/remotes`, loose and packed, that resolves to an object. */ +async function listBranchRefs(repo: Repository, namespace: string): Promise> { + const packed = await readPackedRefs(repo.commonDir); + const refs = new Set(); + for (const ref of packed.keys()) if (ref.startsWith(`${namespace}/`)) refs.add(ref); + + const walk = async (ref: string): Promise => { + const dir = NodePath.join(repo.commonDir, ...ref.split("/")); + await assertNoSymlinks(dir); + const entries = await NodeFSP.readdir(dir, { withFileTypes: true }).catch( + (error: NodeJS.ErrnoException) => + error.code === "ENOENT" ? [] : unsure("unreadable refs directory"), + ); + for (const entry of entries) { + const child = `${ref}/${entry.name}`; + if (entry.isDirectory()) { + await walk(child); + continue; + } + // Lock files, odd names and non-files are git's to judge. + if (!entry.isFile() || !isSafeRefName(child)) unsure("unexpected entry under refs"); + const content = ( + (await readBoundedFile( + NodePath.join(repo.commonDir, ...child.split("/")), + SMALL_FILE_BYTES, + )) ?? unsure("ref vanished") + ).trim(); + if (OBJECT_ID.test(content)) { + refs.add(child); + continue; + } + // A symbolic ref is listed only when its target exists; git warns about the rest. + const symbolic = /^ref: (refs\/\S+)$/.exec(content) ?? unsure("damaged loose ref"); + if ((await refState(repo, symbolic[1]!)) === "missing") unsure("dangling symbolic ref"); + refs.add(child); + } + }; + await walk(namespace); + return [...refs]; +} + +/** git's pattern rule: the whole ref, a leading directory of it, or a glob where `*` stays within one level. */ +function refPatternMatcher(pattern: string): (ref: string) => boolean { + if (!isSafeRefName(pattern.replaceAll("*", "x")) || pattern.includes("**")) unsure("ref pattern"); + if (!pattern.includes("*")) return (ref) => ref === pattern || ref.startsWith(`${pattern}/`); + const glob = new RegExp( + `^${pattern + .split("*") + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("[^/]*")}$`, + ); + return (ref) => glob.test(ref); +} + +/** Tracking ref for `branch..merge` under the remote's fetch refspecs, or `null` when none maps it. */ +function mapThroughFetchRefspecs(refspecs: ReadonlyArray, source: string): string | null { + for (const raw of refspecs) { + const refspec = raw.startsWith("+") ? raw.slice(1) : raw; + const colon = refspec.indexOf(":"); + // Negative and one-sided refspecs change the mapping in ways not modelled here. + if (refspec.startsWith("^") || colon <= 0 || colon === refspec.length - 1) + unsure("unsupported fetch refspec"); + const from = refspec.slice(0, colon); + const to = refspec.slice(colon + 1); + const fromStar = from.indexOf("*"); + const toStar = to.indexOf("*"); + if (fromStar === -1 && toStar === -1) { + if (from === source) return to; + continue; + } + if ( + fromStar === -1 || + toStar === -1 || + from.includes("*", fromStar + 1) || + to.includes("*", toStar + 1) + ) + unsure("unsupported fetch refspec"); + const prefix = from.slice(0, fromStar); + const suffix = from.slice(fromStar + 1); + if ( + source.length >= prefix.length + suffix.length && + source.startsWith(prefix) && + source.endsWith(suffix) + ) { + const middle = source.slice(prefix.length, source.length - suffix.length); + return `${to.slice(0, toStar)}${middle}${to.slice(toStar + 1)}`; + } + } + return null; +} + +/** `refs/remotes/origin/x` -> `origin/x`, only when nothing else could claim that short name. */ +async function shortTrackingName(repo: Repository, ref: string): Promise { + if (!ref.startsWith("refs/remotes/")) unsure("upstream outside refs/remotes"); + // git shortens `refs/remotes/origin/HEAD` to `origin`, by a rule of its own. + if (ref.endsWith("/HEAD")) unsure("upstream is a remote HEAD"); + const short = ref.slice("refs/remotes/".length); + const rivals = [`refs/${short}`, `refs/tags/${short}`, `refs/heads/${short}`, `${ref}/HEAD`]; + const packed = await readPackedRefs(repo.commonDir); + for (const rival of rivals) { + if (!isSafeRefName(rival) || packed.has(rival)) unsure("ambiguous short name"); + if (await statOrNull(NodePath.join(repo.commonDir, ...rival.split("/")))) + unsure("ambiguous short name"); + } + for (const dir of new Set([repo.gitDir, repo.commonDir])) { + if (await statOrNull(NodePath.join(dir, ...short.split("/")))) unsure("ambiguous short name"); + } + return short; +} + +interface Upstream { + /** `origin/main` */ + readonly short: string; + readonly remote: string; + /** `refs/heads/main` on the remote. */ + readonly merge: string; +} + +/** Configured upstream of a local branch, `null` when it has none. */ +async function resolveUpstream( + repo: Repository, + config: ReadonlyArray, + branch: string, +): Promise { + const values = (name: string) => + config.filter(({ key }) => key === `branch.${branch}.${name}`).map(({ value }) => value); + const remote = values("remote").at(-1); + const merges = values("merge"); + if (remote === undefined && merges.length === 0) return null; + // Half-configured, local (".") and multi-merge upstreams follow rules not modelled here. + if (remote === undefined || remote === "." || merges.length !== 1) unsure("unusual upstream"); + const merge = merges[0]!; + if (!isSafeRefName(merge)) unsure("unusual upstream"); + if (!config.some(({ key }) => key === `remote.${remote}.url`)) + unsure("upstream remote is not configured"); + const refspecs = config + .filter(({ key }) => key === `remote.${remote}.fetch`) + .map(({ value }) => value); + const tracking = + mapThroughFetchRefspecs(refspecs, merge) ?? unsure("upstream has no tracking ref"); + if ((await refState(repo, tracking)) === "missing") unsure("tracking ref is missing"); + return { short: await shortTrackingName(repo, tracking), remote: remote!, merge }; +} + +async function upstreamFields( + repo: Repository, + config: ReadonlyArray, + ref: string, +): Promise { + const upstream = ref.startsWith("refs/heads/") + ? await resolveUpstream(repo, config, ref.slice("refs/heads/".length)) + : null; + return upstream ? `${upstream.short}\0${upstream.remote}\0${upstream.merge}` : "\0\0"; +} + +async function answerForEachRef(repo: Repository, args: ReadonlyArray) { + let rest = args; + let count = Number.POSITIVE_INFINITY; + if (rest[0] === "--count=1") { + count = 1; + rest = rest.slice(1); + } + const [format, ...patterns] = rest; + if ((format !== REFNAME_FORMAT && format !== UPSTREAM_FORMAT) || patterns.length === 0) + unsure("for-each-ref arguments"); + + const namespaces = new Set(); + for (const pattern of patterns) { + const namespace = /^(refs\/(?:heads|remotes))(?:\/|$)/.exec(pattern)?.[1]; + namespaces.add(namespace ?? unsure("for-each-ref outside branches")); + } + const matchers = patterns.map(refPatternMatcher); + const listed = await Promise.all( + [...namespaces].map((namespace) => listBranchRefs(repo, namespace)), + ); + const refs = listed + .flat() + .filter((ref) => matchers.some((matches) => matches(ref))) + .toSorted(byteOrder) + .slice(0, count); + + if (format === REFNAME_FORMAT) return ok(refs.map((ref) => `${ref}\n`).join("")); + const config = await mergedConfig(repo); + let stdout = ""; + for (const ref of refs) stdout += `${ref}\0${await upstreamFields(repo, config, ref)}\n`; + return ok(stdout); +} + +// --------------------------------------------------------------------------- +// rev-list counts +// +// Ahead/behind counts are a pure function of the two commits. Equal commits +// need no git at all; any other pair is answered from what git said last time +// for that same pair. + +const REV_LIST_MEMO_CAPACITY = 512; +const revListMemo = new Map(); + +/** A branch-like name -> object id, only when exactly one ref namespace knows it. */ +async function resolveRevision(repo: Repository, name: string): Promise { + if (name === "HEAD") { + const head = await readHead(repo); + if (head.ref === null) return head.objectId; + return (await refObjectId(repo, head.ref)) ?? unsure("unborn branch"); + } + // Anything that could be an object id or revision syntax is git's to parse. + if (/^[0-9a-f]{4,64}$/i.test(name) || !isSafeRefName(`refs/heads/${name}`)) unsure("revision"); + const candidates = [ + `refs/${name}`, + `refs/tags/${name}`, + `refs/heads/${name}`, + `refs/remotes/${name}`, + `refs/remotes/${name}/HEAD`, + ]; + for (const dir of new Set([repo.gitDir, repo.commonDir])) { + if (await statOrNull(NodePath.join(dir, ...name.split("/")))) unsure("ambiguous revision"); + } + const found: Array = []; + for (const candidate of candidates) { + const objectId = await refObjectId(repo, candidate).catch(() => unsure("ambiguous revision")); + if (objectId !== null) found.push(objectId); + } + if (found.length !== 1) unsure("ambiguous or missing revision"); + return found[0]!; +} + +interface RevListQuery { + readonly key: string; + readonly left: string; + readonly right: string; + readonly symmetric: boolean; +} + +async function revListQuery(repo: Repository, args: ReadonlyArray): Promise { + const symmetric = sameArgs(args.slice(0, 2), ["--left-right", "--count"]) && args.length === 3; + const range = symmetric + ? args[2]! + : sameArgs(args.slice(0, 1), ["--count"]) && args.length === 2 + ? args[1]! + : unsure("rev-list arguments"); + const separator = symmetric ? "..." : ".."; + const at = range.indexOf(separator); + if ( + at <= 0 || + range.indexOf("..", at + separator.length) !== -1 || + (!symmetric && range.includes("...")) + ) + unsure("rev-list range"); + // Grafts, replacements and shallow boundaries change counts without changing the commits. + for (const file of ["shallow", NodePath.join("info", "grafts")]) { + if (await statOrNull(NodePath.join(repo.commonDir, file))) unsure("altered history"); + } + const replaceDir = NodePath.join(repo.commonDir, "refs", "replace"); + await assertNoSymlinks(replaceDir); + const replacements = await NodeFSP.readdir(replaceDir).catch(() => []); + const packed = await readPackedRefs(repo.commonDir); + if (replacements.length > 0 || [...packed.keys()].some((ref) => ref.startsWith("refs/replace/"))) + unsure("altered history"); + + const left = await resolveRevision(repo, range.slice(0, at)); + const right = await resolveRevision(repo, range.slice(at + separator.length)); + return { key: `${repo.commonDir}\0${separator}\0${left}\0${right}`, left, right, symmetric }; +} + +async function answerRevList(repo: Repository, args: ReadonlyArray) { + const query = await revListQuery(repo, args); + if (query.left === query.right) return ok(query.symmetric ? "0\t0\n" : "0\n"); + return ok(revListMemo.get(query.key) ?? unsure("not seen before")); +} + +async function answerConfigGet(repo: Repository, key: string) { + const canonical = canonicalConfigKey(key) ?? unsure("config key"); + // Only keys that live in repository config in practice. + if (!canonical.startsWith("branch.") && !canonical.startsWith("remote.")) + unsure("config section"); + const value = lastValue(await mergedConfig(repo), canonical); + return value === undefined ? silentFailure : ok(`${value}\n`); +} + +async function answer(input: GitFastPathInput): Promise { + let args = input.args; + let cwd = input.cwd; + let explicitGitDir: string | null = null; + if (args[0] === "-C" && args.length > 2) { + cwd = NodePath.resolve(cwd, args[1]!); + args = args.slice(2); + } + if (args[0] === "--git-dir" && args.length > 2) { + explicitGitDir = NodePath.resolve(cwd, args[1]!); + args = args.slice(2); + } + const [command, ...rest] = args; + + if (command === "rev-parse") { + if (explicitGitDir) unsure("rev-parse with --git-dir"); + return answerRevParse(cwd, rest, input.env); + } + const repoCommand = + command === "symbolic-ref" || + command === "remote" || + command === "show-ref" || + command === "for-each-ref" || + command === "rev-list" || + (command === "config" && rest.length === 2 && rest[0] === "--get"); + if (!repoCommand) unsure("unsupported command"); + + // With --git-dir only commands that ignore the worktree are answered. + const repo = explicitGitDir + ? await openRepository(explicitGitDir, explicitGitDir, false) + : ( + await discoverRepository(cwd).catch((error: unknown) => + // `git config` works outside a repository, from system and global config alone. + error instanceof NotARepository && command === "config" + ? unsure("config outside a repository") + : Promise.reject(error), + ) + ).repo; + await requireGitAgrees(repo, explicitGitDir !== null); + if (explicitGitDir && command === "symbolic-ref" && rest.includes("HEAD")) + unsure("HEAD with --git-dir"); + + if (command === "symbolic-ref") return answerSymbolicRef(repo, rest); + if (command === "remote") return answerRemote(repo, rest); + if (command === "config") return answerConfigGet(repo, rest[1]!); + if (command === "for-each-ref") return answerForEachRef(repo, rest); + if (command === "rev-list") return answerRevList(repo, rest); + if (rest.length === 3 && rest[0] === "--verify" && rest[1] === "--quiet") { + return (await refState(repo, rest[2]!)) === "exists" ? ok("") : silentFailure; + } + return unsure("show-ref arguments"); +} + +/** + * Returns git's exact output for a supported read-only command, or `null` when + * the caller has to spawn git. Never throws. + */ +export async function tryAnswerGitCommand( + input: GitFastPathInput, +): Promise { + if (!isGitFastPathEnabled() || !isGitFastPathEnabled(input.env ?? {})) return null; + if (hasGitEnvOverride(process.env) || hasGitEnvOverride(input.env)) return null; + if (movesGitOrItsConfig(input.env)) return null; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + try { + // Asking for the outer config first proves git runs at all, so a missing git is not papered over. + const result = await withinReadBudget(input.timeoutMs, () => + withFreshSymlinkChecks(() => getOuterConfig().then(() => answer(input))), + ); + return result !== null && + Math.max(Buffer.byteLength(result.stdout), Buffer.byteLength(result.stderr)) > maxOutputBytes + ? null + : result; + } catch (error) { + return error instanceof NotARepository && !gitMessagesMayBeTranslated(input.env) + ? { exitCode: 128, stdout: "", stderr: error.stderr } + : null; + } +} + +/** + * Settles with `work`'s result, or with `null` once the caller's budget or + * ANSWER_TIMEOUT_MS runs out, whichever comes first. A rejection of `work` + * before then is passed on. + */ +export async function withinReadBudget( + timeoutMs: number | null | undefined, + work: () => Promise, +): Promise { + // No budget at all: nothing may be answered, however fast the reads turn out. + if (typeof timeoutMs === "number" && timeoutMs <= 0) return null; + // A started read cannot be cancelled and holds one of libuv's few threads until + // the disk answers. Starting more while one is stuck would pile them up and + // stall every file operation in the server, so git gets every question instead. + if (abandonedReads > 0) return null; + let timer: NodeJS.Timeout | undefined; + // Reads that take this long mean a stuck disk or share; git gets the question instead. + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, Math.min(ANSWER_TIMEOUT_MS, timeoutMs ?? Infinity), null); + }); + let settled = false; + const working = work().finally(() => { + settled = true; + }); + // When the timer wins, the abandoned attempt still settles; its decline is not an error. + working.catch(() => undefined); + try { + return await Promise.race([working, timedOut]); + } finally { + clearTimeout(timer); + if (!settled) { + abandonedReads++; + void working.then( + () => abandonedReads--, + () => abandonedReads--, + ); + } + } +} + +/** + * Identifies a command whose answer depends only on commits that can be read + * from the repository files, or `null`. Take it before spawning git and hand it + * to `rememberGitAnswer` with git's output, so the same question is answered + * without a process until one of those commits changes. + */ +export async function gitAnswerMemoKey(input: GitFastPathInput): Promise { + if (!isGitFastPathEnabled() || !isGitFastPathEnabled(input.env ?? {})) return null; + if (hasGitEnvOverride(process.env) || hasGitEnvOverride(input.env)) return null; + const [command, ...rest] = input.args; + if (command !== "rev-list") return null; + try { + // Without a key git still runs, so a stuck disk must not hold up the spawn. + return await withinReadBudget(input.timeoutMs, () => + withFreshSymlinkChecks(async () => { + const { repo } = await discoverRepository(input.cwd); + await requireGitAgrees(repo, false); + return (await revListQuery(repo, rest)).key; + }), + ); + } catch { + return null; + } +} + +/** Stores git's output for `key` if the commits behind it did not move while git ran. */ +export async function rememberGitAnswer( + input: GitFastPathInput, + key: string, + stdout: string, +): Promise { + if (!/^\d+(?:\t\d+)?\n$/.test(stdout) || (await gitAnswerMemoKey(input)) !== key) return; + if (revListMemo.size >= REV_LIST_MEMO_CAPACITY) revListMemo.clear(); + revListMemo.set(key, stdout); +} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..4f9678fb5996 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -37,6 +37,7 @@ import { PATCH_RENDER_PREFIX_ARGS, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; +import * as GitMetadataFastPath from "./GitMetadataFastPath.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -503,20 +504,22 @@ function parseGitRemoteVerboseOutput( return remotes; } -const gitCommand = ( +interface GitCommandOptions { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; + readonly appendTruncationMarker?: boolean; +} + +const spawnGitCommand = ( process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, - options?: { - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; - readonly appendTruncationMarker?: boolean; - }, + options?: GitCommandOptions, ) => process.run({ operation, @@ -537,6 +540,38 @@ const gitCommand = ( : {}), }); +const gitCommand = ( + process: VcsProcess.VcsProcess["Service"], + operation: string, + cwd: string, + args: ReadonlyArray, + options?: GitCommandOptions, +) => + Effect.promise(() => + options?.stdin === undefined + ? GitMetadataFastPath.tryAnswerGitCommand({ + cwd, + args, + env: options?.env, + timeoutMs: options?.timeoutMs, + maxOutputBytes: options?.maxOutputBytes, + }) + : Promise.resolve(null), + ).pipe( + Effect.flatMap((answer) => + // A failing exit code without allowNonZeroExit needs git's own error details. + answer !== null && (answer.exitCode === 0 || options?.allowNonZeroExit) + ? Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(answer.exitCode), + stdout: answer.stdout, + stderr: answer.stderr, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies VcsProcess.VcsProcessOutput) + : spawnGitCommand(process, operation, cwd, args, options), + ), + ); + export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c6e24caa6250..0a0db38867fc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -78,6 +78,9 @@ const makeSuccessfulHandle = (stdout: string) => getOutputFd: () => Stream.empty, }); +// Permit tests drive TestClock; the fast path reads real files on the real clock first. +const SPAWN_ONLY_ENV = { T3CODE_GIT_FAST_PATH: "0" }; + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -182,6 +185,7 @@ it.effect("bounds Git bursts across drivers without timing out queued commands", operation: "test.gitBurst", cwd: "/repo", args: ["rev-parse", "HEAD"], + env: SPAWN_ONLY_ENV, ...(index < 4 ? {} : { timeoutMs: index < 8 ? 30_000 : 1_000 }), }), { concurrency: "unbounded" }, @@ -233,12 +237,23 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const slow = yield* driver - .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs }) + .execute({ + operation: "test.slowGit", + cwd: "/repo", + args: ["push"], + env: SPAWN_ONLY_ENV, + timeoutMs, + }) .pipe(Effect.forkChild); yield* Queue.take(starts); const burst = yield* Effect.all( Array.from({ length: 8 }, () => - driver.execute({ operation: "test.fastGit", cwd: "/repo", args: ["status"] }), + driver.execute({ + operation: "test.fastGit", + cwd: "/repo", + args: ["status"], + env: SPAWN_ONLY_ENV, + }), ), { concurrency: "unbounded" }, ).pipe(Effect.forkChild); @@ -338,6 +353,75 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("answers metadata from the repository files and leaves failures to git", () => { + const spawned: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + spawned.push(command.args); + return makeNonRepositoryHandle(); + }), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge( + Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + + return Effect.gen(function* () { + // The suite pins git config through GIT_CONFIG_*, which the file reader treats as an override. + yield* Effect.acquireRelease( + Effect.sync(() => { + const pinned = Object.entries(process.env).filter(([key]) => key.startsWith("GIT_CONFIG_")); + for (const [key] of pinned) delete process.env[key]; + return pinned; + }), + (pinned) => + Effect.sync(() => { + for (const [key, value] of pinned) process.env[key] = value; + }), + ); + const cwd = yield* makeTmpDir(); + yield* writeTextFile(cwd, ".git/HEAD", "ref: refs/heads/main\n"); + yield* writeTextFile(cwd, ".git/objects/.keep", ""); + yield* writeTextFile(cwd, ".git/refs/heads/.keep", ""); + yield* writeTextFile( + cwd, + ".git/config", + '[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = https://example.com/origin.git\n', + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const execute = (args: ReadonlyArray, allowNonZeroExit: boolean) => + driver.execute({ operation: "GitVcsDriver.test.fastPath", cwd, args, allowNonZeroExit }); + + const url = yield* execute(["remote", "get-url", "origin"], false); + assert.strictEqual(url.stdout, "https://example.com/origin.git\n"); + + const missingRef = ["show-ref", "--verify", "--quiet", "refs/heads/missing"]; + const tolerated = yield* execute(missingRef, true); + assert.strictEqual(Number(tolerated.exitCode), 1); + assert.deepStrictEqual(spawned, []); + + // Without allowNonZeroExit the caller gets git's own failure, not a synthesized one. + const failed = yield* execute(missingRef, false).pipe(Effect.result); + assert.isTrue(Result.isFailure(failed)); + assert.deepStrictEqual(spawned, [missingRef]); + + // An answer over the caller's output cap is git's to truncate or reject. + const getUrl = ["remote", "get-url", "origin"]; + yield* driver + .execute({ operation: "GitVcsDriver.test.fastPath", cwd, args: getUrl, maxOutputBytes: 8 }) + .pipe(Effect.result); + assert.deepStrictEqual(spawned, [missingRef, getUrl]); + }).pipe(Effect.provide(layer)); +}); + it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; @@ -386,6 +470,20 @@ it.effect("re-reads origin remote status after cache TTL expiry and bypassed inv it.effect("coalesces concurrent ref pages into one repository snapshot", () => Effect.scoped( Effect.gen(function* () { + // The spawner below sequences the snapshot by watching the `git remote` + // process, so remote names must come from git here, not from the config file. + yield* Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env.T3CODE_GIT_FAST_PATH; + process.env.T3CODE_GIT_FAST_PATH = "0"; + return previous; + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.T3CODE_GIT_FAST_PATH; + else process.env.T3CODE_GIT_FAST_PATH = previous; + }), + ); const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnedArgs = yield* Ref.make>>([]); const firstWorktreeScanStarted = yield* Deferred.make(); @@ -2354,7 +2452,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { commands.length = 0; const full = yield* driver.statusDetailsLocal(cwd); - assert.isTrue(commands.some((args) => args.includes("rev-list"))); assert.equal(full.aheadCount, hasUpstream ? 0 : 1); assert.equal(full.aheadOfDefaultCount, 1); assert.deepEqual(local, { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 140786cce0a4..af12870d347e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,4 +1,5 @@ import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -34,7 +35,13 @@ import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; -import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; +import { + gitCommandDuration, + gitCommandsTotal, + recordMetrics, + withMetrics, +} from "../observability/Metrics.ts"; +import * as GitMetadataFastPath from "./GitMetadataFastPath.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import { @@ -906,6 +913,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; + /** + * The command's result read from the repository files, or `null` when git has + * to run. Holds no git process permit. + */ + const answerWithoutGit = Effect.fnUntraced(function* (input: GitVcsDriver.ExecuteGitInput) { + if (input.stdin !== undefined || input.progress !== undefined) return null; + const answer = yield* Effect.promise(() => + GitMetadataFastPath.tryAnswerGitCommand({ + cwd: input.cwd, + args: input.args, + env: input.env, + timeoutMs: input.timeoutMs, + maxOutputBytes: input.maxOutputBytes, + }), + ); + // A failing exit code without allowNonZeroExit needs git's own error details. + if (answer === null || (answer.exitCode !== 0 && !input.allowNonZeroExit)) return null; + return { + exitCode: ChildProcessSpawner.ExitCode(answer.exitCode), + stdout: answer.stdout, + stderr: answer.stderr, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult; + }); + const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { const commandInput = { @@ -1020,9 +1053,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } satisfies GitVcsDriver.ExecuteGitResult; }); + const fastPathInput = { + cwd: input.cwd, + args: input.args, + env: input.env, + timeoutMs: input.timeoutMs, + }; + const memoKey = + input.stdin === undefined && input.progress === undefined + ? yield* Effect.promise(() => GitMetadataFastPath.gitAnswerMemoKey(fastPathInput)) + : null; + // Runs after the timeout: timeoutMs bounds git, and a slow memo must not discard its answer. + const remember = (result: GitVcsDriver.ExecuteGitResult) => + memoKey !== null && result.exitCode === 0 && !result.stdoutTruncated + ? Effect.promise(() => + GitMetadataFastPath.rememberGitAnswer(fastPathInput, memoKey, result.stdout), + ) + : Effect.void; const execution = runGitCommand().pipe(Effect.scoped); if (timeoutMs === null) { - return yield* execution; + return yield* execution.pipe(Effect.tap(remember)); } return yield* execution.pipe( @@ -1037,23 +1087,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ), + Effect.tap(remember), ); }, ); - const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => - executeRaw(input).pipe( - withMetrics({ - counter: gitCommandsTotal, - timer: gitCommandDuration, - attributes: { - operation: input.operation, - }, - }), - (execution) => - input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS - ? execution - : gitProcesses.withPermits(1)(execution), + const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => { + // Times the command itself, not the wait for a process permit. An answered + // command took as long as its file reads; a miss is timed from its spawn. + const metrics = { + counter: gitCommandsTotal, + timer: gitCommandDuration, + attributes: { + operation: input.operation, + }, + }; + const spawnGit = executeRaw(input).pipe(withMetrics(metrics)); + return Effect.flatMap(Clock.monotonicTimeNanos, (startedAt) => + Effect.flatMap(answerWithoutGit(input), (answer) => + answer !== null + ? recordMetrics(metrics, startedAt, Exit.succeed(answer)).pipe(Effect.as(answer)) + : input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS + ? spawnGit + : gitProcesses.withPermits(1)(spawnGit), + ), + ).pipe( Effect.withSpan(input.operation, { kind: "client", attributes: { @@ -1063,6 +1121,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ); + }; const executeGit = ( operation: string,