diff --git a/CHANGELOG.md b/CHANGELOG.md index e6705bc..9224f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Notable changes to the Swarm plugin. Format follows ## [Unreleased] +### Added +- **PR-based harvest**: the new `publish` verb (`harvest-step.sh publish + `, or `p` + slot digit in the harvest pane) pushes a slot's branch + to a remote (default `origin`, `HERDR_SWARM_PUBLISH_REMOTE` to override) + so review and merge happen on the forge — plain push, never `--force`. + The forge merge is auto-detected by the next re-preview via the existing + ancestry/squash-containment checks, so no new terminal state exists. + ## [0.2.0] — 2026-08-23 ### Added diff --git a/CONCEPTS.md b/CONCEPTS.md index fa3eee0..a5c5ab4 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -54,6 +54,13 @@ at a time, with the user choosing order. Distinct from anything automatic: nothing merges without an explicit per-Slot decision, and the base ref is re-checked for drift before every merge rather than once per session. +### Publish +The PR-based alternative to a local merge: pushing a Slot's branch to a +configured remote (plain push, never force) so review and merge happen on the +forge. Publish deliberately introduces no new terminal state — once the forge +merge lands and base updates, the ordinary preview detection (ancestry or +squash containment) settles the Slot. + ### Locus Where a merge physically executes. Two cases, and the distinction is load-bearing: when the base branch is not checked out anywhere, the merge runs diff --git a/README.md b/README.md index 5f7c5a3..6b50ad9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ add your own, see below): branch-name confirmation; a snapshot ref is written first). - *Conflict or hook failure* — classified distinctly; `s` shells into the merge tree, `a` aborts the merge (`git merge --abort`), `b` backs out. + - *Publish (PR-based harvest)* — `p` then a slot digit pushes that slot's + branch to a remote (default `origin`, override with + `HERDR_SWARM_PUBLISH_REMOTE`) instead of merging locally — plain push, + never `--force`; a non-fast-forward rejection is surfaced, not overridden. + Open the pull request on your forge as usual; once its merge lands and + base updates, the next re-preview auto-detects it (ancestry for merge + commits, tree containment for squashes) and the slot proceeds to archive. + Scriptable as `harvest-step.sh publish `. - *Archive* — after merge/skip, the worktree is removed (branch kept). Recursive ignored-file inventory is byte-safe and requires the exact digest-bound, one-use approval before ignored data can be removed. The diff --git a/bin/renderer.mjs b/bin/renderer.mjs index fc780ef..e717ce5 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -665,13 +665,22 @@ export function renderHarvest(model, cols = 80) { for (const f of ph.files ?? []) lines.push(` ${sanitizeText(f)}`); lines.push(`${ESC}[2m [y]archive anyway [n]keep the worktree${ESC}[0m`); break; + case "publish-pick": + lines.push( + ` PUBLISH: push which slot's branch to the remote (plain push, never force)?`, + ); + lines.push( + ` The forge merge is auto-detected on a later re-preview once base updates.`, + ); + lines.push(`${ESC}[2m [1-9]slot [Esc]cancel${ESC}[0m`); + break; default: { // Any row still carrying a journal wedges every merge (sequencer_scan // / the merge verb's own refusal), so the escape hatch has to be // reachable from the resting phase — not only from conflict. const j = (model.rows ?? []).find((r) => r.journal); lines.push( - `${ESC}[2m 1-9:select slot (merge/prompt) r:re-preview${ + `${ESC}[2m 1-9:select slot (merge/prompt) p:publish to remote r:re-preview${ j ? ` a:abort stale merge (slot ${j.slot})` : "" } q:quit${ESC}[0m`, ); @@ -1108,6 +1117,28 @@ export class HarvestRenderer { this.paint(); } break; + case "publish-pick": + if (ch >= "1" && ch <= "9") { + const slot = Number(ch); + this.phase = { name: "list" }; + if (!this.rows.find((r) => r.slot === slot)) { + this.banner = `no slot ${slot} in this run`; + this.paint(); + break; + } + const r = await this.step("publish", [slot]); + if (r.code === 0) { + const [, remote, sha] = r.out.published?.[0] ?? []; + this.banner = `slot ${slot} published to ${remote ?? "remote"} (${String(sha ?? "").slice(0, 10)})`; + } else { + this.banner = this.lastErrLine(r); + } + await this.reload(); + } else if (ch === "b" || ch === "\x1b") { + this.phase = { name: "list" }; + this.paint(); + } + break; case "ignored": if (ch === "y" || ch === "Y") { const slot = ph.slot; @@ -1128,7 +1159,10 @@ export class HarvestRenderer { break; default: if (ch >= "1" && ch <= "9") await this.selectSlot(Number(ch)); - else if (ch === "r") { + else if (ch === "p") { + this.phase = { name: "publish-pick" }; + this.paint(); + } else if (ch === "r") { this.banner = ""; await this.reload(); } else if (ch === "a") { diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 41893ff..05088e1 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -10,7 +10,8 @@ # # Verbs: preview | commit-wip | snapshot | # discard | skip | merge | -# resume [complete ] | archive | abort-merge +# resume [complete ] | archive | abort-merge | +# publish # # Output protocol: machine-readable "keyvalue…" lines on stdout, human # messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on @@ -26,6 +27,8 @@ # guard) # HERDR_SWARM_CLEANUP_APPROVAL archive: exact one-use JSON approval # emitted by the ignored inventory preview +# HERDR_SWARM_PUBLISH_REMOTE publish: remote to push the slot branch +# to (default: origin) # HERDR_SWARM_HARVEST_WT_NO_HOOKS=1 disable repo hooks in the plugin-owned # harvest worktree ONLY (hook-policy KTD: # fresh worktrees lack node_modules, so @@ -679,6 +682,64 @@ do_resume() { return 0 } +# do_publish: PR-based harvest — hand the slot's committed work to the forge +# instead of merging locally. A plain same-name push of the slot branch to the +# configured remote, NEVER --force: a rejected non-fast-forward means the +# remote branch moved under someone else's hands, which needs a human, not a +# flag. Publish mutates no local ref and touches no worktree, so it composes +# with the rest of the flow: once the forge merge lands and base is updated, +# the next preview detects it (ancestry for merge commits, tree containment +# for squashes) and the slot proceeds to archive as usual — no new terminal +# state exists on purpose. +do_publish() { + read_slot "$1" || return $? + local remote="${HERDR_SWARM_PUBLISH_REMOTE:-origin}" tip out patch + if ! git -C "$REPO_ROOT" remote get-url "$remote" >/dev/null 2>&1; then + echo "herdr-swarm: remote '$remote' is not configured in this repository — add it, or point HERDR_SWARM_PUBLISH_REMOTE at the remote to publish to." >&2 + return "$HS_EC_REFUSED" + fi + tip="$(git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/heads/$SLOT_BRANCH")" || { + echo "herdr-swarm: slot $1 has no branch to publish." >&2 + return "$HS_EC_REFUSED" + } + if [ "$tip" = "$FORK_SHA" ]; then + echo "herdr-swarm: slot $1 has no commits past the fork point — nothing to publish." >&2 + return "$HS_EC_REFUSED" + fi + # The branch must still contain the recorded fork point: a rewritten slot + # branch (reset onto foreign history) would otherwise publish commits this + # run never audited. Same authority prune uses — ancestry, not bookkeeping. + if ! git -C "$REPO_ROOT" merge-base --is-ancestor "$FORK_SHA" "$tip"; then + echo "herdr-swarm: slot $1 branch no longer contains the recorded fork point $FORK_SHA — its history was rewritten; publish refused." >&2 + return "$HS_EC_REFUSED" + fi + # Uncommitted work never travels; say so rather than silently publishing + # half a slot (commit-WIP first to include it). + if [ -n "$SLOT_PATH" ] && [ -d "$SLOT_PATH" ] && + [ -n "$(git -C "$SLOT_PATH" status --porcelain 2>/dev/null)" ]; then + echo "herdr-swarm: note — slot $1 has uncommitted work; only committed work is published. Commit-WIP first to include it." >&2 + fi + # Push the AUDITED SHA, not the branch name: if the agent commits again + # between the checks above and the push, the remote still receives exactly + # the tip that passed them (the branch-name form would race). The seam + # below holds the verb in that window so the race is testable. + if [ -n "${HERDR_SWARM_TEST_PUBLISH_READY_FILE:-}" ]; then : >"$HERDR_SWARM_TEST_PUBLISH_READY_FILE"; fi + if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH:-}" ]; then + sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH" + fi + if ! out="$(git -C "$REPO_ROOT" push "$remote" "$tip:refs/heads/$SLOT_BRANCH" 2>&1)"; then + printf '%s\n' "$out" >&2 + echo "herdr-swarm: publish of slot $1 to '$remote' was rejected — nothing was force-pushed; resolve the refusal above and retry." >&2 + return "$HS_EC_REFUSED" + fi + patch="$(node -e ' + const [remote, sha] = process.argv.slice(1); + process.stdout.write(JSON.stringify({ published: { remote, sha } })); + ' "$remote" "$tip")" || return 1 + manifest_update_slot "$1" "$patch" || return 1 + printf 'published\t%s\t%s\t%s\n' "$1" "$remote" "$tip" +} + do_archive() { read_slot "$1" || return $? case "$SLOT_STATUS" in @@ -909,6 +970,10 @@ abort-merge) require_slot_arg "${1-}" || exit 1 do_abort_merge "$1" ;; +publish) + require_slot_arg "${1-}" || exit 1 + do_publish "$1" + ;; *) echo "herdr-swarm: unknown harvest verb '$VERB'" >&2 exit 1 diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index e3f9b1a..1303105 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -1757,3 +1757,117 @@ test("preview never squash-detects a slot that still has unlanded commits", () = assert.match(r.stdout, /state\tclean/); assert.equal(run.slotRow(1).status, "running", "not marked merged"); }); + +// ---- Publish (PR-based harvest, deferred follow-up now shipped) ------------- +// A local bare repository stands in for the forge; publish is a plain push, +// so file:// semantics are exactly the wire semantics that matter (ff vs +// non-ff rejection). + +function addBareRemote(run) { + const bare = path.join(mkdtemp("hs-remote-"), "origin.git"); + h.git(run.repo, "init", "--bare", bare); + h.git(run.repo, "remote", "add", "origin", bare); + return bare; +} + +test("publish pushes the slot branch to the remote and records it, never with force", () => { + h.writeHerdrStub(); + const run = mkRun(); + const bare = addBareRemote(run); + const tip = commitIn(run.wt(1), "feat.txt", "publishable\n", "slot work"); + const r = step(run, "publish", [1]); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, new RegExp(`published\t1\torigin\t${tip}`)); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + tip, + "remote branch is at the slot tip", + ); + assert.deepEqual(run.slotRow(1).published, { remote: "origin", sha: tip }); + // Fast-forward re-publish after more work is fine. + const tip2 = commitIn(run.wt(1), "more.txt", "more\n", "more work"); + const r2 = step(run, "publish", [1]); + assert.equal(r2.status, 0, `${r2.stdout}\n${r2.stderr}`); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + tip2, + ); +}); + +test("publish refuses: missing remote, empty slot, and non-fast-forward — remote never clobbered", () => { + h.writeHerdrStub(); + // No remote configured at all. + const run = mkRun(); + commitIn(run.wt(1), "a.txt", "a\n", "work"); + let r = step(run, "publish", [1]); + assert.equal(r.status, EC.REFUSED); + assert.match(r.stderr, /remote 'origin' is not configured/); + // Nothing past the fork point. + const run2 = mkRun(); + addBareRemote(run2); + r = step(run2, "publish", [1]); + assert.equal(r.status, EC.REFUSED); + assert.match(r.stderr, /no commits past the fork point/); + // Non-fast-forward: remote holds history the local branch no longer has. + const run3 = mkRun(); + const bare3 = addBareRemote(run3); + const first = commitIn(run3.wt(1), "one.txt", "one\n", "first"); + assert.equal(step(run3, "publish", [1]).status, 0); + // Rewrite the slot branch: back to the fork, different commit. + h.git(run3.wt(1), "reset", "--hard", run3.fork); + commitIn(run3.wt(1), "two.txt", "two\n", "rewritten"); + r = step(run3, "publish", [1]); + assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /rejected|failed/i); + assert.equal( + h.git(run3.repo, "ls-remote", bare3, `refs/heads/${run3.branch(1)}`) + .stdout.split("\t")[0], + first, + "remote branch untouched — publish never forces", + ); +}); + +test("publish refuses a slot branch whose rewritten history lost the fork point", () => { + h.writeHerdrStub(); + const run = mkRun(); + addBareRemote(run); + // Rebuild the slot branch on an orphan root: commits exist, but the + // recorded fork point is no longer in its history. + h.git(run.wt(1), "checkout", "-q", "--orphan", "rebuilt"); + fs.writeFileSync(path.join(run.wt(1), "alien.txt"), "foreign history\n"); + h.git(run.wt(1), "add", "alien.txt"); + h.git(run.wt(1), "commit", "-q", "-m", "alien root"); + h.git(run.wt(1), "branch", "-f", run.branch(1)); + h.git(run.wt(1), "checkout", "-q", run.branch(1)); + const r = step(run, "publish", [1]); + assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /no longer contains the recorded fork point/); +}); + +test("publish pushes the audited tip even when the branch advances mid-flight", async () => { + h.writeHerdrStub(); + const run = mkRun(); + const bare = addBareRemote(run); + const audited = commitIn(run.wt(1), "one.txt", "one\n", "audited work"); + const ready = path.join(h.stateDir, "publish-ready"); + const done = stepAsync(run, "publish", [1], { + HERDR_SWARM_TEST_PUBLISH_READY_FILE: ready, + HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH: "2", + }); + // The verb has captured and validated its tip once the ready file exists; + // advance the branch inside the capture-to-push window. + await until(() => fs.existsSync(ready)); + const racer = commitIn(run.wt(1), "two.txt", "two\n", "raced in"); + const r = await done; + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.notEqual(racer, audited); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + audited, + "the remote received exactly the audited tip, not the mid-flight commit", + ); + assert.deepEqual(run.slotRow(1).published, { remote: "origin", sha: audited }); +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 0f5a5e2..d32c2f7 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -757,3 +757,30 @@ test("agent-supplied state text cannot smuggle escapes into any rendered view", assert.ok(!resume.includes("\x1b]"), `escape survived the resume view:\n${resume}`); assert.ok(!resume.includes("\x07")); }); + +test("publish flow: p opens the picker, a digit routes through step('publish'), Esc cancels", async () => { + const r = mkHarvest(); + r.rows = [{ slot: 1, label: "s1", branch: "b", status: "running", preview: { state: "clean", dirty: 0 } }]; + await r.onKey("p"); + assert.equal(r.phase.name, "publish-pick"); + await r.onKey("1"); + assert.deepEqual(r.calls, [["publish", 1]], "routed through step(), not raw git"); + assert.equal(r.phase.name, "list"); + // Esc cancels without a verb. + await r.onKey("p"); + await r.onKey("\x1b"); + assert.equal(r.phase.name, "list"); + assert.deepEqual(r.calls, [["publish", 1]], "cancel runs nothing"); + // A digit with no matching slot runs nothing and says so. + await r.onKey("p"); + await r.onKey("7"); + assert.deepEqual(r.calls, [["publish", 1]]); + assert.match(r.banner, /no slot 7/); +}); + +test("publish-pick phase renders its prompt and the list footer advertises p", () => { + const model = { runInfo: { run_id: "r1", base_ref: "refs/heads/main" }, rows: [], phase: { name: "publish-pick" } }; + assert.match(renderHarvest(model, 100), /PUBLISH: push which slot/); + const list = renderHarvest({ ...model, phase: { name: "list" } }, 120); + assert.match(list, /p:publish to remote/); +});