298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
// @vitest-environment node
|
|
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import {
|
|
assertCleanupEligibility,
|
|
branchSlug,
|
|
defaultWorktreePath,
|
|
editEnvironment,
|
|
finishSession,
|
|
normalMergeContainsHead,
|
|
parseWorktreeList,
|
|
pullEnvironment,
|
|
resolveAgeKeyFile,
|
|
startSession,
|
|
validateDecryptedEnv,
|
|
validateSessionBranch,
|
|
writeEnvAtomically,
|
|
} from "./worktree-session.mjs";
|
|
|
|
const fixtures = [];
|
|
|
|
function fixture() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hivegrid-worktree-session-"));
|
|
fixtures.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("worktree session policy", () => {
|
|
it("parses branch and detached worktree records", () => {
|
|
assert.deepEqual(
|
|
parseWorktreeList(`worktree /repo
|
|
HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
branch refs/heads/main
|
|
|
|
worktree /repo-feature
|
|
HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
|
branch refs/heads/feat/example
|
|
|
|
worktree /repo-detached
|
|
HEAD cccccccccccccccccccccccccccccccccccccccc
|
|
detached
|
|
`),
|
|
[
|
|
{
|
|
path: "/repo",
|
|
head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
branch: "main",
|
|
detached: false,
|
|
},
|
|
{
|
|
path: "/repo-feature",
|
|
head: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
branch: "feat/example",
|
|
detached: false,
|
|
},
|
|
{
|
|
path: "/repo-detached",
|
|
head: "cccccccccccccccccccccccccccccccccccccccc",
|
|
branch: null,
|
|
detached: true,
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
it("derives a stable sibling worktree path and rejects main", () => {
|
|
assert.equal(branchSlug("fix/KWP details"), "fix-KWP-details");
|
|
assert.equal(defaultWorktreePath("/workspace/HiveGrid-Cloud", "fix/KWP-details"), "/workspace/HiveGrid-Cloud-fix-KWP-details");
|
|
assert.throws(() => validateSessionBranch("main"), /must never be main/);
|
|
assert.throws(() => validateSessionBranch(" fix/example"), /trimmed session branch/);
|
|
});
|
|
|
|
it("rejects a relative explicit worktree path before fetching or creating anything", () => {
|
|
assert.throws(
|
|
() =>
|
|
startSession({
|
|
branch: "fix/example",
|
|
requestedPath: "relative/path",
|
|
cwd: fixture(),
|
|
}),
|
|
/absolute path/
|
|
);
|
|
});
|
|
|
|
it("prefers an explicit age key and otherwise uses the canonical repo path", () => {
|
|
const homeDir = fixture();
|
|
const explicit = path.join(homeDir, "explicit.txt");
|
|
const canonical = path.join(homeDir, ".config", "sops", "age", "keys.txt");
|
|
fs.mkdirSync(path.dirname(canonical), { recursive: true });
|
|
fs.writeFileSync(explicit, "key");
|
|
fs.writeFileSync(canonical, "key");
|
|
|
|
assert.equal(resolveAgeKeyFile({ env: { SOPS_AGE_KEY_FILE: explicit }, homeDir }), explicit);
|
|
assert.equal(resolveAgeKeyFile({ env: {}, homeDir }), canonical);
|
|
});
|
|
|
|
it("writes decrypted env atomically with mode 600", () => {
|
|
const root = fixture();
|
|
fs.writeFileSync(path.join(root, ".env"), "DATABASE_URL=old\n");
|
|
|
|
const envPath = writeEnvAtomically(root, "DATABASE_URL=postgres://local/hivegrid\nOTHER=value\n");
|
|
|
|
assert.equal(fs.readFileSync(envPath, "utf8"), "DATABASE_URL=postgres://local/hivegrid\nOTHER=value\n");
|
|
assert.equal(fs.statSync(envPath).mode & 0o777, 0o600);
|
|
assert.deepEqual(
|
|
fs.readdirSync(root).filter((name) => name.startsWith(".env.tmp-")),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("does not replace an existing env when decryption fails", () => {
|
|
const root = fixture();
|
|
const keyPath = path.join(root, ".config", "sops", "age", "keys.txt");
|
|
fs.mkdirSync(path.join(root, "secrets"), { recursive: true });
|
|
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
fs.writeFileSync(path.join(root, "secrets", "dev.sops.env"), "encrypted");
|
|
fs.writeFileSync(path.join(root, ".env"), "DATABASE_URL=keep\n");
|
|
fs.writeFileSync(keyPath, "key");
|
|
|
|
assert.throws(
|
|
() =>
|
|
pullEnvironment(root, {
|
|
env: {},
|
|
homeDir: root,
|
|
commandRunner() {
|
|
throw new Error("decrypt failed");
|
|
},
|
|
}),
|
|
/decrypt failed/
|
|
);
|
|
assert.equal(fs.readFileSync(path.join(root, ".env"), "utf8"), "DATABASE_URL=keep\n");
|
|
});
|
|
|
|
it("passes the canonical age key to sops without exposing env values", () => {
|
|
const root = fixture();
|
|
const keyPath = path.join(root, ".config", "sops", "age", "keys.txt");
|
|
fs.mkdirSync(path.join(root, "secrets"), { recursive: true });
|
|
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
fs.writeFileSync(path.join(root, "secrets", "dev.sops.env"), "encrypted");
|
|
fs.writeFileSync(keyPath, "key");
|
|
|
|
const envPath = pullEnvironment(root, {
|
|
env: {},
|
|
homeDir: root,
|
|
commandRunner(command, args, options) {
|
|
assert.equal(command, "sops");
|
|
assert.deepEqual(args, ["-d", path.join(root, "secrets", "dev.sops.env")]);
|
|
assert.equal(options.env.SOPS_AGE_KEY_FILE, keyPath);
|
|
return { stdout: "DATABASE_URL=postgres://local/hivegrid\n" };
|
|
},
|
|
});
|
|
|
|
assert.equal(fs.statSync(envPath).mode & 0o777, 0o600);
|
|
});
|
|
|
|
it("passes the canonical age key to interactive sops editing", () => {
|
|
const root = fixture();
|
|
const keyPath = path.join(root, ".config", "sops", "age", "keys.txt");
|
|
fs.mkdirSync(path.join(root, "secrets"), { recursive: true });
|
|
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
fs.writeFileSync(path.join(root, "secrets", "dev.sops.env"), "encrypted");
|
|
fs.writeFileSync(keyPath, "key");
|
|
|
|
editEnvironment(root, {
|
|
env: {},
|
|
homeDir: root,
|
|
commandRunner(command, args, options) {
|
|
assert.equal(command, "sops");
|
|
assert.deepEqual(args, [path.join(root, "secrets", "dev.sops.env")]);
|
|
assert.equal(options.env.SOPS_AGE_KEY_FILE, keyPath);
|
|
assert.equal(options.inherit, true);
|
|
return { status: 0 };
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects empty or incomplete decrypted environments", () => {
|
|
assert.throws(() => validateDecryptedEnv(""), /empty environment/);
|
|
assert.throws(() => validateDecryptedEnv("OTHER=value\n"), /missing a non-empty DATABASE_URL/);
|
|
});
|
|
|
|
it("recognizes only a merge commit whose non-first parent is the feature head", () => {
|
|
const featureHead = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
const mergeLog =
|
|
`cccccccccccccccccccccccccccccccccccccccc aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ${featureHead}\n` +
|
|
"dddddddddddddddddddddddddddddddddddddddd cccccccccccccccccccccccccccccccccccccccc";
|
|
|
|
assert.equal(normalMergeContainsHead(mergeLog, featureHead), true);
|
|
assert.equal(normalMergeContainsHead(mergeLog, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), false);
|
|
});
|
|
|
|
it("allows cleanup only for a clean, normally merged, unchanged branch", () => {
|
|
assert.doesNotThrow(() =>
|
|
assertCleanupEligibility({
|
|
branch: "fix/example",
|
|
status: "",
|
|
localHead: "feature-head",
|
|
remoteHead: "feature-head",
|
|
isAncestor: true,
|
|
hasNormalMergeCommit: true,
|
|
})
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCleanupEligibility({
|
|
branch: "fix/example",
|
|
status: " M file.ts",
|
|
localHead: "feature-head",
|
|
isAncestor: true,
|
|
hasNormalMergeCommit: true,
|
|
}),
|
|
/not clean/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCleanupEligibility({
|
|
branch: "fix/example",
|
|
status: "",
|
|
localHead: "feature-head",
|
|
isAncestor: false,
|
|
hasNormalMergeCommit: false,
|
|
}),
|
|
/unmerged or squashed/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCleanupEligibility({
|
|
branch: "fix/example",
|
|
status: "",
|
|
localHead: "feature-head",
|
|
isAncestor: true,
|
|
hasNormalMergeCommit: false,
|
|
}),
|
|
/not attached to main by a normal merge commit/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCleanupEligibility({
|
|
branch: "fix/example",
|
|
status: "",
|
|
localHead: "feature-head",
|
|
remoteHead: "new-remote-head",
|
|
isAncestor: true,
|
|
hasNormalMergeCommit: true,
|
|
}),
|
|
/Remote branch .* moved/
|
|
);
|
|
});
|
|
|
|
it("removes a clean local worktree and branch after a real normal merge commit", async () => {
|
|
const root = fixture();
|
|
const origin = path.join(root, "origin.git");
|
|
const primary = path.join(root, "primary");
|
|
const session = path.join(root, "session");
|
|
const git = (cwd, ...args) =>
|
|
execFileSync("git", ["-C", cwd, ...args], {
|
|
encoding: "utf8",
|
|
stdio: "pipe",
|
|
});
|
|
|
|
execFileSync("git", ["init", "--bare", "--initial-branch=main", origin], { stdio: "pipe" });
|
|
execFileSync("git", ["clone", origin, primary], { stdio: "pipe" });
|
|
git(primary, "config", "user.name", "Session Policy Test");
|
|
git(primary, "config", "user.email", "session-policy@example.test");
|
|
fs.writeFileSync(path.join(primary, "README.md"), "main\n");
|
|
git(primary, "add", "README.md");
|
|
git(primary, "commit", "-m", "initial");
|
|
git(primary, "push", "-u", "origin", "main");
|
|
|
|
git(primary, "worktree", "add", "-b", "fix/example", session, "origin/main");
|
|
fs.writeFileSync(path.join(session, "feature.txt"), "feature\n");
|
|
git(session, "add", "feature.txt");
|
|
git(session, "commit", "-m", "feature");
|
|
git(primary, "merge", "--no-ff", "fix/example", "-m", "Merge fix/example");
|
|
git(primary, "push", "origin", "main");
|
|
const registeredSessionPath = fs.realpathSync(session);
|
|
|
|
const result = await finishSession({ branch: "fix/example", cwd: session });
|
|
|
|
assert.equal(result.removedWorktree, registeredSessionPath);
|
|
assert.equal(result.removedRemoteBranch, false);
|
|
assert.equal(fs.existsSync(session), false);
|
|
assert.notEqual(spawnSync("git", ["-C", primary, "show-ref", "--verify", "--quiet", "refs/heads/fix/example"]).status, 0);
|
|
});
|
|
});
|