diff --git a/README.md b/README.md index ea321a8f8c..12408a8b19 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract. - [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees. -- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for watching and merging GitLab merge requests on arbitrary instances. +- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. - [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, Cursor, and unknown harness fallback. diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index dea5e34e7b..96cb14dc93 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -71,8 +71,6 @@ fi # bin/fm-teardown.sh reads the head from the forge at teardown rather than from # metadata and falls back to its provider-agnostic content check, and # bin/fm-review-diff.sh resolves the head from the remote when none is recorded. -# bin/fm-pr-merge.sh reads a GitLab head live at merge time for the same reason, -# and treats a recorded value that disagrees as stale rather than authoritative. WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true) PR_HEAD= if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index b8ea9eb8fd..b70d846889 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -163,8 +163,8 @@ fm_pr_gitlab_path_valid() { # # FM_PR_OWNER and FM_PR_REPO are additionally set for github because # bin/fm-pr-merge.sh addresses GitHub by owner/repository. A gitlab URL leaves -# them empty, and that path addresses the project by FM_PR_HOST and FM_PR_PATH -# instead, so a merge request on any instance resolves without a hardcoded host. +# them empty; teaching the merge path about GitLab is a separate change, and +# until then it refuses a GitLab URL rather than merging anything. fm_pr_url_parse() { local raw=${1-} pattern host path local LC_ALL=C diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 9afd4e4acf..8226798a67 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -1,33 +1,13 @@ #!/usr/bin/env bash -# Merge a task's PR or MR after recording pr= and any available pr_head= through +# Merge a task's PR after recording pr= and any available pr_head= through # bin/fm-pr-check.sh, so teardown can verify landed work after squash merges. -# The full canonical URL is parsed by bin/fm-pr-lib.sh. A GitHub pull request is -# addressed through gh-axi by the derived owner and repository; a GitLab merge -# request is addressed through glab by the project URL rebuilt from the parsed -# host and path, so any instance works and no host is hardcoded. +# The full canonical GitHub PR URL is parsed by bin/fm-pr-lib.sh and the derived +# owner/repository and PR number are passed to gh-axi as separate arguments. # -# Merge method on GitHub defaults to --squash when the caller passes none of -# --squash, --merge, --rebase, or --method after the optional -- separator. -# GitLab adds no method flag at all: its merge method is the project's own -# setting, which the merge API applies, and imposing squash there would override -# that convention rather than mirror the GitHub default. -# -# A GitLab merge is refused unless every pre-merge condition holds, each read -# live at merge time rather than taken from recorded metadata: the merge request -# is open, detailed_merge_status is mergeable, has_conflicts is false, -# blocking_discussions_resolved is true, and the head pipeline succeeded at the -# exact current head commit. Every failing condition is reported, not just the -# first. The verified head is then passed to glab as --sha, so a push that lands -# between that read and the merge fails the merge instead of landing commits -# nothing verified. A recorded pr_head that disagrees with the live head is -# reported rather than trusted, because a rebase moves the head and leaves the -# recorded value stale. Reading that state needs glab and jq, and either one -# absent stops the merge before any state is recorded. -# -# Extra args must not include --repo or -R in any form, including a bundled -# short-option cluster such as -yR, because the repository comes only from the -# URL, nor --sha on GitLab because the head comes only from the live read. -# Usage: fm-pr-merge.sh [-- ] +# Merge method defaults to --squash when the caller passes none of --squash, +# --merge, --rebase, or --method after the optional -- separator. Extra args +# must not include --repo or -R because the repository comes only from the URL. +# Usage: fm-pr-merge.sh [-- ] set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -44,18 +24,18 @@ if [ "$#" -lt 2 ]; then fi ID=$1 RAW_URL=$2 -if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL"; then +# bin/fm-pr-lib.sh parses GitLab merge request URLs so the watcher can follow +# them, but this path still addresses only GitHub by owner/repository. The +# provider check holds that refusal exactly as it was until merge parity lands. +if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL" \ + || [ "$FM_PR_PROVIDER" != github ]; then echo "error: invalid PR merge request" >&2 exit 2 fi URL=$FM_PR_URL -PROVIDER=$FM_PR_PROVIDER PR_OWNER=$FM_PR_OWNER PR_REPO=$FM_PR_REPO PR_NUMBER=$FM_PR_NUMBER -# glab resolves the instance from the project URL passed to -R, so the host is -# rebuilt from the parsed identity rather than read from any ambient default. -PROJECT_URL="https://$FM_PR_HOST/$FM_PR_PATH" shift 2 [ "${1:-}" = "--" ] && shift @@ -73,14 +53,7 @@ reject_repo_overrides() { local arg for arg in "$@"; do case "$arg" in - --repo|--repo=*) - echo "error: extra merge arguments must not override the repository" >&2 - return 1 - ;; - --*) ;; - # A single-dash argument is a short-option cluster, which both CLIs expand - # one character at a time, so -yR carries --repo exactly as a bare -R does. - -*R*) + --repo|--repo=*|-R|-R?*) echo "error: extra merge arguments must not override the repository" >&2 return 1 ;; @@ -88,20 +61,7 @@ reject_repo_overrides() { done } -reject_head_overrides() { - local arg - for arg in "$@"; do - case "$arg" in - --sha|--sha=*) - echo "error: extra merge arguments must not override the head commit" >&2 - return 1 - ;; - esac - done -} - reject_repo_overrides "$@" || exit 1 -[ "$PROVIDER" != gitlab ] || reject_head_overrides "$@" || exit 1 # Task-derived paths are constructed only after the canonical ID validation. META="$STATE/$ID.meta" @@ -110,154 +70,15 @@ if [ ! -f "$META" ] || [ -L "$META" ]; then 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 -# than a merge that is armed and then refused for an unexplained reason. -GITLAB_MISSING= -if [ "$PROVIDER" = gitlab ]; then - command -v glab >/dev/null 2>&1 || GITLAB_MISSING="glab" - if ! command -v jq >/dev/null 2>&1; then - GITLAB_MISSING="${GITLAB_MISSING:+$GITLAB_MISSING and }jq" - fi - if [ -n "$GITLAB_MISSING" ]; then - echo "error: merging a GitLab merge request requires $GITLAB_MISSING on PATH" >&2 - exit 1 - fi -fi - -# The recorded head is read before bin/fm-pr-check.sh rewrites the metadata, -# because that script re-records pr= and drops a pr_head= it cannot resolve. -RECORDED_HEAD= -if [ "$PROVIDER" = gitlab ]; then - RECORDED_HEAD=$(grep '^pr_head=' "$META" | tail -1 | cut -d= -f2- || true) -fi - "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL" grep -qxF "pr=$URL" "$META" || { echo "error: PR metadata recording failed" >&2 exit 1 } -# Pre-merge conditions for a GitLab merge request, read from one live view of -# the merge request. Sets FM_PR_MERGE_HEAD to the verified head on success and -# returns non-zero after reporting every condition that failed. -FM_PR_MERGE_HEAD= -gitlab_verify_mergeable() { - local json fields line - local total=0 named=0 refusals='' - local state='' detail='' conflicts='' discussions='' - local live_head='' pipeline_sha='' pipeline_status='' - - # GITLAB_HOST is set to the same host the project URL already carries, so the - # instance is taken from the parsed URL by both signals and never from the - # operator's configured default. - if ! json=$(GITLAB_HOST="$FM_PR_HOST" glab mr view "$PR_NUMBER" -R "$PROJECT_URL" -F json 2>/dev/null) \ - || [ -z "$json" ]; then - echo "error: could not read the GitLab merge request state before merging" >&2 - return 1 - fi - # One named field per line. The names keep a trailing empty value readable - # after command substitution strips blank lines, and an absent or null field - # becomes an empty string or the literal "null", neither of which satisfies any - # check below, so an unreadable field refuses the merge instead of passing it. - if ! fields=$(printf '%s' "$json" | jq -r ' - if type == "object" then - "state=" + ((.state // "") | tostring), - "detail=" + ((.detailed_merge_status // "") | tostring), - "conflicts=" + (.has_conflicts | tostring), - "discussions=" + (.blocking_discussions_resolved | tostring), - "head=" + ((.sha // "") | tostring), - "pipeline_sha=" + ((.head_pipeline.sha // "") | tostring), - "pipeline_status=" + ((.head_pipeline.status // "") | tostring) - else - error("merge request payload is not an object") - end' 2>/dev/null); then - echo "error: could not read the GitLab merge request state before merging" >&2 - return 1 - fi - while IFS= read -r line; do - total=$((total + 1)) - case "$line" in - state=*) state=${line#state=} ;; - detail=*) detail=${line#detail=} ;; - conflicts=*) conflicts=${line#conflicts=} ;; - discussions=*) discussions=${line#discussions=} ;; - head=*) live_head=${line#head=} ;; - pipeline_sha=*) pipeline_sha=${line#pipeline_sha=} ;; - pipeline_status=*) pipeline_status=${line#pipeline_status=} ;; - *) continue ;; - esac - named=$((named + 1)) - done <&2 - return 1 - fi - - if ! fm_pr_head_valid "$live_head"; then - echo "error: could not read the GitLab merge request head commit before merging" >&2 - return 1 - fi - # A rebase moves the head and leaves the recorded value behind, so the - # disagreement is reported and the live head is what gets verified and merged. - if [ -n "$RECORDED_HEAD" ] && [ "$RECORDED_HEAD" != "$live_head" ]; then - printf 'notice: recorded head %s disagrees with the live head %s; verifying the live head\n' \ - "$RECORDED_HEAD" "$live_head" >&2 - fi - - [ "$state" = opened ] \ - || refusals="$refusals - state is \"${state:-unreadable}\", not open -" - [ "$detail" = mergeable ] \ - || refusals="$refusals - detailed_merge_status is \"${detail:-unreadable}\", not mergeable -" - [ "$conflicts" = false ] \ - || refusals="$refusals - has_conflicts is \"${conflicts:-unreadable}\", not false -" - [ "$discussions" = true ] \ - || refusals="$refusals - blocking_discussions_resolved is \"${discussions:-unreadable}\", not true -" - [ "$pipeline_status" = success ] \ - || refusals="$refusals - the head pipeline status is \"${pipeline_status:-none}\", not success -" - [ "$pipeline_sha" = "$live_head" ] \ - || refusals="$refusals - the head pipeline ran at \"${pipeline_sha:-none}\", not at the current head $live_head -" - - if [ -n "$refusals" ]; then - printf 'error: refusing to merge %s\n' "$URL" >&2 - printf '%s' "$refusals" >&2 - return 1 - fi - printf 'verified: %s is open and mergeable, with a successful pipeline at head %s\n' \ - "$URL" "$live_head" >&2 - FM_PR_MERGE_HEAD=$live_head -} +merge_args=() +if ! caller_has_merge_method "$@"; then + merge_args=(--squash) +fi -case "$PROVIDER" in - github) - merge_args=() - if ! caller_has_merge_method "$@"; then - merge_args=(--squash) - fi - gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@" - ;; - gitlab) - gitlab_verify_mergeable || exit 1 - # --sha binds the merge to the head this run verified, so a push that lands - # in between is refused by GitLab instead of merged unverified. --yes only - # skips the interactive confirmation, which no supervised run can answer; - # the conditions above are what authorize the merge. - GITLAB_HOST="$FM_PR_HOST" glab mr merge "$PR_NUMBER" -R "$PROJECT_URL" \ - --sha "$FM_PR_MERGE_HEAD" --yes "$@" - ;; - *) - echo "error: invalid PR merge request" >&2 - exit 2 - ;; -esac +gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c84669d363..8cc748c850 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -9,22 +9,30 @@ # reachable from any remote-tracking branch (a fork counts as a remote, so # upstream-contribution PRs pushed to a fork satisfy this in any mode), OR - for a # normal ship task whose commits are not so reachable - when its PR is merged and -# GitHub reports a PR head that contains the current local work, or its content is -# already present in the up-to-date default branch. This recognizes the common +# GitHub reports a PR head that contains the current local work, its content is +# already present in the up-to-date default branch, or every commit that branch +# cannot reach is already present there as a PATCH that branch has not since +# reverted. This recognizes the common # squash-merge-then-delete-branch flow, where the branch's own commits live nowhere -# on a remote yet the change is fully in main. +# on a remote yet the change is fully in main, and the rebase case, where landing +# rewrote every commit so no reachability test can ever succeed again (see +# patches_are_in_ref). # The PR itself is resolved from the task's recorded pr= when present, or - when # no pr= was ever recorded (e.g. a yolo-authorized merge on a repo with no PR CI, # where the usual "checks green" fm-pr-check.sh trigger never fires) - by looking # up a merged PR whose head branch matches the worktree's branch, fetching its head # via refs/pull//head when the branch itself was deleted. So a missing pr= never # by itself causes a false refusal of landed work. -# A gh lookup error falls back to the content check; if that is also inconclusive, -# teardown refuses rather than risk discarding unlanded work. +# A gh lookup error falls back to the default-branch content and patch checks; if +# those are also inconclusive, teardown refuses rather than risk discarding +# unlanded work. # Uncommitted changes are never landed. # local-only projects additionally accept work merged into the local default # branch (firstmate performs that merge after configured approval) as a fallback -# for the common case where there is no remote at all. +# for the common case where there is no remote at all, by the same reachability- +# or-patch test. That path matters most there: bin/fm-merge-local.sh is +# fast-forward-only, so every chain landing into a moved default branch rebases +# first and leaves its earlier nodes with rewritten commits. # Scout tasks (kind=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data//report.md is the work # product. Teardown proceeds only once the report exists and the shared @@ -796,36 +804,285 @@ ensure_commit_object() { git -C "$WT" cat-file -e "$commit^{commit}" 2>/dev/null } +# --no-prefix only has to be consistent, since every id compared here is +# computed in the same repository. patch_id_for_commit() { - local commit=$1 - git -C "$WT" show --pretty=medium --no-ext-diff "$commit" 2>/dev/null \ - | git patch-id --stable 2>/dev/null \ - | awk 'NR == 1 { print $1 }' + local commit=$1 tmp patch_output status + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-teardown-patch-id.XXXXXX") || return 1 + if ! git -C "$WT" show --pretty=medium --no-ext-diff --no-prefix "$commit" > "$tmp/patch" 2>/dev/null; then + rm -f -- "$tmp/patch" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + if ! git patch-id --stable < "$tmp/patch" > "$tmp/ids" 2>/dev/null; then + rm -f -- "$tmp/patch" "$tmp/ids" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + patch_output=$(awk 'NR == 1 { print $1 }' "$tmp/ids") + status=$? + rm -f -- "$tmp/patch" "$tmp/ids" + rmdir "$tmp" 2>/dev/null || true + [ "$status" -eq 0 ] || return 1 + printf '%s\n' "$patch_output" +} + +# Every distinct patch id the commits select carry, as ONE +# `git log -p | git patch-id` pipeline - the batching `git cherry` itself uses - +# rather than three processes per commit, so a reference side of any length costs +# two processes instead of growing with the default branch's history. Commits that +# emit no diff (empty commits, and merge commits whose conflict resolution +# `git log -p` does not show) contribute nothing, which is what the reference side +# wants: they can never prove a subject patch is present. +patch_ids_for_log() { + local tmp status + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-teardown-patch-log.XXXXXX") || return 1 + if ! git -C "$WT" log --format=%H --no-ext-diff --no-prefix -p "$@" > "$tmp/patches" 2>/dev/null; then + rm -f -- "$tmp/patches" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + if ! git patch-id --stable < "$tmp/patches" > "$tmp/patch-ids" 2>/dev/null; then + rm -f -- "$tmp/patches" "$tmp/patch-ids" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + if ! awk 'NF { print $1 }' "$tmp/patch-ids" > "$tmp/ids"; then + rm -f -- "$tmp/patches" "$tmp/patch-ids" "$tmp/ids" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + sort -u "$tmp/ids" + status=$? + rm -f -- "$tmp/patches" "$tmp/patch-ids" "$tmp/ids" + rmdir "$tmp" 2>/dev/null || true + return "$status" } -unpushed_patches_are_in_pr_head() { - local pr_head=$1 current base pr_patch_ids commit patch_id unpushed - current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 - base=$(git -C "$WT" merge-base "$current" "$pr_head" 2>/dev/null) || return 1 - pr_patch_ids=$( - git -C "$WT" log --format=%H "$base..$pr_head" -- 2>/dev/null \ - | while IFS= read -r commit; do - patch_id_for_commit "$commit" - done \ - | sed '/^$/d' \ - | sort -u - ) || return 1 - [ -n "$pr_patch_ids" ] || return 1 - unpushed=$(git -C "$WT" log --format=%H HEAD --not --remotes -- 2>/dev/null) || return 1 - [ -n "$unpushed" ] || return 1 +changes_are_represented_in_tree() { + local pre=$1 post=$2 tree=$3 paths path tmp status pre_entry post_entry current_entry + local post_meta current_meta post_mode post_type post_oid current_mode current_type current_oid + local numstat current_numstat current_additions current_deletions current_path + paths=$(git -C "$WT" -c core.quotePath=false diff --name-only --no-renames "$pre" "$post" -- 2>/dev/null) || return 1 + [ -n "$paths" ] || return 1 + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-teardown-current-tree.XXXXXX") || return 1 + if ! GIT_INDEX_FILE="$tmp/index" git -C "$WT" read-tree "$tree" 2>/dev/null; then + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + while IFS= read -r path; do + [ -n "$path" ] || continue + if ! git -C "$WT" diff --binary --full-index --no-ext-diff --no-prefix --no-renames "$pre" "$post" -- ":(literal)$path" > "$tmp/path.patch" 2>/dev/null; then + rm -f -- "$tmp/path.patch" "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + [ -s "$tmp/path.patch" ] || { + rm -f -- "$tmp/path.patch" "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + LC_ALL=C GIT_INDEX_FILE="$tmp/index" git -C "$WT" apply --cached --check --reverse --verbose -p0 \ + "$tmp/path.patch" > /dev/null 2> "$tmp/apply-output" + status=$? + rm -f -- "$tmp/path.patch" + if [ "$status" -eq 0 ]; then + grep -Eq '^Hunk #[0-9]+ succeeded at ' "$tmp/apply-output" + status=$? + rm -f -- "$tmp/apply-output" + if [ "$status" -ne 1 ]; then + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + continue + fi + rm -f -- "$tmp/apply-output" + pre_entry=$(git -C "$WT" -c core.quotePath=false ls-tree "$pre" -- ":(literal)$path" 2>/dev/null) || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + [ -z "$pre_entry" ] || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + post_entry=$(git -C "$WT" -c core.quotePath=false ls-tree "$post" -- ":(literal)$path" 2>/dev/null) || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + current_entry=$(git -C "$WT" -c core.quotePath=false ls-tree "$tree" -- ":(literal)$path" 2>/dev/null) || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + [ -n "$post_entry" ] && [ -n "$current_entry" ] || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + post_meta=${post_entry%%$'\t'*} + current_meta=${current_entry%%$'\t'*} + read -r post_mode post_type post_oid </dev/null || true + return 1 + ;; + esac + [ -n "$post_oid" ] && [ -n "$current_oid" ] || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + numstat=$(git -C "$WT" diff --numstat --no-renames "$pre" "$post" -- ":(literal)$path" 2>/dev/null) || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + current_numstat=$(git -C "$WT" diff --numstat --no-renames "$post" "$tree" -- ":(literal)$path" 2>/dev/null) || { + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + } + case "$numstat" in + -$'\t'-$'\t'*) + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + ;; + esac + case "$current_numstat" in + -$'\t'-$'\t'*) + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + ;; + esac + IFS=$'\t' read -r current_additions current_deletions current_path </dev/null || true + return 1 + ;; + esac + case "$current_additions" in + ''|*[!0-9]*) + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + ;; + esac + case "$current_deletions" in + ''|*[!0-9]*) + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + ;; + esac + if [ "$current_deletions" -ne 0 ]; then + rm -f -- "$tmp/index" "$tmp/index.lock" + rmdir "$tmp" 2>/dev/null || true + return 1 + fi + done </dev/null || true + return 0 +} + +# The one patch-set comparison behind every landed-work patch check, so a future +# fix to it can never land on only one copy. Takes the reference-side `git log` +# arguments, the literal separator `::`, then the subject-side ones. Returns 0 +# when EVERY subject commit's patch appeared on the reference side and every +# changed path remains represented in its current tree, 2 when the subject side +# is empty so there is nothing to prove (the caller decides what that means), and +# 1 for everything +# else: an unreadable git log, a subject side that touches no path, a reference +# side that contributes no patch ids at all, an empty or unreadable subject patch +# id, any subject commit whose patch is missing, or any subject path whose change +# is no longer represented in the current reference tree. +# The reference scan is bounded to the paths the subject commits touch: a commit +# can only carry a subject commit's patch if it touches exactly those paths, so +# this can never drop a match, and it keeps unrelated default-branch history out +# of the comparison. --full-history keeps path limiting from simplifying away a +# commit that did touch them. +subject_patches_are_in_reference() { + local -a ref_args=() subject_args=() pathspecs=() + local reference_tree=$1 past_separator=0 arg ref_ids subject_commits subject_paths commit patch_id path status + local subject_tip='' subject_oldest='' subject_base + shift + git -C "$WT" rev-parse --verify "$reference_tree^{tree}" >/dev/null 2>&1 || return 1 + for arg in "$@"; do + if [ "$past_separator" = 0 ] && [ "$arg" = '::' ]; then + past_separator=1 + continue + fi + if [ "$past_separator" = 0 ]; then + ref_args+=("$arg") + else + subject_args+=("$arg") + fi + done + [ "${#ref_args[@]}" -gt 0 ] && [ "${#subject_args[@]}" -gt 0 ] || return 1 + subject_commits=$(git -C "$WT" log --format=%H "${subject_args[@]}" -- 2>/dev/null) || return 1 + [ -n "$subject_commits" ] || return 2 + subject_paths=$(git -C "$WT" -c core.quotePath=false log --format= --name-only --no-renames "${subject_args[@]}" -- 2>/dev/null) || return 1 + [ -n "$subject_paths" ] || return 1 + while IFS= read -r path; do + [ -n "$path" ] || continue + pathspecs+=(":(literal)$path") + done </dev/null) || return 1 + git -C "$WT" diff --quiet --no-ext-diff --no-renames "$subject_base" "$subject_tip" -- 2>/dev/null + status=$? + if [ "$status" -eq 0 ]; then + if ! git -C "$WT" diff --quiet --no-ext-diff --no-renames "$subject_tip" "$reference_tree" -- \ + "${pathspecs[@]}" 2>/dev/null; then + return 1 + fi + return 0 + fi + [ "$status" -eq 1 ] || return 1 + changes_are_represented_in_tree "$subject_base" "$subject_tip" "$reference_tree" +} + +unpushed_patches_are_in_pr_head() { + local pr_head=$1 current base + current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 + base=$(git -C "$WT" merge-base "$current" "$pr_head" 2>/dev/null) || return 1 + subject_patches_are_in_reference "$pr_head" "$base..$pr_head" :: HEAD --not --remotes } # Is the worktree's PR merged for local work contained in that PR? Resolves the @@ -856,24 +1113,31 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. -content_in_default() { - local name ref default_tree merged_tree +# The up-to-date default-branch ref to test landing against: origin's +# remote-tracking ref after a fetch when the worktree has an origin, otherwise the +# local default branch. Returns non-zero when neither exists, so callers stay +# inconclusive rather than guessing. +default_landing_ref() { + local name name=$(default_branch) || return 1 if git -C "$WT" remote get-url origin >/dev/null 2>&1; then git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" - else - return 1 + printf '%s\n' "refs/remotes/origin/$name" + return 0 fi + git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1 || return 1 + printf '%s\n' "refs/heads/$name" +} + +# Is the branch's content already present in ? 3-way merges that ref with +# HEAD: when HEAD introduces nothing the ref does not already contain (e.g. its +# change landed via squash) the merged tree equals the ref's tree. This isolates +# branch-only changes, so unrelated commits the ref gained past the merge-base do +# not count as "added". Returns non-zero when inconclusive (a merge conflict, or a +# ref with no tree), so the caller refuses rather than guesses. +content_in_ref() { + local ref=$1 default_tree merged_tree + [ -n "$ref" ] || return 1 default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 [ -n "$default_tree" ] || return 1 merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 @@ -881,15 +1145,40 @@ content_in_default() { [ "$merged_tree" = "$default_tree" ] } +# Is every commit HEAD holds that cannot reach already present in as a +# PATCH? This is the relationship `git cherry` reports, and it is the only one that +# can recognize work landed under REWRITTEN commits: a rebase gives every commit a +# new object id, so reachability alone can never be satisfied afterwards even when +# the content is fully in. Rebasing is the normal way work lands here, because +# bin/fm-merge-local.sh is fast-forward-only, so a second chain landing into a moved +# default branch must rebase first. +# Deliberately strict, so it can only ever turn a refusal into an allow on proof: +# EVERY unreachable commit must match, an unreadable or empty patch (an empty +# commit, or a merge commit, whose conflict resolution `git show` does not emit) +# never counts as landed, a patch carries in reverse as well (it landed and +# was then reverted, so the work is no longer there) does not count either, and a +# ref that contributes no patches at all is inconclusive. Anything short of a +# complete match returns non-zero, so genuinely unlanded work still refuses. +patches_are_in_ref() { + local ref=$1 status + [ -n "$ref" ] || return 1 + subject_patches_are_in_reference "$ref" "$ref" --not HEAD :: HEAD --not "$ref" + status=$? + [ "$status" -eq 0 ] || [ "$status" -eq 2 ] +} + # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the # current local work is contained in the PR head, OR the content is already in the -# default branch (fallback, which also covers the no-PR and gh-error paths). False -# only for genuinely unlanded work. +# default branch, OR every commit the default branch cannot reach is already there +# as a patch (the rebase case). The two default-branch fallbacks also cover the +# no-PR and gh-error paths. False only for genuinely unlanded work. work_is_landed() { - local branch=$1 + local branch=$1 ref pr_is_merged "$branch" && return 0 - content_in_default + ref=$(default_landing_ref) || return 1 + content_in_ref "$ref" && return 0 + patches_are_in_ref "$ref" } backlog_refresh_reminder() { @@ -1135,7 +1424,7 @@ teardown_treehouse_return() { } validate_worktree_teardown_safety() { - local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch + local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch landed_by_patch [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 case "$KIND" in @@ -1173,6 +1462,21 @@ validate_worktree_teardown_safety() { return 1 fi unmerged=$(printf '%s\n' "$unmerged_raw" | head -5) + # Reachability alone can never recognize a branch whose commits were rewritten + # by the rebase that landed them, so consult patch equivalence before refusing. + # It clears only commits it can prove are already in $DEFAULT; the uncommitted + # check below is untouched, and anything unproven still refuses. + landed_by_patch=0 + if [ -n "$unmerged" ] && patches_are_in_ref "$DEFAULT"; then + unmerged= + landed_by_patch=1 + fi + if [ "$landed_by_patch" = 1 ] && [ -n "$dirty" ]; then + echo "REFUSED: local-only worktree $WT has uncommitted changes; its commits already landed in $DEFAULT." >&2 + echo "uncommitted changes present" >&2 + echo "Commit the uncommitted changes (or get the captain's explicit OK to discard, then --force)." >&2 + return 1 + fi if [ -n "$dirty" ] || [ -n "$unmerged" ]; then echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 [ -n "$dirty" ] && echo "uncommitted changes present" >&2 diff --git a/docs/architecture.md b/docs/architecture.md index 0f1cf8dd9a..d77051afcd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -253,11 +253,8 @@ A ship brief records its mode as a fixed machine-readable line and the spawn ref When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. 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. -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 invokes `gh-axi pr merge --repo /`, defaults to `--squash`, and preserves explicit merge-method flags. -A `https:////-/merge_requests/` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge -R https:///`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies. -That path merges only after one live read of the merge request confirms it is open, mergeable, conflict-free, with blocking discussions resolved and a successful pipeline at the current head, and it binds the merge to that verified head; recorded metadata is never the authority for those conditions because a rebase leaves it stale. +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 `gh-axi pr merge`. +The helper requires a full `https://github.com///pull/` URL, invokes `gh-axi pr merge --repo /`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state; a well-formed GitLab merge request URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) is refused too, explicitly, rather than sent to the wrong forge. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. [`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure. diff --git a/docs/gitlab-merge-watch.md b/docs/gitlab-merge-watch.md index 79dc138e1f..0540ed296d 100644 --- a/docs/gitlab-merge-watch.md +++ b/docs/gitlab-merge-watch.md @@ -1,8 +1,7 @@ -# GitLab merge request watch and merge verification +# GitLab merge request watch verification -Empirical record for the merge watch and the merge path on GitLab, alongside the existing GitHub ones. -Every command through "Upgrade path from an existing armed watch" was run on 2026-07-21; "Merging a merge request" was run on 2026-08-22. -Every output is reproduced exactly. +Empirical record for the merge watch on GitLab, alongside the existing GitHub watch. +Every command below was run on 2026-07-21 and its output is reproduced exactly. ## Versions @@ -14,21 +13,6 @@ $ bash --version | head -1 GNU bash, version 5.3.9(1)-release (x86_64-pc-linux-gnu) ``` -The merge evidence dated 2026-08-22 was collected on a different host, on: - -``` -$ glab --version -glab 1.82.0- () - -$ jq --version -jq-1.8.1 - -$ bash --version | head -1 -GNU bash, version 5.2.15(1)-release (x86_64-amazon-linux-gnu) -``` - -That `glab` is a locally built 1.82.0; only its build tag and commit are elided, because they name a private build rather than a released version. - ## The evidence project All live evidence here reads , a public project that exists only to be this evidence. @@ -206,92 +190,11 @@ merged No armed watch is lost by upgrading. -## Merging a merge request - -`bin/fm-pr-merge.sh` now merges a GitLab merge request through the same recording and the same guards a GitHub pull request gets. -Every run below used a throwaway `FM_HOME`, so no live task record was touched, and a `glab` wrapper that refused any `merge` subcommand outright, so no merge could reach the forge even if a check were wrong. -That wrapper is why the open fixture merge request could be used as evidence at all: it is `mergeable` with discussions resolved, so the pipeline conditions are the only thing between it and a real merge. - -Merging needs `glab` for the read and `jq` to parse it, and either one absent refuses before anything is recorded: - -``` -$ PATH="$noglab" fm-pr-merge.sh e5 https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 -error: merging a GitLab merge request requires glab on PATH -$ echo $? -1 -$ PATH="$nojq" fm-pr-merge.sh e6 https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 -error: merging a GitLab merge request requires jq on PATH -$ echo $? -1 -``` - -Neither refusal armed a poll or recorded a `pr=`, so a missing tool leaves no half-prepared merge behind. - -`jq` is not one of firstmate's common tools, which is why the watch poll reads glab's field output instead. -The merge path cannot do the same: `detailed_merge_status`, `has_conflicts`, `blocking_discussions_resolved`, and the head pipeline appear only in glab's JSON. -The poll's silence on a missing tool is safe because silence means "not merged yet"; a merge cannot be silent about it, so the requirement is reported rather than assumed. - -The merged half of the fixture is refused, and every failing condition is listed rather than just the first: - -``` -$ fm-pr-merge.sh e1 https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/1 -armed: state/e1.check.sh -error: refusing to merge https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/1 - - state is "merged", not open - - detailed_merge_status is "not_open", not mergeable - - the head pipeline status is "none", not success - - the head pipeline ran at "none", not at the current head 33762fcf6777c8d993220d25fb541e56c48081b9 -$ echo $? -1 -``` - -The open half is `mergeable`, conflict-free, and has its discussions resolved, so only the pipeline conditions refuse it. -The fixture runs no CI, so its `head_pipeline` is `null`, which is reported as `none` rather than treated as nothing to check: - -``` -$ fm-pr-merge.sh e2 https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 -armed: state/e2.check.sh -error: refusing to merge https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 - - the head pipeline status is "none", not success - - the head pipeline ran at "none", not at the current head 66b8a6777bea5e291d7fa2fc20c42ad7686f6bc8 -$ echo $? -1 -``` - -A project that runs no pipeline at all therefore cannot merge through this path. -That is the intended reading of the requirement rather than an oversight: a successful pipeline at the head is a condition, and "there is no pipeline" does not satisfy it. - -Both refusals came after `pr=` was recorded and the merge poll was armed, exactly as a failing `gh-axi pr merge` does on the GitHub side, so a refusal still leaves the audit trail and the watch in place. - -A recorded `pr_head=` that no longer matches the live head is reported, and the live head is what gets verified. -The stale value below was written into the task record by hand, because a GitLab task never records one on its own: - -``` -$ fm-pr-merge.sh e4 https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 -armed: state/e4.check.sh -notice: recorded head 1111111111111111111111111111111111111111 disagrees with the live head 66b8a6777bea5e291d7fa2fc20c42ad7686f6bc8; verifying the live head -error: refusing to merge https://gitlab.com/KarotKris/gitlab-merge-watch-fixture/-/merge_requests/2 - - the head pipeline status is "none", not success - - the head pipeline ran at "none", not at the current head 66b8a6777bea5e291d7fa2fc20c42ad7686f6bc8 -``` - -The remaining refusal conditions, and the merge itself, are covered by `tests/fm-pr-merge.test.sh` against fixtures. -The conflict, unresolved-discussion, and running-pipeline conditions were additionally exercised against real merge requests on a private instance; those runs cannot be reproduced here, so their identifiers stay out of this record. -The merge itself is not exercised against any live merge request, in either direction: `glab mr merge` has no dry run, so a live success path would mean merging someone's work to produce evidence. - -## Why the head is read live and bound to the merge - -The verified head is passed to `glab mr merge --sha`, so GitLab refuses the merge if the source branch moved between the read and the merge. -Without it, a push landing in that window would merge commits nothing verified. - -`--yes` is passed for the same reason the watch poll needs no terminal: an unattended run cannot answer a confirmation prompt, and a wedged prompt is worse than a refusal. -It skips only that prompt; the conditions above are what authorize the merge. - -## Why a recorded head is not the authority +## What this change does not cover -`bin/fm-pr-check.sh` records `pr_head=` only for GitHub, where `gh` exposes the head commit as a selectable field. -It is optional by design, and the other consumers already treat it that way: `bin/fm-teardown.sh` reads the head from the forge at teardown and falls back to its provider-agnostic content check, and `bin/fm-review-diff.sh` resolves the head from the remote when none is recorded. +`bin/fm-pr-merge.sh` still addresses GitHub only, by owner and repository. +It refuses a GitLab merge request URL rather than sending it to the wrong forge, so merging a merge request stays a deliberate manual step until merge parity lands separately. -The merge path does not record one either, and deliberately does not depend on one. -A rebase moves the head and leaves any recorded value stale, so a merge decided from metadata can verify a commit that no longer exists. -Reading the head live at merge time, reporting a recorded value that disagrees, and binding the merge to what was actually verified is what closes that gap. +A GitLab task records no `pr_head=`. +`gh` exposes the head commit as a selectable field, while plain `glab` exposes it only inside its JSON output, which would need a JSON processor firstmate does not require. +Both consumers already treat it as optional: `bin/fm-teardown.sh` reads the head from the forge at teardown rather than from metadata and falls back to its provider-agnostic content check, and `bin/fm-review-diff.sh` resolves the head from the remote when none is recorded. diff --git a/docs/scripts.md b/docs/scripts.md index 3359c32e6c..034007393e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -108,7 +108,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | -| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub or GitLab URL | +| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index f9795c3d36..03c6ce688e 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -26,10 +26,6 @@ REAL_MV=$(command -v mv) REAL_STAT=$(command -v stat) REAL_CHMOD=$(command -v chmod) REAL_BASENAME=$(command -v basename) -# The merge path reads a merge request's JSON with the real jq, and BASE_PATH is -# deliberately restricted, so a case that needs jq exposes this one rather than -# depending on the host keeping jq in one of those four directories. -REAL_JQ=$(command -v jq) || fail "these tests read glab's JSON with the real jq, which was not found" ack_watcher_cycle() { # local state=$1 err sequence generation @@ -2890,27 +2886,15 @@ EOF esac [ ! -e "$state/task-b.check.sh" ] || fail "refused GitLab arming left a poll armed" - # The merge path addresses the forge the URL names, and never the other one. - # This fixture's glab answers with the field output the poll reads, so the - # merge's JSON read cannot be parsed, which must refuse rather than merge on a - # state it could not read. + # The merge path still addresses GitHub only, so it refuses rather than + # sending a merge request to the wrong forge. write_task_meta "$dir" task-c - : > "$dir/glab.log" - # The merge path needs jq before it reads anything, so this case supplies it - # and the refusal below is the unreadable state rather than a missing tool. - ln -sf "$REAL_JQ" "$dir/fakebin/jq" set +e - run_merge_entry "$dir" task-c "$url" >/dev/null 2> "$dir/merge-c.err" + run_merge_entry "$dir" task-c "$url" >/dev/null 2>&1 rc=$? set -e - [ "$rc" -ne 0 ] || fail "merge wrapper merged a GitLab merge request it could not read" - grep -qF 'could not read the GitLab merge request state before merging' "$dir/merge-c.err" \ - || fail "merge wrapper refused for some reason other than the state it could not read" + [ "$rc" -eq 2 ] || fail "merge wrapper did not refuse a GitLab merge request URL" [ ! -s "$dir/gh-axi.log" ] || fail "merge wrapper reached the GitHub CLI for a GitLab URL" - grep -qF "mr view 7 -R https://gitlab.example/group/subgroup/project" "$dir/glab.log" \ - || fail "merge wrapper did not read the merge request through glab at its own instance" - ! grep -qF ' mr merge ' "$dir/glab.log" \ - || fail "merge wrapper merged despite an unreadable merge request state" pass "GitLab merge requests are followed on any instance and never wake falsely" } diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index 2367e8e5d2..a064b6919b 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -13,17 +13,7 @@ # (e) PR URL is parsed to number + --repo for gh-axi (defaults to --squash) # (f) malformed PR URL fails fast without calling gh-axi # (g) explicit merge method is not overridden by the default --squash -# (h) repo override args fail fast because the repo comes from the URL, -# including a bundled short-option cluster that carries -R -# (i) a GitLab MR URL resolves and merges through glab instead of erroring -# (j) glab is addressed by the host from the URL, never an assumed one -# (k) no merge method is imposed on GitLab, so the project's own one applies -# (l) each pre-merge condition refuses independently, and all of them report -# (m) a stale recorded pr_head= is reported and the live head is verified -# (n) an unreadable merge request state refuses rather than merging blind -# (o) glab or jq absent refuses before any state is recorded -# (p) --sha in extra GitLab args fails fast, and still forwards on GitHub -# (q) a GitLab refusal still leaves pr= recorded and the merge poll armed +# (h) repo override args fail fast because the repo comes from the URL set -u # shellcheck source=tests/lib.sh @@ -32,18 +22,6 @@ fm_git_identity fmtest fmtest@example.invalid PR_MERGE="$ROOT/bin/fm-pr-merge.sh" TMP_ROOT=$(fm_test_tmproot fm-pr-merge-tests) -BASE_PATH=$PATH - -# The GitLab fixture. A placeholder host that resolves nowhere, and a namespace -# deeper than one group, because a GitLab project has no owner/repository pair. -MR_HOST=gitlab.example -MR_PATH=group/subgroup/project -MR_PROJECT_URL="https://$MR_HOST/$MR_PATH" -MR_URL="$MR_PROJECT_URL/-/merge_requests/7" -MR_HEAD=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -MR_STALE_HEAD=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - -JQ_BIN=$(command -v jq) || fail "these tests read glab's JSON with the real jq, which was not found" # Build a fresh sandbox for one test case: a state dir with a task meta and a # fakebin with a gh-axi mock that records how it was invoked. Echoes the case dir. @@ -106,119 +84,11 @@ SH chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" } -# glab mock recording every invocation together with the GITLAB_HOST it was -# given, so a test can prove the instance came from the URL. `mr view` answers -# from the case's JSON payload; marker files in the case dir drive the failure -# modes, so no test has to leak environment into a shared runner. -add_glab_mock() { - local case_dir=$1 - cat > "$case_dir/fakebin/glab" <<'SH' -#!/usr/bin/env bash -printf 'GITLAB_HOST=%s %s\n' "${GITLAB_HOST-}" "$*" >> "$FM_TEST_GLAB_LOG" -case_dir=$(dirname "$FM_TEST_GLAB_JSON") -case "${1:-} ${2:-}" in - "mr view") - [ ! -e "$case_dir/glab-view-fails" ] || exit 1 - cat "$FM_TEST_GLAB_JSON" - exit 0 - ;; - "mr merge") - [ ! -e "$case_dir/glab-merge-fails" ] || { echo "error: mr merge failed" >&2 ; exit 1 ; } - exit 0 - ;; -esac -exit 0 -SH - chmod +x "$case_dir/fakebin/glab" - ln -sf "$JQ_BIN" "$case_dir/fakebin/jq" -} - -# write_mr_json [= ...] -# A merge request payload that satisfies every pre-merge condition, with the -# named fields overridden so one case drives exactly one condition. Values are -# written into the JSON as-is, so a value may carry a JSON escape. -write_mr_json() { - local file=$1 kv key value - local state=opened detail=mergeable conflicts=false discussions=true - local head=$MR_HEAD pipeline_sha=$MR_HEAD pipeline_status=success pipeline=present - shift - for kv in "$@"; do - key=${kv%%=*} - value=${kv#*=} - case "$key" in - state) state=$value ;; - detail) detail=$value ;; - conflicts) conflicts=$value ;; - discussions) discussions=$value ;; - head) head=$value ;; - pipeline_sha) pipeline_sha=$value ;; - pipeline_status) pipeline_status=$value ;; - pipeline) pipeline=$value ;; - *) fail "write_mr_json: unknown field '$key'" ;; - esac - done - if [ "$pipeline" = present ]; then - pipeline=$(printf '{"sha":"%s","status":"%s"}' "$pipeline_sha" "$pipeline_status") - fi - printf '{"iid":7,"state":"%s","detailed_merge_status":"%s","has_conflicts":%s,' \ - "$state" "$detail" "$conflicts" > "$file" - printf '"blocking_discussions_resolved":%s,"sha":"%s","head_pipeline":%s}\n' \ - "$discussions" "$head" "$pipeline" >> "$file" -} - -# make_gitlab_case [= ...]: a case dir with both forge -# mocks and a merge request payload. Echoes the case dir. -make_gitlab_case() { - local name=$1 case_dir - shift - case_dir=$(make_case "$name") - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" cccccccccccccccccccccccccccccccccccccccc - add_glab_mock "$case_dir" - : > "$case_dir/gh-axi.log" - : > "$case_dir/glab.log" - write_mr_json "$case_dir/mr.json" "$@" - printf '%s\n' "$case_dir" -} - -# mirror_path_without [ ...]: the whole search path -# re-exposed by symlink except one tool, because a real copy anywhere on PATH -# would prove nothing. The named bindirs are mirrored ahead of the search path, -# so the case's own mocks answer for every tool that is not the omitted one and -# the refusal names that tool alone whatever the host happens to have installed. -mirror_path_without() { - local dir=$1 omit=$2 search bindir entry name - shift 2 - mkdir -p "$dir" - search=$(printf '%s\n' "$@"; printf '%s\n' "$BASE_PATH" | tr ':' '\n') - while IFS= read -r bindir; do - [ -d "$bindir" ] || continue - for entry in "$bindir"/*; do - [ -e "$entry" ] || continue - name=${entry##*/} - [ "$name" = "$omit" ] && continue - [ -e "$dir/$name" ] || ln -s "$entry" "$dir/$name" 2>/dev/null - done - done </dev/null 2>&1 \ - || fail "the $omit-free search path still resolved $omit" -} - -# The merge line glab was asked to run, so a test asserts one exact invocation -# rather than a substring of the whole log. -glab_merge_line() { - grep -F ' mr merge ' "$1" || true -} - run_pr_merge() { local case_dir=$1 rc; shift FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \ - FM_TEST_GLAB_LOG="$case_dir/glab.log" \ - FM_TEST_GLAB_JSON="$case_dir/mr.json" \ PATH="$case_dir/fakebin:$PATH" \ "$PR_MERGE" "$@" rc=$? @@ -317,18 +187,15 @@ test_malformed_url_refuses_before_merge() { : > "$case_dir/gh-axi.log" set +e - # A near-miss GitLab URL: one namespace segment where a project needs at - # least two. A well-formed merge request URL is merged now, so the refusal - # has to be proven on a URL that genuinely does not parse. - run_pr_merge "$case_dir" task-x1 'https://gitlab.com/example/-/merge_requests/1' \ + run_pr_merge "$case_dir" task-x1 'https://gitlab.com/example/repo/-/merge_requests/1' \ > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e - expect_code 2 "$rc" "malformed-url: fm-pr-merge should refuse a malformed merge request URL" + expect_code 2 "$rc" "malformed-url: fm-pr-merge should refuse a non-GitHub PR URL" assert_grep 'error: invalid PR merge request' "$case_dir/stderr" \ "malformed-url: refusal was not fixed and non-probing" - assert_no_grep 'pr=https://gitlab.com/example/-/merge_requests/1' "$case_dir/state/task-x1.meta" \ + assert_no_grep 'pr=https://gitlab.com/example/repo/-/merge_requests/1' "$case_dir/state/task-x1.meta" \ "malformed-url: malformed PR URL was recorded in meta" assert_absent "$case_dir/state/task-x1.check.sh" \ "malformed-url: malformed PR URL armed a merge poll" @@ -389,67 +256,6 @@ test_repo_override_args_refuse_before_recording() { pass "fm-pr-merge refuses repo override args before recording state" } -# A bundled short-option cluster carries -R without ever being exactly -R, and -# both CLIs expand it one character at a time, so the guard has to read the -# whole cluster. On GitLab that redirect names an instance, not only a -# repository, so it must refuse before anything is recorded or read. -test_bundled_repo_override_args_refuse_before_recording() { - local case_dir rc - case_dir=$(make_case bundled-repo-override) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" abababababababababababababababababababab - : > "$case_dir/gh-axi.log" - - set +e - run_pr_merge "$case_dir" task-x1 https://github.com/right/repo/pull/6 -- -dR wrong/repo \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "bundled-repo-override: fm-pr-merge should refuse a bundled repo override" - assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \ - "bundled-repo-override: refusal did not explain the repo override" - assert_no_grep 'pr=https://github.com/right/repo/pull/6' "$case_dir/state/task-x1.meta" \ - "bundled-repo-override: PR URL was recorded before rejecting the bundled repo override" - assert_absent "$case_dir/state/task-x1.check.sh" \ - "bundled-repo-override: a bundled repo override armed a merge poll" - assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \ - "bundled-repo-override: gh-axi pr merge was invoked despite the bundled repo override" - - case_dir=$(make_gitlab_case bundled-repo-override-gitlab) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" -- -yR https://other.example/g/p \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "bundled-repo-override-gitlab: fm-pr-merge should refuse a bundled instance override" - assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \ - "bundled-repo-override-gitlab: refusal did not explain the repo override" - assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "bundled-repo-override-gitlab: the URL was recorded before rejecting the bundled override" - assert_absent "$case_dir/state/task-x1.check.sh" \ - "bundled-repo-override-gitlab: a bundled override armed a merge poll" - [ ! -s "$case_dir/glab.log" ] \ - || fail "bundled-repo-override-gitlab: glab was invoked despite the bundled override" - - # Only a cluster carrying the repository flag is refused: every other short - # cluster is still the caller's business and still reaches the forge. - case_dir=$(make_case bundled-non-repo-cluster) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc - : > "$case_dir/gh-axi.log" - - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/8 -- -d \ - > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "bundled-non-repo-cluster: fm-pr-merge refused a short flag that overrides nothing" - - grep -qxF 'pr merge 8 --repo example/repo --squash -d' "$case_dir/gh-axi.log" \ - || fail "bundled-non-repo-cluster: a short flag carrying no repository override was not forwarded" - pass "fm-pr-merge refuses a bundled short-option repo override and forwards other short flags" -} - test_explicit_merge_method_not_overridden() { local case_dir case_dir=$(make_case explicit-merge-method) @@ -495,321 +301,6 @@ test_parses_pr_url_for_gh_axi() { pass "fm-pr-merge parses a GitHub PR URL into gh-axi number and --repo arguments" } -test_gitlab_url_resolves_and_merges() { - local case_dir rc merge_line - case_dir=$(make_gitlab_case gitlab-merges) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gitlab-merges: a well-formed merge request URL should merge, not error" - assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "gitlab-merges: pr= was not recorded before merging" - assert_grep "GITLAB_HOST=$MR_HOST mr view 7 -R $MR_PROJECT_URL -F json" "$case_dir/glab.log" \ - "gitlab-merges: the pre-merge state was not read from the project URL" - merge_line=$(glab_merge_line "$case_dir/glab.log") - [ "$merge_line" = "GITLAB_HOST=$MR_HOST mr merge 7 -R $MR_PROJECT_URL --sha $MR_HEAD --yes" ] \ - || fail "gitlab-merges: unexpected merge invocation: '$merge_line'" - assert_grep "successful pipeline at head $MR_HEAD" "$case_dir/stderr" \ - "gitlab-merges: the verified head was not reported" - [ ! -s "$case_dir/gh-axi.log" ] || fail "gitlab-merges: a merge request reached the GitHub CLI" - pass "fm-pr-merge merges a GitLab merge request through glab instead of refusing it" -} - -test_gitlab_host_comes_from_the_url() { - local case_dir rc host path project_url url - host=gl.self-hosted.example - path=deep/nested/group/project - project_url="https://$host/$path" - url="$project_url/-/merge_requests/31" - case_dir=$(make_gitlab_case gitlab-host-from-url) - - set +e - run_pr_merge "$case_dir" task-x1 "$url" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gitlab-host-from-url: a self-hosted merge request should merge" - assert_grep "GITLAB_HOST=$host mr view 31 -R $project_url -F json" "$case_dir/glab.log" \ - "gitlab-host-from-url: the read did not use the host from the URL" - assert_grep "GITLAB_HOST=$host mr merge 31 -R $project_url" "$case_dir/glab.log" \ - "gitlab-host-from-url: the merge did not use the host from the URL" - assert_no_grep 'gitlab.com' "$case_dir/glab.log" \ - "gitlab-host-from-url: a host was assumed instead of taken from the URL" - assert_no_grep '' "$case_dir/glab.log" \ - "gitlab-host-from-url: glab was left to resolve the instance from its own default" - pass "fm-pr-merge takes the GitLab instance from the URL rather than assuming one" -} - -test_gitlab_imposes_no_merge_method() { - local case_dir rc merge_line flag - case_dir=$(make_gitlab_case gitlab-no-method) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gitlab-no-method: merge should succeed" - merge_line=$(glab_merge_line "$case_dir/glab.log") - for flag in --squash --rebase --merge --method; do - case "$merge_line" in - *"$flag"*) fail "gitlab-no-method: '$flag' was imposed on GitLab: '$merge_line'" ;; - esac - done - pass "fm-pr-merge imposes no merge method on GitLab, leaving the project's own one" -} - -test_gitlab_extra_args_forwarded() { - local case_dir rc merge_line - case_dir=$(make_gitlab_case gitlab-extra-args) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" -- --remove-source-branch \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gitlab-extra-args: merge should succeed" - merge_line=$(glab_merge_line "$case_dir/glab.log") - [ "$merge_line" = "GITLAB_HOST=$MR_HOST mr merge 7 -R $MR_PROJECT_URL --sha $MR_HEAD --yes --remove-source-branch" ] \ - || fail "gitlab-extra-args: extra glab flags were not forwarded: '$merge_line'" - pass "fm-pr-merge forwards extra flags to glab mr merge after the -- separator" -} - -test_gitlab_merge_failure_propagates() { - local case_dir rc - case_dir=$(make_gitlab_case gitlab-merge-fails) - : > "$case_dir/glab-merge-fails" - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-merge-fails: a failing glab merge should not report success" - assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "gitlab-merge-fails: pr= should already be recorded even though the merge failed" - pass "fm-pr-merge propagates a real glab merge failure without silently succeeding" -} - -# Each pre-merge condition, driven one at a time, so no condition can be -# carried by another. The refusal names that condition, no merge is attempted, -# and pr= is still recorded and the poll still armed exactly as the GitHub path -# leaves them when gh-axi itself fails. -test_gitlab_each_condition_refuses_independently() { - local case_dir rc name expected spec - set -- \ - "state|state=closed|state is \"closed\", not open" \ - "detail|detail=need_rebase|detailed_merge_status is \"need_rebase\", not mergeable" \ - "conflicts|conflicts=true|has_conflicts is \"true\", not false" \ - "discussions|discussions=false|blocking_discussions_resolved is \"false\", not true" \ - "pipeline-status|pipeline_status=failed|the head pipeline status is \"failed\", not success" \ - "pipeline-sha|pipeline_sha=$MR_STALE_HEAD|the head pipeline ran at \"$MR_STALE_HEAD\", not at the current head $MR_HEAD" \ - "no-pipeline|pipeline=null|the head pipeline status is \"none\", not success" - for spec in "$@"; do - name=${spec%%|*} - expected=${spec##*|} - spec=${spec#*|} - case_dir=$(make_gitlab_case "gitlab-refuse-$name" "${spec%%|*}") - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-refuse-$name: fm-pr-merge should refuse" - assert_grep "error: refusing to merge $MR_URL" "$case_dir/stderr" \ - "gitlab-refuse-$name: refusal did not name the merge request" - assert_grep "$expected" "$case_dir/stderr" \ - "gitlab-refuse-$name: refusal did not name the failing condition" - [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \ - || fail "gitlab-refuse-$name: a merge was attempted despite the refusal" - assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "gitlab-refuse-$name: a refusal should still leave the recorded PR reference" - assert_present "$case_dir/state/task-x1.check.sh" \ - "gitlab-refuse-$name: a refusal should still leave the merge poll armed" - done - pass "fm-pr-merge refuses on each GitLab pre-merge condition independently" -} - -test_gitlab_reports_every_failing_condition() { - local case_dir rc expected - case_dir=$(make_gitlab_case gitlab-refuse-all \ - state=closed detail=conflict conflicts=true discussions=false \ - pipeline_status=failed "pipeline_sha=$MR_STALE_HEAD") - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-refuse-all: fm-pr-merge should refuse" - for expected in \ - 'state is "closed", not open' \ - 'detailed_merge_status is "conflict", not mergeable' \ - 'has_conflicts is "true", not false' \ - 'blocking_discussions_resolved is "false", not true' \ - 'the head pipeline status is "failed", not success' \ - "the head pipeline ran at \"$MR_STALE_HEAD\", not at the current head $MR_HEAD" - do - assert_grep "$expected" "$case_dir/stderr" \ - "gitlab-refuse-all: '$expected' was not reported" - done - pass "fm-pr-merge reports every failing GitLab condition, not only the first" -} - -test_gitlab_stale_recorded_head_is_reported() { - local case_dir rc merge_line - case_dir=$(make_gitlab_case gitlab-stale-head) - # The recorded head is what a rebase leaves behind. It is read before - # fm-pr-check.sh rewrites the metadata, which drops a head it cannot resolve - # for a GitLab task, so reading it afterwards would find nothing at all. - printf 'pr_head=%s\n' "$MR_STALE_HEAD" >> "$case_dir/state/task-x1.meta" - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 0 "$rc" "gitlab-stale-head: the live head satisfies every condition, so it should merge" - assert_grep "recorded head $MR_STALE_HEAD disagrees with the live head $MR_HEAD" \ - "$case_dir/stderr" "gitlab-stale-head: the stale recorded head was trusted silently" - merge_line=$(glab_merge_line "$case_dir/glab.log") - case "$merge_line" in - *"--sha $MR_HEAD"*) : ;; - *) fail "gitlab-stale-head: the merge was not bound to the live head: '$merge_line'" ;; - esac - assert_no_grep "pr_head=$MR_STALE_HEAD" "$case_dir/state/task-x1.meta" \ - "gitlab-stale-head: the recording step no longer drops an unresolvable GitLab head" - pass "fm-pr-merge reports a stale recorded head and verifies the live one" -} - -test_gitlab_unreadable_state_refuses() { - local case_dir rc name - for name in view-fails not-an-object split-value; do - case_dir=$(make_gitlab_case "gitlab-unreadable-$name") - case "$name" in - view-fails) : > "$case_dir/glab-view-fails" ;; - not-an-object) printf '[]\n' > "$case_dir/mr.json" ;; - # A value carrying a newline splits into a line no field name matches, so - # it must refuse rather than be truncated into a value a check accepts. - split-value) write_mr_json "$case_dir/mr.json" 'state=opened\nnot-a-field' ;; - esac - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-unreadable-$name: fm-pr-merge should refuse" - assert_grep 'could not read the GitLab merge request state before merging' \ - "$case_dir/stderr" "gitlab-unreadable-$name: refusal did not name the unreadable state" - [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \ - || fail "gitlab-unreadable-$name: a merge was attempted on an unreadable state" - done - pass "fm-pr-merge refuses an unreadable GitLab merge request state rather than merging blind" -} - -test_gitlab_invalid_head_refuses() { - local case_dir rc - case_dir=$(make_gitlab_case gitlab-invalid-head head=not-a-sha) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-invalid-head: fm-pr-merge should refuse" - assert_grep 'could not read the GitLab merge request head commit before merging' \ - "$case_dir/stderr" "gitlab-invalid-head: refusal did not name the unreadable head" - [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \ - || fail "gitlab-invalid-head: a merge was bound to a head that is not a commit" - pass "fm-pr-merge refuses a GitLab head commit it cannot validate" -} - -test_gitlab_missing_tool_refuses_before_recording() { - local case_dir rc tool other - for tool in glab jq; do - if [ "$tool" = glab ]; then other=jq; else other=glab; fi - case_dir=$(make_gitlab_case "gitlab-no-$tool") - mirror_path_without "$case_dir/no$tool" "$tool" "$case_dir/fakebin" - # One tool absent, the other still answered by this case's own mock, so the - # refusal names exactly one tool on a host that ships neither. - PATH="$case_dir/no$tool" command -v "$other" >/dev/null 2>&1 \ - || fail "gitlab-no-$tool: the $tool-free search path lost the $other mock as well" - - set +e - FM_ROOT_OVERRIDE="$ROOT" \ - FM_STATE_OVERRIDE="$case_dir/state" \ - FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \ - FM_TEST_GLAB_LOG="$case_dir/glab.log" \ - FM_TEST_GLAB_JSON="$case_dir/mr.json" \ - PATH="$case_dir/no$tool" \ - "$PR_MERGE" task-x1 "$MR_URL" > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-no-$tool: fm-pr-merge should refuse" - assert_grep "error: merging a GitLab merge request requires $tool on PATH" \ - "$case_dir/stderr" "gitlab-no-$tool: refusal did not name the missing tool" - assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "gitlab-no-$tool: a PR reference was recorded despite the missing tool" - assert_absent "$case_dir/state/task-x1.check.sh" \ - "gitlab-no-$tool: a merge poll was armed despite the missing tool" - done - pass "fm-pr-merge refuses before recording anything when glab or jq is absent" -} - -test_gitlab_head_override_args_refuse_before_recording() { - local case_dir rc - case_dir=$(make_gitlab_case gitlab-head-override) - - set +e - run_pr_merge "$case_dir" task-x1 "$MR_URL" -- --sha "$MR_STALE_HEAD" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - expect_code 1 "$rc" "gitlab-head-override: fm-pr-merge should refuse a caller head override" - assert_grep 'extra merge arguments must not override the head commit' "$case_dir/stderr" \ - "gitlab-head-override: refusal did not explain the head override" - assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \ - "gitlab-head-override: the URL was recorded before rejecting the head override" - assert_absent "$case_dir/state/task-x1.check.sh" \ - "gitlab-head-override: a head override armed a merge poll" - [ ! -s "$case_dir/glab.log" ] || fail "gitlab-head-override: glab was invoked despite the head override" - pass "fm-pr-merge refuses a GitLab head override before recording state" -} - -test_github_still_forwards_sha_arg() { - local case_dir - case_dir=$(make_case github-sha-arg) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" dddddddddddddddddddddddddddddddddddddddd - : > "$case_dir/gh-axi.log" - - # --sha is rejected only where the head is firstmate's to determine. GitHub's - # extra args are the caller's business exactly as they were. - run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/44 -- --sha abc123 \ - > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "github-sha-arg: fm-pr-merge failed" - - grep -qxF 'pr merge 44 --repo example/repo --squash --sha abc123' "$case_dir/gh-axi.log" \ - || fail "github-sha-arg: the GitHub path stopped forwarding a caller --sha" - pass "fm-pr-merge leaves GitHub extra-arg handling unchanged, including --sha" -} - test_records_pr_and_head_before_merging test_merge_failure_propagates_after_recording test_extra_merge_args_forwarded @@ -817,20 +308,6 @@ test_missing_meta_refuses_before_merge test_malformed_url_refuses_before_merge test_rejects_unsafe_url_segments_before_recording test_repo_override_args_refuse_before_recording -test_bundled_repo_override_args_refuse_before_recording test_explicit_merge_method_not_overridden test_method_equals_merge_method_not_overridden test_parses_pr_url_for_gh_axi -test_github_still_forwards_sha_arg -test_gitlab_url_resolves_and_merges -test_gitlab_host_comes_from_the_url -test_gitlab_imposes_no_merge_method -test_gitlab_extra_args_forwarded -test_gitlab_merge_failure_propagates -test_gitlab_each_condition_refuses_independently -test_gitlab_reports_every_failing_condition -test_gitlab_stale_recorded_head_is_reported -test_gitlab_unreadable_state_refuses -test_gitlab_invalid_head_refuses -test_gitlab_missing_tool_refuses_before_recording -test_gitlab_head_override_args_refuse_before_recording diff --git a/tests/fm-remote-job.test.sh b/tests/fm-remote-job.test.sh index 0b6dede4d6..753c139efe 100755 --- a/tests/fm-remote-job.test.sh +++ b/tests/fm-remote-job.test.sh @@ -625,22 +625,12 @@ pass "quarantine clears only after recorded execution has stopped" # A replacement stops a Linux worker by signalling its whole isolated group, and # the supervisor in that group forwards a second stop signal to the same serving -# child, so the serving child is always signalled more than once. Signal a small -# bounded burst and then keep signalling until it is gone: the first signal -# starts the shutdown and every later one lands inside it, the same way the group -# signal and the forwarded signal do. A shutdown that dies part way through -# leaves its ownership lock behind holding a half-written temp file no later -# worker can clear, and every replacement then fails to report ready. -# -# The burst is bounded and the follow-up signals are paced deliberately. An -# unpaced signal loop delivers hundreds of thousands of signals per second, -# which corrupts the signalled bash's own pending-trap bookkeeping ("warning: -# run_pending_traps: bad value in trap_list[15]") and then kills it part way -# through the shutdown with SIGTERM or SIGSEGV. That reports a shutdown defect -# this worker does not have. Ten back-to-back signals still all land inside the -# shutdown's first file operation, so the repeat this pins is unchanged: with -# the default disposition restored instead of ignored, the ownership lock is -# left behind every run. +# child, so the serving child is always signalled more than once. Keep signalling +# until it is gone: the first signal starts the shutdown and every later one +# lands inside it, the same way the group signal and the forwarded signal do. A +# shutdown that dies part way through leaves its ownership lock behind holding a +# half-written temp file no later worker can clear, and every replacement then +# fails to report ready. REPEAT_HOME="$TMP_ROOT/repeat-signal-account" REPEAT_STATE="$TMP_ROOT/repeat-signal-jobs" mkdir -p "$REPEAT_HOME" @@ -655,14 +645,8 @@ for _ in $(seq 1 300); do done assert_present "$REPEAT_STATE/worker.ready" "the repeated-signal worker did not become ready" REPEAT_DEADLINE=$((SECONDS + 30)) -REPEAT_BURST=0 -while [ "$REPEAT_BURST" -lt 10 ]; do - kill -TERM "$REPEAT_WORKER_PID" 2>/dev/null || true - REPEAT_BURST=$((REPEAT_BURST + 1)) -done while kill -0 "$REPEAT_WORKER_PID" 2>/dev/null && [ "$SECONDS" -lt "$REPEAT_DEADLINE" ]; do kill -TERM "$REPEAT_WORKER_PID" 2>/dev/null || true - sleep 0.05 done if kill -0 "$REPEAT_WORKER_PID" 2>/dev/null; then kill -KILL "$REPEAT_WORKER_PID" 2>/dev/null || true diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a0815a967e..4c6e45fcfc 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -5,9 +5,10 @@ # treehouse return hard-resets the worktree. "Landed" means reachable from a remote # OR - for a normal ship task whose commits are not so reachable - its PR is merged # and GitHub reports a PR head that contains the current local work, or its content -# is already in the up-to-date default branch. +# is already in the up-to-date default branch, or every commit it holds is already +# present there as a patch. # -# Covers three fixes: +# Covers four fixes: # - local-only fork-remote: a fork IS a remote, so fork-pushed upstream- # contribution PRs are teardown-eligible (the pre-fix code false-refused them). # - squash-merge-then-delete-branch: the branch's own commits live nowhere on a @@ -15,6 +16,12 @@ # main. Reachability alone false-refused this common GitHub flow; the check now # recognizes a merged PR head containing the local work (or the content already # in main) as landed. +# - rebase-rewrites-commits: a rebase gives every commit a new object id, so a +# branch whose content landed under rewritten commits can never satisfy a +# reachability test. Because bin/fm-merge-local.sh is fast-forward-only, rebasing +# onto a moved default branch is the normal way local-only chains land, which +# made this a permanent false refusal. The check now also accepts a branch whose +# every commit is present in the default branch as a patch. # - teardown-lock-race: a killed crew process can leave a transient worktree # git index.lock that blocks teardown. The return path retries on the lock # error signature (even if the lock self-clears mid-check), then only removes a @@ -38,6 +45,26 @@ # (o) fm-pr-check rerun after HEAD moved -> no stale pr_head # (p) fm-pr-check when local HEAD lags -> record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) +# (q1) local-only + patch landed under a rewritten commit -> ALLOW (rebase fix) +# (q2) local-only + one commit's patch absent from main -> REFUSE (safety) +# (q2b) local-only + landed patch reverted on main again -> REFUSE (safety) +# (q2c) local-only + combined revert on main -> REFUSE (safety) +# (q2d) local-only + two landed patches jointly reverted -> REFUSE (safety) +# (q2e) local-only + one file of landed patch reverted -> REFUSE (safety) +# (q2f) local-only + reverted patch re-landed -> ALLOW (current state) +# (q2g) local-only + one same-file hunk reverted -> REFUSE (safety) +# (q2h) local-only + one added line reverted -> REFUSE (safety) +# (q2i) local-only + one added line replaced -> REFUSE (safety) +# (q2j) local-only + added file fully replaced -> REFUSE (safety) +# (q2k) local-only + unrelated line appended after landing -> ALLOW (superset) +# (q2l) local-only + landed trailing newline removed -> REFUSE (safety) +# (q2m) local-only + landed change relocated elsewhere -> REFUSE (safety) +# (q2n) local-only + rewritten inverse sequence is net zero -> ALLOW (current state) +# (q2o) local-only + net-zero inverse later reverted -> REFUSE (safety) +# (q3) local-only + every patch landed but worktree dirty -> REFUSE (dirty wins) +# (q4) no-mistakes + patch landed, all its content replaced -> REFUSE (safety) +# (q5) local-only + rewritten rename patch landed -> ALLOW (rename fix) +# (q6) patch-history read fails after emitting output -> REFUSE (fail-safe) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -255,6 +282,77 @@ land_on_origin_main() { rm -rf "$tmp" } +# Move the project's LOCAL default branch on with an unrelated commit, then apply +# = there as a fresh commit. The resulting commit carries the same +# patch as the task branch's own commit but a different object id, which is what a +# rebase-then-fast-forward leaves behind: the branch's commits are unreachable from +# main while their content is fully landed. Args: case_dir file content +land_rewritten_patch_on_local_main() { + local case_dir=$1 file=$2 content=$3 + printf '%s\n' "main moved on" > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "unrelated main commit" + printf '%s\n' "$content" > "$case_dir/project/$file" + git -C "$case_dir/project" add -- "$file" + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "rebased: add $file" +} + +# Same rewritten-commit landing on the LOCAL default branch, followed by a commit +# that reverts it again. The patch is in main's history but the work is gone from +# main's tree, so teardown must not treat the branch as landed. +# Args: case_dir file content +land_rewritten_patch_then_revert_on_local_main() { + local case_dir=$1 file=$2 content=$3 + land_rewritten_patch_on_local_main "$case_dir" "$file" "$content" + git -C "$case_dir/project" rm -q -- "$file" + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "revert: drop $file again" +} + +# Same rewritten-commit landing, but on origin's default branch, followed by a +# later edit of the same file. The later edit is what makes the whole-tree content +# check inconclusive, so only patch equivalence can prove the work landed. +# Args: case_dir file content later_content +land_rewritten_patch_then_edit_on_origin_main() { + local case_dir=$1 file=$2 content=$3 later=$4 tmp + tmp="$case_dir/_rebased" + git clone -q "$case_dir/origin.git" "$tmp" + printf '%s\n' "main moved on" > "$tmp/unrelated.txt" + git -C "$tmp" add -- unrelated.txt + git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "unrelated main commit" + printf '%s\n' "$content" > "$tmp/$file" + git -C "$tmp" add -- "$file" + git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "rebased: add $file" + printf '%s\n' "$later" > "$tmp/$file" + git -C "$tmp" add -- "$file" + git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "follow-up edit of $file" + git -C "$tmp" push -q origin HEAD:main + rm -rf "$tmp" +} + +add_git_patch_log_failure() { + local case_dir=$1 + cat > "$case_dir/fakebin/git" <<'SH' +#!/usr/bin/env bash +real=${REAL_GIT_FOR_TEST:?} +args=("$@") +is_log=0 +is_patch=0 +for arg in "$@"; do + [ "$arg" = log ] && is_log=1 + [ "$arg" = -p ] && is_patch=1 +done +if [ "$is_log" = 1 ] && [ "$is_patch" = 1 ]; then + "$real" "${args[@]}" + exit 86 +fi +exec "$real" "${args[@]}" +SH + chmod +x "$case_dir/fakebin/git" +} + # Override GitHub lookups to report PR 7 as merged with the supplied head. add_gh_pr_merged_for_head() { local case_dir=$1 head=$2 @@ -653,6 +751,725 @@ test_local_only_merged_to_local_main_allows() { pass "local-only worktree with work merged into local main is torn down (no regression)" } +test_local_only_rebased_patch_landed_allows() { + local case_dir rc + case_dir=$(make_case rebased-landed) + write_meta "$case_dir" local-only ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + # main gained an unrelated commit, so landing this branch needed a rebase; the + # branch's own commit is now unreachable from main while its patch is fully there. + land_rewritten_patch_on_local_main "$case_dir" feature.txt hello + git -C "$case_dir/wt" merge-base --is-ancestor HEAD main 2>/dev/null \ + && fail "rebased-landed: branch commit is still reachable from main; case is vacuous" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "rebased-landed: teardown should succeed when the patch landed under a rewritten commit" + ! grep -q REFUSED "$case_dir/stderr" || fail "rebased-landed: teardown printed a REFUSED line" + pass "local-only worktree whose patch landed under a rewritten commit is torn down" +} + +test_local_only_rebased_patch_with_absent_commit_refuses() { + local case_dir rc + case_dir=$(make_case rebased-partial) + write_meta "$case_dir" local-only ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + # A second commit whose patch never reaches main: patch equivalence must not + # excuse it just because the first commit's patch did land. + wt_commit_file "$case_dir" extra.txt "still here" "add extra" + land_rewritten_patch_on_local_main "$case_dir" feature.txt hello + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-partial: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-partial: no REFUSED line in stderr" + grep -Fq "commits not yet on main" "$case_dir/stderr" \ + || fail "rebased-partial: refusal did not name the commits still missing from main" + pass "local-only worktree with a commit whose patch is absent from main is refused" +} + +test_local_only_rebased_patch_reverted_on_main_refuses() { + local case_dir rc + case_dir=$(make_case rebased-reverted) + write_meta "$case_dir" local-only ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + # The patch landed under a rewritten commit and main then reverted it, so the + # work exists nowhere but this worktree; patch equivalence must not clear it. + land_rewritten_patch_then_revert_on_local_main "$case_dir" feature.txt hello + [ ! -e "$case_dir/project/feature.txt" ] \ + || fail "rebased-reverted: feature.txt is still in main's tree; case is vacuous" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-reverted: teardown should refuse when the landed patch was reverted" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-reverted: no REFUSED line in stderr" + grep -Fq "commits not yet on main" "$case_dir/stderr" \ + || fail "rebased-reverted: refusal did not name the commits still missing from main" + [ -e "$case_dir/wt/feature.txt" ] || fail "rebased-reverted: worktree work was discarded" + pass "local-only worktree whose landed patch was reverted on main is refused" +} + +test_local_only_rebased_patch_combined_revert_on_main_refuses() { + local case_dir rc + case_dir=$(make_case rebased-combined-revert) + write_meta "$case_dir" local-only ship + + printf '%s\n' baseline > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + + printf '%s\n' feature > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change shared feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' feature > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten feature" + printf '%s\n' baseline 'unrelated retained' > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert feature with other work" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-combined-revert: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-combined-revert: no REFUSED line in stderr" + [ -e "$case_dir/wt/shared.txt" ] || fail "rebased-combined-revert: worktree work was discarded" + pass "local-only worktree whose patch was reverted with other work is refused" +} + +test_local_only_rebased_patch_sequence_jointly_reverted_on_main_refuses() { + local case_dir rc + case_dir=$(make_case rebased-sequence-reverted) + write_meta "$case_dir" local-only ship + + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + + printf '%s\n' y > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change x to y" + printf '%s\n' z > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change y to z" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten x to y" + printf '%s\n' z > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten y to z" + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "jointly revert sequence" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-sequence-reverted: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-sequence-reverted: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-sequence-reverted: destructive return was invoked" + [ -e "$case_dir/wt/shared.txt" ] || fail "rebased-sequence-reverted: worktree work was discarded" + pass "local-only worktree whose landed patch sequence was jointly reverted is refused" +} + +test_local_only_rebased_multifile_patch_partially_reverted_on_main_refuses() { + local case_dir rc + case_dir=$(make_case rebased-partial-revert) + write_meta "$case_dir" local-only ship + + printf '%s\n' one > "$case_dir/wt/one.txt" + printf '%s\n' two > "$case_dir/wt/two.txt" + git -C "$case_dir/wt" add -- one.txt two.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add both files" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' one > "$case_dir/project/one.txt" + printf '%s\n' two > "$case_dir/project/two.txt" + git -C "$case_dir/project" add -- one.txt two.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten add both files" + git -C "$case_dir/project" rm -q -- two.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert one file" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-partial-revert: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-partial-revert: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-partial-revert: destructive return was invoked" + [ -e "$case_dir/wt/two.txt" ] || fail "rebased-partial-revert: worktree work was discarded" + pass "local-only worktree whose multi-file patch was partially reverted is refused" +} + +test_local_only_rebased_patch_reverted_then_relanded_allows() { + local case_dir rc + case_dir=$(make_case rebased-relanded) + write_meta "$case_dir" local-only ship + + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + printf '%s\n' y > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change x to y" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten landing" + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert landing" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "re-land change" + git -C "$case_dir/wt" merge-base --is-ancestor HEAD main 2>/dev/null \ + && fail "rebased-relanded: branch commit is still reachable from main" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "rebased-relanded: teardown should accept the final landed state" + ! grep -q REFUSED "$case_dir/stderr" || fail "rebased-relanded: teardown printed a REFUSED line" + pass "local-only worktree whose reverted patch was re-landed is torn down" +} + +test_local_only_rebased_patch_relocated_to_identical_block_refuses() { + local case_dir rc + case_dir=$(make_case rebased-relocated-identical-block) + write_meta "$case_dir" local-only ship + + printf '%s\n' \ + p1 p2 p3 p4 p5 \ + context-one context-two context-three target context-four context-five context-six \ + s1 s2 s3 s4 s5 s6 s7 s8 \ + context-one context-two context-three target context-four context-five context-six \ + z1 z2 z3 z4 z5 > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add repeated baseline" + git -C "$case_dir/wt" rebase main >/dev/null + + awk '$0 == "target" && !changed { print "landed"; changed = 1; next } { print }' \ + "$case_dir/wt/shared.txt" > "$case_dir/wt/shared.next" + mv "$case_dir/wt/shared.next" "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change first repeated block" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + awk '$0 == "target" && !changed { print "landed"; changed = 1; next } { print }' \ + "$case_dir/project/shared.txt" > "$case_dir/project/shared.next" + mv "$case_dir/project/shared.next" "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten first-block landing" + awk ' + $0 == "landed" && !reverted { print "target"; reverted = 1; next } + $0 == "target" { print "landed"; next } + { print } + ' "$case_dir/project/shared.txt" > "$case_dir/project/shared.next" + mv "$case_dir/project/shared.next" "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "relocate landing to second block" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-relocated-identical-block: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-relocated-identical-block: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-relocated-identical-block: destructive return was invoked" + [ "$(sed -n '9p' "$case_dir/wt/shared.txt")" = landed ] \ + || fail "rebased-relocated-identical-block: worktree change was discarded" + pass "local-only worktree whose landed change moved to an identical block is refused" +} + +test_local_only_rebased_inverse_sequence_with_net_zero_change_allows() { + local case_dir rc + case_dir=$(make_case rebased-net-zero-sequence) + write_meta "$case_dir" local-only ship + + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + printf '%s\n' y > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change x to y" + printf '%s\n' x > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change y back to x" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten x to y" + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten y back to x" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "rebased-net-zero-sequence: teardown should allow" + ! grep -q REFUSED "$case_dir/stderr" || fail "rebased-net-zero-sequence: teardown printed a REFUSED line" + [ -e "$case_dir/treehouse-called" ] || fail "rebased-net-zero-sequence: teardown did not invoke worktree return" + pass "local-only worktree whose rewritten inverse patches net to zero is torn down" +} + +test_local_only_rebased_net_zero_inverse_later_reverted_refuses() { + local case_dir rc + case_dir=$(make_case rebased-net-zero-inverse-reverted) + write_meta "$case_dir" local-only ship + + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + printf '%s\n' y > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change x to y" + printf '%s\n' x > "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change y back to x" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten x to y" + printf '%s\n' x > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten y back to x" + printf '%s\n' y > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert rewritten inverse" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-net-zero-inverse-reverted: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-net-zero-inverse-reverted: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-net-zero-inverse-reverted: destructive return was invoked" + grep -Fxq x "$case_dir/wt/shared.txt" || fail "rebased-net-zero-inverse-reverted: worktree content was discarded" + pass "local-only net-zero worktree whose inverse was later reverted is refused" +} + +test_local_only_rebased_same_file_hunk_partially_reverted_refuses() { + local case_dir rc + case_dir=$(make_case rebased-hunk-revert) + write_meta "$case_dir" local-only ship + + printf '%s\n' a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 > "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add baseline" + git -C "$case_dir/wt" rebase main >/dev/null + + sed -e 's/^a2$/landed-two/' -e 's/^a11$/landed-eleven/' "$case_dir/wt/shared.txt" > "$case_dir/wt/shared.next" + mv "$case_dir/wt/shared.next" "$case_dir/wt/shared.txt" + git -C "$case_dir/wt" add -- shared.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "change two separated hunks" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + sed -e 's/^a2$/landed-two/' -e 's/^a11$/landed-eleven/' "$case_dir/project/shared.txt" > "$case_dir/project/shared.next" + mv "$case_dir/project/shared.next" "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten separated hunks" + sed 's/^landed-eleven$/a11/' "$case_dir/project/shared.txt" > "$case_dir/project/shared.next" + mv "$case_dir/project/shared.next" "$case_dir/project/shared.txt" + git -C "$case_dir/project" add -- shared.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert second hunk" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-hunk-revert: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-hunk-revert: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-hunk-revert: destructive return was invoked" + grep -Fxq landed-eleven "$case_dir/wt/shared.txt" || fail "rebased-hunk-revert: worktree hunk was discarded" + pass "local-only worktree whose same-file patch was partially reverted is refused" +} + +test_local_only_rebased_added_line_partially_reverted_refuses() { + local case_dir rc + case_dir=$(make_case rebased-added-line-revert) + write_meta "$case_dir" local-only ship + + printf '%s\n' A B > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add -- feature.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add two-line feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' A B > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten two-line feature" + printf '%s\n' A > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "revert added line" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-added-line-revert: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-added-line-revert: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-added-line-revert: destructive return was invoked" + grep -Fxq B "$case_dir/wt/feature.txt" || fail "rebased-added-line-revert: worktree line was discarded" + pass "local-only worktree whose added line was reverted is refused" +} + +test_local_only_rebased_added_line_replaced_refuses() { + local case_dir rc + case_dir=$(make_case rebased-added-line-replaced) + write_meta "$case_dir" local-only ship + + printf '%s\n' A B > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add -- feature.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add two-line feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' A B > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten two-line feature" + printf '%s\n' A C > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "replace added line" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-added-line-replaced: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-added-line-replaced: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-added-line-replaced: destructive return was invoked" + grep -Fxq B "$case_dir/wt/feature.txt" || fail "rebased-added-line-replaced: worktree line was discarded" + pass "local-only worktree whose added line was replaced is refused" +} + +test_local_only_rebased_added_file_fully_replaced_refuses() { + local case_dir rc + case_dir=$(make_case rebased-added-file-replaced) + write_meta "$case_dir" local-only ship + + printf '%s\n' A B > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add -- feature.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add two-line feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' A B > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten two-line feature" + printf '%s\n' C D > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "replace entire feature" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-added-file-replaced: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-added-file-replaced: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-added-file-replaced: destructive return was invoked" + grep -Fxq A "$case_dir/wt/feature.txt" || fail "rebased-added-file-replaced: worktree content was discarded" + grep -Fxq B "$case_dir/wt/feature.txt" || fail "rebased-added-file-replaced: worktree content was discarded" + pass "local-only worktree whose added file was fully replaced is refused" +} + +test_local_only_rebased_added_file_superset_allows() { + local case_dir rc + case_dir=$(make_case rebased-added-file-superset) + write_meta "$case_dir" local-only ship + + printf '%s\n' A > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add -- feature.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf '%s\n' A > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten feature" + printf '%s\n' A B > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "append unrelated content" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "rebased-added-file-superset: teardown should allow" + ! grep -q REFUSED "$case_dir/stderr" || fail "rebased-added-file-superset: teardown printed a REFUSED line" + [ -e "$case_dir/treehouse-called" ] || fail "rebased-added-file-superset: teardown did not invoke worktree return" + pass "local-only worktree whose landed file gained unrelated content is torn down" +} + +test_local_only_rebased_added_file_loses_trailing_newline_refuses() { + local case_dir rc expected_hash actual_hash + case_dir=$(make_case rebased-added-file-no-newline) + write_meta "$case_dir" local-only ship + + printf 'A\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add -- feature.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add feature" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + printf 'A\n' > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten feature" + printf A > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add -- feature.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "remove trailing newline" + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-added-file-no-newline: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-added-file-no-newline: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "rebased-added-file-no-newline: destructive return was invoked" + expected_hash=$(printf 'A\n' | git hash-object --stdin) + actual_hash=$(git hash-object "$case_dir/wt/feature.txt") + [ "$actual_hash" = "$expected_hash" ] || fail "rebased-added-file-no-newline: worktree content was discarded" + pass "local-only worktree whose landed trailing newline was removed is refused" +} + +test_local_only_rewritten_rename_patch_landed_allows() { + local case_dir rc + case_dir=$(make_case rewritten-rename) + write_meta "$case_dir" local-only ship + + printf '%s\n' payload > "$case_dir/project/old-name.txt" + git -C "$case_dir/project" add -- old-name.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "add rename source" + git -C "$case_dir/wt" rebase main >/dev/null + git -C "$case_dir/wt" mv old-name.txt new-name.txt + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "rename source" + + printf '%s\n' moved > "$case_dir/project/unrelated.txt" + git -C "$case_dir/project" add -- unrelated.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "main moved" + git -C "$case_dir/project" mv old-name.txt new-name.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t commit -q -m "rewritten rename" + git -C "$case_dir/wt" merge-base --is-ancestor HEAD main 2>/dev/null \ + && fail "rewritten-rename: branch commit is still reachable from main" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "rewritten-rename: teardown should accept the landed rename patch" + ! grep -q REFUSED "$case_dir/stderr" || fail "rewritten-rename: teardown printed a REFUSED line" + pass "local-only worktree whose rename landed under a rewritten commit is torn down" +} + +test_patch_log_failure_after_output_refuses() { + local case_dir rc + case_dir=$(make_case patch-log-failure) + write_meta "$case_dir" no-mistakes ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + land_rewritten_patch_then_edit_on_origin_main "$case_dir" feature.txt hello revised + add_git_patch_log_failure "$case_dir" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "patch-log-failure: teardown should fail closed" + grep -q REFUSED "$case_dir/stderr" || fail "patch-log-failure: no REFUSED line in stderr" + [ -e "$case_dir/wt/feature.txt" ] || fail "patch-log-failure: worktree work was discarded" + pass "partial patch-history output cannot authorize teardown" +} + +test_local_only_rebased_patch_landed_but_dirty_refuses() { + local case_dir rc + case_dir=$(make_case rebased-dirty) + write_meta "$case_dir" local-only ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + land_rewritten_patch_on_local_main "$case_dir" feature.txt hello + # Every commit's patch landed, but the worktree still holds uncommitted work. + printf '%s\n' "not committed anywhere" > "$case_dir/wt/scratch.txt" + git -C "$case_dir/wt" add -- scratch.txt + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "rebased-dirty: teardown should refuse on uncommitted changes" + grep -q REFUSED "$case_dir/stderr" || fail "rebased-dirty: no REFUSED line in stderr" + grep -Fq "uncommitted changes present" "$case_dir/stderr" \ + || fail "rebased-dirty: refusal did not name the uncommitted changes" + grep -Fq "its commits already landed in" "$case_dir/stderr" \ + || fail "rebased-dirty: refusal did not credit the commits as already landed" + grep -Fq "Commit the uncommitted changes" "$case_dir/stderr" \ + || fail "rebased-dirty: refusal did not point at the uncommitted change" + ! grep -Eq "not yet merged into|Merge the branch into local" "$case_dir/stderr" \ + || fail "rebased-dirty: refusal still claims the branch has unmerged work" + pass "worktree with uncommitted changes is refused even when every commit's patch landed" +} + +test_no_mistakes_rebased_patch_landed_after_full_replacement_refuses() { + local case_dir rc + case_dir=$(make_case nm-rebased-landed) + write_meta "$case_dir" no-mistakes ship + wt_commit_file "$case_dir" feature.txt hello "add feature" + land_rewritten_patch_then_edit_on_origin_main "$case_dir" feature.txt hello revised + + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "nm-rebased-landed: teardown should refuse when all subject content was replaced" + grep -q REFUSED "$case_dir/stderr" || fail "nm-rebased-landed: no REFUSED line in stderr" + [ ! -e "$case_dir/treehouse-called" ] || fail "nm-rebased-landed: destructive return was invoked" + grep -Fxq hello "$case_dir/wt/feature.txt" || fail "nm-rebased-landed: worktree content was discarded" + pass "no-mistakes worktree whose landed content was fully replaced is refused" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -2596,6 +3413,26 @@ test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows +test_local_only_rebased_patch_landed_allows +test_local_only_rebased_patch_with_absent_commit_refuses +test_local_only_rebased_patch_reverted_on_main_refuses +test_local_only_rebased_patch_combined_revert_on_main_refuses +test_local_only_rebased_patch_sequence_jointly_reverted_on_main_refuses +test_local_only_rebased_multifile_patch_partially_reverted_on_main_refuses +test_local_only_rebased_patch_reverted_then_relanded_allows +test_local_only_rebased_patch_relocated_to_identical_block_refuses +test_local_only_rebased_inverse_sequence_with_net_zero_change_allows +test_local_only_rebased_net_zero_inverse_later_reverted_refuses +test_local_only_rebased_same_file_hunk_partially_reverted_refuses +test_local_only_rebased_added_line_partially_reverted_refuses +test_local_only_rebased_added_line_replaced_refuses +test_local_only_rebased_added_file_fully_replaced_refuses +test_local_only_rebased_added_file_superset_allows +test_local_only_rebased_added_file_loses_trailing_newline_refuses +test_local_only_rebased_patch_landed_but_dirty_refuses +test_no_mistakes_rebased_patch_landed_after_full_replacement_refuses +test_local_only_rewritten_rename_patch_landed_allows +test_patch_log_failure_after_output_refuses test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed