#!/usr/bin/env node import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; const MAIN_BRANCH = "main"; const MAIN_REMOTE_SOURCE = "refs/heads/main:refs/remotes/origin/main"; const REQUIRED_ENV_KEYS = ["DATABASE_URL"]; function commandError(command, args, result) { const detail = result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status ?? "unknown"}`; return new Error(`${command} ${args.join(" ")} failed: ${detail}`); } export function runCommand(command, args, { cwd, env = process.env, inherit = false, allowedExitCodes = [0] } = {}) { const result = spawnSync(command, args, { cwd, env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: inherit ? "inherit" : "pipe", }); if (!allowedExitCodes.includes(result.status ?? 1)) { throw commandError(command, args, result); } return result; } function git(cwd, args, options = {}) { return runCommand("git", ["-C", cwd, ...args], { cwd, ...options }); } function gitOutput(cwd, args, options = {}) { return git(cwd, args, options).stdout.trim(); } export function parseWorktreeList(porcelain) { return porcelain .trim() .split(/\n\n+/) .filter(Boolean) .map((block) => { const lines = block.split("\n"); const worktree = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length); const head = lines.find((line) => line.startsWith("HEAD "))?.slice("HEAD ".length); const branchRef = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length); return { path: worktree, head, branch: branchRef?.replace(/^refs\/heads\//, "") ?? null, detached: lines.includes("detached"), }; }); } export function branchSlug(branch) { return branch .replaceAll("/", "-") .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); } export function validateSessionBranch(branch) { if (!branch || branch.trim() !== branch) { throw new Error("A non-empty, trimmed session branch is required."); } if (branch === MAIN_BRANCH || branch === `origin/${MAIN_BRANCH}`) { throw new Error("A session branch must never be main."); } if (!branchSlug(branch)) { throw new Error("The session branch does not contain a usable name."); } return branch; } export function defaultWorktreePath(primaryWorktree, branch) { return `${primaryWorktree}-${branchSlug(validateSessionBranch(branch))}`; } export function resolveAgeKeyFile({ env = process.env, homeDir = os.homedir(), exists = fs.existsSync } = {}) { const candidates = [ env.SOPS_AGE_KEY_FILE, path.join(homeDir, ".config", "sops", "age", "keys.txt"), path.join(homeDir, "Library", "Application Support", "sops", "age", "keys.txt"), ].filter(Boolean); const keyFile = candidates.find((candidate) => exists(candidate)); if (!keyFile) { throw new Error(`No SOPS age key found. Expected ${path.join(homeDir, ".config", "sops", "age", "keys.txt")} or SOPS_AGE_KEY_FILE.`); } return keyFile; } export function validateDecryptedEnv(plaintext) { if (!plaintext.trim()) { throw new Error("SOPS returned an empty environment file."); } for (const key of REQUIRED_ENV_KEYS) { const pattern = new RegExp(`^${key}=(?:"[^"]+"|'[^']+'|\\S.+)$`, "m"); if (!pattern.test(plaintext)) { throw new Error(`The decrypted environment is missing a non-empty ${key}.`); } } return plaintext; } export function writeEnvAtomically(repoRoot, plaintext) { const envPath = path.join(repoRoot, ".env"); const temporaryPath = path.join(repoRoot, `.env.tmp-${process.pid}-${randomUUID()}`); try { fs.writeFileSync(temporaryPath, validateDecryptedEnv(plaintext), { encoding: "utf8", flag: "wx", mode: 0o600, }); fs.chmodSync(temporaryPath, 0o600); fs.renameSync(temporaryPath, envPath); fs.chmodSync(envPath, 0o600); } catch (error) { fs.rmSync(temporaryPath, { force: true }); throw error; } return envPath; } export function pullEnvironment(repoRoot, { env = process.env, homeDir = os.homedir(), commandRunner = runCommand } = {}) { const ageKeyFile = resolveAgeKeyFile({ env, homeDir }); const encryptedPath = path.join(repoRoot, "secrets", "dev.sops.env"); if (!fs.existsSync(encryptedPath)) { throw new Error(`Encrypted environment file is missing: ${encryptedPath}`); } const result = commandRunner("sops", ["-d", encryptedPath], { cwd: repoRoot, env: { ...env, SOPS_AGE_KEY_FILE: ageKeyFile }, }); return writeEnvAtomically(repoRoot, result.stdout); } export function editEnvironment(repoRoot, { env = process.env, homeDir = os.homedir(), commandRunner = runCommand } = {}) { const ageKeyFile = resolveAgeKeyFile({ env, homeDir }); const encryptedPath = path.join(repoRoot, "secrets", "dev.sops.env"); if (!fs.existsSync(encryptedPath)) { throw new Error(`Encrypted environment file is missing: ${encryptedPath}`); } commandRunner("sops", [encryptedPath], { cwd: repoRoot, env: { ...env, SOPS_AGE_KEY_FILE: ageKeyFile }, inherit: true, }); } export function normalMergeContainsHead(mergeLog, featureHead) { return mergeLog .split(/\r?\n/) .filter(Boolean) .some((line) => { const [, ...parents] = line.trim().split(/\s+/); return parents.slice(1).includes(featureHead); }); } export function assertCleanupEligibility({ branch, status, localHead, remoteHead = null, isAncestor, hasNormalMergeCommit }) { validateSessionBranch(branch); if (status.trim()) { throw new Error(`Worktree for ${branch} is not clean; cleanup refused.`); } if (!localHead) { throw new Error(`Local branch ${branch} has no resolvable head.`); } if (!isAncestor) { throw new Error( `${branch} is not an ancestor of origin/main. The PR may be unmerged or squashed; preserve the branch and repair history with a normal merge commit.` ); } if (!hasNormalMergeCommit) { throw new Error(`${branch} is not attached to main by a normal merge commit. Squash, rebase-only, and fast-forward cleanup are refused.`); } if (remoteHead && remoteHead !== localHead) { throw new Error(`Remote branch ${branch} moved after the local checkout; cleanup refused.`); } } function repoContext(cwd = process.cwd()) { const repoRoot = gitOutput(cwd, ["rev-parse", "--show-toplevel"]); const worktrees = parseWorktreeList(gitOutput(repoRoot, ["worktree", "list", "--porcelain"])); const primaryWorktree = worktrees[0]?.path; if (!primaryWorktree) { throw new Error("Could not resolve the primary Git worktree."); } return { repoRoot, worktrees, primaryWorktree }; } function assertBranchNameWithGit(repoRoot, branch) { validateSessionBranch(branch); git(repoRoot, ["check-ref-format", "--branch", branch]); } function localBranchExists(repoRoot, branch) { const result = git(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowedExitCodes: [0, 1], }); return result.status === 0; } function remoteBranchHead(repoRoot, branch) { const result = git(repoRoot, ["ls-remote", "--exit-code", "--heads", "origin", `refs/heads/${branch}`], { allowedExitCodes: [0, 2], }); if (result.status === 2 || !result.stdout.trim()) { return null; } return result.stdout.trim().split(/\s+/)[0]; } function rollbackStartedSession(primaryWorktree, worktreePath, branch) { const failures = []; if (fs.existsSync(worktreePath)) { try { git(primaryWorktree, ["worktree", "remove", worktreePath]); } catch (error) { failures.push(error.message); } } if (localBranchExists(primaryWorktree, branch)) { try { git(primaryWorktree, ["branch", "-d", branch]); } catch (error) { failures.push(error.message); } } if (failures.length) { throw new Error(`Session start failed and rollback was incomplete:\n${failures.join("\n")}`); } } export function startSession({ branch, requestedPath, cwd = process.cwd() }) { if (requestedPath && !path.isAbsolute(requestedPath)) { throw new Error("--path must be an absolute path."); } const { repoRoot, primaryWorktree } = repoContext(cwd); assertBranchNameWithGit(repoRoot, branch); git(repoRoot, ["fetch", "origin", MAIN_REMOTE_SOURCE], { inherit: true }); if (localBranchExists(repoRoot, branch)) { throw new Error(`Local branch already exists: ${branch}`); } if (remoteBranchHead(repoRoot, branch)) { throw new Error(`Remote branch already exists: ${branch}`); } const worktreePath = path.resolve(requestedPath ?? defaultWorktreePath(primaryWorktree, branch)); if (fs.existsSync(worktreePath)) { throw new Error(`Worktree path already exists: ${worktreePath}`); } git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "origin/main"], { inherit: true }); try { pullEnvironment(worktreePath); runCommand("pnpm", ["install", "--frozen-lockfile"], { cwd: worktreePath, inherit: true, }); } catch (error) { rollbackStartedSession(primaryWorktree, worktreePath, branch); throw error; } return { branch, worktreePath }; } function parseOriginRemote(remoteUrl) { let parsed; try { parsed = new URL(remoteUrl); } catch { throw new Error("origin must use an HTTPS URL for authenticated Gitea cleanup."); } if (parsed.protocol !== "https:") { throw new Error("origin must use HTTPS for authenticated Gitea cleanup."); } const segments = parsed.pathname .replace(/\.git$/, "") .split("/") .filter(Boolean); if (segments.length !== 2) { throw new Error(`Could not derive Gitea owner/repository from origin: ${remoteUrl}`); } return { apiBase: `${parsed.origin}/api/v1`, owner: segments[0], repository: segments[1], }; } function readGiteaToken(expectedUser, homeDir = os.homedir()) { const tokenPath = path.join(homeDir, ".cli-tokens", `gitea-${expectedUser}`); const stat = fs.statSync(tokenPath); if ((stat.mode & 0o077) !== 0) { throw new Error(`${tokenPath} must have mode 600.`); } const raw = fs.readFileSync(tokenPath, "utf8").trim(); const token = raw.includes("=") ? raw.slice(raw.indexOf("=") + 1).trim() : raw; if (!token) { throw new Error(`Gitea token is empty: ${tokenPath}`); } return token; } async function giteaRequest(url, token, options = {}) { const response = await fetch(url, { ...options, headers: { Accept: "application/json", Authorization: `token ${token}`, ...options.headers, }, }); return response; } async function assertGiteaIdentity(apiBase, token, expectedUser) { const response = await giteaRequest(`${apiBase}/user`, token); if (!response.ok) { throw new Error(`Gitea identity lookup failed with HTTP ${response.status}.`); } const user = await response.json(); if (user.login !== expectedUser || user.active !== true) { throw new Error(`Gitea identity mismatch: expected active ${expectedUser}.`); } } async function assertNoOpenPullRequest(apiBase, owner, repository, token, branch) { for (let page = 1; page <= 20; page += 1) { const response = await giteaRequest( `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls?state=open&limit=50&page=${page}`, token ); if (!response.ok) { throw new Error(`Gitea pull-request lookup failed with HTTP ${response.status}.`); } const pulls = await response.json(); if (pulls.some((pull) => pull.head?.ref === branch)) { throw new Error(`Branch ${branch} still has an open pull request; cleanup refused.`); } if (pulls.length < 50) { return; } } throw new Error("Gitea pull-request lookup exceeded the safe pagination limit."); } async function deleteRemoteBranch({ repoRoot, branch, expectedHead }) { const expectedUser = gitOutput(repoRoot, ["config", "--local", "--get", "hivegrid.giteaUser"]); if (!expectedUser) { throw new Error("git config --local hivegrid.giteaUser is required."); } const token = readGiteaToken(expectedUser); const remote = parseOriginRemote(gitOutput(repoRoot, ["remote", "get-url", "origin"])); await assertGiteaIdentity(remote.apiBase, token, expectedUser); await assertNoOpenPullRequest(remote.apiBase, remote.owner, remote.repository, token, branch); const branchUrl = `${remote.apiBase}/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repository)}` + `/branches/${encodeURIComponent(branch)}`; const branchResponse = await giteaRequest(branchUrl, token); if (!branchResponse.ok) { throw new Error(`Gitea branch lookup failed with HTTP ${branchResponse.status}.`); } const remoteBranch = await branchResponse.json(); if (remoteBranch.commit?.id !== expectedHead) { throw new Error(`Remote branch ${branch} moved during cleanup; deletion refused.`); } const deleteResponse = await giteaRequest(branchUrl, token, { method: "DELETE" }); if (!deleteResponse.ok) { throw new Error(`Gitea branch deletion failed with HTTP ${deleteResponse.status}.`); } } export async function finishSession({ branch, cwd = process.cwd() }) { const { repoRoot, worktrees, primaryWorktree } = repoContext(cwd); assertBranchNameWithGit(repoRoot, branch); git(repoRoot, ["fetch", "origin", MAIN_REMOTE_SOURCE], { inherit: true }); const sessionWorktrees = worktrees.filter((worktree) => worktree.branch === branch); if (sessionWorktrees.length !== 1) { throw new Error(`Expected exactly one worktree for ${branch}, found ${sessionWorktrees.length}.`); } const sessionWorktree = sessionWorktrees[0]; const status = gitOutput(sessionWorktree.path, ["status", "--porcelain", "--untracked-files=all"]); const localHead = gitOutput(repoRoot, ["rev-parse", `refs/heads/${branch}`]); const remoteHead = remoteBranchHead(repoRoot, branch); const ancestorResult = git(repoRoot, ["merge-base", "--is-ancestor", localHead, "origin/main"], { allowedExitCodes: [0, 1], }); const mergeLog = gitOutput(repoRoot, ["log", "--format=%H %P", "--merges", "origin/main"]); assertCleanupEligibility({ branch, status, localHead, remoteHead, isAncestor: ancestorResult.status === 0, hasNormalMergeCommit: normalMergeContainsHead(mergeLog, localHead), }); if (remoteHead) { await deleteRemoteBranch({ repoRoot, branch, expectedHead: remoteHead }); if (remoteBranchHead(repoRoot, branch)) { throw new Error(`Remote branch still exists after deletion: ${branch}`); } } git(primaryWorktree, ["worktree", "remove", sessionWorktree.path]); git(primaryWorktree, ["update-ref", "-d", `refs/heads/${branch}`, localHead]); if (remoteHead) { const trackingRef = `refs/remotes/origin/${branch}`; const trackingResult = git(primaryWorktree, ["show-ref", "--verify", "--hash", trackingRef], { allowedExitCodes: [0, 1], }); if (trackingResult.status === 0) { git(primaryWorktree, ["update-ref", "-d", trackingRef, trackingResult.stdout.trim()]); } } if (fs.existsSync(sessionWorktree.path) || localBranchExists(primaryWorktree, branch)) { throw new Error(`Local cleanup verification failed for ${branch}.`); } return { branch, removedWorktree: sessionWorktree.path, removedRemoteBranch: Boolean(remoteHead), }; } function parseCli(argv) { const normalizedArgv = argv[1] === "--" ? [argv[0], ...argv.slice(2)] : argv; const [command, branch, ...rest] = normalizedArgv; if (command === "env-pull" || command === "env-edit") { if (branch || rest.length) { throw new Error(`Usage: pnpm ${command.replace("-", ":")}`); } return { command }; } if (command === "start") { let requestedPath; for (let index = 0; index < rest.length; index += 1) { if (rest[index] !== "--path" || !rest[index + 1] || requestedPath) { throw new Error("Usage: pnpm session:start -- [--path ]"); } requestedPath = rest[index + 1]; index += 1; } return { command, branch: validateSessionBranch(branch), requestedPath }; } if (command === "finish") { if (rest.length) { throw new Error("Usage: pnpm session:finish -- "); } return { command, branch: validateSessionBranch(branch) }; } throw new Error("Usage: node scripts/worktree-session.mjs [branch] [--path ]"); } export async function main(argv = process.argv.slice(2)) { const options = parseCli(argv); if (options.command === "env-pull") { const { repoRoot } = repoContext(); const envPath = pullEnvironment(repoRoot); console.log(`Wrote ${envPath} atomically with mode 600.`); return; } if (options.command === "env-edit") { const { repoRoot } = repoContext(); editEnvironment(repoRoot); return; } if (options.command === "start") { const result = startSession(options); console.log(`Session worktree ready: ${result.worktreePath}`); console.log(`Session branch: ${result.branch}`); console.log(`Next: cd ${JSON.stringify(result.worktreePath)}`); return; } const result = await finishSession(options); console.log(`Removed worktree: ${result.removedWorktree}`); console.log(`Removed local branch: ${result.branch}`); console.log(result.removedRemoteBranch ? `Removed remote branch: ${result.branch}` : `Remote branch was already absent: ${result.branch}`); } const isDirectExecution = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectExecution) { main().catch((error) => { console.error(`Session lifecycle failed: ${error.message}`); process.exit(1); }); }