From 26cfac31c1e1357f77d9ce6b94511a975aa4f9c3 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:02:51 +0300 Subject: [PATCH 01/11] perf(server): answer cheap git metadata from repository files instead of spawning git Background loops ask git the same read-only questions (toplevel, remotes, HEAD, upstream, a config value, branch refs, ahead/behind) many times a minute per project. GitMetadataFastPath answers a fixed set of them byte-for-byte from the files under .git, or declines so the caller spawns git as before. git itself decides once per repository whether it opens it; reads are bounded and refuse UNC pointers. T3CODE_GIT_FAST_PATH=0 turns it off. --- .../src/project/RepositoryIdentityResolver.ts | 36 +- .../src/vcs/GitMetadataFastPath.test.ts | 655 +++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 1208 +++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 49 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 76 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 73 +- 6 files changed, 2056 insertions(+), 41 deletions(-) create mode 100644 apps/server/src/vcs/GitMetadataFastPath.test.ts create mode 100644 apps/server/src/vcs/GitMetadataFastPath.ts diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 5acafa47e2e2..4a6881c39bea 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 @@ -97,19 +99,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; } @@ -124,14 +133,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..ca152c41fb9e --- /dev/null +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -0,0 +1,655 @@ +// @effect-diagnostics nodeBuiltinImport: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 } from "vite-plus/test"; + +import { + gitAnswerMemoKey, + parseGitConfig, + rememberGitAnswer, + resetGitFastPathCaches, + tryAnswerGitCommand, +} 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" }); + + // 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("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("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("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..c894925504d3 --- /dev/null +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -0,0 +1,1208 @@ +// @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 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; +} + +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. */ +export const isGitFastPathEnabled = (env: NodeJS.ProcessEnv = process.env) => + env.T3CODE_GIT_FAST_PATH !== "0"; + +const ANSWER_TIMEOUT_MS = 2_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; +} + +const toGitPath = (value: string) => (NodePath.sep === "\\" ? value.replaceAll("\\", "/") : value); + +async function statOrNull(target: string) { + try { + return await NodeFSP.stat(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 { + const stat = await NodeFSP.stat(file).catch((error: NodeJS.ErrnoException) => + error.code === "ENOENT" || error.code === "ENOTDIR" ? null : unsure("unreadable file"), + ); + if (stat === null) return null; + if (!stat.isFile() || stat.size > maxBytes) unsure("not a small regular file"); + const text = await NodeFSP.readFile(file, "utf8"); + // 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; +} + +/** + * 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; +let outerConfig: Promise | 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 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)), + ); + }); +} + +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"); + origins.push(NodePath.resolve(origin.slice("file:".length))); + const separator = record.indexOf("\n"); + if (separator === -1) unsure("outer config: value-less key"); + entries.push({ key: record.slice(0, separator), value: record.slice(separator + 1) }); + } + 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) outerConfig = loadOuterConfig(); + return (await outerConfig!).entries; +} + +/** Test seam: forget the cached system/global config. */ +export const resetGitFastPathCaches = () => { + outerConfig = 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); + // 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 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 }), + ); + }); +} + +function gitVerdict(args: ReadonlyArray): Promise { + const key = args.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 verdict = explicitGitDir + ? await gitVerdict(["--git-dir", repo.gitDir, "rev-parse", "--git-dir"]) + : await gitVerdict(["-C", repo.workTree, "rev-parse", "--show-toplevel"]); + 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 + 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"); + 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 names = await NodeFSP.readdir(NodePath.join(repo.commonDir, legacy)).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 entries = await NodeFSP.readdir(NodePath.join(repo.commonDir, ...ref.split("/")), { + 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 replacements = await NodeFSP.readdir( + NodePath.join(repo.commonDir, "refs", "replace"), + ).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; + let timer: NodeJS.Timeout | undefined; + try { + // Reads that take this long mean a stuck disk or share; git gets the question instead. + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, ANSWER_TIMEOUT_MS, null); + }); + // Asking for the outer config first proves git runs at all, so a missing git is not papered over. + const answering = getOuterConfig().then(() => answer(input)); + // When the timer wins, the abandoned attempt still settles; its decline is not an error. + answering.catch(() => undefined); + return await Promise.race([answering, timedOut]); + } catch (error) { + return error instanceof NotARepository && !gitMessagesMayBeTranslated(input.env) + ? { exitCode: 128, stdout: "", stderr: error.stderr } + : null; + } finally { + clearTimeout(timer); + } +} + +/** + * 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 { + 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 71ebeb1cfdb3..3175f098f05e 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"; @@ -472,20 +473,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, @@ -506,6 +509,32 @@ 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 }) + : 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 85c4d1a59d1d..3925a3901796 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -339,6 +339,68 @@ 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(ServerConfigLayer), + 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]); + }).pipe(Effect.provide(layer)); +}); + it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; @@ -387,6 +449,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(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..e5b60f96b99b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -34,6 +34,7 @@ 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 * as GitMetadataFastPath from "./GitMetadataFastPath.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, @@ -831,6 +832,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* 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, + }), + ); + // 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 = { @@ -940,7 +965,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } satisfies GitVcsDriver.ExecuteGitResult; }); - const execution = runGitCommand().pipe(Effect.scoped); + const fastPathInput = { cwd: input.cwd, args: input.args, env: input.env }; + const memoKey = + input.stdin === undefined && input.progress === undefined + ? yield* Effect.promise(() => GitMetadataFastPath.gitAnswerMemoKey(fastPathInput)) + : null; + const execution = runGitCommand().pipe( + Effect.scoped, + Effect.tap((result) => + memoKey !== null && result.exitCode === 0 && !result.stdoutTruncated + ? Effect.promise(() => + GitMetadataFastPath.rememberGitAnswer(fastPathInput, memoKey, result.stdout), + ) + : Effect.void, + ), + ); if (timeoutMs === null) { return yield* execution; } @@ -961,19 +1000,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - 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. + const metrics = { + counter: gitCommandsTotal, + timer: gitCommandDuration, + attributes: { + operation: input.operation, + }, + }; + const spawnGit = executeRaw(input).pipe(withMetrics(metrics)); + return answerWithoutGit(input).pipe( + Effect.flatMap((answer) => + answer !== null + ? Effect.succeed(answer).pipe(withMetrics(metrics)) + : input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS + ? spawnGit + : gitProcesses.withPermits(1)(spawnGit), + ), Effect.withSpan(input.operation, { kind: "client", attributes: { @@ -983,6 +1027,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ); + }; const executeGit = ( operation: string, From 9b1354a36d9289b338195addce25c72530710c32 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:03:21 +0300 Subject: [PATCH 02/11] fix(server): keep file-answered git commands inside the caller's limits and environment Read repository files through one handle, watch global include targets, honour the caller's timeout and output cap, and leave commands whose environment moves git or its config to git. --- .../src/vcs/GitMetadataFastPath.test.ts | 86 ++++++++++++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 89 ++++++++++++++++--- apps/server/src/vcs/GitVcsDriver.ts | 8 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 7 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 2 + 5 files changed, 180 insertions(+), 12 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index ca152c41fb9e..9b9090c35030 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -455,6 +455,92 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { ).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("stays inside the caller's time and output budget", async () => { + const cwd = repos.plain!; + 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("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"), diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index c894925504d3..14fe8e659d9c 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -28,6 +28,10 @@ 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 { @@ -41,6 +45,8 @@ export const isGitFastPathEnabled = (env: NodeJS.ProcessEnv = process.env) => env.T3CODE_GIT_FAST_PATH !== "0"; const ANSWER_TIMEOUT_MS = 2_000; +// 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. */ @@ -88,6 +94,27 @@ function hasGitEnvOverride(env: NodeJS.ProcessEnv | undefined): boolean { 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; +} + const toGitPath = (value: string) => (NodePath.sep === "\\" ? value.replaceAll("\\", "/") : value); async function statOrNull(target: string) { @@ -106,15 +133,35 @@ 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 { - const stat = await NodeFSP.stat(file).catch((error: NodeJS.ErrnoException) => + // 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; Windows has neither. + const handle = await NodeFSP.open( + file, + NodeFSP.constants.O_RDONLY | (NodeFSP.constants.O_NONBLOCK ?? 0), + ).catch((error: NodeJS.ErrnoException) => error.code === "ENOENT" || error.code === "ENOTDIR" ? null : unsure("unreadable file"), ); - if (stat === null) return null; - if (!stat.isFile() || stat.size > maxBytes) unsure("not a small regular file"); - const text = await NodeFSP.readFile(file, "utf8"); - // 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; + 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(); + } } /** @@ -289,6 +336,15 @@ function listOuterConfig(): Promise { }); } +/** 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"); @@ -300,10 +356,15 @@ async function loadOuterConfig(): Promise { const record = fields[index + 2]!; if (scope !== "system" && scope !== "global") continue; if (!origin.startsWith("file:")) unsure("outer config: non-file origin"); - origins.push(NodePath.resolve(origin.slice("file:".length))); + 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"); - entries.push({ key: record.slice(0, separator), value: record.slice(separator + 1) }); + 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() }; @@ -1156,17 +1217,23 @@ export async function tryAnswerGitCommand( ): 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; let timer: NodeJS.Timeout | undefined; try { // Reads that take this long mean a stuck disk or share; git gets the question instead. const timedOut = new Promise((resolve) => { - timer = setTimeout(resolve, ANSWER_TIMEOUT_MS, null); + timer = setTimeout(resolve, Math.min(ANSWER_TIMEOUT_MS, input.timeoutMs ?? Infinity), null); }); // Asking for the outer config first proves git runs at all, so a missing git is not papered over. const answering = getOuterConfig().then(() => answer(input)); // When the timer wins, the abandoned attempt still settles; its decline is not an error. answering.catch(() => undefined); - return await Promise.race([answering, timedOut]); + const result = await Promise.race([answering, timedOut]); + 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 } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 3175f098f05e..0ec3de14d889 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -518,7 +518,13 @@ const gitCommand = ( ) => Effect.promise(() => options?.stdin === undefined - ? GitMetadataFastPath.tryAnswerGitCommand({ cwd, args, env: options?.env }) + ? GitMetadataFastPath.tryAnswerGitCommand({ + cwd, + args, + env: options?.env, + timeoutMs: options?.timeoutMs, + maxOutputBytes: options?.maxOutputBytes, + }) : Promise.resolve(null), ).pipe( Effect.flatMap((answer) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3925a3901796..b635182986e0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -398,6 +398,13 @@ it.effect("answers metadata from the repository files and leaves failures to git 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)); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e5b60f96b99b..40b672a47956 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -843,6 +843,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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. From adbecf81db459eeb39465060be7e2c916770770c Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:33:42 +0300 Subject: [PATCH 03/11] fix(server): remember a failed global config listing and leave per-worktree refs to git --- .../src/vcs/GitMetadataFastPath.test.ts | 38 ++++++++++++++++++- apps/server/src/vcs/GitMetadataFastPath.ts | 21 +++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 9b9090c35030..2743a623c573 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -1,9 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off - the module under test is plain Node; fixtures are built with real git. +// @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 } from "vite-plus/test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; import { gitAnswerMemoKey, @@ -541,6 +541,40 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { }, ); + 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 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"), diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 14fe8e659d9c..225c550609f2 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -306,7 +306,9 @@ interface OuterConfig { } 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(); @@ -385,13 +387,28 @@ async function getOuterConfig(): Promise> { return current.entries; } // Concurrent callers share one listing. - if (outerConfig === pending) outerConfig = loadOuterConfig(); + 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(); @@ -646,6 +663,8 @@ 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() From 25a26ef11c7983c5e3fbfdaaf2361631c0b74923 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:59:20 +0300 Subject: [PATCH 04/11] fix(server): cap the git processes the metadata reader starts on its own --- .../src/vcs/GitMetadataFastPath.test.ts | 41 +++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 87 +++++++++++++------ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 2743a623c573..007d79394e54 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -7,6 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-p import { gitAnswerMemoKey, + makeTaskLimiter, parseGitConfig, rememberGitAnswer, resetGitFastPathCaches, @@ -509,6 +510,9 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { 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(); @@ -740,6 +744,43 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { }); }); +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( diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 225c550609f2..f438862aa853 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -115,6 +115,31 @@ function movesGitOrItsConfig(env: NodeJS.ProcessEnv | undefined): boolean { 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); async function statOrNull(target: string) { @@ -328,14 +353,17 @@ async function fingerprintFiles(files: ReadonlyArray): Promise { } function listOuterConfig(): Promise { - return 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)), - ); - }); + 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`. */ @@ -555,23 +583,30 @@ interface GitVerdict { const verdicts = new Map }>(); function askGit(args: ReadonlyArray): Promise { - return 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 }), - ); - }); + 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, + }), + ); + }), + ); } function gitVerdict(args: ReadonlyArray): Promise { @@ -1237,6 +1272,8 @@ export async function tryAnswerGitCommand( if (!isGitFastPathEnabled() || !isGitFastPathEnabled(input.env ?? {})) return null; if (hasGitEnvOverride(process.env) || hasGitEnvOverride(input.env)) return null; if (movesGitOrItsConfig(input.env)) return null; + // No budget at all: nothing may be answered, however fast the reads turn out. + if (typeof input.timeoutMs === "number" && input.timeoutMs <= 0) return null; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; let timer: NodeJS.Timeout | undefined; try { From 35e22bd23cb2f7024a147a9b46420abc11e72477 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:36:47 +0300 Subject: [PATCH 05/11] fix(server): keep the rev-list memo lookup inside the caller's time budget The memo key read the repository files with no limit before git was spawned, so a stuck disk could hold up the timed fallback. It now shares the reader's budget, min(2 s, timeoutMs). The git permit tests drive a virtual clock and opt out of the fast path, whose file reads run on the real one. --- .../src/vcs/GitMetadataFastPath.test.ts | 2 + apps/server/src/vcs/GitMetadataFastPath.ts | 47 +++++++++++++------ apps/server/src/vcs/GitVcsDriverCore.test.ts | 19 +++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 7 ++- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 007d79394e54..1aeea374c45d 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -292,6 +292,8 @@ describe("GitMetadataFastPath", () => { 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"); diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index f438862aa853..047fe679de5f 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -1272,20 +1272,12 @@ export async function tryAnswerGitCommand( if (!isGitFastPathEnabled() || !isGitFastPathEnabled(input.env ?? {})) return null; if (hasGitEnvOverride(process.env) || hasGitEnvOverride(input.env)) return null; if (movesGitOrItsConfig(input.env)) return null; - // No budget at all: nothing may be answered, however fast the reads turn out. - if (typeof input.timeoutMs === "number" && input.timeoutMs <= 0) return null; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - let timer: NodeJS.Timeout | undefined; try { - // 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, input.timeoutMs ?? Infinity), null); - }); // Asking for the outer config first proves git runs at all, so a missing git is not papered over. - const answering = getOuterConfig().then(() => answer(input)); - // When the timer wins, the abandoned attempt still settles; its decline is not an error. - answering.catch(() => undefined); - const result = await Promise.race([answering, timedOut]); + const result = await withinReadBudget(input.timeoutMs, () => + getOuterConfig().then(() => answer(input)), + ); return result !== null && Math.max(Buffer.byteLength(result.stdout), Buffer.byteLength(result.stderr)) > maxOutputBytes ? null @@ -1294,6 +1286,30 @@ export async function tryAnswerGitCommand( 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. + */ +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; + 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); + }); + const working = work(); + // 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); } @@ -1311,9 +1327,12 @@ export async function gitAnswerMemoKey(input: GitFastPathInput): Promise { + const { repo } = await discoverRepository(input.cwd); + await requireGitAgrees(repo, false); + return (await revListQuery(repo, rest)).key; + }); } catch { return null; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b635182986e0..491404a10ab7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -77,6 +77,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 => @@ -181,6 +184,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" }, @@ -232,12 +236,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); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 40b672a47956..3ff8ba9e12c8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -967,7 +967,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } satisfies GitVcsDriver.ExecuteGitResult; }); - const fastPathInput = { cwd: input.cwd, args: input.args, env: input.env }; + 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)) From 0d71f463c5426edb8a6c1a9f61e850e1ef0df344 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:04:10 +0300 Subject: [PATCH 06/11] fix(server): stop the metadata reader from piling up stuck reads, and time its answers A file read cannot be cancelled once started and holds a libuv thread until the disk answers. While an attempt that ran out of budget is still pending, the reader now starts no new reads and leaves every command to git, so a stuck disk or share cannot exhaust the threadpool and stall unrelated file work. Answered commands now record their read time in t3_git_command_duration instead of almost zero. --- apps/server/src/observability/Metrics.ts | 20 +++++++++----- .../src/vcs/GitMetadataFastPath.test.ts | 27 +++++++++++++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 20 ++++++++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 16 +++++++---- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6e2c7..ff8edcf5b411 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -104,7 +104,20 @@ const withMetricsImpl = ( const exit = yield* Effect.exit(effect); const endedAt = yield* Clock.currentTimeNanos; const elapsedNanos = endedAt > startedAt ? endedAt - startedAt : 0n; - const duration = Duration.nanos(elapsedNanos); + yield* recordMetrics(options, exit, Duration.nanos(elapsedNanos)); + if (Exit.isSuccess(exit)) { + return exit.value; + } + return yield* Effect.failCause(exit.cause); + }); + +/** Records what `withMetrics` would for an outcome whose duration was measured elsewhere. */ +export const recordMetrics = ( + options: WithMetricsOptions, + exit: Exit.Exit, + duration: Duration.Duration, +) => + Effect.gen(function* () { const baseAttributes = typeof options.attributes === "function" ? options.attributes() : (options.attributes ?? {}); @@ -129,11 +142,6 @@ const withMetricsImpl = ( 1, ); } - - if (Exit.isSuccess(exit)) { - return exit.value; - } - return yield* Effect.failCause(exit.cause); }); export const withMetrics: { diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 1aeea374c45d..71d3fde48751 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -12,6 +12,7 @@ import { rememberGitAnswer, resetGitFastPathCaches, tryAnswerGitCommand, + withinReadBudget, } from "./GitMetadataFastPath.ts"; const git = (cwd: string, ...args: ReadonlyArray) => @@ -509,6 +510,32 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { 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(); diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 047fe679de5f..b875a3dca327 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -45,6 +45,8 @@ export 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; @@ -1294,24 +1296,38 @@ export async function tryAnswerGitCommand( * ANSWER_TIMEOUT_MS runs out, whichever comes first. A rejection of `work` * before then is passed on. */ -async function withinReadBudget( +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); }); - const working = work(); + 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--, + ); + } } } diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3ff8ba9e12c8..aa97605359aa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -33,7 +33,12 @@ 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 { @@ -1008,7 +1013,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => { - // Times the command itself, not the wait for a process permit. + // 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, @@ -1017,10 +1023,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }; const spawnGit = executeRaw(input).pipe(withMetrics(metrics)); - return answerWithoutGit(input).pipe( - Effect.flatMap((answer) => + return Effect.timed(answerWithoutGit(input)).pipe( + Effect.flatMap(([readDuration, answer]) => answer !== null - ? Effect.succeed(answer).pipe(withMetrics(metrics)) + ? recordMetrics(metrics, Exit.succeed(answer), readDuration).pipe(Effect.as(answer)) : input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS ? spawnGit : gitProcesses.withPermits(1)(spawnGit), From 4b1918c5cfaa54003597f5b855896376cc764519 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:20:32 +0300 Subject: [PATCH 07/11] fix(server): remember git answers after the timeout check, not inside it The memo step rereads repository files, so inside the timeout a git command that finished close to its deadline could be discarded as timed out. timeoutMs now bounds only the git process. Also drops the unused isGitFastPathEnabled export that knip flags. --- apps/server/src/vcs/GitMetadataFastPath.ts | 2 +- apps/server/src/vcs/GitVcsDriverCore.ts | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index b875a3dca327..bcba31c4a003 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -41,7 +41,7 @@ export interface GitFastPathAnswer { } /** `T3CODE_GIT_FAST_PATH=0` turns the fast path off; every command spawns git again. */ -export const isGitFastPathEnabled = (env: NodeJS.ProcessEnv = process.env) => +const isGitFastPathEnabled = (env: NodeJS.ProcessEnv = process.env) => env.T3CODE_GIT_FAST_PATH !== "0"; const ANSWER_TIMEOUT_MS = 2_000; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index aa97605359aa..37be2c078862 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -982,18 +982,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.stdin === undefined && input.progress === undefined ? yield* Effect.promise(() => GitMetadataFastPath.gitAnswerMemoKey(fastPathInput)) : null; - const execution = runGitCommand().pipe( - Effect.scoped, - Effect.tap((result) => - memoKey !== null && result.exitCode === 0 && !result.stdoutTruncated - ? Effect.promise(() => - GitMetadataFastPath.rememberGitAnswer(fastPathInput, memoKey, result.stdout), - ) - : Effect.void, - ), - ); + // 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( @@ -1008,6 +1006,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ), + Effect.tap(remember), ); }, ); From 911bace8d25d7bd198f45f0bab0070c805c8347e Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:35:14 +0300 Subject: [PATCH 08/11] fix(server): leave a file given as the working directory to git Discovery walked up from a regular file and answered for the parent repository, where spawned git fails because its working directory is not a directory. --- apps/server/src/vcs/GitMetadataFastPath.test.ts | 11 +++++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 71d3fde48751..f03279836033 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -597,6 +597,17 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { } }); + 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"); diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index bcba31c4a003..44346dcd19c1 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -532,6 +532,8 @@ async function discoverRepository(cwd: string): Promise<{ repo: Repository; real 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"); // Windows has no device ids worth comparing, and git for Windows does not compare them either. const checkBoundaries = NodePath.sep !== "\\"; From 780cf611c1b6f8b2d210860d261e75219738cab9 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:00:34 +0300 Subject: [PATCH 09/11] fix(server): git fast path re-asks git for a replaced repository and leaves symlinks to git The cached verdict on whether git accepts a repository is now bound to the repository found at the path: its directories' identity and owner, its config, and the system and global config that hold safe.directory. A repository replaced at the same path, or changed acceptance settings, gets a fresh verdict instead of up to five minutes of the old one. Metadata reads no longer follow symlinks. A symlink anywhere below an already checked directory, such as .git/HEAD pointing at a \server\share path, now leaves the command to git instead of making Windows authenticate to that server. Also gives the no-control-regex suppression the reason the new lint rule requires. --- .../src/vcs/GitMetadataFastPath.test.ts | 51 ++++++++++ apps/server/src/vcs/GitMetadataFastPath.ts | 98 ++++++++++++++++--- 2 files changed, 133 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index f03279836033..251f393a4230 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -663,6 +663,57 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { await declines(linked, "remote"); }); + it("leaves a symlinked refs directory to git", async () => { + const cwd = makeRepo("symlinkedRefs", (dir) => git(dir, "branch", "feature/linked")); + const heads = NodePath.join(cwd, ".git", "refs", "heads"); + 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"); diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 44346dcd19c1..454fadd0d549 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -144,9 +144,48 @@ 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 are remembered briefly, with their ancestors. +const SYMLINK_FREE_MAX_AGE_MS = 5 * 60_000; +const symlinkFreeDirs = new Map(); + +function isKnownSymlinkFree(dir: string): boolean { + const now = Date.now(); + for (const [known, at] of symlinkFreeDirs) { + if (now - at >= SYMLINK_FREE_MAX_AGE_MS) continue; + if (known === dir || known.startsWith(dir.endsWith(NodePath.sep) ? dir : dir + NodePath.sep)) + return true; + } + return false; +} + +function rememberSymlinkFree(dir: string): void { + if (symlinkFreeDirs.size >= 256) symlinkFreeDirs.clear(); + symlinkFreeDirs.set(dir, Date.now()); +} + +/** 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.stat(target); + return await NodeFSP.lstat(target); } catch { return null; } @@ -162,10 +201,14 @@ const PACKED_REFS_BYTES = 32 * 1024 * 1024; 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; Windows has neither. + // 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_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"), ); @@ -440,6 +483,7 @@ export const resetGitFastPathCaches = () => { outerConfig = null; outerConfigFailedAtMs = null; verdicts.clear(); + symlinkFreeDirs.clear(); packedRefsCache.clear(); revListMemo.clear(); }; @@ -534,6 +578,7 @@ async function discoverRepository(cwd: string): Promise<{ repo: Repository; real 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 !== "\\"; @@ -613,8 +658,25 @@ function askGit(args: ReadonlyArray): Promise { ); } -function gitVerdict(args: ReadonlyArray): Promise { - const key = args.join("\0"); +/** + * 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(); @@ -627,9 +689,10 @@ function gitVerdict(args: ReadonlyArray): Promise { /** 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"]) - : await gitVerdict(["-C", repo.workTree, "rev-parse", "--show-toplevel"]); + ? 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"); @@ -652,7 +715,7 @@ const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9]|conin\$|conou /** 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 + // 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) => @@ -778,7 +841,9 @@ async function readRemotes(repo: Repository): Promise> { // 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 names = await NodeFSP.readdir(NodePath.join(repo.commonDir, legacy)).catch(() => []); + 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"); } @@ -942,10 +1007,11 @@ async function listBranchRefs(repo: Repository, namespace: string): Promise => { - const entries = await NodeFSP.readdir(NodePath.join(repo.commonDir, ...ref.split("/")), { - withFileTypes: true, - }).catch((error: NodeJS.ErrnoException) => - error.code === "ENOENT" ? [] : unsure("unreadable refs directory"), + 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}`; @@ -1186,9 +1252,9 @@ async function revListQuery(repo: Repository, args: ReadonlyArray): Prom for (const file of ["shallow", NodePath.join("info", "grafts")]) { if (await statOrNull(NodePath.join(repo.commonDir, file))) unsure("altered history"); } - const replacements = await NodeFSP.readdir( - NodePath.join(repo.commonDir, "refs", "replace"), - ).catch(() => []); + 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"); From 01f0a8480f962afd82eaeed12b5bab4134888058 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:21:43 +0300 Subject: [PATCH 10/11] fix(server): git fast path checks the path for symlinks again on every query Directories found free of symlinks were trusted for five minutes, so one swapped for a symlink to a \server\share path in that time would be followed. They are now remembered only within one query. --- .../src/vcs/GitMetadataFastPath.test.ts | 8 +++- apps/server/src/vcs/GitMetadataFastPath.ts | 38 ++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.test.ts b/apps/server/src/vcs/GitMetadataFastPath.test.ts index 251f393a4230..3764e4f37885 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.test.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.test.ts @@ -663,9 +663,15 @@ describe("GitMetadataFastPath on repositories git treats differently", () => { await declines(linked, "remote"); }); - it("leaves a symlinked refs directory to git", async () => { + 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. diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 454fadd0d549..49603e582eb1 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -19,6 +19,7 @@ * 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 { AsyncLocalStorage } from "node:async_hooks"; import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; @@ -146,23 +147,25 @@ const toGitPath = (value: string) => (NodePath.sep === "\\" ? value.replaceAll(" // 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 are remembered briefly, with their ancestors. -const SYMLINK_FREE_MAX_AGE_MS = 5 * 60_000; -const symlinkFreeDirs = new Map(); +// 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 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 now = Date.now(); - for (const [known, at] of symlinkFreeDirs) { - if (now - at >= SYMLINK_FREE_MAX_AGE_MS) continue; - if (known === dir || known.startsWith(dir.endsWith(NodePath.sep) ? dir : dir + NodePath.sep)) - return true; + 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 { - if (symlinkFreeDirs.size >= 256) symlinkFreeDirs.clear(); - symlinkFreeDirs.set(dir, Date.now()); + symlinkFreeDirs.getStore()?.add(dir); } /** Declines when `target` or a directory above it is a symlink. */ @@ -483,7 +486,6 @@ export const resetGitFastPathCaches = () => { outerConfig = null; outerConfigFailedAtMs = null; verdicts.clear(); - symlinkFreeDirs.clear(); packedRefsCache.clear(); revListMemo.clear(); }; @@ -1346,7 +1348,7 @@ export async function tryAnswerGitCommand( 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, () => - getOuterConfig().then(() => answer(input)), + withFreshSymlinkChecks(() => getOuterConfig().then(() => answer(input))), ); return result !== null && Math.max(Buffer.byteLength(result.stdout), Buffer.byteLength(result.stderr)) > maxOutputBytes @@ -1412,11 +1414,13 @@ export async function gitAnswerMemoKey(input: GitFastPathInput): Promise { - const { repo } = await discoverRepository(input.cwd); - await requireGitAgrees(repo, false); - return (await revListQuery(repo, rest)).key; - }); + 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; } From 8c45eef598bc485c4081f6f0b28e8b34c387498f Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:26:16 +0300 Subject: [PATCH 11/11] fix(server): import node:async_hooks as a namespace in the git fast path --- apps/server/src/vcs/GitMetadataFastPath.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitMetadataFastPath.ts b/apps/server/src/vcs/GitMetadataFastPath.ts index 49603e582eb1..c6f5ef4b4ce2 100644 --- a/apps/server/src/vcs/GitMetadataFastPath.ts +++ b/apps/server/src/vcs/GitMetadataFastPath.ts @@ -19,7 +19,7 @@ * 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 { AsyncLocalStorage } from "node:async_hooks"; +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"; @@ -150,7 +150,7 @@ const toGitPath = (value: string) => (NodePath.sep === "\\" ? value.replaceAll(" // 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 AsyncLocalStorage>(); +const symlinkFreeDirs = new NodeAsyncHooks.AsyncLocalStorage>(); /** Runs one query with its own memory of checked directories. */ const withFreshSymlinkChecks = (work: () => Promise): Promise =>