diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..f9924f3c11ab 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -163,6 +164,87 @@ it.effect("checkpoint capture skips untracked nested repositories without a comm }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect.skipIf(!symlinksSupported)( + "checkpoint capture writes its index into the real git dir when cwd is a symlinked subdirectory", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveRunner = yield* ProcessRunner.ProcessRunner; + const indexFiles: string[] = []; + const captureProcess = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + const indexFile = input.env?.GIT_INDEX_FILE; + if (indexFile !== undefined) indexFiles.push(indexFile); + return liveRunner.run(input); + }, + }), + ); + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, captureProcess), + ); + const sandbox = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-checkpoint-symlink-", + }); + const repo = path.join(sandbox, "repo"); + const subdir = path.join(repo, "sub", "dir"); + const link = path.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(subdir, { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(link), { recursive: true }); + yield* fileSystem.symlink(subdir, link); + const git = (cwd: string, args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + + // Place the decoy where a lexical join of the symlink and Git's relative + // common dir lands, and do it before capture. Cleanup deletes the temp + // index afterward, so the recorded GIT_INDEX_FILE is what shows which + // repository was used. + yield* git(sandbox, ["init", "-b", "decoy-only"]); + yield* git(sandbox, ["config", "user.name", "Test"]); + yield* git(sandbox, ["config", "user.email", "test@test.com"]); + yield* git(sandbox, ["commit", "--allow-empty", "-m", "decoy"]); + yield* git(repo, ["init"]); + yield* git(repo, ["config", "user.name", "Test"]); + yield* git(repo, ["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(repo, "file.txt"), "initial\n"); + yield* git(repo, ["add", "."]); + yield* git(repo, ["commit", "-m", "initial"]); + yield* fileSystem.writeFileString(path.join(subdir, "nested.txt"), "from-link\n"); + + const relativeCommonDir = (yield* git(link, ["rev-parse", "--git-common-dir"])).stdout.trim(); + assert.isFalse(path.isAbsolute(relativeCommonDir)); + const decoyGitDir = yield* fileSystem.realPath(path.join(sandbox, ".git")); + assert.strictEqual( + yield* fileSystem.realPath(path.resolve(link, relativeCommonDir)), + decoyGitDir, + ); + const realCommonDir = yield* fileSystem.realPath( + (yield* git(repo, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ])).stdout.trim(), + ); + assert.notStrictEqual(realCommonDir, decoyGitDir); + + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/symlink"); + yield* driver.checkpoints.captureCheckpoint({ cwd: link, checkpointRef }); + + assert.isTrue(indexFiles.length > 0); + for (const indexFile of indexFiles) { + assert.isTrue(path.basename(indexFile).startsWith("t3-checkpoint-index")); + assert.strictEqual(yield* fileSystem.realPath(path.dirname(indexFile)), realCommonDir); + } + const decoyEntries = yield* fileSystem.readDirectory(decoyGitDir); + assert.isFalse(decoyEntries.some((entry) => entry.startsWith("t3-checkpoint-index"))); + assert.strictEqual( + (yield* git(repo, ["show", `${checkpointRef}:sub/dir/nested.txt`])).stdout, + "from-link\n", + ); + }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), +); + it.effect("checkpoint recovery discovers nested HEAD independently of inherited GIT_DIR", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 71ebeb1cfdb3..2d078e2b685e 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -753,12 +753,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); + // A relative `--git-common-dir` is computed from Git's physical working + // directory. `path.resolve` applies `..` to the project path string, so a + // symlink into a repository subdirectory climbs out of the link and the + // checkpoint index is created in the wrong directory. `--path-format=absolute` + // is the same request already used for `--git-path index`. const resolveGitCommonDir = (cwd: string) => Effect.gen(function* () { const result = yield* execute({ operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", cwd, - args: ["rev-parse", "--git-common-dir"], + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], }); const gitCommonDir = result.stdout.trim(); return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 85c4d1a59d1d..035f4c8a70e4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -28,6 +28,7 @@ import { type ReviewDiffFileContentsInput, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; import { @@ -334,7 +335,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, + { args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); }); @@ -1771,6 +1772,46 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect.skipIf(!symlinksSupported)( + "lists the real repository through a symlink when a decoy repo occupies the lexical common dir", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sandbox = yield* makeTmpDir("git-symlink-subdir-"); + const repo = pathService.join(sandbox, "repo"); + const link = pathService.join(sandbox, "nest", "link"); + yield* fileSystem.makeDirectory(pathService.join(repo, "sub", "dir"), { + recursive: true, + }); + yield* fileSystem.makeDirectory(pathService.dirname(link), { recursive: true }); + yield* fileSystem.symlink(pathService.join(repo, "sub", "dir"), link); + + // The decoy has to exist before the only lookup. Refreshing listRefs + // on the same cwd coalesces path resolution, so a second call would + // replay a result captured before this repository existed. + yield* git(sandbox, ["init", "-b", "decoy-only"]); + yield* git(sandbox, ["config", "user.email", "test@test.com"]); + yield* git(sandbox, ["config", "user.name", "Test"]); + yield* git(sandbox, ["commit", "--allow-empty", "-m", "decoy"]); + yield* initRepoWithCommit(repo); + yield* git(repo, ["checkout", "-b", "real-only"]); + + const relativeCommonDir = yield* git(link, ["rev-parse", "--git-common-dir"]); + assert.isFalse(pathService.isAbsolute(relativeCommonDir)); + const decoyGitDir = pathService.join(sandbox, ".git"); + assert.strictEqual( + yield* fileSystem.realPath(pathService.resolve(link, relativeCommonDir)), + yield* fileSystem.realPath(decoyGitDir), + ); + + const refs = yield* (yield* GitVcsDriver.GitVcsDriver).listRefs({ cwd: link }); + assert.isTrue(refs.isRepo); + assert.isFalse(refs.refs.some((ref) => ref.name === "decoy-only")); + assert.isTrue(refs.refs.some((ref) => ref.name === "real-only")); + }), + ); + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..56ee7f4089b1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1146,10 +1146,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( cwd: string, ) { + // Same absolute common-dir request as checkpoint capture. A relative + // answer joined with `path.resolve` walks `..` out of a symlinked + // subdirectory and can cache a missing path or another repository. + // `realPath` still runs afterward so Windows 8.3 short names collapse. + const gitCommonDirArgs = ["rev-parse", "--path-format=absolute", "--git-common-dir"] as const; const commonDirResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.resolveRepositoryPaths.commonDir", cwd, - ["rev-parse", "--git-common-dir"], + gitCommonDirArgs, { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1164,7 +1169,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", cwd, - args: ["rev-parse", "--git-common-dir"], + args: gitCommonDirArgs, }), detail: "Failed to resolve the Git common directory.", exitCode: commonDirResult.exitCode, @@ -1324,7 +1329,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.resolveGitCommonDir", cwd, - args: ["rev-parse", "--git-common-dir"], + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], }), detail: "Cannot resolve a Git common directory outside a repository.", });