diff --git a/common/changes/@microsoft/rush/fix-git-dir-worktree-hook-repo-root_2026-06-08-19-02.json b/common/changes/@microsoft/rush/fix-git-dir-worktree-hook-repo-root_2026-06-08-19-02.json new file mode 100644 index 0000000000..1c9c893417 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-git-dir-worktree-hook-repo-root_2026-06-08-19-02.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix build cache failures when running inside a git linked worktree via a pre-commit hook, caused by GIT_DIR being set to the per-worktree metadata directory", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/package-deps-hash/fix-git-dir-worktree-hook_2026-06-03-00-00.json b/common/changes/@rushstack/package-deps-hash/fix-git-dir-worktree-hook_2026-06-03-00-00.json new file mode 100644 index 0000000000..d67ccffa27 --- /dev/null +++ b/common/changes/@rushstack/package-deps-hash/fix-git-dir-worktree-hook_2026-06-03-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Strip GIT_DIR and GIT_WORK_TREE Node env variables to fix issues with miscalculating the git repo root when working in a linked worktree", + "type": "patch", + "packageName": "@rushstack/package-deps-hash" + } + ], + "packageName": "@rushstack/package-deps-hash", + "email": "istateside@users.noreply.github.com" +} diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index 7f01cde162..45cb07a8dd 100644 --- a/libraries/package-deps-hash/src/getRepoState.ts +++ b/libraries/package-deps-hash/src/getRepoState.ts @@ -33,9 +33,28 @@ const STANDARD_GIT_OPTIONS: readonly string[] = [ // `git hash-object` aborts the process. Such files are typically untracked artifacts left behind // by tooling (e.g. stray `nul` from a shell redirect). const WINDOWS_RESERVED_BASENAMES: ReadonlySet = new Set([ - 'CON', 'PRN', 'AUX', 'NUL', - 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', - 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9' ]); /** @@ -254,6 +273,13 @@ export function parseGitStatus(output: string): Map { const repoRootCache: Map = new Map(); +// Strip GIT_DIR/GIT_WORK_TREE: git hooks in linked worktrees set GIT_DIR to the per-worktree metadata dir, causing rev-parse --show-toplevel to return CWD instead of the worktree root. +function getCleanGitEnvironment(): NodeJS.ProcessEnv { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { GIT_DIR, GIT_WORK_TREE, ...trimmedEnv } = process.env; + return trimmedEnv; +} + /** * Finds the root of the current Git repository * @@ -270,7 +296,8 @@ export function getRepoRoot(currentWorkingDirectory: string, gitPath?: string): gitPath || 'git', ['--no-optional-locks', 'rev-parse', '--show-toplevel'], { - currentWorkingDirectory + currentWorkingDirectory, + environment: getCleanGitEnvironment() } ); @@ -305,7 +332,8 @@ async function spawnGitAsync( ): Promise { const spawnOptions: IExecutableSpawnOptions = { currentWorkingDirectory, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + environment: getCleanGitEnvironment() }; let stdout: string = ''; @@ -591,7 +619,8 @@ export function getRepoChanges( '--' ]), { - currentWorkingDirectory: rootDirectory + currentWorkingDirectory: rootDirectory, + environment: getCleanGitEnvironment() } ); diff --git a/libraries/package-deps-hash/src/test/getRepoDeps.test.ts b/libraries/package-deps-hash/src/test/getRepoDeps.test.ts index 6e01af1fd6..c78e034386 100644 --- a/libraries/package-deps-hash/src/test/getRepoDeps.test.ts +++ b/libraries/package-deps-hash/src/test/getRepoDeps.test.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; -import { execSync } from 'node:child_process'; +import { execSync, type SpawnSyncReturns } from 'node:child_process'; import { getDetailedRepoStateAsync, @@ -12,7 +12,7 @@ import { parseGitHashObject } from '../getRepoState'; -import { FileSystem } from '@rushstack/node-core-library'; +import { Executable, FileSystem } from '@rushstack/node-core-library'; const SOURCE_PATH: string = path .join(__dirname) @@ -45,6 +45,59 @@ describe(getRepoRoot.name, () => { const expectedRoot: string = path.resolve(__dirname, '../../../..').replace(/\\/g, '/'); expect(root).toEqual(expectedRoot); }); + + it(`strips GIT_DIR and GIT_WORK_TREE before invoking git`, () => { + // Regression test for the linked-worktree bug. When git runs a hook it injects GIT_DIR (and + // sometimes GIT_WORK_TREE) into the environment; in a linked worktree GIT_DIR points at the + // per-worktree metadata directory, which makes `git rev-parse --show-toplevel` resolve against + // the current directory instead of the true repository root. getRepoRoot must therefore invoke + // git with those variables removed, so the root is derived solely from currentWorkingDirectory. + // + // This is asserted at the spawn boundary rather than by mutating process.env and shelling out for + // real: the Jest environment does not propagate in-process process.env writes to child processes, + // so an end-to-end variant would pass whether or not the stripping actually happens. + const fakeRoot: string = '/fake/repo/root'; + const mockResult: SpawnSyncReturns = { + pid: 0, + output: [], + stdout: fakeRoot, + stderr: '', + status: 0, + signal: null + }; + const spawnSyncSpy: jest.SpyInstance = jest.spyOn(Executable, 'spawnSync').mockReturnValue(mockResult); + + const originalGitDir: string | undefined = process.env.GIT_DIR; + const originalGitWorkTree: string | undefined = process.env.GIT_WORK_TREE; + try { + process.env.GIT_DIR = '/repo/.git/worktrees/feature'; + process.env.GIT_WORK_TREE = '/repo/work/tree'; + + // A unique cwd that no other test resolves, so getRepoRoot's module-level cache can't satisfy + // this from a previous call and skip the spawn. + getRepoRoot('/nonexistent/getRepoRoot-strips-git-env'); + + expect(spawnSyncSpy).toHaveBeenCalledTimes(1); + const passedEnvironment: NodeJS.ProcessEnv | undefined = spawnSyncSpy.mock.calls[0][2]?.environment; + // The fix passes an explicit environment (pre-fix code passed none) that omits both variables, + // while leaving the rest of process.env intact. + expect(passedEnvironment).toBeDefined(); + expect(passedEnvironment).not.toHaveProperty('GIT_DIR'); + expect(passedEnvironment).not.toHaveProperty('GIT_WORK_TREE'); + } finally { + spawnSyncSpy.mockRestore(); + if (originalGitDir === undefined) { + delete process.env.GIT_DIR; + } else { + process.env.GIT_DIR = originalGitDir; + } + if (originalGitWorkTree === undefined) { + delete process.env.GIT_WORK_TREE; + } else { + process.env.GIT_WORK_TREE = originalGitWorkTree; + } + } + }); }); describe(parseGitLsTree.name, () => {