Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .atomic/workflows/lib/release-docs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { createGitEnvironment } from "@bastani/atomic";

export type StaleDocTask = {
id: string;
Expand Down Expand Up @@ -37,10 +38,17 @@ export const DEFAULT_RELEASE_DOCS_BASE_BRANCH = "main";

const repoRoot = (): string => process.cwd();

// Sanitize repository-local Git environment variables (GIT_DIR, GIT_WORK_TREE,
// GIT_INDEX_FILE, ...) so subprocesses always target `cwd`. Git honors these
// variables over cwd, so when this lib runs under a hook runner (e.g. prek
// pre-commit/pre-push), inherited values would silently redirect every command
// at the real repository — `git init` even persists `core.worktree` into the
// shared .git/config of the invoking worktree (see git-env.ts).
const runCommand = (command: string, args: string[], cwd = repoRoot()): string =>
execFileSync(command, args, {
cwd,
encoding: "utf8",
env: createGitEnvironment(),
maxBuffer: 1024 * 1024 * 20,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
Expand Down
92 changes: 78 additions & 14 deletions test/unit/release-docs-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import assert from "node:assert/strict";
import {
currentBranchName,
Expand All @@ -17,6 +18,7 @@ import {
type StaleDocTask,
type UpdateArtifactStatus,
} from "../../.atomic/workflows/lib/release-docs.js";
import { createGitEnvironment } from "@bastani/atomic";

const task = (id: string, ownerDocs: string[]): StaleDocTask => ({
id,
Expand All @@ -28,8 +30,29 @@ const task = (id: string, ownerDocs: string[]): StaleDocTask => ({
acceptance_criteria: [`Criteria ${id}`],
});

// Always sanitize the Git environment for fixture repos: under a hook runner
// (e.g. prek) Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE, which Git
// honors over cwd — an unsanitized `git init` would then re-initialize the
// real repository and persist core.worktree into its shared .git/config
// (see git-env.ts and the regression test below).
const runGit = (cwd: string, args: string[]): void => {
execFileSync("git", args, { cwd, stdio: "ignore" });
execFileSync("git", args, { cwd, stdio: "ignore", env: createGitEnvironment() });
};

const commitAll = (repo: string, message: string): void => {
runGit(repo, [
"-c",
"user.name=Atomic Test",
"-c",
"user.email=atomic-test@example.com",
"-c",
"core.hooksPath=/dev/null",
"commit",
"--no-gpg-sign",
"--message",
message,
"--quiet",
]);
};

describe("release-docs workflow guards", () => {
Expand All @@ -39,19 +62,7 @@ describe("release-docs workflow guards", () => {
runGit(repo, ["init", "--quiet"]);
writeFileSync(join(repo, "README.md"), "# test\n");
runGit(repo, ["add", "README.md"]);
runGit(repo, [
"-c",
"user.name=Atomic Test",
"-c",
"user.email=atomic-test@example.com",
"-c",
"core.hooksPath=/dev/null",
"commit",
"--no-gpg-sign",
"--message",
"initial",
"--quiet",
]);
commitAll(repo, "initial");
runGit(repo, ["checkout", "--detach", "HEAD", "--quiet"]);

assert.throws(
Expand All @@ -63,6 +74,59 @@ describe("release-docs workflow guards", () => {
}
});

test("resolves branch state from the target repo despite ambient Git hook environment", () => {
const repo = mkdtempSync(join(tmpdir(), "release-docs-target-"));
const ambientRepo = mkdtempSync(join(tmpdir(), "release-docs-ambient-"));
const scriptPath = join(tmpdir(), `release-docs-current-branch-${process.pid}-${Date.now()}.ts`);
try {
// Target repo sits on a branch.
runGit(repo, ["init", "--quiet"]);
runGit(repo, ["checkout", "-b", "feature/docs", "--quiet"]);
writeFileSync(join(repo, "README.md"), "# target\n");
runGit(repo, ["add", "README.md"]);
commitAll(repo, "initial");

// Decoy repo is detached; a hook runner (e.g. prek) exports
// repository-local Git env vars pointing at the invoking repo.
runGit(ambientRepo, ["init", "--quiet"]);
writeFileSync(join(ambientRepo, "README.md"), "# ambient\n");
runGit(ambientRepo, ["add", "README.md"]);
commitAll(ambientRepo, "initial");
runGit(ambientRepo, ["checkout", "--detach", "HEAD", "--quiet"]);

const moduleUrl = pathToFileURL(join(process.cwd(), ".atomic/workflows/lib/release-docs.ts")).href;
writeFileSync(
scriptPath,
[
`import { currentBranchName } from ${JSON.stringify(moduleUrl)};`,
`console.log(currentBranchName(${JSON.stringify(repo)}));`,
].join("\n"),
);

// The ambient Git env must be present at child-process startup to
// match hook runners. The repo mandates Bun, so process.execPath is
// intentionally the Bun runtime for this child TypeScript script.
// Without release-docs' sanitizer, nested Git commands would read
// the detached decoy repo instead of `repo`.
const output = execFileSync(process.execPath, [scriptPath], {
encoding: "utf8",
env: {
...process.env,
GIT_DIR: join(ambientRepo, ".git"),
GIT_WORK_TREE: ambientRepo,
GIT_INDEX_FILE: join(ambientRepo, ".git", "index"),
},
stdio: ["ignore", "pipe", "pipe"],
}).trim();

assert.equal(output, "feature/docs");
} finally {
rmSync(scriptPath, { force: true });
rmSync(repo, { recursive: true, force: true });
rmSync(ambientRepo, { recursive: true, force: true });
}
});

test("allows release-docs from a non-base branch", () => {
assert.equal(requireNonBaseBranch("feature/docs", "main"), "feature/docs");
});
Expand Down
Loading