Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .runseal/lib/std/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ function envOptions(
}

async function run(command: string, args: string[] = [], options: CommandOptions = {}) {
const code = await status(command, args, options);
if (code !== 0) {
Deno.exit(code);
}
}

async function status(command: string, args: string[] = [], options: CommandOptions = {}) {
const status = await new Deno.Command(command, {
args,
cwd: options.cwd,
Expand All @@ -52,9 +59,7 @@ async function run(command: string, args: string[] = [], options: CommandOptions
stdout: options.stdout ?? "inherit",
stderr: options.stderr ?? "inherit",
}).spawn().status;
if (!status.success) {
Deno.exit(status.code);
}
return status.code;
}

async function text(
Expand Down Expand Up @@ -120,6 +125,7 @@ async function exists(name: string): Promise<boolean> {

export const cmd = {
run,
status,
text,
input,
exists,
Expand Down
2 changes: 1 addition & 1 deletion .runseal/wrappers/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ await cmd.run("deno", [
".runseal/wrappers/cloudflare.ts",
".runseal/wrappers/guard.ts",
".runseal/wrappers/init.ts",
".runseal/wrappers/pr.ts",
".runseal/wrappers/land.ts",
".runseal/wrappers/release.ts",
]);

Expand Down
54 changes: 10 additions & 44 deletions .runseal/wrappers/init.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { booleanOption, helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts";
import { helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts";
import { cmd } from "@/lib/std/cmd.ts";
import { fs } from "@/lib/std/fs.ts";
import { io } from "@/lib/std/io.ts";
import { path } from "@/lib/std/path.ts";

const HOOKS_PATH = ".runseal/hooks";

function usage(): void {
io.print("Usage: runseal :init [--force]");
io.print("");
io.print("Validate the repository and install generated git hooks.");
io.print("Usage: runseal :init");
io.print("");
io.print("Options:");
io.print(" --force back up custom hooks before replacing them");
io.print("Validate the repository and install versioned git hooks.");
}

async function requireTool(name: string): Promise<void> {
Expand All @@ -25,40 +24,15 @@ async function requirePath(root: string, relPath: string): Promise<void> {
}
}

async function prepareHook(path: string): Promise<void> {
if (!(await fs.file.exists(path))) {
return;
}
const generated = await fs.file.containsAny(path, [
"runseal init hook",
"runseal bootstrap hook",
]);
if (generated) {
return;
}
if (!force) {
io.fail(
`init: ${path} already exists and was not generated by runseal init; rerun with --force to back it up and replace it`,
);
}
const backup = await fs.file.backup.numbered(path);
io.print(`backed up existing hook to ${backup}`);
}

const args = parseArgs(Deno.args, { boolean: ["force", "help", "h"] });
const args = parseArgs(Deno.args, { boolean: ["help", "h"] });
requireNoPositionals(args, "init", { allowHelp: true });
if (helpRequested(args)) {
usage();
Deno.exit(0);
}
const force = booleanOption(args, "force");

io.print("==> resolving repository");
const root = await cmd.text("git", ["rev-parse", "--show-toplevel"]);
const gitDir = await cmd.text("git", ["rev-parse", "--absolute-git-dir"]);
const hooksDir = path.join(gitDir, "hooks");
const preCommit = path.join(hooksDir, "pre-commit");
const commitMsg = path.join(hooksDir, "commit-msg");
io.print(`repository: ${root}`);

io.print("==> checking required tools");
Expand Down Expand Up @@ -107,7 +81,7 @@ for (
".runseal/wrappers/cloudflare.ts",
".runseal/wrappers/guard.ts",
".runseal/wrappers/init.ts",
".runseal/wrappers/pr.ts",
".runseal/wrappers/land.ts",
".runseal/wrappers/release.ts",
".github/workflows/guard.yml",
".github/workflows/release-beta.yml",
Expand All @@ -132,17 +106,9 @@ for (
io.print("ok: repository entrypoints");

io.print("==> installing git hooks");
await fs.dir.ensure(hooksDir, "700");

await prepareHook(preCommit);
await Deno.copyFile(".runseal/hooks/pre-commit", preCommit);
await fs.file.chmodIfUnix(preCommit, "755");
io.print(`installed ${preCommit}`);

await prepareHook(commitMsg);
await Deno.copyFile(".runseal/hooks/commit-msg", commitMsg);
await fs.file.chmodIfUnix(commitMsg, "755");
io.print(`installed ${commitMsg}`);
await cmd.run("git", ["config", "core.hooksPath", HOOKS_PATH], { cwd: root });
const current = await cmd.text("git", ["config", "--get", "core.hooksPath"], { cwd: root });
io.print(`core.hooksPath = ${current}`);

await cmd.run("deno", ["--version"], { stdout: "null" });
io.print("development environment ready");
214 changes: 214 additions & 0 deletions .runseal/wrappers/land.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
booleanOption,
helpRequested,
parseArgs as parseCliArgs,
requireNoPositionals,
stringOption,
} from "@/lib/cli.ts";
import { cmd } from "@/lib/std/cmd.ts";
import { io } from "@/lib/std/io.ts";
import { runseal } from "@/lib/std/runseal.ts";

type Options = {
base: string;
body: string;
dryRun: boolean;
deleteBranch: boolean;
};

function usage(): void {
io.print("Usage: runseal :land [options]");
io.print("");
io.print("Land the current clean topic branch on GitHub.");
io.print("The branch is pushed, a PR is created or reused, checks are watched,");
io.print("the PR is squash-merged, main is synced, and the topic branch is deleted.");
io.print("");
io.print("Options:");
io.print(" --base <branch> base branch (default: main)");
io.print(" --body <body> pull request body override");
io.print(" --dry-run print planned actions without changing git or GitHub");
io.print(" --no-delete keep the topic branch after merge");
}

function parseArgs(args: string[]): Options & { help: boolean } {
const parsed = parseCliArgs(args, {
string: ["base", "body"],
boolean: ["dry-run", "no-delete", "help", "h"],
});
requireNoPositionals(parsed, "land", { allowHelp: true });
return {
base: stringOption(parsed, "base", "main"),
body: stringOption(parsed, "body"),
dryRun: booleanOption(parsed, "dry-run"),
deleteBranch: !booleanOption(parsed, "no-delete"),
help: helpRequested(parsed),
};
}

const options = parseArgs([...Deno.args]);
if (options.help) {
usage();
Deno.exit(0);
}

await cmd.run("git", ["--version"], { stdout: "null" });
await cmd.run("gh", ["--version"], { stdout: "null" });

const branch = await currentBranch();
if (options.dryRun) {
await ensureLandable(options.base, branch, { fetch: false });
printPlan(options, branch);
Deno.exit(0);
}

await cmd.run("gh", ["auth", "status"], { stdout: "piped" });
await ensureLandable(options.base, branch, { fetch: true });
await cmd.run("git", ["push", "-u", "origin", branch]);

const prUrl = await findOrCreatePr(options, branch);
io.print(prUrl);
await watchChecks(prUrl);
await mergePr(prUrl, options.deleteBranch);
await cmd.run("git", ["checkout", options.base]);
await cmd.run("git", ["pull", "--ff-only", "origin", options.base]);
if (options.deleteBranch && await gitOk(["rev-parse", "--verify", `refs/heads/${branch}`])) {
await cmd.run("git", ["branch", "-D", branch]);
}

async function currentBranch(): Promise<string> {
const branch = await cmd.text("git", ["branch", "--show-current"]);
if (branch === "") {
io.fail("land: detached HEAD is not a landable topic branch");
}
return branch;
}

async function ensureLandable(
base: string,
branch: string,
options: { fetch: boolean },
): Promise<void> {
if (branch === base || branch === "main" || branch === "master") {
io.fail(`land: must run on a topic branch, not ${branch}`);
}
const dirty = await cmd.text("git", ["status", "--short"]);
if (dirty.trim() !== "") {
io.fail("land: working tree must be clean; commit or discard changes first");
}
if (options.fetch) {
await cmd.run("git", ["fetch", "origin", base]);
}
const remoteBase = `origin/${base}`;
if (!await gitOk(["rev-parse", "--verify", remoteBase])) {
io.fail(`land: missing ${remoteBase}; fetch or check the base branch name`);
}
if (!await gitOk(["merge-base", "--is-ancestor", remoteBase, "HEAD"])) {
io.fail(`land: current branch must contain latest ${remoteBase}; rebase onto ${base} first`);
}
const ahead = Number(await cmd.text("git", ["rev-list", "--count", `${remoteBase}..HEAD`]));
if (!Number.isFinite(ahead) || ahead <= 0) {
io.fail(`land: current branch has no commits ahead of ${remoteBase}`);
}
}

async function gitOk(args: string[]): Promise<boolean> {
return await cmd.status("git", args, {
stdin: "null",
stdout: "null",
stderr: "null",
}) === 0;
}

async function findOrCreatePr(options: Options, branch: string): Promise<string> {
const existing = await cmd.text("gh", [
"pr",
"list",
"--head",
branch,
"--base",
options.base,
"--state",
"open",
"--json",
"url",
"--jq",
'.[0].url // ""',
]);
if (existing !== "") {
return existing;
}

const args = ["pr", "create", "--base", options.base, "--head", branch];
if (options.body === "") {
args.push("--fill");
} else {
args.push("--title", await deriveTitle(options.base), "--body", options.body);
}
return await cmd.text("gh", args);
}

async function deriveTitle(base: string): Promise<string> {
const subjects = await cmd.text("git", [
"log",
"--reverse",
"--format=%s",
`origin/${base}..HEAD`,
]);
const first = subjects.split(/\r?\n/).find((line) => line.trim() !== "");
return first ?? "land branch";
}

async function watchChecks(prUrl: string): Promise<void> {
let checksSeen = false;
for (let attempt = 0; attempt < 12; attempt += 1) {
checksSeen = (await runseal.text(["@tool", "github", "pr", "checks", "probe", prUrl])) ===
"true";
if (checksSeen) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (!checksSeen) {
io.print(`no checks reported on ${prUrl}; skipping watch`);
return;
}
let lastCode = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
lastCode = await cmd.status("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]);
if (lastCode === 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (lastCode !== 0) {
io.print(`checks watch exited with ${lastCode}; continuing to merge`);
}
}

async function mergePr(prUrl: string, deleteBranch: boolean): Promise<void> {
const args = ["pr", "merge", prUrl, "--squash"];
if (deleteBranch) {
args.push("--delete-branch");
}
await cmd.run("gh", args);
}

function printPlan(options: Options, branch: string): void {
const createTail = options.body === "" ? "--fill" : "--title <commit> --body <given>";
const steps = [
"[dry-run] would run:",
` git fetch origin ${options.base}`,
` verify ${branch} is clean, not ${options.base}, contains origin/${options.base}, ahead >= 1`,
` git push -u origin ${branch}`,
` gh pr list --head ${branch} --base ${options.base} --state open --json url --jq ...`,
` gh pr create --base ${options.base} --head ${branch} ${createTail} # if missing`,
" gh pr checks <url> --watch --interval 10 # if checks exist",
` gh pr merge <url> --squash${options.deleteBranch ? " --delete-branch" : ""}`,
` git checkout ${options.base}`,
` git pull --ff-only origin ${options.base}`,
];
if (options.deleteBranch) {
steps.push(` git branch -D ${branch} # if still present locally`);
}
io.print(steps.join("\n"));
}
Loading
Loading