Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/git-env-in-worktree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@changesets/git": patch
---

Drop the inherited git environment variables, such as `GIT_DIR`, when running git commands, so that the repository is always resolved from the given `cwd`. This fixes `status --since=<ref>` reporting that no changesets were found when it runs from a git hook inside a `git worktree`.
140 changes: 138 additions & 2 deletions packages/git/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import { gitdir, outputFile, shallowClone } from "@changesets/test-utils";
import {
gitdir,
outputFile,
shallowClone,
testdir,
} from "@changesets/test-utils";
import { writeChangeset } from "@changesets/write";
import { exec } from "tinyexec";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
add,
commit,
Expand All @@ -14,6 +19,7 @@ import {
getCommitsThatAddFiles,
getCurrentCommitId,
getDivergedCommit,
remoteTagExists,
tag,
tagExists,
} from "./index.ts";
Expand Down Expand Up @@ -896,4 +902,134 @@ describe("git", { tags: ["slow"] }, () => {
expect(files).toEqual([`.changeset/${changesetId}.md`]);
});
});

describe("inherited git environment variables", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("should resolve the repository from cwd even when GIT_DIR points at another repository", async () => {
const cwd = await gitdir({ "a.js": 'export default "a"' });
const otherRepo = await gitdir({ "b.js": 'export default "b"' });
const headOfCwd = await getCurrentCommitId({ cwd });

vi.stubEnv("GIT_DIR", path.join(otherRepo, ".git"));

expect(await getCurrentCommitId({ cwd })).toBe(headOfCwd);
expect(await getCurrentCommitId({ cwd: otherRepo })).not.toBe(headOfCwd);
});

it("should get the relative path to the changeset file when GIT_DIR is inherited and cwd is a subdirectory", async () => {
const cwd = await gitdir({
"package.json": JSON.stringify({
private: true,
workspaces: ["packages/*"],
}),
"package-lock.json": "",
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
}),
".changeset/config.json": JSON.stringify({}),
});

const changesetId = await writeChangeset(
{
releases: [{ name: "pkg-a", type: "minor" }],
summary: "Awesome summary",
},
cwd,
);
await add(".changeset", cwd);

// `GIT_DIR` without `GIT_WORK_TREE` makes git treat the directory it runs in as the root
// of the work tree, which is not where the repository actually starts here
vi.stubEnv("GIT_DIR", path.join(cwd, ".git"));

const files = await getChangedChangesetFilesSinceRef({
cwd: path.join(cwd, ".changeset"),
ref: "main",
});
expect(files).toEqual([`.changeset/${changesetId}.md`]);
});

it("should get the relative path to the changeset file in a worktree entered with the environment of a git hook", async () => {
const cwd = await gitdir({
"package.json": JSON.stringify({
private: true,
workspaces: ["packages/*"],
}),
"package-lock.json": "",
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
}),
".changeset/config.json": JSON.stringify({}),
});

const worktree = path.join(await testdir(), "feature-worktree");
await exec("git", ["worktree", "add", worktree, "-b", "feature"], {
nodeOptions: { cwd },
});

const changesetId = await writeChangeset(
{
releases: [{ name: "pkg-a", type: "minor" }],
summary: "Awesome summary",
},
worktree,
);
await add(".changeset", worktree);

// this is what git puts in the environment of a hook that runs in a worktree, it points at
// the worktree specific git directory and it comes without a matching `GIT_WORK_TREE`
const worktreeGitDir = (
await exec("git", ["rev-parse", "--git-dir"], {
nodeOptions: { cwd: worktree },
})
).stdout
.toString()
.trim();
vi.stubEnv("GIT_DIR", path.resolve(worktree, worktreeGitDir));

const files = await getChangedChangesetFilesSinceRef({
cwd: path.join(worktree, ".changeset"),
ref: "main",
});
expect(files).toEqual([`.changeset/${changesetId}.md`]);
});

