Skip to content

fix(bin): make fm-teardown's landed-work proof forge-agnostic - #891

Open
mattwwatson wants to merge 2 commits into
kunchenguid:mainfrom
mattwwatson:up/teardown-landed-proof
Open

fix(bin): make fm-teardown's landed-work proof forge-agnostic#891
mattwwatson wants to merge 2 commits into
kunchenguid:mainfrom
mattwwatson:up/teardown-landed-proof

Conversation

@mattwwatson

Copy link
Copy Markdown

Intent

Upstream contribution from the mattwwatson/firstmate fork: make fm-teardown.sh's landed-work proof forge-agnostic by adding a default-branch patch-id proof (bounded, revert-safe), accepting Bitbucket /pull-requests/ PR URLs, and skipping GitHub-shaped refs/pull/* fetches on forges that do not publish them, so teardown refuses correctly on any forge. The new tests/fm-teardown.test.sh suite runs in the portable serial lane unless maintainers prefer shard placement; it passes isolated on the upstream base (42/42 locally).

What Changed

  • bin/fm-teardown.sh gains a third, forge-agnostic landed-work proof: work_is_landed now falls through to patches_in_default, which checks that every unpushed commit is present by patch id in the up-to-date default branch and still rolls back out of that branch's current tree (via a throwaway index, walked newest-first), so squash-merged work on any forge is recognized while landed-then-reverted work still refuses. The default-branch scan is bounded by a new FM_LANDED_PATCH_SCAN_LIMIT knob (default 1000; 0 or non-numeric means unbounded), documented in docs/configuration.md.
  • PR resolution is no longer GitHub-shaped only: pr_number_from_target accepts Bitbucket /pull-requests/<n> URLs, and ensure_commit_object skips the refs/pull/<n>/head fetch when origin is Bitbucket Cloud, which publishes no such refs (deny-list, so unrecognized hosts keep trying the fetch).
  • Adds a tests/fm-teardown.test.sh suite (42 cases, runs in the portable-serial lane, verified 42/42 by the pipeline's Test stage with a negative control against the base commit) and points docs/gitlab-merge-watch.md at both content and patch-id fallback proofs.

Risk Assessment

⚠️ Medium: The change widens the allow-path of a gate that authorizes destroying unpushed local work and leans on subtle git plumbing semantics (patch-id normalization, reverse binary apply, topo-order rollback), but every inconclusive path I traced refuses, the new proof requires both patch-id containment and a still-present rollback proof, and ten targeted tests cover the dangerous edges — safe to merge once the pipeline's test step confirms the suite passes.

Testing

Ran the full 42-case fm-teardown suite isolated (all pass, exit 0) and confirmed its portable-serial lane placement; proved the new forge-agnostic cases fail on the upstream base script (so they test the fix, not vacuously pass); then demonstrated the end-user behavior with a CLI transcript of the real fm-teardown.sh on Bitbucket-shaped repos — allowing squash-merged work via the default-branch patch-id proof with no GitHub-shaped refs/pull fetches, and refusing both never-landed and landed-then-reverted work. No UI surface is involved (CLI-only change), so the transcript is the reviewer-visible artifact.

Evidence: Bitbucket end-to-end teardown CLI transcript (ALLOW + 2 REFUSE scenarios, fetch log)

=== SCENARIO 1: Bitbucket Cloud, PR squash-merged then branch deleted (should ALLOW) ===
origin url: https://bitbucket.org/example/repo.git (no refs/pull/* namespace)
recorded pr= https://bitbucket.org/example/repo/pull-requests/7
-- unpushed commit on the task worktree (reachable from no remote):
40940f8 branch change
4611965 side work
-- fm-teardown task-x1:
/var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-squash-merged/project: synced 0468049..cf5c08d
teardown task-x1 complete (window fm-task-x1, worktree /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-squash-merged/wt)
Backlog: task-x1 just finished. Run tasks-axi done task-x1 --pr https://bitbucket.org/example/repo/pull-requests/7, then run tasks-axi ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due.
[exit status: 0]
-- fetches attempted during teardown (must contain NO refs/pull/*):
   git -C /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-squash-merged/wt fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
   git -C /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-squash-merged/wt fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
   git -C /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-squash-merged/project fetch origin --prune --quiet

=== SCENARIO 2: same shape but the patch NEVER landed on main (must REFUSE) ===
-- fm-teardown task-x1:
REFUSED: worktree /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-unlanded/wt has work not on any remote and not landed.
unpushed commits:
0bdb85a branch change
Push the branch, land its PR, or get the captain's explicit OK to discard, then --force.
[exit status: 1]
-- worktree preserved after refusal: yes

=== SCENARIO 3: patch landed on main but was later REVERTED (must REFUSE) ===
-- default branch history on the forge:
   e29d605 main advances (conflicts with branch)
   afd6ee2 Revert PR #7
   3eed4f9 squash-merged PR #7
   d8e6ae1 baseline other.txt
   ae52182 baseline shared.txt
   582eaaf origin baseline
-- fm-teardown task-x1:
REFUSED: worktree /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-teardown-demo.0HG00x/bb-reverted/wt has work not on any remote and not landed.
unpushed commits:
fad8c7f branch change
Push the branch, land its PR, or get the captain's explicit OK to discard, then --force.
[exit status: 1]
-- worktree preserved after refusal: yes

=== done ===
Evidence: Demo script that builds the Bitbucket-shaped sandboxes and drives the real bin/fm-teardown.sh
#!/usr/bin/env bash
# End-to-end demo of fm-teardown.sh's forge-agnostic landed-work proof.
# Builds real git sandboxes shaped like a Bitbucket Cloud squash-merge flow
# (origin URL on bitbucket.org, NO refs/pull/* namespace, no GitHub PR data)
# and runs the REAL bin/fm-teardown.sh against them, printing the exact CLI
# output an end user would see.
set -u

ROOT=$1          # firstmate checkout
DEMO=$(mktemp -d "${TMPDIR:-/tmp}/fm-teardown-demo.XXXXXX")
trap 'rm -rf "$DEMO"' EXIT
export GIT_AUTHOR_NAME=demo GIT_AUTHOR_EMAIL=demo@example.invalid
export GIT_COMMITTER_NAME=demo GIT_COMMITTER_EMAIL=demo@example.invalid

hr() { printf '\n=== %s ===\n' "$1"; }

# Build one sandbox: bare origin + project clone + task worktree, with mocks so
# teardown's post-check steps (treehouse/tmux) and forge lookups (gh/gh-axi:
# "no PR found", as on a forge with no gh) are hermetic.
make_case() {
  local name=$1 case_dir=$DEMO/$1 fakebin
  fakebin="$case_dir/fakebin"
  mkdir -p "$case_dir/state" "$case_dir/config" "$fakebin"
  printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/treehouse"
  printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/tmux"
  cat > "$fakebin/gh-axi" <<'SH'
#!/usr/bin/env bash
case "${1:-} ${2:-}" in
  "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 ;;
  "pr view") echo "error: pull request not found" >&2 ; exit 1 ;;
esac
exit 0
SH
  cp "$fakebin/gh-axi" "$fakebin/gh"
  # git wrapper: report a bitbucket.org origin URL and log every fetch, so the
  # transcript can prove no GitHub-shaped refs/pull/* fetch is ever attempted.
  cat > "$fakebin/git" <<SH
#!/usr/bin/env bash
real=\$(PATH=\${PATH#*fakebin:} command -v git)
case " \$* " in
  *" fetch "*) printf '%s\n' "git \$*" >> "$case_dir/fetch.log" ;;
  *" remote get-url origin "*) printf '%s\n' "https://bitbucket.org/example/repo.git"; exit 0 ;;
esac
exec "\$real" "\$@"
SH
  chmod +x "$fakebin/treehouse" "$fakebin/tmux" "$fakebin/gh-axi" "$fakebin/gh" "$fakebin/git"

  git init -q --bare "$case_dir/origin.git"
  git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main
  git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null
  git -C "$case_dir/_seed" commit -q --allow-empty -m "origin baseline"
  git -C "$case_dir/_seed" push -q origin main
  rm -rf "$case_dir/_seed"
  git clone -q "$case_dir/origin.git" "$case_dir/project"
  git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true
  git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main
  touch "$case_dir/state/.last-watcher-beat"
  { printf '%s\n' "window=fm-task-x1" "worktree=$case_dir/wt" \
      "project=$case_dir/project" "kind=ship" "mode=no-mistakes"
    [ -n "${2:-}" ] && printf 'pr=%s\n' "$2"
  } > "$case_dir/state/task-x1.meta"
  printf '%s\n' "$case_dir"
}

wt_commit_file() {
  local case_dir=$1 file=$2 content=$3 msg=$4
  printf '%s\n' "$content" > "$case_dir/wt/$file"
  git -C "$case_dir/wt" add -- "$file"
  git -C "$case_dir/wt" commit -q -m "$msg"
}

# The Bitbucket Cloud squash-merge shape:
#   origin/main = baseline -> [replayed patch] -> [revert?] -> conflicting advance
#   worktree    = baseline -> side work (pushed to side-work) -> patch (UNPUSHED)
# content_in_default is inconclusive (other.txt conflicts), and Bitbucket Cloud
# publishes no PR head ref — only the new default-branch patch-id proof can answer.
shape_case() {
  local case_dir=$1 landed=$2 tmp
  wt_commit_file "$case_dir" shared.txt baseline "baseline shared.txt"
  wt_commit_file "$case_dir" other.txt baseline "baseline other.txt"
  git -C "$case_dir/wt" push -q origin HEAD:refs/heads/main
  git -C "$case_dir/project" fetch -q origin main
  wt_commit_file "$case_dir" other.txt side "side work"
  git -C "$case_dir/wt" push -q origin HEAD:refs/heads/side-work
  git -C "$case_dir/wt" fetch -q origin
  wt_commit_file "$case_dir" shared.txt branch-change "branch change"   # never pushed

  tmp="$case_dir/_advance"
  git clone -q "$case_dir/origin.git" "$tmp"
  if [ "$landed" != no ]; then
    printf '%s\n' branch-change > "$tmp/shared.txt"
    git -C "$tmp" add shared.txt && git -C "$tmp" commit -q -m "squash-merged PR #7"
  fi
  if [ "$landed" = reverted ]; then
    printf '%s\n' baseline > "$tmp/shared.txt"
    git -C "$tmp" add shared.txt && git -C "$tmp" commit -q -m "Revert PR #7"
  fi
  printf '%s\n' "advanced past the branch" > "$tmp/other.txt"
  git -C "$tmp" add other.txt && git -C "$tmp" commit -q -m "main advances (conflicts with branch)"
  git -C "$tmp" push -q origin HEAD:main
  rm -rf "$tmp"
}

run_teardown() {
  local case_dir=$1 rc
  set +e
  FM_GATE_REFUSE_BYPASS=1 \
  FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" \
  FM_CONFIG_OVERRIDE="$case_dir/config" PATH="$case_dir/fakebin:$PATH" \
    "$ROOT/bin/fm-teardown.sh" task-x1
  rc=$?
  set -e
  echo "[exit status: $rc]"
}

hr "SCENARIO 1: Bitbucket Cloud, PR squash-merged then branch deleted (should ALLOW)"
echo "origin url: https://bitbucket.org/example/repo.git (no refs/pull/* namespace)"
echo "recorded pr= https://bitbucket.org/example/repo/pull-requests/7"
case1=$(make_case bb-squash-merged "https://bitbucket.org/example/repo/pull-requests/7")
shape_case "$case1" yes
echo "-- unpushed commit on the task worktree (reachable from no remote):"
git -C "$case1/wt" log --oneline origin/main..HEAD -- | head -3
echo "-- fm-teardown task-x1:"
run_teardown "$case1"
echo "-- fetches attempted during teardown (must contain NO refs/pull/*):"
sed 's/^/   /' "$case1/fetch.log"

hr "SCENARIO 2: same shape but the patch NEVER landed on main (must REFUSE)"
case2=$(make_case bb-unlanded "https://bitbucket.org/example/repo/pull-requests/7")
shape_case "$case2" no
echo "-- fm-teardown task-x1:"
run_teardown "$case2"
echo "-- worktree preserved after refusal: $(test -d "$case2/wt" && echo yes || echo NO)"

hr "SCENARIO 3: patch landed on main but was later REVERTED (must REFUSE)"
case3=$(make_case bb-reverted "https://bitbucket.org/example/repo/pull-requests/7")
shape_case "$case3" reverted
echo "-- default branch history on the forge:"
git -C "$case3/origin.git" log --oneline main | sed 's/^/   /'
echo "-- fm-teardown task-x1:"
run_teardown "$case3"
echo "-- worktree preserved after refusal: $(test -d "$case3/wt" && echo yes || echo NO)"

hr "done"
Evidence: Base-commit negative control (new tests fail on old fm-teardown.sh)
with bin/fm-teardown.sh @ 4497181 (base) + new test suite:
not ok - patch-id-default-range: teardown should succeed on the default-branch patch-id proof: expected exit 0, got 1
suite-exit=1

restored to f7ce2c4: suite 42/42 ok, exit 0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-teardown.sh:552 - work_is_landed's fall-through path fetches the default branch twice: content_in_default and patches_in_default each call default_ref_up_to_date, which re-runs git fetch origin +refs/heads/&lt;name&gt;:refs/remotes/origin/&lt;name&gt;. On every refusal and every patch-id rescue this doubles the network round-trip and adds a second transient-failure point. The comments state the proofs are deliberately independent and additive, and both failure modes are fail-safe (refuse), so this is a deliberate tradeoff worth acknowledging rather than an issue to fix; resolving the ref once in work_is_landed and passing it down would remove the duplicate fetch if it ever matters.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-teardown.test.sh isolated on the branch — 42/42 ok, exit 0 (matches the intent's 42/42 claim)
  • bin/fm-test-run.sh --list --lane portable-serial — confirms tests/fm-teardown.test.sh is selected by the portable-serial lane (pr-forge family)
  • Negative control: checked out base-commit bin/fm-teardown.sh (4497181) under the new test suite — first forge-agnostic case fails (expected exit 0, got 1), suite exit 1; restored to f7ce2c4 and re-verified suite exit 0
  • Manual end-to-end demo (bitbucket-teardown-demo.sh) driving the real bin/fm-teardown.sh against Bitbucket-shaped sandboxes: squash-merged+conflicting-advance → ALLOW (exit 0) via default-branch patch-id proof; never-landed → REFUSED (exit 1, worktree preserved); landed-then-reverted → REFUSED (exit 1, worktree preserved); fetch log shows no refs/pull/* fetch on a bitbucket.org origin and the /pull-requests/7 PR URL is accepted
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

fm-teardown.sh's landed-work test relied on GitHub-shaped refs/pull/*
fetches, so on forges that do not publish those refs a landed branch
could be misjudged. Add a third, forge-agnostic proof: patches_in_default
walks the default branch by patch-id (bounded by
FM_LANDED_PATCH_SCAN_LIMIT) so conflicted-advance cases are rescued on
any forge, with revert-rollback safety so a landed-then-reverted change
still refuses teardown. Accept Bitbucket /pull-requests/<n> URLs and
skip refs/pull/* fetches on hosts that do not publish them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant