Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/worktree-session.mjs
T

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