Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/git-dir-scoped-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@react-doctor/core": patch
---

fix: clear GIT_DIR for nested git commands in scoped scans

Git hooks export `GIT_DIR`, which causes nested git commands to ignore the scoped `cwd` and return repository-root paths. This fix explicitly clears `GIT_DIR` when spawning git commands while preserving other environment variables like `GIT_INDEX_FILE`.
8 changes: 7 additions & 1 deletion packages/core/src/services/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,13 @@ export class Git extends Context.Service<
// default; ChildProcess's option flips the polarity.)
ChildProcess.make(input.command, [...input.args], {
cwd: input.directory,
env: input.env,
env:
input.command === "git"
? {
...input.env,
GIT_DIR: undefined,
}
: input.env,
extendEnv: true,
}),
);
Expand Down
156 changes: 156 additions & 0 deletions packages/core/tests/regressions/issue-1501-git-dir-scoped-scan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as child_process from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as Effect from "effect/Effect";
import { afterAll, describe, expect, it } from "vite-plus/test";
import { Git } from "@react-doctor/core";

const runNode = <Value>(program: Effect.Effect<Value, unknown, Git>): Promise<Value> =>
Effect.runPromise(program.pipe(Effect.provide(Git.layerNode)));

const setupLinkedWorktreeRepo = (): { mainRepoPath: string; linkedWorktreePath: string } => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-1501-"));
const mainRepoPath = path.join(tempRoot, "main-repo");
const linkedWorktreePath = path.join(tempRoot, "linked-worktree");

child_process.execSync("git init", { cwd: tempRoot, stdio: "ignore" });
fs.renameSync(path.join(tempRoot, ".git"), path.join(tempRoot, "main-repo"));

fs.mkdirSync(path.join(mainRepoPath, "webapp"), { recursive: true });
fs.writeFileSync(path.join(mainRepoPath, "webapp", "index.html"), "<html></html>");
fs.writeFileSync(path.join(mainRepoPath, "webapp", "app.js"), "console.log('app');");

child_process.execSync("git config user.email 'test@example.com'", {
cwd: mainRepoPath,
env: { ...process.env, GIT_DIR: mainRepoPath },
stdio: "ignore",
});
child_process.execSync("git config user.name 'Test User'", {
cwd: mainRepoPath,
env: { ...process.env, GIT_DIR: mainRepoPath },
stdio: "ignore",
});
child_process.execSync("git add .", {
cwd: mainRepoPath,
env: { ...process.env, GIT_DIR: mainRepoPath },
stdio: "ignore",
});
child_process.execSync("git commit -m 'initial'", {
cwd: mainRepoPath,
env: { ...process.env, GIT_DIR: mainRepoPath },
stdio: "ignore",
});

child_process.execSync(`git worktree add ${linkedWorktreePath} -b feature`, {
cwd: mainRepoPath,
env: { ...process.env, GIT_DIR: mainRepoPath },
stdio: "ignore",
});

fs.writeFileSync(path.join(linkedWorktreePath, "webapp", "app.js"), "console.log('modified');");

return { mainRepoPath, linkedWorktreePath };
};

const testRepo = setupLinkedWorktreeRepo();

afterAll(() => {
fs.rmSync(path.dirname(testRepo.mainRepoPath), { recursive: true, force: true });
});

describe("issue #1501: scoped scans fail inside Git hooks when GIT_DIR is set", () => {
it("returns paths relative to the scoped directory when GIT_DIR is set", async () => {
const webappPath = path.join(testRepo.linkedWorktreePath, "webapp");

const originalGitDir = process.env.GIT_DIR;
process.env.GIT_DIR = testRepo.mainRepoPath;

try {
const result = await runNode(
Effect.gen(function* () {
const git = yield* Git;
return yield* git.diffSelection({
directory: webappPath,
explicitBaseBranch: "HEAD",
});
}),
);

expect(result).not.toBeNull();
if (result !== null) {
expect(result.changedFiles).toEqual(["app.js"]);
}
} finally {
if (originalGitDir === undefined) {
delete process.env.GIT_DIR;
} else {
process.env.GIT_DIR = originalGitDir;
}
}
});

it("does not return repository-root paths when scanning a subdirectory with GIT_DIR set", async () => {
const webappPath = path.join(testRepo.linkedWorktreePath, "webapp");

const originalGitDir = process.env.GIT_DIR;
process.env.GIT_DIR = testRepo.mainRepoPath;

try {
const result = await runNode(
Effect.gen(function* () {
const git = yield* Git;
return yield* git.diffSelection({
directory: webappPath,
explicitBaseBranch: "HEAD",
});
}),
);

if (result !== null) {
for (const filePath of result.changedFiles) {
expect(filePath).not.toContain("webapp/");
expect(path.isAbsolute(filePath)).toBe(false);
}
}
} finally {
if (originalGitDir === undefined) {
delete process.env.GIT_DIR;
} else {
process.env.GIT_DIR = originalGitDir;
}
}
});

it("preserves GIT_INDEX_FILE when clearing GIT_DIR", async () => {
const webappPath = path.join(testRepo.linkedWorktreePath, "webapp");
const originalGitDir = process.env.GIT_DIR;
const originalGitIndexFile = process.env.GIT_INDEX_FILE;
const customIndexPath = path.join(testRepo.linkedWorktreePath, ".git", "custom-index");

process.env.GIT_DIR = testRepo.mainRepoPath;
process.env.GIT_INDEX_FILE = customIndexPath;

try {
const result = await runNode(
Effect.gen(function* () {
const git = yield* Git;
return yield* git.currentBranch(webappPath);
}),
);

expect(result).toBe("feature");
} finally {
if (originalGitDir === undefined) {
delete process.env.GIT_DIR;
} else {
process.env.GIT_DIR = originalGitDir;
}
if (originalGitIndexFile === undefined) {
delete process.env.GIT_INDEX_FILE;
} else {
process.env.GIT_INDEX_FILE = originalGitIndexFile;
}
}
});
});
Loading