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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we strip those env vars iff they point to nonexistent locations/locations without a rush.json?

Also note that AFAIK rush.json doesn't have to be at the repo root.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the GIT_DIR directory always points to the metadata directory (./.git/, not the root of the git repository - so when GIT_DIR is set, it will never point to a directory with a rush.json file in it. So only stripping the vars if they point to locations without a rush.json would effectively be the same as always stripping them.

As for when they point to nonexistent locations - that wouldn't apply in the bug case, because the GIT_DIR value points to the metadata directory, which does exist. I believe GIT_DIR is not set in the node env when you are working in the "main worktree" (i.e. you haven't created a separate worktree at all), so if that var is set at all, it should always point to a directory that does exist. In older git versions, GIT_DIR is set to the default .git location, but stripping it still forces git to navigate upwards to find the worktree root naturally, resulting in the same logic.

The only users that might be incidentally affected by this would be those who are explicitly setting GIT_DIR to some unexpected value - but even in that case, I think stripping the variables is the better choice. The changed utils are all given a currentWorkingDirectory value, and I would expect that git would follow its typical logic to find the repo root by navigating up from the given directory. When GIT_DIR is set and GIT_WORK_TREE is missing, the logic for git rev-parse --show-toplevel changes, and it treats the cwd as the root without navigating upwards. Stripping GIT_DIR fixes the git environment for subprocesses created within hooks (such as when running rush commands in a pre-commit hook, which then run their own git commands to determine repo state)

The way this works right now is that it strips variables that are only defined in the case that we need to fix - so stripping them unconditionally feels like the right move, even though it might appear overly aggressive. I'm open to making the change more limited if you have concerns.

Also note that AFAIK rush.json doesn't have to be at the repo root.

To be clear, our use case is exactly that - our rush.json file lives in a subdirectory one level deeper than the repo root (./our-special-directory/rush.json instead of ./rush.json). Part of the intention of this fix is specifically to handle cases where the rush.json is not at the repo root, where the currentWorkingDirectory value is the subdirectory where the rush.json file lives. In the "standard" case, where the rush.json file does live at the repo root, users wouldn't be affected by this bug anyway.

}
);

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