// @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); }); });