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: 5 additions & 3 deletions tests/e2e/helpers/tempEnv.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { disableGitAutoMaintenance, removeTempDir } from "../../helpers/tempGit.js";

const FIXTURE_DIR = fileURLToPath(new URL("../fixtures/minimal-repo", import.meta.url));

Expand Down Expand Up @@ -34,6 +35,7 @@ export function createTempEnv(): TempEnv {
// Initialise a real git repo so phax can create worktrees
const gitOpts = { cwd: repoDir, stdio: "pipe" as const };
execSync("git init", gitOpts);
disableGitAutoMaintenance(repoDir);
execSync("git config --local user.email e2e@phax.test", gitOpts);
execSync("git config --local user.name 'phax E2E'", gitOpts);
execSync("git add .", gitOpts);
Expand All @@ -43,8 +45,8 @@ export function createTempEnv(): TempEnv {
repoDir,
phaxHome,
cleanup() {
rmSync(repoDir, { recursive: true, force: true });
rmSync(phaxHome, { recursive: true, force: true });
removeTempDir(repoDir);
removeTempDir(phaxHome);
},
};
}
33 changes: 33 additions & 0 deletions tests/helpers/tempGit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { execFileSync } from "node:child_process";
import { rmSync } from "node:fs";

/**
* Stop a fixture repo from leaving background work behind.
*
* Commands that create objects — `commit`, and `receive-pack` on the far side
* of a push — spawn a detached `git maintenance run --auto --quiet`. That
* process can still be writing into `.git` when the test's teardown removes
* the tree, and the removal then fails with `ENOTEMPTY`.
*
* Call this on every repo a test creates: the working repo, a bare remote, and
* any clone made mid-test.
*/
export function disableGitAutoMaintenance(repoDir: string): void {
execFileSync("git", ["config", "--local", "maintenance.auto", "false"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "--local", "gc.auto", "0"], { cwd: repoDir, stdio: "pipe" });
}

/**
* Remove a temp directory, tolerating a writer that races the walk.
*
* Node's recursive removal does not retry by default, so a single entry
* appearing after its parent was enumerated fails the whole call. Undefined is
* accepted so a teardown can run before its `beforeEach` ever assigned a path.
*/
export function removeTempDir(dir: string | undefined): void {
if (dir === undefined) return;
rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
}
8 changes: 5 additions & 3 deletions tests/integration/cliErrors.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, expect, it, afterEach } from "vitest";
import { execSync, spawnSync, spawnSyncReturns } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

