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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<slot>`, 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
Expand Down
7 changes: 7 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slot>`.
- *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
Expand Down
38 changes: 36 additions & 2 deletions bin/renderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand Down
67 changes: 66 additions & 1 deletion scripts/harvest-step.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
#
# Verbs: preview <slot> | commit-wip <slot> | snapshot <slot> |
# discard <slot> | skip <slot> | merge <slot> <expected-base-sha> |
# resume [complete <slot>] | archive <slot> | abort-merge <slot>
# resume [complete <slot>] | archive <slot> | abort-merge <slot> |
# publish <slot>
#
# Output protocol: machine-readable "key<TAB>value…" lines on stdout, human
# messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
printf 'published\t%s\t%s\t%s\n' "$1" "$remote" "$tip"
}

do_archive() {
read_slot "$1" || return $?
case "$SLOT_STATUS" in
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions tests/harvest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
27 changes: 27 additions & 0 deletions tests/renderer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading