Skip to content
Back to skills

Merge

ASecurity

Atomically close the linked ticket(s) and merge a PR. Must be run from the PR head branch. Works in git worktrees and on VMs. GitHub-only (requires the GitHub CLI).

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 19, 2026
toolsrustgoshellbashgitapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 19, 2026

npx -y skills add MinhHaDuong/ImperialDragonHarness --skill merge --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Merge?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Merge
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/minhhaduong-merge/badge)](https://www.skillsdirectory.com/skills/minhhaduong-merge)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: merge
description: "Atomically close the linked ticket(s) and merge a PR. Must be run from the PR head branch. Works in git worktrees and on VMs. GitHub-only (requires the GitHub CLI)."
user-invocable: true
argument-hint: "[-C path] [pr-number]"
---

# Merge $ARGUMENTS

For helper commands, set `IDH_ROOT="$(cd -P "$(dirname "<loaded-SKILL.md>")/../.." && pwd -P)"` in the same shell call. Replace `<loaded-SKILL.md>` with the absolute path the runtime supplied for this skill. This follows a projected skill symlink to the canonical checkout; do not derive the helper root from the project cwd.

Run:
```bash
"$IDH_ROOT/skills/merge/erg-pr-merge" $ARGUMENTS
```

**Cross-repo prerequisite**: the script operates on the checkout it runs in,
with the PR branch checked out. Point it at a checkout in one of two ways:

- **`-C PATH` (preferred for agents)** — the script cds into `PATH` before any
  git/gh/erg call. The portable invocation is
  `"$IDH_ROOT/skills/merge/erg-pr-merge" -C WORKTREE N`.
  <!-- harness-extension-point -->
  Use the loaded skill's resolved checkout path; runtime permission rules
  must authorize that path explicitly rather than depend on a retired alias.
- **Implicit cwd** — with no `-C`, the script uses the current directory, so the
  caller must `cd <project-path> && git fetch origin` and check out the PR branch
  before the call.

`-C` with a missing or non-directory path fails loudly before any other work.

## Ticket lines in the PR body

The script reads close intent from the PR **body** only — never the title:

- `**Ticket:** tickets/NNNN-...` or `**Ticket:** tickets/closed/NNNN-...`
  (bold or bare `Ticket:`) — a **close claim**: the named ticket is closed and
  archived on merge. An already closed and archived ticket is accepted as a
  clean no-op.
- `Ticket-ref: tickets/NNNN-...` — references a ticket **without closing it**
  (for annotating a deliberately-open ticket).
- `Ticket: NNNN` (short form) — normalized, not refused: when exactly one
  `tickets/NNNN-*.erg` or `tickets/closed/NNNN-*.erg` matches, the script
  rewrites the body to the canonical path on the forge, reads it back, and
  proceeds; zero or several matches fall through to the error below. Only a
  4-digit ID alone after `Ticket:` qualifies — never `NNNN-slug`,
  `NNNN, MMMM` or `#NNNN`.
- `Ticket: none` — the PR closes nothing.
- With no recognised `Ticket` line, the PR closes nothing (same as
  `Ticket: none`); a line the grammar cannot read is not a claim. Only a
  malformed `Ticket: tickets/...` path errors.
- Ticket lines inside Markdown code fences are examples and are ignored.
  Ticket paths outside fences that cannot be parsed cause an error.
- Title prefixes like `chore(0216):` are subject references — they **never**
  close anything.

Report stdout/stderr verbatim. If the script exits non-zero, stop and show the error.

A killed or failed `erg-pr-merge` run (non-zero exit after its rebase) can
leave the PR-branch worktree with a stale pre-rebase index: staged diffs that
appear to re-open closed tickets and revert prose. That is not WIP — run
`git reset --hard HEAD` in that worktree before any salvage or gc decision
(ticket 0249 incident, aedist PR #979).

Merge is queued via auto-merge at once, **whether or not checks are still running**: the forge gates on the required checks and merges when they pass, so the script neither refuses on pending checks nor waits for them. A **failing** check is still a refusal, before anything is closed or queued. Where auto-merge is disabled the script reads the checks once and merges directly only if every check is done; pending checks there stop it with a pointer to the status command, never a wait.

Queued is not merged. To learn the outcome, ask once with
`"$IDH_ROOT/skills/merge/erg-pr-status" [-C WORKTREE] N`: exit 0 merged, 1
failed (a check failed, or the PR closed unmerged), 2 pending, 3 the forge
could not be read. It reads the forge a single time and never waits; **pending
is a normal answer, not an error**. The caller asks again at its next step
(raid: before the per-PR roar; roar: before its merged-branch pre-check) and
never loops, sleeps or watches in the meantime. A runtime may wrap that one
command in its own background wake-up as an optimisation; correctness never
depends on it. A **draft** PR (roar/raid sweeps file bootstrap PRs as draft) is marked ready automatically before merging — invoking `/merge` is explicit intent to merge.

## Before merging

Two prerequisites the caller checks before invoking this script. (Moved here
from `rules/git.md`, which is resident in every session: a recovery procedure
belongs with the command it recovers.)

- **Merge method drifts per repo.** This harness repo disables squash;
  `MERGE_FLAGS` above hardcodes `--merge`. A repo whose branch protection is
  squash-only rejects that flag — a method recorded in a note goes stale, so
  read the repo's actual merge settings rather than trusting a cached one.
  Trust the merge attempt over the flag: branch protection can bounce a method
  the forge API still advertises. On a squash-merged branch, `git cherry`
  shows `-` for every commit but `is-ancestor` is false — that ancestry check
  is `/roar`'s pre-check, and it is what confirms a squash-merge through the
  forge before a branch gets deleted.
- **After an APPROVED `/gaze`, sync before merging.** `/gaze` may have pushed
  fixes from its own review worktree since the caller last fetched:
  `git fetch origin && git merge --ff-only origin/<branch>` first, or a stale
  rebase silently drops the verify fix.
- **Run the local CI on padme when it is reachable.** `"$IDH_ROOT/scripts/local-ci.sh"`
  (ticket 1041) reproduces the forge's checks; run it on padme, over ssh from
  another host, and when padme is unreachable run it where you are and name the
  host in the report (the script prints it). The image is the same, so the
  result is equivalent: this chooses a machine, it never replaces the forge's
  required checks, which still gate the merge.

## Merge conflict recovery

- **Multi-PR wave on one file: verify the union survived, don't trust a clean
  exit.** Fetch before each sibling merge. A stale `origin/main` ref produces
  a clean-looking auto-merge that silently drops a sibling's non-conflicting
  addition — no conflict, exit 0, wrong content. "Auto-merging `<file>`" plus
  a clean exit is not proof the union survived: grep the merged file for
  every sibling's marker before committing. On a drop, don't hand-patch —
  restore the known-good base by writing `git show origin/main:<file>` to the
  path (never `git checkout <ref> -- <path>`, which overwrites the index),
  re-layer only this PR's change, re-verify.
- **Conflict inside a *generated* file: regenerate, don't hand-merge.**
  Hand-merging rows mixes one side's text with the other's measurements, so
  resolve the *source*. The subtler trap is the reverse: regenerating from a
  stale input reverts the other branch's data fix while producing a clean
  merge and a green suite, because a generated file carries no marker of
  which input produced it. Take `origin/main`'s copy as the base, regenerate
  to a scratch path, and grep the one value the other branch changed.
  Reproduces → commit. Reverts → your input is stale.

## When the script bounces

`erg-pr-merge` bounces in sequence, and each bounce has its own retry — never
blanket-fall back to a plain forge merge. (Moved here from `rules/git.md`,
which is resident in every session: a recovery procedure belongs with the
command it recovers.)

- **"close: no ticket found" on a retry.** If the claimed ticket is absent
  from both `tickets/` and `tickets/closed/`, stop and inspect the branch and
  claim before retrying. An already archived ticket with a `Closed:` header
  is handled automatically: the script skips closing it again.
- **"auto-merge is unavailable and N check(s) are pending".** The close commit
  is already pushed and the script did not wait. Ask `erg-pr-status` at the
  next step; once the checks are done and green, re-run the script (a re-run
  after the close commit adds no second close) or finish with a direct forge
  merge. Do not loop on the status command.
- **Empty rollup and unavailable requirement inspection.** With a mergeable
  PR and an explicit empty rollup, the script inspects legacy protection and
  effective rules. Known required checks refuse. HTTP 403 (including permission
  or plan restrictions) permits an ordinary merge attempt without `--admin`
  after validating the other endpoint. This delegates unseen requirements to
  the forge's ordinary enforcement, including its configured actor permissions.
  The script adds no bypass. Other API errors, partial response pages,
  unexpected denial bodies, and malformed successful data still refuse.
  If the forge refuses, its error is reported and the close commit stays pushed.
  Once the blocking requirement is resolved, re-run `erg-pr-merge N`; an
  archived closed ticket is skipped, so the retry adds no second close commit.
- **"must run from PR branch" after a fast-forward.** HEAD is detached after
  `merge --ff-only`: `git checkout <branch>`, then retry.
- **Force-push denied, branch already pushed.** Rebasing rewrites the branch's
  SHAs, local diverges from the remote, and the script's own ticket-close
  commit then lands on the local rewrite with its push rejected — leaving the
  ticket closed and archived locally but the close commit unpushed; a re-run
  fails "close: no ticket found". Recovery: check out the origin branch tip
  detached, `git cherry-pick <close-commit>`, fast-forward push, then merge
  directly (raid 234/235, PR #998).

## Dropped close-claim sweep

After a direct forge merge that bypassed this script, verify each `**Ticket:**`
claim immediately: the ticket must be in `tickets/closed/` with a `Closed:`
header. After a merge wave, run the wider sweep from the updated base checkout:

```bash
bash "$IDH_ROOT/skills/roar/check-close-claims.sh" --days 7 --limit 40
```

It reports how many merged PRs it examined, how many close claims it parsed,
and how many it found unhonoured (`DROPPED` or `UNRESOLVED`). A zero finding
count is not an all-clear when zero PRs or zero claims were parsed; check the
window and any unrecognised bodies. The test suite gives this detector a
positive control: a merged PR claiming an open ticket must be named and cause
exit 1. `Ticket-ref:` is excluded.

## Manual chore-close — when a PR merged without this script

First confirm no close commit rode the PR; if one did, the ticket is already
closed and there is nothing to do. `erg close <ID> <reason>` edits the ticket
file but does **not** stage its own edit, so `git add -u tickets/` BEFORE the
`git mv` to `closed/` and the commit — otherwise the commit carries the rename
with the pre-edit blob and silently drops the `Closed:` header (aedist PR #1008;
a 100%-rename, 0-insertion commit is the tell). A later "no ticket found"
bounce here means a review round already closed it — harmless, skip it.

An `erg-pr-merge` mode taking only a PR number was considered for a head branch
owned by another session. It would close on the caller's branch rather than
putting the close commit on the reviewed PR, weakening the current atomic
close-and-merge path. Keep the branch requirement and `-C` form; if the
isolation guard still makes that branch unreachable, use a direct forge merge
with the immediate verification and sweep above, then repair on a separate
branch and PR.

## After the merge lands

The script reads the PR state once after queueing; when it already reads MERGED
it runs `"$IDH_ROOT/scripts/sync-local-main.sh"` on the base branch
(rules/git.md § Local main syncs eagerly) — no manual sync step. Two outputs
still need action:

- "Merge not yet landed" — the usual case when checks were pending. Ask
  `erg-pr-status` once at the next step; on exit 0 run
  `"$IDH_ROOT/scripts/sync-local-main.sh"`; on 2 carry on and ask again later;
  on 1 report the failure.
- "left untouched" in the sync report — dirty overlap or divergence where the
  base branch is checked out; report it to the caller rather than forcing.

Do not substitute a hand-rolled `merge --ff-only` on the primary checkout:
that advances whatever branch is checked out there.

Files in this skill

  • SKILL.md12.1 KB
  • erg-pr-merge33.6 KB
  • erg-pr-status2.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.