Skip to content
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
41 changes: 35 additions & 6 deletions libraries/package-deps-hash/src/getRepoState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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'
Comment thread
iclanton marked this conversation as resolved.
]);

/**
Expand Down Expand Up @@ -254,6 +273,13 @@ export function parseGitStatus(output: string): Map<string, boolean> {

const repoRootCache: Map<string, string> = 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
*
Expand All @@ -270,7 +296,8 @@ export function getRepoRoot(currentWorkingDirectory: string, gitPath?: string):
gitPath || 'git',
['--no-optional-locks', 'rev-parse', '--show-toplevel'],
{
currentWorkingDirectory
currentWorkingDirectory,
environment: getCleanGitEnvironment()
Comment thread
iclanton marked this conversation as resolved.
}
);

Expand Down Expand Up @@ -305,7 +332,8 @@ async function spawnGitAsync(
): Promise<string> {
const spawnOptions: IExecutableSpawnOptions = {
currentWorkingDirectory,
stdio: ['pipe', 'pipe', 'pipe']
stdio: ['pipe', 'pipe', 'pipe'],
environment: getCleanGitEnvironment()
};

let stdout: string = '';
Expand Down Expand Up @@ -591,7 +619,8 @@ export function getRepoChanges(
'--'
]),
{
currentWorkingDirectory: rootDirectory
currentWorkingDirectory: rootDirectory,
environment: getCleanGitEnvironment()
}
);

Expand Down
57 changes: 55 additions & 2 deletions libraries/package-deps-hash/src/test/getRepoDeps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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<string> = {
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, () => {
Expand Down
Loading