@feigi/fleet-ctl 3.79.2 → 3.79.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.79.2",
3
+ "version": "3.79.3",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -289,9 +289,11 @@ else
289
289
  # computes `%(upstream:track)` against the REMOTE ref, and an upstream naming
290
290
  # a remote ref that has never existed reads as `[gone]`, not as "no upstream"
291
291
  # — measured, a freshly claimed branch was reaped by `reap.sh --apply` before
292
- # any work was done in it. The real upstream arrives with the documented first
293
- # push, `git push --force-with-lease -u origin HEAD` (skills/next-ticket/SKILL.md
294
- # step 7; its no-lease fallback carries `-u` too), and only then can be `[gone]`.
292
+ # any work was done in it. The real upstream arrives with the first push with `-u`: the documented form,
293
+ # `git push --force-with-lease -u origin HEAD` (skills/next-ticket/SKILL.md
294
+ # step 7; its no-lease fallback carries `-u` too), or else when the controller
295
+ # settles the implementer's report, which writes it. Only then can the branch
296
+ # be `[gone]`. Until then reap.sh reports it kept.
295
297
  #
296
298
  # `--no-track` is also why no branch delete uses `-d`: `-d` measures an
297
299
  # upstream-less branch against local HEAD, refusing a pristine claim behind
@@ -49,6 +49,7 @@
49
49
  // not on origin/main, and releases; any other answer, or a detail that
50
50
  // opens with no sha, is a NEED and N is left untouched, claim and all.
51
51
  // The PR a `covered` detail names is never written to.
52
+ // A `PR#M` outcome also sets the claim branch's upstream when it has none: `reap.sh` keeps a branch with no upstream, and only an upstream's `[gone]` state makes it reapable. It writes `branch.<b>.remote` and `branch.<b>.merge` only while the merge key is unset, and prints `NEED upstream <b> — <why>` when a write fails.
52
53
  // fix-report <fix-pr-M[-x]>
53
54
  // `settle --from-report`, which records the filings and rulings the
54
55
  // report carries. Settled `applied:`/`no-op`, on a PR whose rows carry
@@ -512,8 +513,45 @@ function blocker(r) {
512
513
  return said(r);
513
514
  }
514
515
 
