Skip to content

Commit ecbf913

Browse files
PerishCodePerishFire
andauthored
ops: replace pr wrapper with land (#55)
* ops: replace pr wrapper with land * ops: tolerate missing checks in land * ops: wait for land checks --------- Co-authored-by: PerishFire <perishfire@noreply.git.perish.top>
1 parent f0ce744 commit ecbf913

8 files changed

Lines changed: 396 additions & 361 deletions

File tree

.runseal/lib/std/cmd.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ function envOptions(
4444
}
4545

4646
async function run(command: string, args: string[] = [], options: CommandOptions = {}) {
47+
const code = await status(command, args, options);
48+
if (code !== 0) {
49+
Deno.exit(code);
50+
}
51+
}
52+
53+
async function status(command: string, args: string[] = [], options: CommandOptions = {}) {
4754
const status = await new Deno.Command(command, {
4855
args,
4956
cwd: options.cwd,
@@ -52,9 +59,7 @@ async function run(command: string, args: string[] = [], options: CommandOptions
5259
stdout: options.stdout ?? "inherit",
5360
stderr: options.stderr ?? "inherit",
5461
}).spawn().status;
55-
if (!status.success) {
56-
Deno.exit(status.code);
57-
}
62+
return status.code;
5863
}
5964

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

121126
export const cmd = {
122127
run,
128+
status,
123129
text,
124130
input,
125131
exists,

.runseal/wrappers/guard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ await cmd.run("deno", [
158158
".runseal/wrappers/cloudflare.ts",
159159
".runseal/wrappers/guard.ts",
160160
".runseal/wrappers/init.ts",
161-
".runseal/wrappers/pr.ts",
161+
".runseal/wrappers/land.ts",
162162
".runseal/wrappers/release.ts",
163163
]);
164164

.runseal/wrappers/init.ts

Lines changed: 10 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import { booleanOption, helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts";
1+
import { helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts";
22
import { cmd } from "@/lib/std/cmd.ts";
33
import { fs } from "@/lib/std/fs.ts";
44
import { io } from "@/lib/std/io.ts";
55
import { path } from "@/lib/std/path.ts";
66

7+
const HOOKS_PATH = ".runseal/hooks";
8+
79
function usage(): void {
8-
io.print("Usage: runseal :init [--force]");
9-
io.print("");
10-
io.print("Validate the repository and install generated git hooks.");
10+
io.print("Usage: runseal :init");
1111
io.print("");
12-
io.print("Options:");
13-
io.print(" --force back up custom hooks before replacing them");
12+
io.print("Validate the repository and install versioned git hooks.");
1413
}
1514

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

28-
async function prepareHook(path: string): Promise<void> {
29-
if (!(await fs.file.exists(path))) {
30-
return;
31-
}
32-
const generated = await fs.file.containsAny(path, [
33-
"runseal init hook",
34-
"runseal bootstrap hook",
35-
]);
36-
if (generated) {
37-
return;
38-
}
39-
if (!force) {
40-
io.fail(
41-
`init: ${path} already exists and was not generated by runseal init; rerun with --force to back it up and replace it`,
42-
);
43-
}
44-
const backup = await fs.file.backup.numbered(path);
45-
io.print(`backed up existing hook to ${backup}`);
46-
}
47-
48-
const args = parseArgs(Deno.args, { boolean: ["force", "help", "h"] });
27+
const args = parseArgs(Deno.args, { boolean: ["help", "h"] });
4928
requireNoPositionals(args, "init", { allowHelp: true });
5029
if (helpRequested(args)) {
5130
usage();
5231
Deno.exit(0);
5332
}
54-
const force = booleanOption(args, "force");
5533

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

6438
io.print("==> checking required tools");
@@ -107,7 +81,7 @@ for (
10781
".runseal/wrappers/cloudflare.ts",
10882
".runseal/wrappers/guard.ts",
10983
".runseal/wrappers/init.ts",
110-
".runseal/wrappers/pr.ts",
84+
".runseal/wrappers/land.ts",
11185
".runseal/wrappers/release.ts",
11286
".github/workflows/guard.yml",
11387
".github/workflows/release-beta.yml",
@@ -132,17 +106,9 @@ for (
132106
io.print("ok: repository entrypoints");
133107

134108
io.print("==> installing git hooks");
135-
await fs.dir.ensure(hooksDir, "700");
136-
137-
await prepareHook(preCommit);
138-
await Deno.copyFile(".runseal/hooks/pre-commit", preCommit);
139-
await fs.file.chmodIfUnix(preCommit, "755");
140-
io.print(`installed ${preCommit}`);
141-
142-
await prepareHook(commitMsg);
143-
await Deno.copyFile(".runseal/hooks/commit-msg", commitMsg);
144-
await fs.file.chmodIfUnix(commitMsg, "755");
145-
io.print(`installed ${commitMsg}`);
109+
await cmd.run("git", ["config", "core.hooksPath", HOOKS_PATH], { cwd: root });
110+
const current = await cmd.text("git", ["config", "--get", "core.hooksPath"], { cwd: root });
111+
io.print(`core.hooksPath = ${current}`);
146112

147113
await cmd.run("deno", ["--version"], { stdout: "null" });
148114
io.print("development environment ready");

.runseal/wrappers/land.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import {
2+
booleanOption,
3+
helpRequested,
4+
parseArgs as parseCliArgs,
5+
requireNoPositionals,
6+
stringOption,
7+
} from "@/lib/cli.ts";
8+
import { cmd } from "@/lib/std/cmd.ts";
9+
import { io } from "@/lib/std/io.ts";
10+
import { runseal } from "@/lib/std/runseal.ts";
11+
12+
type Options = {
13+
base: string;
14+
body: string;
15+
dryRun: boolean;
16+
deleteBranch: boolean;
17+
};
18+
19+
function usage(): void {
20+
io.print("Usage: runseal :land [options]");
21+
io.print("");
22+
io.print("Land the current clean topic branch on GitHub.");
23+
io.print("The branch is pushed, a PR is created or reused, checks are watched,");
24+
io.print("the PR is squash-merged, main is synced, and the topic branch is deleted.");
25+
io.print("");
26+
io.print("Options:");
27+
io.print(" --base <branch> base branch (default: main)");
28+
io.print(" --body <body> pull request body override");
29+
io.print(" --dry-run print planned actions without changing git or GitHub");
30+
io.print(" --no-delete keep the topic branch after merge");
31+
}
32+
33+
function parseArgs(args: string[]): Options & { help: boolean } {
34+
const parsed = parseCliArgs(args, {
35+
string: ["base", "body"],
36+
boolean: ["dry-run", "no-delete", "help", "h"],
37+
});
38+
requireNoPositionals(parsed, "land", { allowHelp: true });
39+
return {
40+
base: stringOption(parsed, "base", "main"),
41+
body: stringOption(parsed, "body"),
42+
dryRun: booleanOption(parsed, "dry-run"),
43+
deleteBranch: !booleanOption(parsed, "no-delete"),
44+
help: helpRequested(parsed),
45+
};
46+
}
47+
48+
const options = parseArgs([...Deno.args]);
49+
if (options.help) {
50+
usage();
51+
Deno.exit(0);
52+
}
53+
54+
await cmd.run("git", ["--version"], { stdout: "null" });
55+
await cmd.run("gh", ["--version"], { stdout: "null" });
56+
57+
const branch = await currentBranch();
58+
if (options.dryRun) {
59+
await ensureLandable(options.base, branch, { fetch: false });
60+
printPlan(options, branch);
61+
Deno.exit(0);
62+
}
63+
64+
await cmd.run("gh", ["auth", "status"], { stdout: "piped" });
65+
await ensureLandable(options.base, branch, { fetch: true });
66+
await cmd.run("git", ["push", "-u", "origin", branch]);
67+
68+
const prUrl = await findOrCreatePr(options, branch);
69+
io.print(prUrl);
70+
await watchChecks(prUrl);
71+
await mergePr(prUrl, options.deleteBranch);
72+
await cmd.run("git", ["checkout", options.base]);
73+
await cmd.run("git", ["pull", "--ff-only", "origin", options.base]);
74+
if (options.deleteBranch && await gitOk(["rev-parse", "--verify", `refs/heads/${branch}`])) {
75+
await cmd.run("git", ["branch", "-D", branch]);
76+
}
77+
78+
async function currentBranch(): Promise<string> {
79+
const branch = await cmd.text("git", ["branch", "--show-current"]);
80+
if (branch === "") {
81+
io.fail("land: detached HEAD is not a landable topic branch");
82+
}
83+
return branch;
84+
}
85+
86+
async function ensureLandable(
87+
base: string,
88+
branch: string,
89+
options: { fetch: boolean },
90+
): Promise<void> {
91+
if (branch === base || branch === "main" || branch === "master") {
92+
io.fail(`land: must run on a topic branch, not ${branch}`);
93+
}
94+
const dirty = await cmd.text("git", ["status", "--short"]);
95+
if (dirty.trim() !== "") {
96+
io.fail("land: working tree must be clean; commit or discard changes first");
97+
}
98+
if (options.fetch) {
99+
await cmd.run("git", ["fetch", "origin", base]);
100+
}
101+
const remoteBase = `origin/${base}`;
102+
if (!await gitOk(["rev-parse", "--verify", remoteBase])) {
103+
io.fail(`land: missing ${remoteBase}; fetch or check the base branch name`);
104+
}
105+
if (!await gitOk(["merge-base", "--is-ancestor", remoteBase, "HEAD"])) {
106+
io.fail(`land: current branch must contain latest ${remoteBase}; rebase onto ${base} first`);
107+
}
108+
const ahead = Number(await cmd.text("git", ["rev-list", "--count", `${remoteBase}..HEAD`]));
109+
if (!Number.isFinite(ahead) || ahead <= 0) {
110+
io.fail(`land: current branch has no commits ahead of ${remoteBase}`);
111+
}
112+
}
113+
114+
async function gitOk(args: string[]): Promise<boolean> {
115+
return await cmd.status("git", args, {
116+
stdin: "null",
117+
stdout: "null",
118+
stderr: "null",
119+
}) === 0;
120+
}
121+
122+
async function findOrCreatePr(options: Options, branch: string): Promise<string> {
123+
const existing = await cmd.text("gh", [
124+
"pr",
125+
"list",
126+
"--head",
127+
branch,
128+
"--base",
129+
options.base,
130+
"--state",
131+
"open",
132+
"--json",
133+
"url",
134+
"--jq",
135+
'.[0].url // ""',
136+
]);
137+
if (existing !== "") {
138+
return existing;
139+
}
140+
141+
const args = ["pr", "create", "--base", options.base, "--head", branch];
142+
if (options.body === "") {
143+
args.push("--fill");
144+
} else {
145+
args.push("--title", await deriveTitle(options.base), "--body", options.body);
146+
}
147+
return await cmd.text("gh", args);
148+
}
149+
150+
async function deriveTitle(base: string): Promise<string> {
151+
const subjects = await cmd.text("git", [
152+
"log",
153+
"--reverse",
154+
"--format=%s",
155+
`origin/${base}..HEAD`,
156+
]);
157+
const first = subjects.split(/\r?\n/).find((line) => line.trim() !== "");
158+
return first ?? "land branch";
159+
}
160+
161+
async function watchChecks(prUrl: string): Promise<void> {
162+
let checksSeen = false;
163+
for (let attempt = 0; attempt < 12; attempt += 1) {
164+
checksSeen = (await runseal.text(["@tool", "github", "pr", "checks", "probe", prUrl])) ===
165+
"true";
166+
if (checksSeen) {
167+
break;
168+
}
169+
await new Promise((resolve) => setTimeout(resolve, 5000));
170+
}
171+
if (!checksSeen) {
172+
io.print(`no checks reported on ${prUrl}; skipping watch`);
173+
return;
174+
}
175+
let lastCode = 0;
176+
for (let attempt = 0; attempt < 12; attempt += 1) {
177+
lastCode = await cmd.status("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]);
178+
if (lastCode === 0) {
179+
return;
180+
}
181+
await new Promise((resolve) => setTimeout(resolve, 5000));
182+
}
183+
if (lastCode !== 0) {
184+
io.print(`checks watch exited with ${lastCode}; continuing to merge`);
185+
}
186+
}
187+
188+
async function mergePr(prUrl: string, deleteBranch: boolean): Promise<void> {
189+
const args = ["pr", "merge", prUrl, "--squash"];
190+
if (deleteBranch) {
191+
args.push("--delete-branch");
192+
}
193+
await cmd.run("gh", args);
194+
}
195+
196+
function printPlan(options: Options, branch: string): void {
197+
const createTail = options.body === "" ? "--fill" : "--title <commit> --body <given>";
198+
const steps = [
199+
"[dry-run] would run:",
200+
` git fetch origin ${options.base}`,
201+
` verify ${branch} is clean, not ${options.base}, contains origin/${options.base}, ahead >= 1`,
202+
` git push -u origin ${branch}`,
203+
` gh pr list --head ${branch} --base ${options.base} --state open --json url --jq ...`,
204+
` gh pr create --base ${options.base} --head ${branch} ${createTail} # if missing`,
205+
" gh pr checks <url> --watch --interval 10 # if checks exist",
206+
` gh pr merge <url> --squash${options.deleteBranch ? " --delete-branch" : ""}`,
207+
` git checkout ${options.base}`,
208+
` git pull --ff-only origin ${options.base}`,
209+
];
210+
if (options.deleteBranch) {
211+
steps.push(` git branch -D ${branch} # if still present locally`);
212+
}
213+
io.print(steps.join("\n"));
214+
}

0 commit comments

Comments
 (0)