Row 24. A writable root that is a git work tree may carry a git object in the binding; the seat then has git_status, git_commit (explicit paths, seat author, Requested-by trailer from the envelope requester, push at once per D6), git_pull (ff-only) and git_push (one branch, never force), plus reserve_id and per-write clone locks under protocol vault. Git children run with no host config and one credential helper, bin/git-credential.mjs, reading the 0600 seat token file named in the binding; the fleet helper serves only the Gitea hosts. Suite 58/58, node 143. rev-code-02 APPROVED round 1 (#1509 comment 26375, tree 82ab962f). Co-Authored-By: Claude Fable 5.1 <[email protected]>
385 lines
20 KiB
JavaScript
385 lines
20 KiB
JavaScript
// Git for the Discord Sage (row 24, Jason's word 2026-09-16: "Sage will need
|
|
// to have git tooling to commit, push, pull, etc. for the shared-signals
|
|
// repo"). Four fixed verbs plus one record helper, each a child process
|
|
// with a fixed argument list, run only in a root that is marked writable
|
|
// and carries a `git` key. No verb takes free-form arguments and nothing
|
|
// here goes through a shell.
|
|
//
|
|
// git_status(root) branch, ahead/behind, changed paths
|
|
// git_commit(root, message, paths) stage exactly those paths, commit as
|
|
// the seat, push at once (D6)
|
|
// git_pull(root) pull --ff-only origin <branch>
|
|
// git_push(root) push origin <branch>, never --force
|
|
// reserve_id(root, prefix, title) vault protocol only: the next free
|
|
// record id, appended to the registry
|
|
//
|
|
// Fences shared by the verbs, decided here and tested without a remote:
|
|
// - the current branch must be the one the binding names; a detached
|
|
// head, another branch, a merge, rebase or cherry-pick in progress,
|
|
// or a conflicted path refuses every verb
|
|
// - a commit refuses an index that already holds staged changes (Jason's
|
|
// own work in the same clone is never swept in), a message that is
|
|
// empty or over COMMIT_MESSAGE_MAX characters, a path that is not a
|
|
// vetted regular file under the root, and an empty result
|
|
// - the commit author is the seat (`git.author`), with a trailer naming
|
|
// the Discord requester by the name the binding gives, never an id
|
|
// - a child runs at most GIT_TIMEOUT_MS; its output is cut at
|
|
// GIT_OUTPUT_CAP and masked before it reaches the model or a record
|
|
// - children see an allowlisted environment: no global or system git
|
|
// config (so the host's own credential helpers never run), terminal
|
|
// prompts off, one credential helper (bin/git-credential.mjs) that
|
|
// reads the seat's token file only during a push or pull, and the
|
|
// seat's name for the vault lock tool
|
|
//
|
|
// The token is never read here, never printed, never on a command line:
|
|
// the helper receives the file's path in its environment and hands the
|
|
// value to git on the credential protocol's stdout, nowhere else.
|
|
//
|
|
// The `vault` protocol is the shared-signals record protocol
|
|
// (tools/vault_lock.py, tools/validate_vault.py, docs/ID-REGISTRY.txt in
|
|
// that repository): a commit first checks that no other owner locks a
|
|
// staged path and that the validator passes; writes take the clone lock
|
|
// around the replace; reserve_id appends the next free id. Those scripts
|
|
// belong to that repository; this file calls them as fixed argv inside
|
|
// the root and nowhere else.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, lstatSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]);
|
|
export const RESERVE_TOOL_NAME = "reserve_id";
|
|
export const GIT_PROTOCOLS = Object.freeze(["vault"]);
|
|
export const GIT_TIMEOUT_MS = 60_000;
|
|
export const GIT_OUTPUT_CAP = 4096;
|
|
export const COMMIT_MESSAGE_MAX = 500;
|
|
export const COMMIT_PATHS_MAX = 50;
|
|
export const STATUS_PATHS_MAX = 100;
|
|
export const GIT_TOKEN_FILE_ENV = "MOSAIC_DISCORD_GIT_TOKEN_FILE";
|
|
export const GIT_USERNAME_ENV = "MOSAIC_DISCORD_GIT_USERNAME";
|
|
export const CREDENTIAL_HELPER = fileURLToPath(new URL("../bin/git-credential.mjs", import.meta.url));
|
|
export const VAULT_PREFIXES = Object.freeze(["BUS", "PRJ", "SS", "DEC", "REF"]);
|
|
export const VAULT_REGISTRY = "docs/ID-REGISTRY.txt";
|
|
export const VAULT_LOCK_TOOL = "tools/vault_lock.py";
|
|
export const VAULT_VALIDATOR = "tools/validate_vault.py";
|
|
export const RESERVE_TITLE_MAX = 200;
|
|
|
|
export const GIT_REFUSAL = Object.freeze({
|
|
NO_GIT: "that root has no git",
|
|
DETACHED: "the work tree is on a detached head; Jason resolves it from the terminal",
|
|
BRANCH: "the work tree is not on the branch the binding names; Jason resolves it from the terminal",
|
|
IN_PROGRESS: "a merge, rebase or cherry-pick is in progress; Jason resolves it from the terminal",
|
|
CONFLICT: "the work tree has conflicted paths; Jason resolves them from the terminal",
|
|
INDEX_DIRTY: "the index already holds staged changes that are not yours; Jason resolves them from the terminal",
|
|
BAD_MESSAGE: `message must be one to ${COMMIT_MESSAGE_MAX} characters of plain text`,
|
|
BAD_PATHS: `paths must name one to ${COMMIT_PATHS_MAX} files under the root`,
|
|
NO_REQUESTER: "the requester of this message is unknown; the commit is refused",
|
|
LOCKED: "a staged path is locked by another contributor",
|
|
INVALID: "the record validator failed; fix the records before committing",
|
|
NOTHING: "nothing to commit: those paths have no changes",
|
|
COMMIT_FAILED: "git refused the commit",
|
|
NON_FF: "origin has moved in a way that is not a fast-forward; Jason reconciles from the terminal",
|
|
DIRTY: "local changes would be overwritten by the pull; commit or ask Jason first",
|
|
PULL_FAILED: "git could not pull",
|
|
PUSH_FAILED: "git could not push",
|
|
BAD_PREFIX: `prefix must be one of ${VAULT_PREFIXES.join(", ")}`,
|
|
BAD_TITLE: `title must be one to ${RESERVE_TITLE_MAX} characters on one line`,
|
|
RESERVE_FAILED: "the id could not be reserved",
|
|
NO_PROTOCOL: "that root has no record protocol; ids are not reserved there",
|
|
TIMEOUT: "git took too long and was stopped",
|
|
});
|
|
|
|
export class GitRefusal extends Error {
|
|
constructor(reason, detail = null) {
|
|
super(detail ? `${reason}: ${detail}` : reason);
|
|
this.reason = reason;
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
|
|
const IDENTITY = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/;
|
|
|
|
// Validate a root's `git` key. `real` is the root's real path; it must be a
|
|
// git work tree (a .git directory or file). The token file must be a
|
|
// private file now so a bad binding fails the start, not the first push.
|
|
export function loadGitConfig(raw, where, real) {
|
|
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
|
for (const k of Object.keys(raw)) {
|
|
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
|
}
|
|
if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`);
|
|
if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`);
|
|
if (typeof raw.tokenFile !== "string" || !raw.tokenFile.startsWith("/") || raw.tokenFile.includes("\0") || raw.tokenFile.includes("'")) throw new Error(`${where}.tokenFile: must be an absolute path`);
|
|
if (typeof raw.author !== "string" || !AUTHOR.test(raw.author)) throw new Error(`${where}.author: must be "Name <email>"`);
|
|
const [, name, email] = raw.author.match(AUTHOR);
|
|
if (raw.protocol !== undefined && !GIT_PROTOCOLS.includes(raw.protocol)) throw new Error(`${where}.protocol: must be one of ${GIT_PROTOCOLS.join(", ")}`);
|
|
checkPrivateFile(raw.tokenFile, `${where}.tokenFile`);
|
|
if (typeof real === "string") {
|
|
let st = null;
|
|
try {
|
|
st = lstatSync(join(real, ".git"));
|
|
} catch {
|
|
// handled below
|
|
}
|
|
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`);
|
|
}
|
|
if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`);
|
|
return Object.freeze({
|
|
branch: raw.branch, identity: raw.identity, tokenFile: raw.tokenFile,
|
|
author: Object.freeze({ name: name.trim(), email }), protocol: raw.protocol ?? null,
|
|
});
|
|
}
|
|
|
|
function checkPrivateFile(path, what) {
|
|
let st;
|
|
try {
|
|
st = lstatSync(path);
|
|
} catch {
|
|
throw new Error(`${what}: not found: ${path}`);
|
|
}
|
|
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
|
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
|
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
|
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
|
}
|
|
|
|
// The environment every child sees. No global or system git configuration,
|
|
// so the host's own helpers (gh, the fleet helper) never run for the seat;
|
|
// one helper of our own, named with the node binary that runs the
|
|
// connector; the seat's author identity; the seat's name for the lock
|
|
// tool. The token file's path is added only for the verbs that talk to
|
|
// origin.
|
|
export function gitEnv(git, { remote = false } = {}) {
|
|
const config = [
|
|
["credential.helper", `!'${process.execPath}' '${CREDENTIAL_HELPER}'`],
|
|
["user.name", git.author.name],
|
|
["user.email", git.author.email],
|
|
["core.askPass", ""],
|
|
["push.default", "nothing"],
|
|
];
|
|
const env = {
|
|
PATH: process.env.PATH || "/usr/bin:/bin",
|
|
HOME: process.env.HOME || "/nonexistent",
|
|
LANG: "C.UTF-8",
|
|
LC_ALL: "C.UTF-8",
|
|
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_CONFIG_COUNT: String(config.length),
|
|
MOSAIC_AGENT_NAME: git.identity,
|
|
VAULT_LOCK_OWNER: git.identity,
|
|
[GIT_USERNAME_ENV]: git.identity,
|
|
};
|
|
config.forEach(([k, v], i) => {
|
|
env[`GIT_CONFIG_KEY_${i}`] = k;
|
|
env[`GIT_CONFIG_VALUE_${i}`] = v;
|
|
});
|
|
if (remote) env[GIT_TOKEN_FILE_ENV] = git.tokenFile;
|
|
return env;
|
|
}
|
|
|
|
// Anything that could carry a credential is masked before it goes further:
|
|
// a userinfo part in a url, and token-shaped words.
|
|
export function maskSecrets(text) {
|
|
return String(text ?? "")
|
|
.replace(/(https?:\/\/)[^/\s@]*@/g, "$1<masked>@")
|
|
.replace(/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, "<masked>")
|
|
.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "<masked>");
|
|
}
|
|
|
|
function cap(text) {
|
|
const s = maskSecrets(text).replace(/\0/g, "");
|
|
return s.length > GIT_OUTPUT_CAP ? `${s.slice(0, GIT_OUTPUT_CAP)}\n… cut at ${GIT_OUTPUT_CAP} characters` : s;
|
|
}
|
|
|
|
// Run one child with a fixed argv. Returns {status, stdout, stderr}; a
|
|
// timeout or spawn failure is a refusal, never an exception.
|
|
function run(cmd, args, cwd, env, spawn) {
|
|
const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`);
|
|
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`);
|
|
return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") };
|
|
}
|
|
|
|
function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) {
|
|
return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn);
|
|
}
|
|
|
|
function firstLines(text, n = 5) {
|
|
return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n");
|
|
}
|
|
|
|
// The guard every verb runs first: on the named branch, nothing in
|
|
// progress, no conflicts.
|
|
function guard(root, gitCfg, deps) {
|
|
const head = git(root, gitCfg, ["symbolic-ref", "--short", "-q", "HEAD"], deps);
|
|
if (head.status !== 0) throw new GitRefusal(GIT_REFUSAL.DETACHED);
|
|
const branch = head.stdout.trim();
|
|
if (branch !== gitCfg.branch) throw new GitRefusal(GIT_REFUSAL.BRANCH, `on ${branch}, binding names ${gitCfg.branch}`);
|
|
const dir = git(root, gitCfg, ["rev-parse", "--absolute-git-dir"], deps);
|
|
if (dir.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(dir.stderr, 1));
|
|
const gitDir = dir.stdout.trim();
|
|
for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"]) {
|
|
if (existsSync(join(gitDir, marker))) throw new GitRefusal(GIT_REFUSAL.IN_PROGRESS, marker);
|
|
}
|
|
const conflicts = git(root, gitCfg, ["diff", "--name-only", "--diff-filter=U"], deps);
|
|
if (conflicts.status === 0 && conflicts.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.CONFLICT, firstLines(conflicts.stdout, 3));
|
|
return branch;
|
|
}
|
|
|
|
// Parse `git status --porcelain=v2 --branch` into plain data.
|
|
export function parseStatus(text) {
|
|
const out = { branch: null, upstream: null, ahead: null, behind: null, changed: [], untracked: [], conflicts: [], truncated: false };
|
|
let count = 0;
|
|
const push = (list, item) => {
|
|
if (count >= STATUS_PATHS_MAX) {
|
|
out.truncated = true;
|
|
return;
|
|
}
|
|
list.push(item);
|
|
count += 1;
|
|
};
|
|
for (const line of text.split("\n")) {
|
|
if (line.startsWith("# branch.head ")) out.branch = line.slice(14).trim();
|
|
else if (line.startsWith("# branch.upstream ")) out.upstream = line.slice(18).trim();
|
|
else if (line.startsWith("# branch.ab ")) {
|
|
const m = line.match(/\+(\d+) -(\d+)/);
|
|
if (m) {
|
|
out.ahead = Number(m[1]);
|
|
out.behind = Number(m[2]);
|
|
}
|
|
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
const f = line.split(" ");
|
|
const path = line.startsWith("2 ") ? f.slice(9).join(" ").split("\t")[0] : f.slice(8).join(" ");
|
|
push(out.changed, { path, state: f[1] });
|
|
} else if (line.startsWith("? ")) push(out.untracked, line.slice(2));
|
|
else if (line.startsWith("u ")) push(out.conflicts, line.split(" ").slice(10).join(" "));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function gitStatus(root, deps = {}) {
|
|
const gitCfg = root.git;
|
|
guard(root, gitCfg, deps);
|
|
const r = git(root, gitCfg, ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], deps);
|
|
if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(r.stderr, 1));
|
|
return { root: root.name, ...parseStatus(r.stdout) };
|
|
}
|
|
|
|
// The vault protocol's pre-commit checks, run as fixed argv inside the root.
|
|
function vaultChecks(root, gitCfg, rels, deps) {
|
|
const env = gitEnv(gitCfg);
|
|
const check = run("python3", [VAULT_LOCK_TOOL, "check", "--owner", gitCfg.identity, "--", ...rels], root.real, env, deps.spawn || spawnSync);
|
|
if (check.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(check.stderr || check.stdout, 3));
|
|
const validate = run("python3", [VAULT_VALIDATOR], root.real, env, deps.spawn || spawnSync);
|
|
if (validate.status !== 0) throw new GitRefusal(GIT_REFUSAL.INVALID, firstLines(validate.stderr || validate.stdout, 5));
|
|
}
|
|
|
|
// Stage exactly `rels` (already vetted by the caller as regular files under
|
|
// the root), commit as the seat with the requester trailer, then push. A
|
|
// push that fails is reported in the result, not thrown: the commit is
|
|
// real and the next commit's push carries it (D6).
|
|
export function gitCommit(root, { message, rels, requester }, deps = {}) {
|
|
const gitCfg = root.git;
|
|
if (typeof message !== "string" || message.includes("\0") || message.trim().length === 0 || message.length > COMMIT_MESSAGE_MAX) throw new GitRefusal(GIT_REFUSAL.BAD_MESSAGE);
|
|
if (!Array.isArray(rels) || rels.length === 0 || rels.length > COMMIT_PATHS_MAX || rels.some((p) => typeof p !== "string" || p.length === 0 || p.startsWith("-"))) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
|
|
if (typeof requester !== "string" || !/^[^\r\n:<>]{1,100}$/.test(requester)) throw new GitRefusal(GIT_REFUSAL.NO_REQUESTER);
|
|
const branch = guard(root, gitCfg, deps);
|
|
const staged = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps);
|
|
if (staged.status !== 0) throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(staged.stderr, 1));
|
|
if (staged.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.INDEX_DIRTY, firstLines(staged.stdout, 3));
|
|
if (gitCfg.protocol === "vault") vaultChecks(root, gitCfg, rels, deps);
|
|
const add = git(root, gitCfg, ["add", "--", ...rels], deps);
|
|
if (add.status !== 0) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS, firstLines(add.stderr, 2));
|
|
const unstage = () => git(root, gitCfg, ["reset", "-q", "--", ...rels], deps);
|
|
const now = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps);
|
|
if (now.stdout.trim().length === 0) {
|
|
unstage();
|
|
throw new GitRefusal(GIT_REFUSAL.NOTHING);
|
|
}
|
|
const commit = git(root, gitCfg, ["commit", "-q", "--no-status", "-m", message.trim(), "--trailer", `Requested-by: ${requester.trim()}`], deps);
|
|
if (commit.status !== 0) {
|
|
unstage();
|
|
throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(commit.stderr || commit.stdout, 5));
|
|
}
|
|
const rev = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps);
|
|
const hash = rev.status === 0 ? rev.stdout.trim() : null;
|
|
const committed = now.stdout.trim().split("\n");
|
|
let pushed = false;
|
|
let pushError = null;
|
|
try {
|
|
pushOnce(root, gitCfg, branch, deps);
|
|
pushed = true;
|
|
} catch (err) {
|
|
if (!(err instanceof GitRefusal)) throw err;
|
|
pushError = err.message;
|
|
}
|
|
return { root: root.name, branch, hash, paths: committed, requester: requester.trim(), pushed, pushError };
|
|
}
|
|
|
|
function pushOnce(root, gitCfg, branch, deps) {
|
|
const r = git(root, gitCfg, ["push", "origin", `${branch}:${branch}`], { ...deps, remote: true });
|
|
if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.PUSH_FAILED, firstLines(r.stderr || r.stdout, 3));
|
|
return /Everything up-to-date/.test(r.stderr) ? { upToDate: true } : { upToDate: false };
|
|
}
|
|
|
|
export function gitPush(root, deps = {}) {
|
|
const gitCfg = root.git;
|
|
const branch = guard(root, gitCfg, deps);
|
|
const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
|
|
const r = pushOnce(root, gitCfg, branch, deps);
|
|
return { root: root.name, branch, hash: before, pushed: true, upToDate: r.upToDate };
|
|
}
|
|
|
|
export function gitPull(root, deps = {}) {
|
|
const gitCfg = root.git;
|
|
const branch = guard(root, gitCfg, deps);
|
|
const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
|
|
const r = git(root, gitCfg, ["pull", "--ff-only", "--no-rebase", "origin", branch], { ...deps, remote: true });
|
|
if (r.status !== 0) {
|
|
const text = `${r.stderr}\n${r.stdout}`;
|
|
if (/fast-forward|diverg|Not possible/i.test(text)) throw new GitRefusal(GIT_REFUSAL.NON_FF, firstLines(r.stderr || r.stdout, 2));
|
|
if (/would be overwritten|local changes/i.test(text)) throw new GitRefusal(GIT_REFUSAL.DIRTY, firstLines(r.stderr || r.stdout, 3));
|
|
throw new GitRefusal(GIT_REFUSAL.PULL_FAILED, firstLines(r.stderr || r.stdout, 3));
|
|
}
|
|
const after = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim();
|
|
return { root: root.name, branch, from: before, to: after, updated: before !== after };
|
|
}
|
|
|
|
// vault protocol: reserve the next free id for a prefix. The tool appends
|
|
// the registry line itself; the caller stages `docs/ID-REGISTRY.txt` with
|
|
// the record in the next commit.
|
|
export function reserveId(root, { prefix, title }, deps = {}) {
|
|
const gitCfg = root.git;
|
|
if (gitCfg.protocol !== "vault") throw new GitRefusal(GIT_REFUSAL.NO_PROTOCOL);
|
|
if (typeof prefix !== "string" || !VAULT_PREFIXES.includes(prefix)) throw new GitRefusal(GIT_REFUSAL.BAD_PREFIX);
|
|
if (typeof title !== "string" || title.trim().length === 0 || title.length > RESERVE_TITLE_MAX || /[\r\n\0]/.test(title) || title.startsWith("-")) throw new GitRefusal(GIT_REFUSAL.BAD_TITLE);
|
|
guard(root, gitCfg, deps);
|
|
const r = run("python3", [VAULT_LOCK_TOOL, "reserve", prefix, "--owner", gitCfg.identity, "--title", title.trim()], root.real, gitEnv(gitCfg, { remote: true }), deps.spawn || spawnSync);
|
|
const id = r.stdout.trim().split("\n").pop() || "";
|
|
if (r.status !== 0 || !/^[A-Z]{2,3}-\d{3,}$/.test(id)) throw new GitRefusal(GIT_REFUSAL.RESERVE_FAILED, firstLines(r.stderr || r.stdout, 3));
|
|
const note = firstLines(r.stderr, 1) || null;
|
|
return { root: root.name, id, registry: VAULT_REGISTRY, note };
|
|
}
|
|
|
|
// vault protocol: hold the clone lock for `rel` while `fn` runs. A lock
|
|
// another owner holds refuses with that owner named; the lock is released
|
|
// whatever `fn` does.
|
|
export function withVaultLock(root, rel, fn, deps = {}) {
|
|
const gitCfg = root.git;
|
|
if (!gitCfg || gitCfg.protocol !== "vault") return fn();
|
|
const env = gitEnv(gitCfg);
|
|
const spawn = deps.spawn || spawnSync;
|
|
const lock = run("python3", [VAULT_LOCK_TOOL, "lock", "--owner", gitCfg.identity, "--ttl", "300", "--", rel], root.real, env, spawn);
|
|
if (lock.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(lock.stderr || lock.stdout, 2));
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
run("python3", [VAULT_LOCK_TOOL, "unlock", "--owner", gitCfg.identity, "--", rel], root.real, env, spawn);
|
|
}
|
|
}
|