516
+ // One word of a command printed for a human to paste. A branch name git
517
+ // accepts can still carry `;`, `$`, a backtick or `'`, so any word outside a
518
+ // plain charset is single-quoted, its own quotes closed, escaped and reopened.
519
+ const shellWord = (s) => (/^[\w/.@%+=:,-]+$/.test(s) ? s : `'${s.replaceAll("'", "'\\''")}'`);
520
+
521
+ // The claim branch's upstream, set once a PR is opened from it. Only the merge
522
+ // key being unset triggers the write, so a configured upstream is never touched.
523
+ function claimUpstream(ev, member) {
524
+ const read = ev.report(member);
525
+ if (read.state !== "ok") {
526
+ ev.need(`upstream of ${member}'s claim branch — its report ${read.path} is ${read.state}: set the upstream by hand`);
527
+ return;
528
+ }
529
+ const b = read.report.branch;
530
+ const want = `run git config ${shellWord(`branch.${b}.remote`)} origin && git config ${shellWord(`branch.${b}.merge`)} ${shellWord(`refs/heads/${b}`)}`;
531
+ const fail = (r) => ev.need(`upstream ${b} — ${said(r)}; ${want}`);
532
+ const exists = run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${b}`]);
533
+ if (exists.status === 1) {
534
+ ev.note(`upstream ${b} not set: no local branch`);
535
+ return;
536
+ }
537
+ if (exists.status !== 0) return fail(exists);
538
+ const merge = run("git", ["config", "--get", `branch.${b}.merge`]);
539
+ if (merge.status === 0) {
540
+ ev.note(`upstream ${b} already set`);
541
+ return;
542
+ }
543
+ if (merge.status !== 1) return fail(merge);
544
+ const remote = run("git", ["config", `branch.${b}.remote`, "origin"]);
545
+ if (remote.status !== 0) return fail(remote);
546
+ const set = run("git", ["config", `branch.${b}.merge`, `refs/heads/${b}`]);
547
+ if (set.status !== 0) return fail(set);
548
+ ev.note(`upstream ${b} set`);
549
+ }
550
+
515
551
  function implReport(ev, member) {
516
- if (ev.settle(member) === "bailed") bailed(ev, member);
552
+ const outcome = ev.settle(member);
553
+ if (outcome === "bailed") bailed(ev, member);
554
+ else if (outcome?.startsWith("PR#")) claimUpstream(ev, member);
517
555
  }
518
556
 
519
557
  function fixReport(ev, member) {
package/scripts/reap.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # Delete local branches whose upstream is gone, and their worktrees.
2
+ # Delete local branches whose upstream is gone, and their worktrees. A branch with no upstream is reported as kept and never deleted.
3
3
  #
4
4
  # Dry-run by default; --apply mutates. Every precondition is recomputed inside
5
5
  # this invocation, because a branch list from an earlier call is already false:
@@ -683,12 +683,15 @@ null_head_why() {
683
683
  # away from them. %(refname) is always refs/heads/<name>, so the strip yields
684
684
  # the bare name for every branch, ambiguous or not.
685
685
  #
686
- # The $2=="[gone]" field test is deliberately untouched, and it still selects
687
- # the same branches: a space is not legal in a branch name (measured — git
688
- # refuses `has space` as invalid), so the name is always the whole of $1, and a
689
- # branch with no upstream leaves $2 empty rather than matching. The tracking
690
- # forms that DO carry a space, `[ahead 1]` and its siblings, split so that $2
691
- # holds `[ahead` — not `[gone]` either way.
686
+ # The [gone] test reads $3 and the no-upstream test reads $2: a space is not
687
+ # legal in a branch name (measured — git refuses `has space` as invalid), so
688
+ # the name is always the whole of $1, and $2 is `upstream=` followed by
689
+ # %(upstream)'s ref — exactly `upstream=` when the branch has none. The
690
+ # tracking forms that DO carry a space, `[ahead 1]` and its siblings, split so
691
+ # that $3 holds `[ahead` — not `[gone]` either way. That layout holds only
692
+ # while the upstream ref carries no whitespace: %(upstream) is built from the
693
+ # branch.<b>.merge value, which git does not validate, and a space there moves
694
+ # the track out of $3 (measured, git 2.50.1).
692
695
  # A command substitution inside a `for ... in` word list discards its own
693
696
  # exit status entirely, in every shell measured here (bash, dash, macOS
694
697
  # /bin/sh): `for x in $(false); do …; done` completes at the loop's own end,
@@ -713,9 +716,13 @@ null_head_why() {
713
716
  # the same reason — dying here would also abort the second sweep and the
714
717
  # final `worktree prune`, neither of which this enumeration failing has
715
718
  # anything to do with, over a failure that only defers reaping to a later pass.
716
- if ! git_probe for-each-ref --format='%(refname) %(upstream:track)' refs/heads; then
719
+ # `upstream=` makes an unset %(upstream) a field of its own. Without it the
720
+ # whitespace split drops the empty field, and `$2` reads the track instead, so
721
+ # `$2=="upstream="` is exactly the no-upstream case and `$3` the [gone] test.
722
+ if ! git_probe for-each-ref --format='%(refname) upstream=%(upstream) %(upstream:track)' refs/heads; then
717
723
  keep "" "could not enumerate [gone] branches — none reaped, and none reported reapable either$(gp_why)"
718
724
  gone_branches=""
725
+ no_upstream=""
719
726
  else
720
727
  # git_probe captures git's stderr into $gp_err instead of leaving it on the
721
728
  # real fd — the whole reason it exists — and every OTHER call site in this
@@ -726,11 +733,20 @@ else
726
733
  # `git … | awk …` pipeline with git's stderr inherited, and now reaches no
727
734
  # one unless forwarded here explicitly.
728
735
  [ -z "$gp_err" ] || printf '%s' "$gp_err" >&2
729
- if ! gone_branches=$(printf '%s\n' "$gp_out" | awk '$2=="[gone]"{sub(/^refs\/heads\//,"",$1); print $1}'); then
736
+ if ! gone_branches=$(printf '%s\n' "$gp_out" | awk '$3=="[gone]"{sub(/^refs\/heads\//,"",$1); print $1}'); then
730
737
  keep "" "could not enumerate [gone] branches — none reaped, and none reported reapable either"
731
738
  gone_branches=""
732
739
  fi
740
+ if ! no_upstream=$(printf '%s\n' "$gp_out" | awk '$2=="upstream="{sub(/^refs\/heads\//,"",$1); print $1}'); then
741
+ keep "" "could not enumerate branches with no upstream — none reported kept"
742
+ no_upstream=""
743
+ fi
733
744
  fi
745
+ # A branch with no upstream has no [gone] state to act on. It is reported and
746
+ # never selected for deletion, and no other guard runs on it.
747
+ for b in $no_upstream; do
748
+ keep "$b" "no upstream — never selected"
749
+ done
734
750
  for b in $gone_branches; do
735
751
 
736
752
  # The tip is read ONCE, here, and everything after it measures that frozen
@@ -20,6 +20,7 @@ Skill is maintainer's to fix. Do not patch it; do reap yourself.
20
20
  Every precondition recomputed **inside** same command as delete:
21
21
 
22
22
  - **`for-each-ref`, not `git branch | grep`.** `%(upstream:track)` emits exactly `[gone]` as own field. Nothing to pattern-match, no `-v`/`-vv` trap.
23
+ - **A branch with no upstream is reported `kept`, never selected.** `%(upstream)` is empty for it, so `%(upstream:track)` has no `[gone]` to match. The sweep reports it with reason `no upstream — never selected` and runs no other guard on it. Its upstream arrives with the first `-u` push, or when the controller settles the implementer's report.
23
24
  - **Recompute per branch, in this command.** Branch list from earlier tool call already false: one observed run listed 28 gone branches, two calls later 27 reaped by concurrent session. Benign direction is no-op; dangerous one is worktree that gained work *after* check.
24
25
  - **`git cherry origin/main`, not `git diff main..`.** Against `origin/main` — local `main` you never fast-forwarded reads every merged branch as unmerged. Any `+` line is commit that exists nowhere else.
25
26
  - **The delete is `git update-ref -d`'s compare-and-swap on a tip read once, authorized by that cherry check and by a fresh check, re-read immediately before the delete, that no worktree holds the branch.** Tip read before the cherry, cherry runs against that SHA, delete refuses unless ref still equals it — commit landing between check and delete is kept with git's own `cannot lock ref`, never force-deleted, as it was under `git branch -D`, the delete this replaced. `--no-deref`, so symref branch deletes the symref, not its target (measured). Branch's config section removed after, as `-D` did — left behind, re-created branch of same name inherits dead upstream and reads `[gone]` at once. `git branch -d` would refuse everything here: upstream gone, so falls back to comparing against `HEAD`, a possibly-behind local `main`. Never delete branch whose cherry output you did not just read.