it("should read the remotes of the repository in the current working directory when GIT_DIR points at another repository", async () => {
// `remoteTagExists` doesn't take a `cwd`, so it runs in the one of the current process and
// an inherited `GIT_DIR` would otherwise make it resolve `origin` in another repository
const gitdirWithRemoteTag = async (tagStr: string) => {
const remote = await testdir();
await exec("git", ["init", "--bare"], { nodeOptions: { cwd: remote } });

const cwd = await gitdir({ "a.js": 'export default "a"' });
await exec("git", ["remote", "add", "origin", remote], {
nodeOptions: { cwd },
});
await tag(tagStr, cwd);
await exec("git", ["push", "origin", tagStr], {
nodeOptions: { cwd },
});

return cwd;
};

const cwd = await gitdirWithRemoteTag("v1.0.0");
const otherRepo = await gitdirWithRemoteTag("v2.0.0");

const originalCwd = process.cwd();
process.chdir(cwd);

try {
vi.stubEnv("GIT_DIR", path.join(otherRepo, ".git"));

expect(await remoteTagExists("v1.0.0")).toBe(true);
expect(await remoteTagExists("v2.0.0")).toBe(false);
} finally {
process.chdir(originalCwd);
}
});
});
});
108 changes: 59 additions & 49 deletions packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,47 @@ import { getPackages } from "@manypkg/get-packages";
import picomatch from "picomatch";
import { exec } from "tinyexec";