const repoRoot = join(fileURLToPath(import.meta.url), "../../..");
const mainTs = join(repoRoot, "src/cli/main.ts");
Expand Down Expand Up @@ -54,7 +55,7 @@ describe("CLI error messages", () => {
let tmpDir: string;

afterEach(() => {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
removeTempDir(tmpDir);
});

it("Draft plan: non-zero exit, message names the file and status, no stack trace", () => {
Expand All @@ -78,7 +79,7 @@ describe("CLI error messages", () => {
let tmpRepoRoot: string;

afterEach(() => {
if (tmpRepoRoot) rmSync(tmpRepoRoot, { recursive: true, force: true });
removeTempDir(tmpRepoRoot);
});

it("Approved docs/plans/ plan with no approval record: exit 12, message names the missing record and the approve remedy, no stack trace", () => {
Expand All @@ -87,6 +88,7 @@ describe("CLI error messages", () => {
// repo-relative classification silently fails on macOS's /tmp symlink.
tmpRepoRoot = realpathSync(mkdtempSync(join(tmpdir(), "phax-cli-errors-stale-")));
execSync("git init -q", { cwd: tmpRepoRoot });
disableGitAutoMaintenance(tmpRepoRoot);
execSync('git config user.email "test@example.com"', { cwd: tmpRepoRoot });
execSync('git config user.name "Test"', { cwd: tmpRepoRoot });

Expand Down
8 changes: 5 additions & 3 deletions tests/integration/gitBaseline.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rm, writeFile, appendFile } from "node:fs/promises";
import { writeFile, appendFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand All @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Effect } from "effect";
import { NodeGitLayer } from "../../src/infra/git.js";
import { Git } from "../../src/ports/git.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

function runGit(args: string, cwd: string): void {
execSync(`git ${args}`, { cwd, stdio: "pipe" });
Expand All @@ -22,6 +23,7 @@ describe("NodeGitLayer baseline operations", () => {
beforeEach(async () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-git-baseline-test-"));
runGit("init", repoDir);
disableGitAutoMaintenance(repoDir);
runGit("config --local user.email test@phax.test", repoDir);
runGit("config --local user.name 'phax test'", repoDir);

Expand All @@ -30,8 +32,8 @@ describe("NodeGitLayer baseline operations", () => {
runGit("commit -m 'chore: initial commit'", repoDir);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
afterEach(() => {
removeTempDir(repoDir);
});

it("headCommit returns the sha git rev-parse HEAD reports", async () => {
Expand Down
13 changes: 8 additions & 5 deletions tests/integration/gitCommitPaths.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand All @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Effect } from "effect";
import { NodeGitLayer } from "../../src/infra/git.js";
import { Git } from "../../src/ports/git.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

function runGit(args: string, cwd: string): void {
execSync(`git ${args}`, { cwd, stdio: "pipe" });
Expand All @@ -18,6 +19,7 @@ describe("NodeGitLayer.commitPaths", () => {
beforeEach(async () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-git-commit-paths-test-"));
runGit("init", repoDir);
disableGitAutoMaintenance(repoDir);
runGit("config --local user.email test@phax.test", repoDir);
runGit("config --local user.name 'phax test'", repoDir);

Expand All @@ -26,8 +28,8 @@ describe("NodeGitLayer.commitPaths", () => {
runGit("commit -m 'chore: initial commit'", repoDir);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
afterEach(() => {
removeTempDir(repoDir);
});

it("commits only the given paths, leaving an unrelated dirty file untouched", async () => {
Expand Down Expand Up @@ -98,6 +100,7 @@ describe("NodeGitLayer.dirtyPaths", () => {
beforeEach(async () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-git-dirty-paths-test-"));
runGit("init", repoDir);
disableGitAutoMaintenance(repoDir);
runGit("config --local user.email test@phax.test", repoDir);
runGit("config --local user.name 'phax test'", repoDir);

Expand All @@ -108,8 +111,8 @@ describe("NodeGitLayer.dirtyPaths", () => {
runGit("commit -m 'chore: initial commit'", repoDir);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
afterEach(() => {
removeTempDir(repoDir);
});

it("reports exactly the dirty subset for modified, staged, untracked, and clean paths", async () => {
Expand Down
11 changes: 7 additions & 4 deletions tests/integration/gitDiffNameStatus.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand All @@ -8,6 +8,7 @@ import { Effect, Either } from "effect";
import { NodeGitLayer } from "../../src/infra/git.js";
import { Git, GitError } from "../../src/ports/git.js";
import type { BranchName, WorktreePath } from "../../src/domain/branded.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

function runGit(args: string, cwd: string): void {
execSync(`git ${args}`, { cwd, stdio: "pipe" });
Expand All @@ -19,6 +20,7 @@ describe("NodeGitLayer.diffNameStatus", () => {
beforeEach(async () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-git-diff-test-"));
runGit("init", repoDir);
disableGitAutoMaintenance(repoDir);
runGit("config --local user.email test@phax.test", repoDir);
runGit("config --local user.name 'phax test'", repoDir);

Expand All @@ -29,7 +31,7 @@ describe("NodeGitLayer.diffNameStatus", () => {
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
removeTempDir(repoDir);
});

it("returns added, modified, and deleted entries for HEAD^..HEAD", async () => {
Expand Down Expand Up @@ -170,6 +172,7 @@ describe("NodeGitLayer.remoteExists and pushBranch", () => {
bareRemoteDir = mkdtempSync(join(tmpdir(), "phax-git-push-remote-"));

execSync("git init --bare -b main", { cwd: bareRemoteDir, stdio: "pipe" });
disableGitAutoMaintenance(bareRemoteDir);

runGit("init -b main", repoDir);
runGit("config --local user.email test@phax.test", repoDir);
Expand All @@ -182,8 +185,8 @@ describe("NodeGitLayer.remoteExists and pushBranch", () => {
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
await rm(bareRemoteDir, { recursive: true, force: true });
removeTempDir(repoDir);
removeTempDir(bareRemoteDir);
});

it("remoteExists returns true for a configured remote", async () => {
Expand Down
6 changes: 4 additions & 2 deletions tests/integration/gitObjectPlumbing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdtempSync, readFileSync } from "node:fs";
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
Expand All @@ -8,6 +8,7 @@ import { Effect, Either } from "effect";
import { NodeGitLayer } from "../../src/infra/git.js";
import { Git, type GitError } from "../../src/ports/git.js";
import { decodeBranchName, type BranchName } from "../../src/domain/branded.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

const RECORDS_BRANCH = ((): BranchName => {
const decoded = decodeBranchName("phax/records/v1");
Expand All @@ -32,6 +33,7 @@ describe("NodeGitLayer object plumbing", () => {
beforeEach(async () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-git-object-plumbing-"));
git(["init"], repoDir);
disableGitAutoMaintenance(repoDir);
git(["config", "--local", "user.email", "test@phax.test"], repoDir);
git(["config", "--local", "user.name", "phax test"], repoDir);
await writeFile(join(repoDir, "README.md"), "# test\n");
Expand All @@ -40,7 +42,7 @@ describe("NodeGitLayer object plumbing", () => {
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
removeTempDir(repoDir);
});

it("leaves a dirty working tree and the repo index byte-for-byte unchanged", async () => {
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/recordsExplain.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
Expand All @@ -14,6 +13,7 @@ import { explainRecord } from "../../src/app/recordsExplain.js";
import { listRecords } from "../../src/app/recordsList.js";
import { encodeRunRecordManifest, type RunRecordManifest } from "../../src/schemas/runRecord.js";
import type { ResolvedRecordsConfig } from "../../src/schemas/recordsConfig.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

const RECORDS_BRANCH: BranchName = Either.getOrThrow(decodeBranchName("phax/records/v1"));
const LAYER = Layer.mergeAll(NodeGitLayer, NodeShellLayer);
Expand Down Expand Up @@ -94,13 +94,14 @@ describe("records explain and list (real git)", () => {
beforeEach(() => {
repoDir = mkdtempSync(join(tmpdir(), "phax-records-explain-repo-"));
execGit(["init"], repoDir);
disableGitAutoMaintenance(repoDir);
execGit(["config", "--local", "user.email", "test@phax.test"], repoDir);
execGit(["config", "--local", "user.name", "phax test"], repoDir);
execGit(["commit", "--allow-empty", "-m", "chore: initial commit"], repoDir);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
removeTempDir(repoDir);
});

it("resolves a record through the commit's Run-Id/Phase-Id trailers, surviving a rebase", async () => {
Expand Down
13 changes: 8 additions & 5 deletions tests/integration/recordsPush.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
Expand All @@ -18,6 +17,7 @@ import { NoopSystemTelemetryLayer } from "../../src/ports/systemTelemetry.js";
import type { RunReviewInfo } from "../../src/domain/runReviewInfo.js";
import type { ResolvedPublishConfig } from "../../src/schemas/phaxConfig.js";
import type { ResolvedRecordsConfig } from "../../src/schemas/recordsConfig.js";
import { disableGitAutoMaintenance, removeTempDir } from "../helpers/tempGit.js";

const RECORDS_BRANCH: BranchName = Either.getOrThrow(decodeBranchName("phax/records/v1"));

Expand Down Expand Up @@ -62,15 +62,17 @@ describe("records push and pending status (real git)", () => {
repoDir = mkdtempSync(join(tmpdir(), "phax-records-push-repo-"));
remoteDir = mkdtempSync(join(tmpdir(), "phax-records-push-remote-"));
execGit(["init"], repoDir);
disableGitAutoMaintenance(repoDir);
execGit(["config", "--local", "user.email", "test@phax.test"], repoDir);
execGit(["config", "--local", "user.name", "phax test"], repoDir);
execGit(["init", "--bare"], remoteDir);
disableGitAutoMaintenance(remoteDir);
execGit(["remote", "add", "origin", remoteDir], repoDir);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
await rm(remoteDir, { recursive: true, force: true });
removeTempDir(repoDir);
removeTempDir(remoteDir);
});

it("a run's records are committed locally and absent from the remote until pushed", async () => {
Expand Down Expand Up @@ -145,8 +147,9 @@ describe("records push and pending status (real git)", () => {

it("pushes to the local clone's origin for a dedicated repo destination", async () => {
const cloneDir = mkdtempSync(join(tmpdir(), "phax-records-push-clone-"));
await rm(cloneDir, { recursive: true, force: true });
removeTempDir(cloneDir);
execGit(["clone", "--", remoteDir, cloneDir], tmpdir());
disableGitAutoMaintenance(cloneDir);
// A fresh clone inherits no committer identity, and CI runners set none
// globally; configure it locally like every other repo this suite commits
// into, so `commit-tree` does not fail with "empty ident name".
Expand Down Expand Up @@ -181,7 +184,7 @@ describe("records push and pending status (real git)", () => {
);
expect(pending.pending).toEqual([]);

await rm(cloneDir, { recursive: true, force: true });
removeTempDir(cloneDir);
});

it("does not push and reports nothing pending when records are disabled", async () => {
Expand Down
Loading
Loading