519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
#!/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 -- <branch> [--path <absolute-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 -- <branch>");
|
|
}
|
|
return { command, branch: validateSessionBranch(branch) };
|
|
}
|
|
throw new Error("Usage: node scripts/worktree-session.mjs <env-pull|env-edit|start|finish> [branch] [--path <absolute-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);
|
|
});
|
|
}
|