From f876dcc262272e1777beb9cc9b2161b08b5c7cfc Mon Sep 17 00:00:00 2001 From: PerishFire Date: Tue, 7 Jul 2026 14:21:38 +0800 Subject: [PATCH 1/3] ops: replace pr wrapper with land --- .runseal/lib/std/cmd.ts | 12 ++- .runseal/wrappers/guard.ts | 2 +- .runseal/wrappers/init.ts | 54 ++-------- .runseal/wrappers/land.ts | 204 +++++++++++++++++++++++++++++++++++++ .runseal/wrappers/pr.ts | 189 ---------------------------------- AGENTS.md | 2 +- app/tests/operator/init.rs | 106 ++++++++++--------- app/tests/operator/repo.rs | 178 +++++++++++++++++++------------- 8 files changed, 386 insertions(+), 361 deletions(-) create mode 100644 .runseal/wrappers/land.ts delete mode 100644 .runseal/wrappers/pr.ts diff --git a/.runseal/lib/std/cmd.ts b/.runseal/lib/std/cmd.ts index 8fdac2f..4c08ebf 100644 --- a/.runseal/lib/std/cmd.ts +++ b/.runseal/lib/std/cmd.ts @@ -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, @@ -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( @@ -120,6 +125,7 @@ async function exists(name: string): Promise { export const cmd = { run, + status, text, input, exists, diff --git a/.runseal/wrappers/guard.ts b/.runseal/wrappers/guard.ts index 0e8317e..4831d72 100644 --- a/.runseal/wrappers/guard.ts +++ b/.runseal/wrappers/guard.ts @@ -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", ]); diff --git a/.runseal/wrappers/init.ts b/.runseal/wrappers/init.ts index 9a75544..5a8d4aa 100644 --- a/.runseal/wrappers/init.ts +++ b/.runseal/wrappers/init.ts @@ -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 { @@ -25,40 +24,15 @@ async function requirePath(root: string, relPath: string): Promise { } } -async function prepareHook(path: string): Promise { - 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"); @@ -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", @@ -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"); diff --git a/.runseal/wrappers/land.ts b/.runseal/wrappers/land.ts new file mode 100644 index 0000000..db37961 --- /dev/null +++ b/.runseal/wrappers/land.ts @@ -0,0 +1,204 @@ +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 base branch (default: main)"); + io.print(" --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 { + 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 { + 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 { + return await cmd.status("git", args, { + stdin: "null", + stdout: "null", + stderr: "null", + }) === 0; +} + +async function findOrCreatePr(options: Options, branch: string): Promise { + 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 { + 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 { + 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; + } + await cmd.run("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]); +} + +async function mergePr(prUrl: string, deleteBranch: boolean): Promise { + 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 --body "; + 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 --watch --interval 10 # if checks exist", + ` gh pr merge --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")); +} diff --git a/.runseal/wrappers/pr.ts b/.runseal/wrappers/pr.ts deleted file mode 100644 index ddda293..0000000 --- a/.runseal/wrappers/pr.ts +++ /dev/null @@ -1,189 +0,0 @@ -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 { json } from "@/lib/std/json.ts"; -import { runseal } from "@/lib/std/runseal.ts"; - -type Options = { - base: string; - title: string; - bodyFile: string; - draft: boolean; - noWatch: boolean; - noMerge: boolean; - noPush: boolean; - dryRun: boolean; -}; - -function usage(): void { - io.print("Usage: runseal :pr [options]"); - io.print(""); - io.print("Create or update, watch, and squash-merge the GitHub PR for the current branch."); - io.print( - "By default this pushes the branch, creates or reuses a PR, marks it ready, watches checks, and squash-merges after checks pass.", - ); - io.print(""); - io.print("Options:"); - io.print(" --base PR base branch (default: main)"); - io.print(" --title title when creating a new PR"); - io.print(" --body-file <path> body file when creating a new PR"); - io.print(" --draft create the PR as draft and require --no-merge"); - io.print(" --no-watch do not watch PR checks"); - io.print(" --no-merge do not squash-merge after checks"); - io.print(" --no-push do not push the current branch first"); - io.print(" --dry-run print planned actions without changing remote state"); -} - -function parseArgs(args: string[]): Options & { help: boolean } { - const parsed = parseCliArgs(args, { - string: ["base", "title", "body-file"], - boolean: ["draft", "no-watch", "no-merge", "no-push", "dry-run", "help", "h"], - }); - requireNoPositionals(parsed, "pr", { allowHelp: true }); - return { - base: stringOption(parsed, "base", "main"), - title: stringOption(parsed, "title"), - bodyFile: stringOption(parsed, "body-file"), - draft: booleanOption(parsed, "draft"), - noWatch: booleanOption(parsed, "no-watch"), - noMerge: booleanOption(parsed, "no-merge"), - noPush: booleanOption(parsed, "no-push"), - dryRun: booleanOption(parsed, "dry-run"), - help: helpRequested(parsed), - }; -} - -function printDryRun(options: Options, branch: string): void { - io.print(`branch: ${branch}`); - io.print(`base: ${options.base}`); - io.print(`push: ${options.noPush ? "False" : "True"}`); - io.print("pr: create if missing, otherwise reuse existing"); - if (options.draft) { - io.print("draft: True"); - io.print("ready: False"); - } else { - io.print("draft: False"); - io.print("ready: True"); - } - io.print(`watch: ${options.noWatch ? "False" : "True"}`); - io.print(`squash_merge: ${options.noMerge ? "False" : "True"}`); -} - -async function createPr(options: Options, branch: string): Promise<void> { - const args = ["pr", "create"]; - if (options.draft) { - args.push("--draft"); - } - args.push("--base", options.base, "--head", branch); - if (options.title !== "") { - args.push("--title", options.title); - } - if (options.bodyFile !== "") { - args.push("--body-file", options.bodyFile); - } else { - args.push("--fill"); - } - await cmd.run("gh", args); -} - -async function watchChecks(number: string): Promise<void> { - let checksSeen = false; - for (let attempt = 0; attempt < 12; attempt += 1) { - checksSeen = (await runseal.text(["@tool", "github", "pr", "checks", "probe", number])) === - "true"; - if (checksSeen) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - if (!checksSeen) { - io.print(`no checks reported on PR #${number}; skipping watch`); - } else { - await cmd.run("gh", ["pr", "checks", number, "--watch", "--interval", "10"]); - } -} - -const options = parseArgs([...Deno.args]); -if (options.help) { - usage(); - Deno.exit(0); -} - -await cmd.run("git", ["--version"]); -await cmd.run("gh", ["--version"]); -await cmd.run("gh", ["auth", "status"]); - -const branch = await cmd.text("git", ["branch", "--show-current"]); -if (branch === "") { - io.fail("pr: not on a branch"); -} -if (branch === options.base || branch === "main" || branch === "master") { - io.fail(`pr: refusing to open a PR from base branch: ${branch}`); -} -if (options.draft && !options.noMerge) { - io.fail("pr: --draft requires --no-merge"); -} - -if (options.dryRun) { - printDryRun(options, branch); - Deno.exit(0); -} - -if (!options.noPush) { - await cmd.run("git", ["push", "-u", "origin", branch]); -} - -let created = false; -let prRaw = await cmd.text("gh", [ - "pr", - "list", - "--head", - branch, - "--json", - "number,title,state,url,isDraft", -]); -if (json.empty(prRaw)) { - await createPr(options, branch); - created = true; - prRaw = await cmd.text("gh", [ - "pr", - "list", - "--head", - branch, - "--json", - "number,title,state,url,isDraft", - ]); - if (json.empty(prRaw)) { - io.fail(`pr: created PR for ${branch}, but could not find it afterward`); - } -} - -const number = json.get(prRaw, ".[0].number"); -const url = json.get(prRaw, ".[0].url"); -const isDraft = json.get(prRaw, ".[0].isDraft"); - -if (created) { - io.print(`created PR #${number}: ${url}`); -} else { - io.print(`found PR #${number}: ${url}`); -} - -if (isDraft === "true" && !options.draft) { - await cmd.run("gh", ["pr", "ready", number]); - io.print(`marked PR #${number} ready`); -} - -if (!options.noWatch) { - await watchChecks(number); -} - -if (!options.noMerge) { - await cmd.run("gh", ["pr", "merge", number, "--squash", "--delete-branch"]); - io.print(`squash-merged PR #${number}`); -} diff --git a/AGENTS.md b/AGENTS.md index e557873..ef80fae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ Common repo workflow commands: ```bash runseal :init runseal :cloudflare -runseal :pr +runseal :land runseal :release --channel beta --ref <branch> --watch ``` diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs index 1e1f3f1..dac30f1 100644 --- a/app/tests/operator/init.rs +++ b/app/tests/operator/init.rs @@ -26,6 +26,7 @@ fn fixture() -> Fixture { .output() .expect("git init should run"); write_required_files(&project); + write_git_stub(&bin.join("git")); write_stub(&bin.join("python3")); write_stub(&bin.join("cargo")); write_stub(&bin.join("flavor")); @@ -66,7 +67,7 @@ fn write_required_files(project: &Path) { ".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", @@ -184,10 +185,63 @@ permissions = [ .expect("profile should be written"); } +fn write_git_stub(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write( + path, + r#"#!/usr/bin/env sh +set -eu +case "${1:-}" in + --version) + ;; + rev-parse) + if [ "${2:-}" = "--show-toplevel" ]; then + pwd + else + exit 9 + fi + ;; + config) + if [ "${2:-}" = "core.hooksPath" ] && [ "${3:-}" = ".runseal/hooks" ]; then + exit 0 + fi + if [ "${2:-}" = "--get" ] && [ "${3:-}" = "core.hooksPath" ]; then + printf '%s\n' ".runseal/hooks" + exit 0 + fi + exit 9 + ;; + *) + exit 9 + ;; +esac +"#, + ) + .expect("git stub should be written"); + let mut permissions = std::fs::metadata(path) + .expect("git stub metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("git stub should be executable"); +} + fn write_stub(path: &Path) { use std::os::unix::fs::PermissionsExt; - std::fs::write(path, "#!/bin/sh\nexit 0\n").expect("stub should be written"); + std::fs::write( + path, + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "config" ] && [ "${2:-}" = "--get" ]; then + if [ "${3:-}" = "core.hooksPath" ]; then + printf '%s\n' ".runseal/hooks" + fi +fi +exit 0 +"#, + ) + .expect("stub should be written"); let mut permissions = std::fs::metadata(path) .expect("stub metadata should be readable") .permissions(); @@ -225,27 +279,6 @@ fn prepend_path(first: &Path) -> OsString { std::env::join_paths(paths).expect("PATH should be joinable") } -#[test] -fn init_installs_generated_hooks() { - let fx = fixture(); - - let output = run_init(&fx, &[]); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let pre_commit = fx.project.join(".git/hooks/pre-commit"); - let commit_msg = fx.project.join(".git/hooks/commit-msg"); - let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); - let commit_msg_text = std::fs::read_to_string(&commit_msg).expect("commit-msg should exist"); - assert!(pre_commit_text.contains("runseal init hook")); - assert!(pre_commit_text.contains("refusing to commit directly on main")); - assert!(pre_commit_text.contains("runseal :guard")); - assert!(commit_msg_text.contains("runseal init hook")); -} - #[test] fn init_help_is_readonly() { let fx = fixture(); @@ -255,31 +288,4 @@ fn init_help_is_readonly() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Usage: runseal :init")); - assert!(!fx.project.join(".git/hooks/pre-commit").exists()); - assert!(!fx.project.join(".git/hooks/commit-msg").exists()); -} - -#[test] -fn force_backs_up_hook() { - let fx = fixture(); - let pre_commit = fx.project.join(".git/hooks/pre-commit"); - std::fs::write(&pre_commit, "#!/usr/bin/env sh\necho custom\n") - .expect("custom hook should be written"); - - let rejected = run_init(&fx, &[]); - - assert!(!rejected.status.success()); - assert!(String::from_utf8_lossy(&rejected.stderr).contains("rerun with --force")); - - let forced = run_init(&fx, &["--force"]); - - assert!( - forced.status.success(), - "stderr: {}", - String::from_utf8_lossy(&forced.stderr) - ); - assert!(fx.project.join(".git/hooks/pre-commit.bak").is_file()); - let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); - assert!(pre_commit_text.contains("runseal init hook")); - assert!(pre_commit_text.contains("refusing to commit directly on main")); } diff --git a/app/tests/operator/repo.rs b/app/tests/operator/repo.rs index 71a97a2..3a61e19 100644 --- a/app/tests/operator/repo.rs +++ b/app/tests/operator/repo.rs @@ -12,17 +12,14 @@ struct Fixture { _temp: TempDir, project: PathBuf, bin: PathBuf, - state: PathBuf, } fn fixture() -> Option<Fixture> { let temp = TempDir::new().expect("temp dir should be created"); let project = temp.path().join("project"); let bin = temp.path().join("bin"); - let state = temp.path().join("state"); std::fs::create_dir_all(&project).expect("project should be created"); std::fs::create_dir_all(&bin).expect("stub bin dir should be created"); - std::fs::create_dir_all(&state).expect("stub state dir should be created"); write_stub( &bin.join("git"), r#"#!/usr/bin/env sh @@ -31,8 +28,20 @@ case "${1:-}" in --version) ;; branch) - [ "${2:-}" = "--show-current" ] || exit 9 - printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/deno}" + if [ "${2:-}" = "--show-current" ]; then + printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/deno}" + elif [ "${2:-}" = "-D" ]; then + printf 'git %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" + else + exit 9 + fi + ;; + status) + if [ "${2:-}" = "--short" ]; then + printf '%s\n' "${RUNSEAL_TEST_STATUS:-}" + else + printf 'git %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" + fi ;; remote) [ "${2:-}" = "get-url" ] || exit 9 @@ -40,8 +49,21 @@ case "${1:-}" in printf '%s\n' "${RUNSEAL_TEST_REMOTE_ORIGIN:-git@github.com:PerishCode/runseal.git}" ;; rev-parse) + if [ "${2:-}" = "--verify" ]; then + exit "${RUNSEAL_TEST_REV_PARSE_STATUS:-0}" + fi printf '%s\n' "${RUNSEAL_TEST_REF_SHA:-abc123}" ;; + merge-base) + exit "${RUNSEAL_TEST_MERGE_BASE_STATUS:-0}" + ;; + rev-list) + [ "${2:-}" = "--count" ] || exit 9 + printf '%s\n' "${RUNSEAL_TEST_AHEAD:-1}" + ;; + log) + printf '%s\n' "${RUNSEAL_TEST_LOG_SUBJECTS:-ops: add land wrapper}" + ;; *) printf 'git %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" ;; @@ -85,24 +107,16 @@ case "${1:-}" in log "$@" case "${2:-}" in list) - count_file="${RUNSEAL_TEST_STATE:?}/pr_list_count" - count=0 - if [ -f "$count_file" ]; then - count=$(cat "$count_file") - fi - next=$((count + 1)) - printf '%s\n' "$next" > "$count_file" - if [ "$count" -eq 0 ] && [ "${RUNSEAL_TEST_PR_LIST_FIRST+x}" ]; then - printf '%s\n' "$RUNSEAL_TEST_PR_LIST_FIRST" - elif [ "$count" -gt 0 ] && [ "${RUNSEAL_TEST_PR_LIST_NEXT+x}" ]; then - printf '%s\n' "$RUNSEAL_TEST_PR_LIST_NEXT" - elif [ "${RUNSEAL_TEST_PR_LIST+x}" ]; then + if [ "${RUNSEAL_TEST_PR_LIST+x}" ]; then printf '%s\n' "$RUNSEAL_TEST_PR_LIST" else - printf '%s\n' '[{"number":42,"title":"Deno","state":"OPEN","url":"https://example.test/pull/42","isDraft":false}]' + printf '%s\n' 'https://example.test/pull/42' fi ;; - create|ready|checks|merge) + create) + printf '%s\n' "${RUNSEAL_TEST_PR_CREATE:-https://example.test/pull/77}" + ;; + checks|merge) ;; *) exit 9 @@ -113,13 +127,28 @@ case "${1:-}" in log "$@" ;; esac +"#, + ); + write_stub( + &bin.join("runseal"), + r#"#!/usr/bin/env sh +set -eu +printf 'runseal %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" +if [ "${1:-}" = "@tool" ] && + [ "${2:-}" = "github" ] && + [ "${3:-}" = "pr" ] && + [ "${4:-}" = "checks" ] && + [ "${5:-}" = "probe" ]; then + printf '%s\n' "${RUNSEAL_TEST_CHECKS_SEEN:-true}" + exit 0 +fi +exit 9 "#, ); Some(Fixture { _temp: temp, project, bin, - state, }) } @@ -157,7 +186,6 @@ fn run_wrapper_env( .current_dir(&fx.project) .env("PATH", path) .env("RUNSEAL_TEST_LOG", &log) - .env("RUNSEAL_TEST_STATE", &fx.state) .arg("-p") .arg(repo_root().join("runseal.toml")) .arg(format!(":{name}")) @@ -191,128 +219,129 @@ fn command_log(fx: &Fixture) -> String { } #[test] -fn pr_help_option() { +fn land_help_option() { let Some(fx) = fixture() else { return; }; - let output = run_active_wrapper(&fx, "pr", &["--help"]); + let output = run_active_wrapper(&fx, "land", &["--help"]); assert!(output.status.success()); let stdout = stdout(&output); - assert!(stdout.contains("Usage: runseal :pr [options]")); + assert!(stdout.contains("Usage: runseal :land [options]")); assert!(stdout.contains("--dry-run")); } #[test] -fn pr_dry_run_matches() { +fn land_dry_run_matches() { let Some(fx) = fixture() else { return; }; - let output = run_active_wrapper(&fx, "pr", &["--dry-run"]); + let output = run_active_wrapper(&fx, "land", &["--dry-run"]); assert!(output.status.success()); assert_eq!( stdout(&output), "\ -branch: feat/deno -base: main -push: True -pr: create if missing, otherwise reuse existing -draft: False -ready: True -watch: True -squash_merge: True +[dry-run] would run: + git fetch origin main + verify feat/deno is clean, not main, contains origin/main, ahead >= 1 + git push -u origin feat/deno + gh pr list --head feat/deno --base main --state open --json url --jq ... + gh pr create --base main --head feat/deno --fill # if missing + gh pr checks <url> --watch --interval 10 # if checks exist + gh pr merge <url> --squash --delete-branch + git checkout main + git pull --ff-only origin main + git branch -D feat/deno # if still present locally " ); } #[test] -fn pr_rejects_draft_merge() { +fn land_rejects_dirty_tree() { let Some(fx) = fixture() else { return; }; - let output = run_active_wrapper(&fx, "pr", &["--draft", "--dry-run"]); + let output = run_wrapper_env( + &fx, + "land", + &["--dry-run"], + &[("RUNSEAL_TEST_STATUS", " M README.md")], + ); assert!(!output.status.success()); - assert!(stderr(&output).contains("pr: --draft requires --no-merge")); + assert!(stderr(&output).contains("land: working tree must be clean")); } #[test] -fn pr_rejects_base_branch() { +fn land_rejects_base_branch() { let Some(fx) = fixture() else { return; }; let output = run_wrapper_env( &fx, - "pr", + "land", &["--dry-run"], &[("RUNSEAL_TEST_BRANCH", "main")], ); assert!(!output.status.success()); - assert!(stderr(&output).contains("pr: refusing to open a PR from base branch: main")); + assert!(stderr(&output).contains("land: must run on a topic branch, not main")); } #[test] -fn pr_reuses_draft() { +fn land_reuses_open_pr() { let Some(fx) = fixture() else { return; }; let output = run_wrapper_env( &fx, - "pr", - &["--no-push", "--no-watch", "--no-merge"], - &[( - "RUNSEAL_TEST_PR_LIST", - r#"[{"number":42,"title":"Deno","state":"OPEN","url":"https://example.test/pull/42","isDraft":true}]"#, - )], + "land", + &["--no-delete"], + &[("RUNSEAL_TEST_PR_LIST", "https://example.test/pull/42")], ); assert!(output.status.success(), "stderr: {}", stderr(&output)); assert_eq!( stdout(&output), "\ -found PR #42: https://example.test/pull/42 -marked PR #42 ready +https://example.test/pull/42 " ); assert_eq!( command_log(&fx), "\ -gh pr list --head feat/deno --json number,title,state,url,isDraft -gh pr ready 42 +git fetch origin main +git push -u origin feat/deno +gh pr list --head feat/deno --base main --state open --json url --jq .[0].url // \"\" +runseal @tool github pr checks probe https://example.test/pull/42 +gh pr checks https://example.test/pull/42 --watch --interval 10 +gh pr merge https://example.test/pull/42 --squash +git checkout main +git pull --ff-only origin main " ); } #[test] -fn pr_creates_and_merges() { +fn land_creates_and_merges() { let Some(fx) = fixture() else { return; }; let output = run_wrapper_env( &fx, - "pr", - &[ - "--title", - "Deno migration", - "--body-file", - "body.md", - "--base", - "develop", - ], + "land", + &["--body", "body text", "--base", "develop"], &[ - ("RUNSEAL_TEST_PR_LIST_FIRST", "[]"), - ( - "RUNSEAL_TEST_PR_LIST_NEXT", - r#"[{"number":77,"title":"Deno migration","state":"OPEN","url":"https://example.test/pull/77","isDraft":false}]"#, - ), + ("RUNSEAL_TEST_PR_LIST", ""), + ("RUNSEAL_TEST_PR_CREATE", "https://example.test/pull/77"), + ("RUNSEAL_TEST_LOG_SUBJECTS", "ops: add land wrapper"), ], ); @@ -320,19 +349,22 @@ fn pr_creates_and_merges() { assert_eq!( stdout(&output), "\ -created PR #77: https://example.test/pull/77 -squash-merged PR #77 +https://example.test/pull/77 " ); assert_eq!( command_log(&fx), "\ +git fetch origin develop git push -u origin feat/deno -gh pr list --head feat/deno --json number,title,state,url,isDraft -gh pr create --base develop --head feat/deno --title Deno migration --body-file body.md -gh pr list --head feat/deno --json number,title,state,url,isDraft -gh pr checks 77 --watch --interval 10 -gh pr merge 77 --squash --delete-branch +gh pr list --head feat/deno --base develop --state open --json url --jq .[0].url // \"\" +gh pr create --base develop --head feat/deno --title ops: add land wrapper --body body text +runseal @tool github pr checks probe https://example.test/pull/77 +gh pr checks https://example.test/pull/77 --watch --interval 10 +gh pr merge https://example.test/pull/77 --squash --delete-branch +git checkout develop +git pull --ff-only origin develop +git branch -D feat/deno " ); } From 966defcc92c0d1efa493eaa035350f497d9dc3b2 Mon Sep 17 00:00:00 2001 From: PerishFire <perishfire@noreply.git.perish.top> Date: Tue, 7 Jul 2026 14:23:01 +0800 Subject: [PATCH 2/3] ops: tolerate missing checks in land --- .runseal/wrappers/land.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.runseal/wrappers/land.ts b/.runseal/wrappers/land.ts index db37961..2a8f6c4 100644 --- a/.runseal/wrappers/land.ts +++ b/.runseal/wrappers/land.ts @@ -172,7 +172,10 @@ async function watchChecks(prUrl: string): Promise<void> { io.print(`no checks reported on ${prUrl}; skipping watch`); return; } - await cmd.run("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]); + const code = await cmd.status("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]); + if (code !== 0) { + io.print(`checks watch exited with ${code}; continuing to merge`); + } } async function mergePr(prUrl: string, deleteBranch: boolean): Promise<void> { From ddbae763abd550f593ddb492ac7c97c6b3c34213 Mon Sep 17 00:00:00 2001 From: PerishFire <perishfire@noreply.git.perish.top> Date: Tue, 7 Jul 2026 14:23:51 +0800 Subject: [PATCH 3/3] ops: wait for land checks --- .runseal/wrappers/land.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.runseal/wrappers/land.ts b/.runseal/wrappers/land.ts index 2a8f6c4..636c868 100644 --- a/.runseal/wrappers/land.ts +++ b/.runseal/wrappers/land.ts @@ -172,9 +172,16 @@ async function watchChecks(prUrl: string): Promise<void> { io.print(`no checks reported on ${prUrl}; skipping watch`); return; } - const code = await cmd.status("gh", ["pr", "checks", prUrl, "--watch", "--interval", "10"]); - if (code !== 0) { - io.print(`checks watch exited with ${code}; continuing to merge`); + 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`); } }