export async function add(pathToFile: string, cwd: string) {
const gitCmd = await exec("git", ["add", pathToFile], {
nodeOptions: { cwd },
// `git` tells its child processes, including hooks, which repository it is working on through
// environment variables like `GIT_DIR`. Those variables win over the lookup `git` would otherwise
// do starting from the current directory, so a `changeset` process started by a hook inherits them
// and reads a different repository, work tree or index than the `cwd` it was handed. A pre-push
// hook running inside a `git worktree` is the case people hit, because `git` sets `GIT_DIR` to the
// worktree specific directory there and leaves `GIT_WORK_TREE` unset, which makes `git` treat
// whatever directory it is invoked in as the root of the work tree.
//
// Every function below identifies the repository by the directory the command runs in, so these
// variables are removed from the environment of the `git` processes that get spawned.
const REPOSITORY_ENV_VARS = new Set([
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_DIR",
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_WORK_TREE",
]);

// The spawned process inherits `process.env`, so a variable is dropped by overriding it with
// `undefined` rather than by leaving it out of the returned object.
function getEnvOverrides(): NodeJS.ProcessEnv {
const overrides: NodeJS.ProcessEnv = {};
for (const key of Object.keys(process.env)) {
// environment variable names are case-insensitive on Windows
if (REPOSITORY_ENV_VARS.has(key.toUpperCase())) {
overrides[key] = undefined;
}
}
return overrides;
}

function execGit(args: string[], cwd?: string) {
return exec("git", args, {
nodeOptions: { cwd, env: getEnvOverrides() },
});
}

export async function add(pathToFile: string, cwd: string) {
const gitCmd = await execGit(["add", pathToFile], cwd);

if (gitCmd.exitCode !== 0) {
console.log(pathToFile, gitCmd.stderr.toString());
Expand All @@ -18,14 +55,12 @@ export async function add(pathToFile: string, cwd: string) {
}

export async function commit(message: string, cwd: string) {
const gitCmd = await exec("git", ["commit", "-m", message, "--allow-empty"], {
nodeOptions: { cwd },
});
const gitCmd = await execGit(["commit", "-m", message, "--allow-empty"], cwd);
return gitCmd.exitCode === 0;
}

export async function getAllTags(cwd: string): Promise<Set<string>> {
const gitCmd = await exec("git", ["tag"], { nodeOptions: { cwd } });
const gitCmd = await execGit(["tag"], cwd);

if (gitCmd.exitCode !== 0) {
throw new Error(gitCmd.stderr.toString());
Expand All @@ -40,17 +75,13 @@ export async function getAllTags(cwd: string): Promise<Set<string>> {
export async function tag(tagStr: string, cwd: string) {
// NOTE: it's important we use the -m flag to create annotated tag otherwise 'git push --follow-tags' won't actually push
// the tags
const gitCmd = await exec("git", ["tag", tagStr, "-m", tagStr], {
nodeOptions: { cwd },
});
const gitCmd = await execGit(["tag", tagStr, "-m", tagStr], cwd);
return gitCmd.exitCode === 0;
}

// Find the commit where we diverged from `ref` at using `git merge-base`
export async function getDivergedCommit(cwd: string, ref: string) {
const cmd = await exec("git", ["merge-base", ref, "HEAD"], {
nodeOptions: { cwd },
});
const cmd = await execGit(["merge-base", ref, "HEAD"], cwd);
if (cmd.exitCode !== 0) {
throw new Error(
`Failed to find where HEAD diverged from "${ref}". Does "${ref}" exist and it's synced with remote?`,
Expand Down Expand Up @@ -80,8 +111,7 @@ export async function getCommitsThatAddFiles(
const commitInfos = await Promise.all(
remaining.map(async (gitPath: string) => {
const [commitSha, parentSha] = (
await exec(
"git",
await execGit(
[
"log",
"--diff-filter=A",
Expand All @@ -90,7 +120,7 @@ export async function getCommitsThatAddFiles(
short ? "--pretty=format:%h:%p" : "--pretty=format:%H:%p",
gitPath,
],
{ nodeOptions: { cwd } },
cwd,
)
).stdout
.toString()
Expand Down Expand Up @@ -144,9 +174,7 @@ export async function getCommitsThatAddFiles(

export async function isRepoShallow({ cwd }: { cwd: string }) {
const isShallowRepoOutput = (
await exec("git", ["rev-parse", "--is-shallow-repository"], {
nodeOptions: { cwd },
})
await execGit(["rev-parse", "--is-shallow-repository"], cwd)
).stdout
.toString()
.trim();
Expand All @@ -156,9 +184,7 @@ export async function isRepoShallow({ cwd }: { cwd: string }) {
// In that case, we'll test for the existence of .git/shallow.

// Firstly, find the .git folder for the repo; note that this will be relative to the repo dir
const gitDir = (
await exec("git", ["rev-parse", "--git-dir"], { nodeOptions: { cwd } })
).stdout
const gitDir = (await execGit(["rev-parse", "--git-dir"], cwd)).stdout
.toString()
.trim();

Expand All @@ -179,18 +205,15 @@ export async function isRepoShallow({ cwd }: { cwd: string }) {
}

export async function deepenCloneBy({ by, cwd }: { by: number; cwd: string }) {
const cmd = await exec("git", ["fetch", `--deepen=${by}`], {
nodeOptions: { cwd },
});
const cmd = await execGit(["fetch", `--deepen=${by}`], cwd);
if (cmd.exitCode !== 0) {
throw new Error(cmd.stderr.toString());
}
}
async function getRepoRoot({ cwd }: { cwd: string }) {
const { stdout, exitCode, stderr } = await exec(
"git",
const { stdout, exitCode, stderr } = await execGit(
["rev-parse", "--show-cdup"],
{ nodeOptions: { cwd } },
cwd,
);

if (exitCode !== 0) {
Expand All @@ -211,10 +234,9 @@ export async function getChangedFilesSince({
}): Promise<Array<string>> {
const divergedAt = await getDivergedCommit(cwd, ref);
// Now we can find which files we added
const cmd = await exec(
"git",
const cmd = await execGit(
["diff", "--name-only", "--no-relative", divergedAt],
{ nodeOptions: { cwd } },
cwd,
);
if (cmd.exitCode !== 0) {
throw new Error(
Expand Down Expand Up @@ -244,12 +266,9 @@ export async function getChangedChangesetFilesSinceRef({
try {
const divergedAt = await getDivergedCommit(cwd, ref);
// Now we can find which files we added
const cmd = await exec(
"git",
const cmd = await execGit(
["diff", "--name-only", "--diff-filter=d", "--no-relative", divergedAt],
{
nodeOptions: { cwd },
},
cwd,
);

const rootChangesetsRegex = /\.changeset\/[^/]+\.md$/;
Expand Down Expand Up @@ -304,9 +323,7 @@ export async function getChangedPackagesSinceRef({
}

export async function tagExists(tagStr: string, cwd: string) {
const gitCmd = await exec("git", ["tag", "-l", tagStr], {
nodeOptions: { cwd },
});
const gitCmd = await execGit(["tag", "-l", tagStr], cwd);
const output = gitCmd.stdout.toString().trim();
const tagExists = !!output;
return tagExists;
Expand All @@ -320,24 +337,17 @@ export async function getCurrentCommitId({
short?: boolean;
}): Promise<string> {
return (
await exec(
"git",
await execGit(
["rev-parse", short && "--short", "HEAD"].filter<string>(Boolean as any),
{ nodeOptions: { cwd } },
cwd,
)
).stdout
.toString()
.trim();
}

export async function remoteTagExists(tagStr: string) {
const gitCmd = await exec("git", [
"ls-remote",
"--tags",
"origin",
"-l",
tagStr,
]);
const gitCmd = await execGit(["ls-remote", "--tags", "origin", "-l", tagStr]);
const output = gitCmd.stdout.toString().trim();
const tagExists = !!output;
return tagExists;
Expand Down