diff --git a/AGENTS.md b/AGENTS.md index a32af6b4c96..94d609ef05a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. -Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, while the outward-facing PR remains open; `docs/configuration.md` owns which remote that PR opens on. +Firstmate repo tasks land through `bin/fm-merge-local.sh` once approved; [`docs/configuration.md`](docs/configuration.md#landing-remote-git-origin) owns the configured-fork sync and recorded GitHub PR read-back. Never add an agent name as a commit co-author. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags. diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 655d26df794..b9ea51f0e5c 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -15,6 +15,21 @@ # merge, so a captain approval must be recorded as an `answer --release` before # this entrypoint is invoked. The lock ends when the fast-forward returns; # docs/captain-hold-lifecycle.md owns the accepted merge-to-cleanup residual. +# +# For firstmate's own repository (bin/fm-self-repo-lib.sh), the landing also +# updates the fork: after the local fast-forward it pushes local default to the +# landing remote `origin` only after bin/fm-landing-remote.sh proves the remap +# and every effective origin push URL names that same remote, as a plain +# fast-forward, never forced and never anywhere else, then reads the remote +# branch back. When the task records a GitHub pr=, that PR must then read back +# merged through the same forge read bin/fm-pr-merge.sh uses, retried a bounded +# number of times while the forge catches up. A checkout with no `upstream` +# remote has no configured fork, so it reports that nothing was pushed. +# Exit status: 0 landed (and any configured fork was synced and proved); +# 3 landed locally but the fork sync or PR read-back was not proved - the local +# landing stays, and the message names why and the exact command to finish; +# any other non-zero value means nothing was landed. +# Project landings never push: local-only projects have no remote by design. # Usage: fm-merge-local.sh set -eu @@ -84,6 +99,7 @@ fi PROJ=$(grep '^project=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) +PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true) if [ "$MODE" != "local-only" ] && ! fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then echo "error: task $ID is mode=$MODE on $PROJ, not local-only; merge PR tasks with bin/fm-pr-merge.sh after approval" >&2 @@ -159,3 +175,114 @@ after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") # Opt-in fleet activity ledger (docs/fleet-ledger.md); off costs one file test. [ ! -e "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/fleet-ledger" ] || FM_HOME=$FM_HOME FM_STATE_OVERRIDE=$STATE "$SCRIPT_DIR/fm-fleet-ledger.sh" merged "$ID" local || true echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" + +# Firstmate's own repository also updates its landing remote, so the fork on +# the forge holds exactly what this home runs. Project landings stop here. +fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME" || exit 0 + +FORK_REMOTE=origin +LOCAL_SHA=$(git -C "$PROJ" rev-parse "refs/heads/$DEFAULT") +FINISH=$(printf 'git -C %q push %s refs/heads/%s:refs/heads/%s' "$PROJ" "$FORK_REMOTE" "$DEFAULT" "$DEFAULT") +# Never wait on a credential prompt nobody will answer. +export GIT_TERMINAL_PROMPT=0 + +# Report an unsynced fork and exit 3. The local landing above stays as it is. +fork_not_synced() { # [...] + echo "fork not updated: local $DEFAULT landed at $after, but $FORK_REMOTE/$DEFAULT was not updated: $1" >&2 + shift + local line + for line in "$@"; do + echo "$line" >&2 + done + exit 3 +} + +if ! git -C "$PROJ" remote | grep -Fx upstream >/dev/null 2>&1; then + echo "no fork is configured in $PROJ because there is no upstream remote; nothing was pushed" + exit 0 +fi +if ! upstream_url=$(git -C "$PROJ" config --local --get remote.upstream.url 2>&1) \ + || [ -z "$upstream_url" ]; then + fork_not_synced "upstream is present but has no configured URL" \ + "Repair the remotes, then finish with: $FINISH" +fi +if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + fork_not_synced "$FORK_REMOTE is absent from this remapped checkout" \ + "Repair the remotes, then finish with: $FINISH" +fi +# bin/fm-landing-remote.sh owns whether origin is the landing remote rather +# than the parent we forked from; any doubt keeps the push from happening. +if ! verify_output=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --repo "$PROJ" 2>&1); then + fork_not_synced "$FORK_REMOTE is not proven to be our fork: $verify_output" \ + "Repair the remotes as described, then finish with: $FINISH" +fi +if ! push_urls=$(git -C "$PROJ" remote get-url --push --all "$FORK_REMOTE" 2>&1) \ + || [ -z "$push_urls" ]; then + fork_not_synced "could not resolve where $FORK_REMOTE would push: $push_urls" \ + "Repair remote.$FORK_REMOTE.pushurl, then finish with: $FINISH" +fi +while IFS= read -r push_url; do + if ! push_verify=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --ours "$push_url" --repo "$PROJ" 2>&1); then + fork_not_synced "$FORK_REMOTE would push to $push_url rather than its verified fetch URL: $push_verify" \ + "Remove or correct remote.$FORK_REMOTE.pushurl, then finish with: $FINISH" + fi +done <&1); then + fork_not_synced "could not read $FORK_REMOTE/$DEFAULT: $remote_line" \ + "Finish with: $FINISH" +fi +REMOTE_SHA=${remote_line%%[[:space:]]*} +if [ -n "$REMOTE_SHA" ] && [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then + if ! git -C "$PROJ" cat-file -e "$REMOTE_SHA^{commit}" 2>/dev/null \ + && ! fetch_output=$(git -C "$PROJ" fetch --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1); then + fork_not_synced "could not fetch $FORK_REMOTE/$DEFAULT: $fetch_output" \ + "Finish with: $FINISH" + fi + if ! git -C "$PROJ" merge-base --is-ancestor "$REMOTE_SHA" "$LOCAL_SHA" 2>/dev/null; then + fork_not_synced "it is not a fast-forward: $FORK_REMOTE/$DEFAULT is at ${REMOTE_SHA:0:8}, which local $DEFAULT does not contain" \ + "See the missing commits with: git -C $(printf '%q' "$PROJ") log --oneline $LOCAL_SHA..$REMOTE_SHA" \ + "Bring them into local $DEFAULT through a reviewed task, then finish with: $FINISH" + fi +fi +if [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then + # A plain refspec without "+": the remote itself refuses anything but a + # fast-forward, even if its branch moved after the check above. + if ! push_output=$(git -C "$PROJ" push --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT:refs/heads/$DEFAULT" 2>&1); then + fork_not_synced "the push failed: $push_output" \ + "Finish with: $FINISH" + fi + if ! remote_line=$(git -C "$PROJ" ls-remote "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1) \ + || [ "${remote_line%%[[:space:]]*}" != "$LOCAL_SHA" ]; then + fork_not_synced "the push returned, but $FORK_REMOTE/$DEFAULT does not read back as ${LOCAL_SHA:0:8}: $remote_line" \ + "Check it, and if needed finish with: $FINISH" + fi + echo "pushed local $DEFAULT to $FORK_REMOTE/$DEFAULT (${REMOTE_SHA:0:8} -> ${LOCAL_SHA:0:8})" +else + echo "$FORK_REMOTE/$DEFAULT already at ${LOCAL_SHA:0:8}" +fi + +# A recorded PR is proved merged by the same forge read the merge path uses. +[ -n "$PR_URL" ] || exit 0 +if ! fm_pr_url_parse "$PR_URL" || [ "$FM_PR_PROVIDER" != github ]; then + echo "$FORK_REMOTE/$DEFAULT now holds local $DEFAULT; PR state was not checked because '$PR_URL' is not a GitHub pull request" + exit 0 +fi +# The forge marks a PR merged shortly after its head reaches the base branch, +# so the read is retried a bounded number of times. +readback_attempt=1 +while :; do + FM_PR_RECORD_STATE= + FM_PR_RECORD_MERGED= + fm_pr_github_read_record "$FM_PR_OWNER" "$FM_PR_REPO" "$FM_PR_NUMBER" || true + [ "$FM_PR_RECORD_MERGED" != true ] || break + if [ "$readback_attempt" -ge 5 ]; then + echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but $PR_URL does not read back as merged (state=${FM_PR_RECORD_STATE:-unreadable})" >&2 + echo "Re-check that PR on the forge; the fork itself is up to date." >&2 + exit 3 + fi + sleep 3 + readback_attempt=$((readback_attempt + 1)) +done +echo "verified: $PR_URL is merged" diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 84ae55998f8..238e0bf3cf2 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -7,6 +7,8 @@ # host and path, so any instance works and no host is hardcoded. A Gerrit change # is refused outright: that adapter is read-only, and the refusal at the parse # below owns why. +# Firstmate's own repository is local-authoritative, so this script refuses its +# tasks before any forge read or write and directs them to bin/fm-merge-local.sh. # # Merge method on GitHub defaults to --squash when the caller passes none of # --squash, --merge, --rebase, or --method after the optional -- separator. @@ -421,6 +423,11 @@ if [ "$FM_BACKLOG_META_SPAWN_GEN" != "$MERGE_EXPECTED_SPAWN_GEN" ]; then echo "error: task $ID changed incarnation while waiting to merge; refusing" >&2 exit 1 fi +PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) +if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then + echo "error: task $ID is Firstmate's local-authoritative repository; use bin/fm-merge-local.sh $ID after approval instead of merging its PR" >&2 + exit 1 +fi # Reading the merge request state needs both tools. Report them together and # before anything is recorded, so a missing tool is a named prerequisite rather @@ -1489,11 +1496,3 @@ case "$outcome_rc" in printf 'actionable: merged %s but could not record the outcome for supervision\n' "$URL" >&2 ;; esac - -# Firstmate's own repository is local-authoritative: the outward PR stays open on -# origin, and the proved merge above is followed by the guarded fast-forward into -# this home's local main. Reached only after the forge confirmed the merge landed. -PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) -if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then - "$SCRIPT_DIR/fm-merge-local.sh" "$ID" -fi diff --git a/bin/fm-self-repo-lib.sh b/bin/fm-self-repo-lib.sh index c2c9d501abf..22f7f7dec1c 100644 --- a/bin/fm-self-repo-lib.sh +++ b/bin/fm-self-repo-lib.sh @@ -5,14 +5,15 @@ # Firstmate ships work on itself, so its tracked code root (FM_ROOT) and its # operational home (FM_HOME) both turn up as a task's project directory. Four # decisions branch on that fact and must agree exactly: -# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward -# bin/fm-pr-merge.sh follows a merged PR with that same local landing +# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward, +# then syncs any configured fork +# bin/fm-pr-merge.sh refuses a forge merge for the local-authoritative repo # bin/fm-fleet-sync.sh leaves the checkout alone (upstream sync is manual) # bin/fm-spawn.sh refreshes a task worktree from the LOCAL default # branch instead of fetching origin # A project that counts as firstmate for one of them and not another is how a -# worker gets reset onto a remote tip the fleet never reviewed, or how a merged -# firstmate PR silently fails to reach the running tree. One predicate here +# worker gets reset onto a remote tip the fleet never reviewed, or how a +# Firstmate landing silently fails to reach its fork. One predicate here # keeps a later fix from reaching three call sites and missing the fourth. # # Comparison is by resolved PHYSICAL path, so a symlinked home, a trailing diff --git a/docs/architecture.md b/docs/architecture.md index 3a4735cbb40..461146e7d23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -399,7 +399,7 @@ When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshe A GitLab merge request and a Gerrit change expose no such ref, so a task recording one of those diffs the local branch under that same warning, which is its current content. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. -PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI. +PR-based task merges on remote-authoritative projects go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI; Firstmate's local-authoritative repository is refused before any forge read or write and lands through `bin/fm-merge-local.sh`. The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state. A `https://github.com///pull/` URL requires `gh` and `jq`, is merged only after live reads confirm the pull request is open, not a draft, mergeable, conflict-free, every unwaived check is green at the current head, and every unwaived check the base branch requires has reported at that head, then `gh pr merge` binds that verified head with `--match-head-commit`. A required check that never reported is absent from the checks list rather than red; [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns required-context sources, producer identity, partial-read refusals, and attended check waivers. @@ -428,6 +428,7 @@ The project-owned quality-gate contract and the receipt a hardened run must emit [`bin/fm-quality.sh`](../bin/fm-quality.sh) runs those commands, enforces the contract's bounds including its wall-clock one, and writes each phase's receipt; its header owns the round shape, the outcome-to-exit-code table, and the read-only mode that reports a standard task's scores without gating anything. A task recorded `quality=hardened` is not done until that receipt exists and passes, so `bin/fm-crew-state.sh` filters every `done` verdict through that script's own status verdict and leaves every other posture's line exactly as it was. `local-only` tasks and Firstmate's own repository tasks land into the local default branch through `bin/fm-merge-local.sh`. +[`configuration.md`](configuration.md#landing-remote-git-origin) owns the configured-fork sync that applies only to Firstmate's own repository. After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while an away record exists any green merge runs under away authority, while a quiet record keeps attended authority, and which merge the captain's away words meant is the supervision session's reading. A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced. [`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer. diff --git a/docs/configuration.md b/docs/configuration.md index 6d49269d591..bb1fd721a27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,6 +151,13 @@ Given `--ours` it is an identity check on `origin`; with no `--ours` it asserts `bin/fm-bootstrap.sh` runs the second form against the firstmate primary at every session start and relays a refusal as one `LANDING_REMOTE:` line, so a checkout that drifted back toward the parent - or that was never remapped at all - is surfaced there rather than discovered by a branch, a push, or a PR that went to the wrong repository. A clone with neither an `upstream` nor a `fork` remote never had a parent to be remapped away from, so the check passes silently for it. +In a remapped checkout, every approved Firstmate landing updates this remote. +`bin/fm-merge-local.sh` pushes the local default branch to the same branch on `origin` as a plain fast-forward only after `verify` passes and every effective push URL identifies `origin`'s verified fetch URL. +A checkout without an `upstream` remote has no configured fork, so the local landing succeeds without a push. +If a configured fork cannot be proved or synchronized, the local landing remains and the command exits non-zero with the reason and the exact push command to finish after repairing the remote. +When the task records a GitHub PR, the command also confirms that GitHub reads it as merged. +The script header owns the exact checks, read-back retries, messages, and exit statuses. + ## Calm preference (config/calm) The Pi Calm extension and the Claude Code Calm mod share the local, gitignored `config/calm` preference under the effective Firstmate home. diff --git a/docs/scripts.md b/docs/scripts.md index f1abc17853b..35de18da2b0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -80,7 +80,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-forge-detect.sh` | Propose a clone's forge binding from its origin remote for project-add intake, never recording it | | `fm-quality.sh` | Run a project's quality phase under its own bounds, write its receipt, report one outcome | | `fm-quality-receipt.sh` | Validate a quality-gate receipt against the D2 schema, or print that schema | -| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval, then sync Firstmate's configured fork | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder | @@ -144,7 +144,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated pull-request, merge-request, and Gerrit-change poll sidecars | | `fm-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals | | `fm-pr-check.sh` | Record validated task `pr=` and `pr_head=` values, then atomically arm a static merge poll; refuses GitHub drafts and persistent secondmate records (see [architecture.md](architecture.md)) | -| `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, refuse a Gerrit change because firstmate never submits one, then refuse an outcome it cannot prove landed or queued | +| `fm-pr-merge.sh` | Record PR metadata and merge a remote-authoritative task's canonical full GitHub or GitLab URL, while refusing Firstmate's local-authoritative repository and Gerrit changes before forge access | | `fm-pr-state.sh` | Read-only: print one line per GitHub pull-request blocker it can see, reporting on checks that have reported rather than verdicting merge-readiness | | `fm-pr-reviewers.sh` | Read-only: suggest reviewers from GitHub's own author mapping of recent commits on a pull request's changed files, never requesting one | | `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome | diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index ee5af32f17d..ecd9394265a 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -16,6 +16,16 @@ # (i) refuses when project checkout is not on its default branch # (j) refuses when project checkout is dirty # (k) refuses when branch has diverged (not a fast-forward) +# (l) Firstmate's own repository pushes the landing to origin as a fast-forward +# (m) Firstmate's own repository keeps the landing but reports, exit 3, an +# origin that local main does not contain, and leaves origin untouched +# (n) Firstmate's own repository keeps the landing but reports, exit 3, a +# push the remote rejects +# (o) Firstmate's own repository reads a recorded PR back as merged, and +# reports, exit 3, one that does not read back merged +# (p) Firstmate's own repository skips a checkout with no configured fork +# (q) Firstmate's own repository refuses an incomplete or unsafe fork remap +# (r) Firstmate's own repository does not read back a non-GitHub PR # # "Firstmate's own repository" is one shared predicate, bin/fm-self-repo-lib.sh, # used identically by fm-merge-local.sh, fm-pr-merge.sh, fm-fleet-sync.sh, and @@ -497,6 +507,438 @@ test_refuses_diverged_branch() { pass "fm-merge-local refuses when branch has diverged (not a fast-forward)" } +# A remapped firstmate repository whose origin and upstream are local bare +# clones, plus one ship branch ahead of main. Prints origin's path. +make_fm_repo_with_origin() { + local case_dir=$1 id=$2 fm_root="$1/firstmate" remote="$1/origin.git" upstream="$1/upstream.git" + mkdir -p "$case_dir/state" + make_repo "$fm_root" main + fm_git_add_origin "$fm_root" "$remote" + git clone --quiet --bare "$fm_root" "$upstream" + git -C "$fm_root" remote add upstream "file://$upstream" + git -C "$fm_root" config checkout.defaultRemote origin + git -C "$fm_root" config remote.pushDefault origin + git -C "$fm_root" config remote.origin.gh-resolved base + git -C "$fm_root" checkout -b "fm/$id" --quiet + echo "firstmate feature $id" >> "$fm_root/file.txt" + git -C "$fm_root" commit --quiet -am "firstmate fix $id" + git -C "$fm_root" checkout main --quiet + printf '%s\n' "$remote" +} + +# A gh stand-in that answers the PR read-back with FAKE_GH_STATE. +make_fake_gh() { + local fakebin + fakebin=$(fm_fakebin "$1") + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FAKE_GH_LOG" +case "${FAKE_GH_STATE:-MERGED}" in + MERGED) printf 'state=MERGED\nmerged=true\n' ;; + *) printf 'state=%s\nmerged=false\n' "$FAKE_GH_STATE" ;; +esac +SH + chmod +x "$fakebin/gh" + fm_fake_exit0 "$fakebin" sleep + printf '%s\n' "$fakebin" +} + +run_fm_merge_local() { # + local case_dir=$1 fm_root="$1/firstmate" + FM_ROOT_OVERRIDE="$fm_root" \ + FM_HOME="$fm_root" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + run_merge_local "$2" > "$case_dir/stdout" 2> "$case_dir/stderr" +} + +test_firstmate_repo_pushes_fork() { + local case_dir fm_root remote rc local_sha + case_dir="$TMP_ROOT/fm-push-fork" + remote=$(make_fm_repo_with_origin "$case_dir" task-push) + fm_root="$case_dir/firstmate" + fm_write_meta "$case_dir/state/task-push.meta" \ + "window=fm-task-push" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-push + rc=$? + set -e + + expect_code 0 "$rc" "push-fork: fm-merge-local should succeed: $(cat "$case_dir/stderr")" + local_sha=$(git -C "$fm_root" rev-parse main) + assert_equals "$(git -C "$fm_root" rev-parse fm/task-push)" "$local_sha" \ + "push-fork: local main was not fast-forwarded" + assert_equals "$local_sha" "$(git -C "$remote" rev-parse main)" \ + "push-fork: origin main does not hold local main" + assert_grep "pushed local main to origin/main" "$case_dir/stdout" \ + "push-fork: the push was not reported" + pass "fm-merge-local pushes Firstmate's own landing to origin as a fast-forward" +} + +test_firstmate_repo_refuses_non_fast_forward_push() { + local case_dir fm_root remote other rc remote_before + case_dir="$TMP_ROOT/fm-push-diverged" + remote=$(make_fm_repo_with_origin "$case_dir" task-div2) + fm_root="$case_dir/firstmate" + other="$case_dir/other" + git clone --quiet "$remote" "$other" + echo "landed elsewhere" > "$other/elsewhere.txt" + git -C "$other" add elsewhere.txt + git -C "$other" commit --quiet -m "commit only the fork has" + git -C "$other" push --quiet origin main + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-div2.meta" \ + "window=fm-task-div2" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-div2 + rc=$? + set -e + + expect_code 3 "$rc" "push-diverged: an unsynced fork must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-div2)" "$(git -C "$fm_root" rev-parse main)" \ + "push-diverged: the local landing was not kept" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-diverged: origin main moved" + assert_grep "fork not updated" "$case_dir/stderr" \ + "push-diverged: the unsynced fork was not reported" + assert_grep "not a fast-forward" "$case_dir/stderr" \ + "push-diverged: the reason was not given" + assert_grep "push origin refs/heads/main:refs/heads/main" "$case_dir/stderr" \ + "push-diverged: the finishing command was not given" + assert_no_grep "pushed local main" "$case_dir/stdout" \ + "push-diverged: a push was claimed" + pass "fm-merge-local keeps the landing and reports a fork it cannot fast-forward" +} + +test_firstmate_repo_reports_push_failure() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-push-rejected" + remote=$(make_fm_repo_with_origin "$case_dir" task-rej) + fm_root="$case_dir/firstmate" + printf '#!/bin/sh\necho "fork refuses pushes"\nexit 1\n' > "$remote/hooks/pre-receive" + chmod +x "$remote/hooks/pre-receive" + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-rej.meta" \ + "window=fm-task-rej" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=direct-PR" + + set +e + run_fm_merge_local "$case_dir" task-rej + rc=$? + set -e + + expect_code 3 "$rc" "push-rejected: a failed push must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-rej)" "$(git -C "$fm_root" rev-parse main)" \ + "push-rejected: the local landing was not kept" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-rejected: origin main moved" + assert_grep "the push failed" "$case_dir/stderr" \ + "push-rejected: the failed push was not reported" + assert_grep "fork refuses pushes" "$case_dir/stderr" \ + "push-rejected: the remote's reason was not passed on" + assert_grep "push origin refs/heads/main:refs/heads/main" "$case_dir/stderr" \ + "push-rejected: the finishing command was not given" + pass "fm-merge-local keeps the landing and reports a push the fork rejects" +} + +test_firstmate_repo_reads_pr_back_merged() { + local case_dir fm_root fakebin rc + case_dir="$TMP_ROOT/fm-pr-readback" + make_fm_repo_with_origin "$case_dir" task-prm >/dev/null + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + fm_write_meta "$case_dir/state/task-prm.meta" \ + "window=fm-task-prm" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "pr=https://github.com/example/firstmate/pull/7" + + set +e + PATH="$fakebin:$PATH" FAKE_GH_STATE=MERGED FAKE_GH_LOG="$case_dir/gh.log" \ + run_fm_merge_local "$case_dir" task-prm + rc=$? + set -e + + expect_code 0 "$rc" "pr-readback: a merged PR should succeed: $(cat "$case_dir/stderr")" + assert_grep "verified: https://github.com/example/firstmate/pull/7 is merged" "$case_dir/stdout" \ + "pr-readback: the merged PR was not verified" + assert_grep "number=7" "$case_dir/gh.log" \ + "pr-readback: the recorded PR was not the one read" + pass "fm-merge-local reads a recorded PR back as merged after the push" +} + +test_firstmate_repo_reports_pr_not_merged() { + local case_dir fm_root remote fakebin rc + case_dir="$TMP_ROOT/fm-pr-open" + remote=$(make_fm_repo_with_origin "$case_dir" task-pro) + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + fm_write_meta "$case_dir/state/task-pro.meta" \ + "window=fm-task-pro" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "pr=https://github.com/example/firstmate/pull/8" + + set +e + PATH="$fakebin:$PATH" FAKE_GH_STATE=OPEN FAKE_GH_LOG="$case_dir/gh.log" \ + run_fm_merge_local "$case_dir" task-pro + rc=$? + set -e + + expect_code 3 "$rc" "pr-open: a PR that does not read back merged must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "pr-open: the fork was not updated" + assert_grep "does not read back as merged (state=OPEN)" "$case_dir/stderr" \ + "pr-open: the unproved merge was not reported" + assert_no_grep "verified:" "$case_dir/stdout" \ + "pr-open: an unproved merge was claimed" + pass "fm-merge-local reports a recorded PR that does not read back merged" +} + +test_firstmate_repo_skips_single_origin_without_fork() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-no-configured-fork" + fm_root="$case_dir/firstmate" + remote="$case_dir/origin.git" + mkdir -p "$case_dir/state" + make_repo "$fm_root" main + fm_git_add_origin "$fm_root" "$remote" + git -C "$fm_root" checkout -b fm/task-no-fork --quiet + echo "firstmate feature" >> "$fm_root/file.txt" + git -C "$fm_root" commit --quiet -am "firstmate fix" + git -C "$fm_root" checkout main --quiet + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-no-fork.meta" \ + "window=fm-task-no-fork" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-no-fork + rc=$? + set -e + + expect_code 0 "$rc" "no-configured-fork: a single-origin checkout should succeed" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "no-configured-fork: origin was pushed" + assert_grep "no fork is configured" "$case_dir/stdout" \ + "no-configured-fork: the skipped push was not explained" + assert_grep "nothing was pushed" "$case_dir/stdout" \ + "no-configured-fork: the output did not say that nothing was pushed" + pass "fm-merge-local skips a checkout with no configured fork" +} + +test_firstmate_repo_refuses_missing_origin_after_remap() { + local case_dir fm_root upstream rc upstream_before + case_dir="$TMP_ROOT/fm-remap-missing-origin" + make_fm_repo_with_origin "$case_dir" task-missing-origin >/dev/null + fm_root="$case_dir/firstmate" + upstream="$case_dir/upstream.git" + upstream_before=$(git -C "$upstream" rev-parse main) + git -C "$fm_root" remote remove origin + fm_write_meta "$case_dir/state/task-missing-origin.meta" \ + "window=fm-task-missing-origin" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-missing-origin + rc=$? + set -e + + expect_code 3 "$rc" "missing-origin: an incomplete remap must exit 3" + assert_equals "$upstream_before" "$(git -C "$upstream" rev-parse main)" \ + "missing-origin: upstream main moved" + assert_grep "fork not updated" "$case_dir/stderr" \ + "missing-origin: the unsynced fork was not reported" + assert_grep "origin is absent" "$case_dir/stderr" \ + "missing-origin: the incomplete remap was not explained" + pass "fm-merge-local reports a remapped checkout whose origin is missing" +} + +test_firstmate_repo_refuses_upstream_without_url() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-remap-upstream-without-url" + remote=$(make_fm_repo_with_origin "$case_dir" task-upstream-url) + fm_root="$case_dir/firstmate" + remote_before=$(git -C "$remote" rev-parse main) + git -C "$fm_root" config --unset-all remote.upstream.url + fm_write_meta "$case_dir/state/task-upstream-url.meta" \ + "window=fm-task-upstream-url" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-upstream-url + rc=$? + set -e + + expect_code 3 "$rc" "upstream-without-url: an incomplete remap must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "upstream-without-url: origin main moved" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-upstream-url)" \ + "$(git -C "$fm_root" rev-parse main)" \ + "upstream-without-url: local main was not landed" + assert_grep "fork not updated" "$case_dir/stderr" \ + "upstream-without-url: the unsynced fork was not reported" + assert_grep "upstream is present but has no configured URL" "$case_dir/stderr" \ + "upstream-without-url: the malformed remap was not explained" + pass "fm-merge-local refuses a named upstream remote without a URL" +} + +test_firstmate_repo_never_pushes_unproven_origin() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-push-unproven" + remote=$(make_fm_repo_with_origin "$case_dir" task-unp) + fm_root="$case_dir/firstmate" + # A leftover fork remote means the remap is incomplete. + git -C "$fm_root" remote add fork "file://$case_dir/elsewhere.git" + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-unp.meta" \ + "window=fm-task-unp" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-unp + rc=$? + set -e + + expect_code 3 "$rc" "push-unproven: an unproven origin must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-unproven: origin main moved" + assert_grep "origin is not proven to be our fork" "$case_dir/stderr" \ + "push-unproven: the refusal was not explained" + pass "fm-merge-local never pushes to an origin that may be the parent" +} + +test_firstmate_repo_never_uses_mismatched_push_url() { + local case_dir fm_root remote upstream rc remote_before upstream_before origin_url upstream_url + case_dir="$TMP_ROOT/fm-mismatched-push-url" + remote=$(make_fm_repo_with_origin "$case_dir" task-push-url) + fm_root="$case_dir/firstmate" + upstream="$case_dir/upstream.git" + origin_url=$(git -C "$fm_root" remote get-url origin) + upstream_url=$(git -C "$fm_root" remote get-url upstream) + git -C "$fm_root" remote set-url --add --push origin "$origin_url" + git -C "$fm_root" remote set-url --add --push origin "$upstream_url" + remote_before=$(git -C "$remote" rev-parse main) + upstream_before=$(git -C "$upstream" rev-parse main) + fm_write_meta "$case_dir/state/task-push-url.meta" \ + "window=fm-task-push-url" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-push-url + rc=$? + set -e + + expect_code 3 "$rc" "push-url: a mismatched push URL must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-url: origin main moved" + assert_equals "$upstream_before" "$(git -C "$upstream" rev-parse main)" \ + "push-url: upstream main moved" + assert_grep "would push to $upstream_url rather than its verified fetch URL" "$case_dir/stderr" \ + "push-url: the unsafe destination was not explained" + pass "fm-merge-local checks every configured origin push URL before pushing" +} + +test_firstmate_repo_skips_non_github_pr_readback() { + local case_dir fm_root remote rc + case_dir="$TMP_ROOT/fm-non-github-pr" + remote=$(make_fm_repo_with_origin "$case_dir" task-non-github) + fm_root="$case_dir/firstmate" + fm_write_meta "$case_dir/state/task-non-github.meta" \ + "window=fm-task-non-github" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" \ + "pr=https://gitlab.example.com/group/firstmate/-/merge_requests/9" + + set +e + run_fm_merge_local "$case_dir" task-non-github + rc=$? + set -e + + expect_code 0 "$rc" "non-github-pr: a proved fork push should succeed" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "non-github-pr: the fork was not updated" + assert_grep "PR state was not checked" "$case_dir/stdout" \ + "non-github-pr: the skipped provider read-back was not reported" + pass "fm-merge-local stops after branch proof for a non-GitHub PR" +} + +test_firstmate_repo_uses_locked_pr_snapshot() { + local case_dir fm_root remote fakebin real_git real_sleep ready release pid i rc + case_dir="$TMP_ROOT/fm-locked-pr-snapshot" + remote=$(make_fm_repo_with_origin "$case_dir" task-pr-snapshot) + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + real_git=$(command -v git) + real_sleep=$(command -v sleep) + ready="$case_dir/post-lock.ready" + release="$case_dir/post-lock.release" + fm_write_meta "$case_dir/state/task-pr-snapshot.meta" \ + "window=fm-task-pr-snapshot" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "spawn_gen=original" \ + "pr=https://github.com/BohnBawerick/firstmate/pull/7" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +if [ "$*" = "-C ${FM_TEST_RACE_REPO} rev-parse refs/heads/main" ]; then + output=$("$FM_TEST_REAL_GIT" "$@") || exit $? + : > "$FM_TEST_RACE_READY" + while [ ! -e "$FM_TEST_RACE_RELEASE" ]; do + "$FM_TEST_REAL_SLEEP" 0.01 + done + printf '%s\n' "$output" + exit 0 +fi +exec "$FM_TEST_REAL_GIT" "$@" +SH + chmod +x "$fakebin/git" + + PATH="$fakebin:$PATH" \ + FAKE_GH_LOG="$case_dir/gh.log" \ + FAKE_GH_STATE=MERGED \ + FM_TEST_RACE_REPO="$fm_root" \ + FM_TEST_RACE_READY="$ready" \ + FM_TEST_RACE_RELEASE="$release" \ + FM_TEST_REAL_GIT="$real_git" \ + FM_TEST_REAL_SLEEP="$real_sleep" \ + run_fm_merge_local "$case_dir" task-pr-snapshot & + pid=$! + + i=0 + while [ ! -e "$ready" ] && kill -0 "$pid" 2>/dev/null && [ "$i" -lt 500 ]; do + "$real_sleep" 0.01 + i=$((i + 1)) + done + if [ ! -e "$ready" ]; then + : > "$release" + wait "$pid" || true + fail "locked-pr-snapshot: merge did not reach the post-lock fork read: $(cat "$case_dir/stderr")" + fi + if [ -e "$case_dir/state/.control-task-pr-snapshot.lock" ]; then + : > "$release" + wait "$pid" || true + fail "locked-pr-snapshot: fork read began before the task control lock was released" + fi + + fm_write_meta "$case_dir/state/task-pr-snapshot.meta" \ + "window=fm-task-pr-snapshot-reused" "worktree=$case_dir/reused-wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "spawn_gen=replacement" \ + "pr=https://github.com/BohnBawerick/firstmate/pull/99" + : > "$release" + set +e + wait "$pid" + rc=$? + set -e + + expect_code 0 "$rc" "locked-pr-snapshot: proved fork sync should succeed: $(cat "$case_dir/stderr")" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "locked-pr-snapshot: the fork was not updated" + assert_grep "number=7" "$case_dir/gh.log" \ + "locked-pr-snapshot: the original task PR was not read back" + assert_no_grep "number=99" "$case_dir/gh.log" \ + "locked-pr-snapshot: the replacement task PR was read back" + assert_grep "verified: https://github.com/BohnBawerick/firstmate/pull/7 is merged" "$case_dir/stdout" \ + "locked-pr-snapshot: the original task PR was not reported" + pass "fm-merge-local keeps the locked task PR through fork sync" +} + test_shared_firstmate_repo_predicate_contract test_fast_forward_local_only_project test_fast_forward_no_mistakes_firstmate_repo @@ -510,3 +952,15 @@ test_refuses_missing_branch test_refuses_dirty_project test_refuses_off_default_project test_refuses_diverged_branch +test_firstmate_repo_pushes_fork +test_firstmate_repo_refuses_non_fast_forward_push +test_firstmate_repo_reports_push_failure +test_firstmate_repo_reads_pr_back_merged +test_firstmate_repo_reports_pr_not_merged +test_firstmate_repo_skips_single_origin_without_fork +test_firstmate_repo_refuses_missing_origin_after_remap +test_firstmate_repo_refuses_upstream_without_url +test_firstmate_repo_never_pushes_unproven_origin +test_firstmate_repo_never_uses_mismatched_push_url +test_firstmate_repo_skips_non_github_pr_readback +test_firstmate_repo_uses_locked_pr_snapshot diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index ae34f99b13d..1e204461a38 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -2358,9 +2358,9 @@ test_secondmate_without_parent_binding_is_loud() { pass "a secondmate home that cannot report upward says so instead of merging in silence" } -test_merges_firstmate_repo_locally() { +test_refuses_firstmate_repo_before_forge_merge() { local case_dir fm_root state_dir rc before after - case_dir=$(make_case fm-pr-merge-local) + case_dir=$(make_case fm-pr-merge-refuses-local-authority) fm_root="$case_dir/firstmate" state_dir="$case_dir/state" mkdir -p "$state_dir" "$case_dir/fakebin" "$fm_root/data" @@ -2391,12 +2391,17 @@ test_merges_firstmate_repo_locally() { rc=$? set -e - expect_code 0 "$rc" "fm-pr-merge-local: fm-pr-merge should succeed: $(cat "$case_dir/stderr")" + expect_code 1 "$rc" "fm-pr-merge-local: fm-pr-merge should refuse Firstmate's repository" after=$(git -C "$fm_root" rev-parse HEAD) - [ "$before" != "$after" ] || fail "fm-pr-merge-local: local main was not advanced after remote merge" - assert_grep "merged fm/task-fmpr into local main" "$case_dir/stdout" \ - "fm-pr-merge-local: local fast-forward output was not printed" - pass "fm-pr-merge fast-forwards local main when project is Firstmate's own repository" + assert_equals "$before" "$after" \ + "fm-pr-merge-local: local main changed despite the refusal" + [ ! -s "$case_dir/gh.log" ] && [ ! -s "$case_dir/gh-axi.log" ] \ + || fail "fm-pr-merge-local: a forge command ran for the local-authoritative repository" + assert_no_grep '^pr=' "$state_dir/task-fmpr.meta" \ + "fm-pr-merge-local: PR metadata was recorded before the refusal" + assert_grep "use bin/fm-merge-local.sh task-fmpr after approval" "$case_dir/stderr" \ + "fm-pr-merge-local: the refusal did not point at the local landing command" + pass "fm-pr-merge refuses Firstmate's repository before any forge merge" } test_github_zero_exit_queue_required_refuses_with_exact_retry @@ -3885,7 +3890,7 @@ test_queued_github_merge_leaves_the_poll_armed test_distinct_merged_prs_keep_distinct_wakes test_uncommitted_marker_retry_is_never_silent test_secondmate_without_parent_binding_is_loud -test_merges_firstmate_repo_locally +test_refuses_firstmate_repo_before_forge_merge test_absent_backlog_still_merges test_unreadable_backlog_refuses_the_merge test_unreadable_backend_config_refuses_the_merge