From beb43b94b5140253be681096fa500a3c9034b0e9 Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Wed, 3 Jun 2026 10:49:18 -0400 Subject: [PATCH 1/8] fix(package-deps-hash): strip GIT_DIR/GIT_WORK_TREE in git subprocess calls to fix build cache in linked worktrees When a git pre-commit hook runs in a linked worktree, git sets GIT_DIR to the per-worktree metadata directory (.git/worktrees/{name}) without setting GIT_WORK_TREE. With GIT_DIR set this way, `git rev-parse --show-toplevel` returns the CWD (e.g. the rushJsonFolder subdirectory) instead of the actual worktree root, causing all subsequent git calls to use the wrong root directory. This makes `git status -u` miss the top-level .gitignore, surfacing node_modules symlinks as untracked files, which then causes `git hash-object` to fail on symlink-to-directory entries and ultimately breaks the build cache. Fix: strip GIT_DIR and GIT_WORK_TREE from the environment in getRepoRoot, spawnGitAsync, and getRepoChanges so git auto-discovers the correct repo root from the working directory regardless of hook-injected env vars. --- .../package-deps-hash/src/getRepoState.ts | 42 ++++++++++++++++--- .../src/test/getRepoDeps.test.ts | 18 ++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index 7f01cde1620..21fe84e41b0 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,14 @@ 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 { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + return env; +} + /** * Finds the root of the current Git repository * @@ -270,7 +297,8 @@ export function getRepoRoot(currentWorkingDirectory: string, gitPath?: string): gitPath || 'git', ['--no-optional-locks', 'rev-parse', '--show-toplevel'], { - currentWorkingDirectory + currentWorkingDirectory, + environment: getCleanGitEnvironment() } ); @@ -305,7 +333,8 @@ async function spawnGitAsync( ): Promise { const spawnOptions: IExecutableSpawnOptions = { currentWorkingDirectory, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + environment: getCleanGitEnvironment() }; let stdout: string = ''; @@ -591,7 +620,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 6e01af1fd6a..fd5746072dc 100644 --- a/libraries/package-deps-hash/src/test/getRepoDeps.test.ts +++ b/libraries/package-deps-hash/src/test/getRepoDeps.test.ts @@ -45,6 +45,24 @@ describe(getRepoRoot.name, () => { const expectedRoot: string = path.resolve(__dirname, '../../../..').replace(/\\/g, '/'); expect(root).toEqual(expectedRoot); }); + + it(`ignores GIT_DIR set by git hooks in linked worktrees`, () => { + // GIT_DIR pointing to a non-existent path causes git rev-parse to fail unless stripped. + const originalGitDir: string | undefined = process.env.GIT_DIR; + try { + process.env.GIT_DIR = '/nonexistent-fake-gitdir-worktrees-for-testing'; + const testCwd: string = path.resolve(SOURCE_PATH, '..'); + const root: string = getRepoRoot(testCwd); + const expectedRoot: string = path.resolve(__dirname, '../../../..').replace(/\\/g, '/'); + expect(root).toEqual(expectedRoot); + } finally { + if (originalGitDir === undefined) { + delete process.env.GIT_DIR; + } else { + process.env.GIT_DIR = originalGitDir; + } + } + }); }); describe(parseGitLsTree.name, () => { From ef318f0c380ea982ddc079198bef879e8158699f Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Wed, 3 Jun 2026 10:49:24 -0400 Subject: [PATCH 2/8] chore: add rush change file for package-deps-hash worktree fix --- .../fix-git-dir-worktree-hook_2026-06-03-00-00.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/package-deps-hash/fix-git-dir-worktree-hook_2026-06-03-00-00.json 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 00000000000..584fb8ff317 --- /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": "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": "patch", + "packageName": "@rushstack/package-deps-hash" + } + ], + "packageName": "@rushstack/package-deps-hash", + "email": "kfleischman@squarespace.com" +} From 4cc0538cd711d57f2f5e5632479967ebe1c0c37c Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Mon, 8 Jun 2026 09:54:12 -0400 Subject: [PATCH 3/8] Update common/changes/@rushstack/package-deps-hash/fix-git-dir-worktree-hook_2026-06-03-00-00.json Co-authored-by: Ian Clanton-Thuon --- .../fix-git-dir-worktree-hook_2026-06-03-00-00.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 584fb8ff317..76af3426323 100644 --- 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 @@ -7,5 +7,5 @@ } ], "packageName": "@rushstack/package-deps-hash", - "email": "kfleischman@squarespace.com" + "email": "istateside@users.noreply.github.com" } From ee1ee19f4745504a21fe7606412e4f4a26d10578 Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Mon, 8 Jun 2026 09:54:31 -0400 Subject: [PATCH 4/8] Update libraries/package-deps-hash/src/getRepoState.ts Co-authored-by: Ian Clanton-Thuon --- libraries/package-deps-hash/src/getRepoState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index 21fe84e41b0..4e14bb33927 100644 --- a/libraries/package-deps-hash/src/getRepoState.ts +++ b/libraries/package-deps-hash/src/getRepoState.ts @@ -278,7 +278,9 @@ function getCleanGitEnvironment(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; - return env; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { GIT_DIR, GIT_WORK_TREE, ...trimmedEnv } = process.env; + return trimmedEnv; } /** From 20d7b0618dd0771045c4771023c635078178160b Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Mon, 8 Jun 2026 14:59:59 -0400 Subject: [PATCH 5/8] Update libraries/package-deps-hash/src/getRepoState.ts Co-authored-by: Ian Clanton-Thuon --- libraries/package-deps-hash/src/getRepoState.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index 4e14bb33927..45cb07a8dd7 100644 --- a/libraries/package-deps-hash/src/getRepoState.ts +++ b/libraries/package-deps-hash/src/getRepoState.ts @@ -275,9 +275,6 @@ 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 { - const env: NodeJS.ProcessEnv = { ...process.env }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { GIT_DIR, GIT_WORK_TREE, ...trimmedEnv } = process.env; return trimmedEnv; From 013803f0acf7be0e91771aca5d36535be5c9c314 Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Mon, 8 Jun 2026 15:03:01 -0400 Subject: [PATCH 6/8] Commit changefile for rush-lib to allow publish --- ...t-dir-worktree-hook-repo-root_2026-06-08-19-02.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@microsoft/rush/fix-git-dir-worktree-hook-repo-root_2026-06-08-19-02.json 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 00000000000..736a9362489 --- /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": "No-op change to trigger changeset for rush publish for package-deps-hash changes.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file From fb194a0d90edfb34c6706c9bf506fb07b182fc93 Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Tue, 9 Jun 2026 10:15:39 -0400 Subject: [PATCH 7/8] fix: update changefile descriptions --- .../fix-git-dir-worktree-hook-repo-root_2026-06-08-19-02.json | 2 +- .../fix-git-dir-worktree-hook_2026-06-03-00-00.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 736a9362489..1c9c8934170 100644 --- 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 @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "No-op change to trigger changeset for rush publish for package-deps-hash changes.", + "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" } ], 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 index 76af3426323..d67ccffa277 100644 --- 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 @@ -1,7 +1,7 @@ { "changes": [ { - "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", + "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" } From 30e948ed1ebcdd3d59237882d0e8a2bf1f57c02e Mon Sep 17 00:00:00 2001 From: Kevin Fleischman Date: Tue, 9 Jun 2026 12:17:06 -0400 Subject: [PATCH 8/8] test: assert getRepoRoot strips GIT_DIR/GIT_WORK_TREE at the spawn boundary The previous test set process.env.GIT_DIR and shelled out to git, but the Jest environment does not propagate in-process env writes to child processes, so git never saw the variable and the test passed with or without the fix. Assert instead that getRepoRoot invokes Executable.spawnSync with an explicit environment that omits GIT_DIR/GIT_WORK_TREE. This fails against the pre-fix code (no environment passed) and passes with the fix. Co-Authored-By: Claude Opus 4.8 --- .../src/test/getRepoDeps.test.ts | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/libraries/package-deps-hash/src/test/getRepoDeps.test.ts b/libraries/package-deps-hash/src/test/getRepoDeps.test.ts index fd5746072dc..c78e034386b 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) @@ -46,21 +46,56 @@ describe(getRepoRoot.name, () => { expect(root).toEqual(expectedRoot); }); - it(`ignores GIT_DIR set by git hooks in linked worktrees`, () => { - // GIT_DIR pointing to a non-existent path causes git rev-parse to fail unless stripped. + 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 = '/nonexistent-fake-gitdir-worktrees-for-testing'; - const testCwd: string = path.resolve(SOURCE_PATH, '..'); - const root: string = getRepoRoot(testCwd); - const expectedRoot: string = path.resolve(__dirname, '../../../..').replace(/\\/g, '/'); - expect(root).toEqual(expectedRoot); + 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; + } } }); });