322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
import { parseArgs } from "node:util";
|
|
|
|
import manifest from "./subtrees.json";
|
|
|
|
interface Subtree {
|
|
readonly name: string;
|
|
readonly prefix: string;
|
|
readonly upstream: string;
|
|
readonly ref: string;
|
|
readonly squash: true;
|
|
readonly role: string;
|
|
readonly refreshPolicy: "allowed" | "pinned";
|
|
}
|
|
|
|
interface CommandResult {
|
|
readonly exitCode: number;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
const subtrees = manifest.subtrees as readonly Subtree[];
|
|
|
|
const run = async (
|
|
command: readonly string[],
|
|
options: { readonly inherit?: boolean } = {}
|
|
): Promise<CommandResult> => {
|
|
const process = Bun.spawn([...command], {
|
|
cwd,
|
|
stdin: options.inherit ? "inherit" : "ignore",
|
|
stdout: options.inherit ? "inherit" : "pipe",
|
|
stderr: options.inherit ? "inherit" : "pipe",
|
|
});
|
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
process.exited,
|
|
options.inherit ? Promise.resolve("") : new Response(process.stdout).text(),
|
|
options.inherit ? Promise.resolve("") : new Response(process.stderr).text(),
|
|
]);
|
|
return { exitCode, stdout, stderr };
|
|
};
|
|
|
|
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
const formatCommand = (command: readonly string[]) =>
|
|
command.map(shellQuote).join(" ");
|
|
|
|
const requireRepositoryRoot = async () => {
|
|
const result = await run(["git", "rev-parse", "--show-toplevel"]);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error("The current directory is not inside a Git checkout.");
|
|
}
|
|
if (result.stdout.trim() !== cwd) {
|
|
throw new Error(
|
|
`Run this script from the repository root: ${result.stdout.trim()}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const requireCleanWorktree = async () => {
|
|
const result = await run(["git", "status", "--porcelain"]);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(result.stderr.trim());
|
|
}
|
|
if (result.stdout.trim() !== "") {
|
|
throw new Error(
|
|
"Subtree mutations require a clean worktree. Commit or stash changes first."
|
|
);
|
|
}
|
|
};
|
|
|
|
const getSubtree = (name: string | undefined) => {
|
|
if (name === undefined) {
|
|
throw new Error(
|
|
`A subtree name is required. Available: ${subtrees.map((entry) => entry.name).join(", ")}`
|
|
);
|
|
}
|
|
const subtree = subtrees.find((entry) => entry.name === name);
|
|
if (subtree === undefined) {
|
|
throw new Error(
|
|
`Unknown subtree ${shellQuote(name)}. Available: ${subtrees.map((entry) => entry.name).join(", ")}`
|
|
);
|
|
}
|
|
if (
|
|
!subtree.prefix.startsWith("repos/") ||
|
|
!subtree.upstream.startsWith("https://")
|
|
) {
|
|
throw new Error(
|
|
`Unsafe subtree manifest entry ${shellQuote(subtree.name)}.`
|
|
);
|
|
}
|
|
return subtree;
|
|
};
|
|
|
|
const readSubtreeMetadata = async (subtree: Subtree) => {
|
|
const merge = await run([
|
|
"git",
|
|
"log",
|
|
"--all",
|
|
"-n",
|
|
"1",
|
|
"--format=%H%x09%ci%x09%s",
|
|
"--",
|
|
subtree.prefix,
|
|
]);
|
|
if (merge.exitCode !== 0) {
|
|
throw new Error(merge.stderr.trim());
|
|
}
|
|
const mergeLine = merge.stdout.trim();
|
|
if (mergeLine === "") {
|
|
return { mergeLine: "No subtree merge found", split: undefined };
|
|
}
|
|
const mergeSha = mergeLine.split("\t")[0];
|
|
if (mergeSha === undefined) {
|
|
return { mergeLine, split: undefined };
|
|
}
|
|
const squash = await run([
|
|
"git",
|
|
"show",
|
|
"--no-patch",
|
|
"--format=%b",
|
|
`${mergeSha}^2`,
|
|
]);
|
|
const split = squash.stdout
|
|
.split("\n")
|
|
.find((line) => line.startsWith("git-subtree-split: "))
|
|
?.slice("git-subtree-split: ".length);
|
|
return { mergeLine, split };
|
|
};
|
|
|
|
const readStatus = async (subtree: Subtree) => {
|
|
const [dirty, metadata] = await Promise.all([
|
|
run(["git", "status", "--porcelain", "--", subtree.prefix]),
|
|
readSubtreeMetadata(subtree),
|
|
]);
|
|
if (dirty.exitCode !== 0) {
|
|
throw new Error(dirty.stderr.trim());
|
|
}
|
|
return {
|
|
dirty: dirty.stdout.trim() !== "",
|
|
latest: metadata.mergeLine,
|
|
};
|
|
};
|
|
|
|
const updateCommand = (subtree: Subtree) => [
|
|
"git",
|
|
"subtree",
|
|
"pull",
|
|
`--prefix=${subtree.prefix}`,
|
|
subtree.upstream,
|
|
subtree.ref,
|
|
"--squash",
|
|
];
|
|
|
|
const addCommand = (subtree: Subtree) => [
|
|
"git",
|
|
"subtree",
|
|
"add",
|
|
`--prefix=${subtree.prefix}`,
|
|
subtree.upstream,
|
|
subtree.ref,
|
|
"--squash",
|
|
];
|
|
|
|
const { values, positionals } = parseArgs({
|
|
args: process.argv.slice(2),
|
|
options: {
|
|
"dry-run": { type: "boolean", default: false },
|
|
"force-pinned": { type: "boolean", default: false },
|
|
help: { type: "boolean", short: "h", default: false },
|
|
yes: { type: "boolean", short: "y", default: false },
|
|
},
|
|
allowPositionals: true,
|
|
strict: true,
|
|
});
|
|
|
|
const [operation = "list", name, ...extra] = positionals;
|
|
if (values.help || extra.length > 0) {
|
|
console.log(`Usage: bun run subtree [list|status|preview|add|update] [name] [options]
|
|
|
|
Commands:
|
|
list List configured vendored subtrees
|
|
status [name] Show local subtree metadata and dirtiness
|
|
preview <name> Fetch the configured upstream and show incoming commits
|
|
add <name> Add an absent manifest entry with git subtree add --squash
|
|
update <name> Pull a configured subtree with git subtree pull --squash
|
|
|
|
Options:
|
|
--dry-run Print mutating/fetch commands without executing them
|
|
--yes, -y Required for add and update
|
|
--force-pinned Allow updating a pinned historical subtree together with --yes`);
|
|
process.exit(extra.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
try {
|
|
await requireRepositoryRoot();
|
|
|
|
switch (operation) {
|
|
case "list": {
|
|
for (const subtree of subtrees) {
|
|
console.log(
|
|
`${subtree.name}\t${subtree.prefix}\t${subtree.upstream}#${subtree.ref}\t${subtree.refreshPolicy}`
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "status": {
|
|
const selected = name === undefined ? subtrees : [getSubtree(name)];
|
|
for (const subtree of selected) {
|
|
const status = await readStatus(subtree);
|
|
console.log(`${subtree.name}:`);
|
|
console.log(` prefix: ${subtree.prefix}`);
|
|
console.log(` upstream: ${subtree.upstream}#${subtree.ref}`);
|
|
console.log(` policy: ${subtree.refreshPolicy}`);
|
|
console.log(` prefix dirty: ${status.dirty ? "yes" : "no"}`);
|
|
console.log(` latest metadata: ${status.latest}`);
|
|
}
|
|
break;
|
|
}
|
|
case "preview": {
|
|
const subtree = getSubtree(name);
|
|
const fetch = [
|
|
"git",
|
|
"fetch",
|
|
"--no-tags",
|
|
subtree.upstream,
|
|
subtree.ref,
|
|
];
|
|
if (values["dry-run"]) {
|
|
console.log(formatCommand(fetch));
|
|
console.log(
|
|
formatCommand([
|
|
"git",
|
|
"log",
|
|
"--oneline",
|
|
"--decorate",
|
|
"<last-subtree-split>..FETCH_HEAD",
|
|
])
|
|
);
|
|
break;
|
|
}
|
|
const fetched = await run(fetch, { inherit: true });
|
|
if (fetched.exitCode !== 0) {
|
|
process.exit(fetched.exitCode);
|
|
}
|
|
const metadata = await readSubtreeMetadata(subtree);
|
|
if (metadata.split === undefined) {
|
|
throw new Error(
|
|
`No git-subtree-split metadata found for ${subtree.prefix}.`
|
|
);
|
|
}
|
|
const log = await run([
|
|
"git",
|
|
"log",
|
|
"--oneline",
|
|
"--decorate",
|
|
`${metadata.split}..FETCH_HEAD`,
|
|
]);
|
|
if (log.exitCode !== 0) {
|
|
throw new Error(log.stderr.trim());
|
|
}
|
|
console.log(log.stdout.trim() || `${subtree.name} is up to date.`);
|
|
break;
|
|
}
|
|
case "add":
|
|
case "update": {
|
|
const subtree = getSubtree(name);
|
|
const prefixType = await run([
|
|
"git",
|
|
"cat-file",
|
|
"-t",
|
|
`HEAD:${subtree.prefix}`,
|
|
]);
|
|
const prefixExists =
|
|
prefixType.exitCode === 0 && prefixType.stdout.trim() === "tree";
|
|
if (
|
|
operation === "update" &&
|
|
subtree.refreshPolicy === "pinned" &&
|
|
!values["force-pinned"]
|
|
) {
|
|
throw new Error(
|
|
`${subtree.name} is pinned historical reference material. Pass --force-pinned --yes only when historical synchronization is explicitly required.`
|
|
);
|
|
}
|
|
if (!values["dry-run"]) {
|
|
await requireCleanWorktree();
|
|
}
|
|
if (operation === "add" && prefixExists) {
|
|
throw new Error(
|
|
`Cannot add ${subtree.name}: ${subtree.prefix} already exists.`
|
|
);
|
|
}
|
|
if (operation === "update" && !prefixExists) {
|
|
throw new Error(
|
|
`Cannot update ${subtree.name}: ${subtree.prefix} does not exist.`
|
|
);
|
|
}
|
|
const command =
|
|
operation === "add" ? addCommand(subtree) : updateCommand(subtree);
|
|
console.log(formatCommand(command));
|
|
if (values["dry-run"]) {
|
|
break;
|
|
}
|
|
if (!values.yes) {
|
|
throw new Error(
|
|
`Refusing to ${operation} without --yes. Re-run after reviewing the command above.`
|
|
);
|
|
}
|
|
const result = await run(command, { inherit: true });
|
|
if (result.exitCode !== 0) {
|
|
process.exit(result.exitCode);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
throw new Error(
|
|
`Unknown command ${shellQuote(operation)}. Use list, status, preview, add, or update.`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|