From 4cb28f98f80ae5b3e07ccf5ecea0628c539e70a1 Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:30:26 -0400 Subject: [PATCH 1/6] Add explicit identical-squash reconciliation --- bin/fm-reconcile-identical-squash.sh | 566 +++++++++++++++++++ docs/scripts.md | 1 + tests/fm-reconcile-identical-squash.test.sh | 586 ++++++++++++++++++++ 3 files changed, 1153 insertions(+) create mode 100755 bin/fm-reconcile-identical-squash.sh create mode 100755 tests/fm-reconcile-identical-squash.test.sh diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh new file mode 100755 index 00000000000..c219bdd9463 --- /dev/null +++ b/bin/fm-reconcile-identical-squash.sh @@ -0,0 +1,566 @@ +#!/usr/bin/env bash +# Explicitly reconcile one clean repository whose checked-out local default +# branch and origin's source-backed default branch genuinely diverged but have +# identical root trees. +# +# The operator must quiesce every other Git claimant before invoking this helper. +# The helper proves the local repository shape, obtains origin's current default +# branch object with a source-only fetch, and atomically creates or verifies a +# deterministic preservation ref while moving only the direct local default +# branch ref. It never changes ordinary fm-fleet-sync.sh behavior. +# +# Source acquisition uses an empty --refmap together with a source-only refspec, +# --no-write-fetch-head, --no-tags, and --no-recurse-submodules. This suppresses +# configured destination refspecs, remote-tracking updates, FETCH_HEAD writes, +# tag following, and submodule recursion. A fixed three-attempt loop tolerates a +# source tip advancing between fetch and source-backed observation. +# +# Preservation refs have this form: +# refs/firstmate/identical-squash// +# The branch encoding and complete old object identity make the name +# deterministic. Existing exact direct refs are reused; conflicting or symbolic +# refs are refused. Ref creation/verification and branch movement share one +# `git update-ref --stdin` transaction with `option no-deref` and expected-old +# verification. +# +# A successful second invocation is recognized only when the local branch still +# equals the source-backed remote tip and a valid preservation ref proves a prior +# divergent, single-merge-base, identical-tree reconciliation for that branch. +# +# Git does not provide repository-wide serialization across unrelated commands. +# This helper therefore relies on explicit operator quiescence, checks evidence +# immediately before and after its transaction, and never rolls back after a +# committed transaction. If a new claimant appears after commit, diagnostics +# retain the exact committed target and preserved old head so an operator can +# investigate without overwriting that claimant. +# +# Usage: fm-reconcile-identical-squash.sh +set -eu + +PROGRAM=fm-reconcile-identical-squash +MAX_SOURCE_ATTEMPTS=3 +AUTHORITATIVE_REMOTE=origin +TAB=$(printf '\t') +export GIT_OPTIONAL_LOCKS=0 +export GIT_TERMINAL_PROMPT=0 + +usage() { + printf 'usage: fm-reconcile-identical-squash.sh \n' >&2 +} + +refuse() { + printf '%s: refusing: %s\n' "$PROGRAM" "$*" >&2 + exit 1 +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi +[ "$#" -eq 1 ] || { usage; exit 2; } +REPO_ARG=$1 +[ -d "$REPO_ARG" ] || refuse "repository directory does not exist" + +if ! inside=$(git -C "$REPO_ARG" rev-parse --is-inside-work-tree 2>/dev/null); then + refuse "target is not an existing Git worktree" +fi +[ "$inside" = true ] || refuse "target is not an existing Git worktree" +if ! REPO=$(git -C "$REPO_ARG" rev-parse --path-format=absolute --show-toplevel 2>/dev/null); then + refuse "cannot resolve the repository worktree root" +fi +if ! GIT_DIR=$(git -C "$REPO" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null); then + refuse "cannot resolve the repository Git directory" +fi +if ! COMMON_DIR=$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then + refuse "cannot resolve the repository common Git directory" +fi +[ -d "$GIT_DIR" ] || refuse "resolved Git directory is not a directory" +[ -d "$COMMON_DIR" ] || refuse "resolved common Git directory is not a directory" + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-reconcile-identical-squash.XXXXXX") \ + || refuse "cannot create private temporary evidence directory" +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +repo_git() { + git -C "$REPO" "$@" +} + +require_clean() { + local status_output + status_output=$(repo_git status --porcelain=v1 --untracked-files=normal 2>/dev/null) \ + || return 1 + [ -z "$status_output" ] +} + +worktree_count() { + local output count + output=$(repo_git worktree list --porcelain 2>/dev/null) || return 1 + count=$(printf '%s\n' "$output" | grep -c '^worktree ' || true) + printf '%s\n' "$count" +} + +active_operation_present() { + local marker path + for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD REBASE_HEAD BISECT_START; do + path=$(repo_git rev-parse --path-format=absolute --git-path "$marker" 2>/dev/null) \ + || return 0 + [ ! -e "$path" ] || return 0 + done + for marker in rebase-apply rebase-merge sequencer; do + path=$(repo_git rev-parse --path-format=absolute --git-path "$marker" 2>/dev/null) \ + || return 0 + [ ! -e "$path" ] || return 0 + done + for marker in index.lock HEAD.lock FETCH_HEAD.lock packed-refs.lock "$HEAD_REF.lock"; do + path=$(repo_git rev-parse --path-format=absolute --git-path "$marker" 2>/dev/null) \ + || return 0 + [ ! -e "$path" ] || return 0 + done + return 1 +} + +snapshot_refs() { + local destination=$1 + repo_git for-each-ref --format='%(refname)%09%(objectname)%09%(symref)' \ + > "$destination" 2>/dev/null +} + +snapshot_unrelated_refs() { + local source=$1 destination=$2 ref object symref + : > "$destination" + while IFS="$TAB" read -r ref object symref || [ -n "${ref:-}${object:-}${symref:-}" ]; do + [ -n "${ref:-}" ] || continue + case "$ref" in + "$HEAD_REF"|"$PRESERVE_REF") continue ;; + esac + printf '%s\t%s\t%s\n' "$ref" "$object" "$symref" >> "$destination" + done < "$source" +} + +snapshot_fetch_head() { + if [ -L "$FETCH_HEAD_PATH" ]; then + refuse "FETCH_HEAD is a symbolic link" + fi + if [ -e "$FETCH_HEAD_PATH" ]; then + printf 'present\n' > "$TMP_ROOT/fetch-head.state" + cp "$FETCH_HEAD_PATH" "$TMP_ROOT/fetch-head.bytes" \ + || refuse "cannot snapshot FETCH_HEAD" + else + printf 'absent\n' > "$TMP_ROOT/fetch-head.state" + fi +} + +fetch_head_unchanged() { + local state + state=$(cat "$TMP_ROOT/fetch-head.state") || return 1 + case "$state" in + present) + [ -f "$FETCH_HEAD_PATH" ] && [ ! -L "$FETCH_HEAD_PATH" ] \ + && cmp -s "$TMP_ROOT/fetch-head.bytes" "$FETCH_HEAD_PATH" + ;; + absent) + [ ! -e "$FETCH_HEAD_PATH" ] && [ ! -L "$FETCH_HEAD_PATH" ] + ;; + *) return 1 ;; + esac +} + +observe_remote_default() { + local output first second extra ref_count=0 oid_count=0 candidate + OBSERVED_REMOTE_REF= + OBSERVED_REMOTE_OID= + if ! output=$(repo_git ls-remote --symref --exit-code \ + "$AUTHORITATIVE_REMOTE" HEAD 2>"$TMP_ROOT/ls-remote.err"); then + return 1 + fi + while IFS="$TAB" read -r first second extra || [ -n "${first:-}${second:-}${extra:-}" ]; do + [ "$second" = HEAD ] || continue + [ -z "${extra:-}" ] || return 1 + case "$first" in + 'ref: refs/heads/'*) + candidate=${first#ref: } + ref_count=$((ref_count + 1)) + OBSERVED_REMOTE_REF=$candidate + ;; + *) + case "$first" in + ''|*[!0-9a-fA-F]*) return 1 ;; + esac + oid_count=$((oid_count + 1)) + OBSERVED_REMOTE_OID=$first + ;; + esac + done </dev/null 2>&1 +} + +branch_hex() { + LC_ALL=C printf '%s' "$1" | od -An -tx1 | tr -d ' \n' +} + +anchor_namespace_valid() { + local ref object symref suffix resolved output + ANCHOR_ERROR= + if ! output=$(repo_git for-each-ref \ + --format='%(refname)%09%(objectname)%09%(symref)' "$PRESERVE_PREFIX" 2>/dev/null); then + ANCHOR_ERROR="cannot inspect preservation refs" + return 1 + fi + while IFS="$TAB" read -r ref object symref || [ -n "${ref:-}${object:-}${symref:-}" ]; do + [ -n "${ref:-}" ] || continue + case "$ref" in + "$PRESERVE_PREFIX"/*) ;; + *) continue ;; + esac + suffix=${ref#"$PRESERVE_PREFIX"/} + case "$suffix" in + ''|*/*) + ANCHOR_ERROR="malformed preservation ref exists for the default branch" + return 1 + ;; + esac + if [ -n "${symref:-}" ] || repo_git symbolic-ref -q "$ref" >/dev/null 2>&1; then + ANCHOR_ERROR="symbolic preservation ref exists for the default branch" + return 1 + fi + [ "$suffix" = "$object" ] || { + ANCHOR_ERROR="conflicting preservation ref exists for the default branch" + return 1 + } + if ! resolved=$(repo_git rev-parse --verify "$object^{commit}" 2>/dev/null); then + ANCHOR_ERROR="preservation ref does not name a commit" + return 1 + fi + [ "$resolved" = "$object" ] || { + ANCHOR_ERROR="preservation ref does not directly name its bound commit" + return 1 + } + done </dev/null) + rc=$? + set -e + if [ "$rc" -ne 0 ] && [ -z "$output" ]; then + printf '0\n' + return 0 + fi + [ "$rc" -eq 0 ] || return 1 + count=$(printf '%s\n' "$output" | awk 'NF { n++ } END { print n + 0 }') + printf '%s\n' "$count" +} + +find_idempotent_anchor() { + local current_oid=$1 current_tree=$2 output ref object symref anchor_tree bases + IDEMPOTENT_REF= + if ! output=$(repo_git for-each-ref \ + --format='%(refname)%09%(objectname)%09%(symref)' "$PRESERVE_PREFIX" 2>/dev/null); then + return 1 + fi + while IFS="$TAB" read -r ref object symref || [ -n "${ref:-}${object:-}${symref:-}" ]; do + case "${ref:-}" in + "$PRESERVE_PREFIX"/*) ;; + *) continue ;; + esac + [ -z "${symref:-}" ] || return 1 + anchor_tree=$(repo_git rev-parse --verify "$object^{tree}" 2>/dev/null) || return 1 + [ "$anchor_tree" = "$current_tree" ] || continue + if repo_git merge-base --is-ancestor "$object" "$current_oid" 2>/dev/null; then + continue + fi + if repo_git merge-base --is-ancestor "$current_oid" "$object" 2>/dev/null; then + continue + fi + bases=$(merge_base_count "$object" "$current_oid") || return 1 + [ "$bases" -eq 1 ] || continue + if [ -z "$IDEMPOTENT_REF" ] || [ "$ref" \< "$IDEMPOTENT_REF" ]; then + IDEMPOTENT_REF=$ref + fi + done </dev/null || printf 'unresolved') + printf '%s: post-commit evidence changed: %s\n' "$PROGRAM" "$detail" >&2 + printf '%s: committed outcome: %s moved from %s to %s; preserved old head at %s; current branch ref is %s; no rollback was attempted\n' \ + "$PROGRAM" "$HEAD_REF" "$LOCAL_OID" "$REMOTE_OID" "$PRESERVE_REF" "$claimant" >&2 + exit 1 +} + +if ! HEAD_REF=$(repo_git symbolic-ref --no-recurse -q HEAD 2>/dev/null); then + refuse "HEAD is detached" +fi +case "$HEAD_REF" in + refs/heads/*) ;; + *) refuse "HEAD is not attached to a local branch" ;; +esac +DEFAULT_BRANCH=${HEAD_REF#refs/heads/} +repo_git check-ref-format --branch "$DEFAULT_BRANCH" >/dev/null 2>&1 \ + || refuse "checked-out branch name is unsupported" +if repo_git symbolic-ref -q "$HEAD_REF" >/dev/null 2>&1; then + refuse "checked-out local branch ref is symbolic" +fi +if ! LOCAL_RAW=$(repo_git rev-parse --verify "$HEAD_REF" 2>/dev/null); then + refuse "cannot resolve the checked-out local branch ref" +fi +if ! LOCAL_OID=$(repo_git rev-parse --verify "$HEAD_REF^{commit}" 2>/dev/null); then + refuse "checked-out local branch does not name a commit" +fi +[ "$LOCAL_RAW" = "$LOCAL_OID" ] \ + || refuse "checked-out local branch does not directly name a commit" +HEAD_OID=$(repo_git rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || refuse "cannot resolve current HEAD" +[ "$HEAD_OID" = "$LOCAL_OID" ] \ + || refuse "HEAD and the checked-out local branch disagree" +require_clean || refuse "worktree or index is not clean" +COUNT=$(worktree_count) || refuse "cannot enumerate repository worktrees" +[ "$COUNT" -eq 1 ] || refuse "repository has $COUNT registered worktrees; exactly one is required" +active_operation_present && refuse "an active Git operation or lock is present" +repo_git remote get-url "$AUTHORITATIVE_REMOTE" >/dev/null 2>&1 \ + || refuse "authoritative origin remote is missing" + +observe_remote_default \ + || refuse "cannot confidently resolve origin's source-backed default branch" +REMOTE_REF=$OBSERVED_REMOTE_REF +[ "$REMOTE_REF" = "refs/heads/$DEFAULT_BRANCH" ] \ + || refuse "checked-out branch is not origin's source-backed default branch" +REMOTE_TRACKING_REF="refs/remotes/$AUTHORITATIVE_REMOTE/$DEFAULT_BRANCH" +if repo_git symbolic-ref -q "$REMOTE_TRACKING_REF" >/dev/null 2>&1; then + refuse "observed remote-tracking default ref is symbolic" +fi + +BRANCH_HEX=$(branch_hex "$DEFAULT_BRANCH") +[ -n "$BRANCH_HEX" ] || refuse "cannot encode the default branch name" +PRESERVE_PREFIX="refs/firstmate/identical-squash/$BRANCH_HEX" +PRESERVE_REF="$PRESERVE_PREFIX/$LOCAL_OID" +repo_git check-ref-format "$PRESERVE_REF" >/dev/null 2>&1 \ + || refuse "deterministic preservation ref name is invalid" +anchor_namespace_valid || refuse "$ANCHOR_ERROR" +PRESERVE_MODE=create +if repo_git symbolic-ref -q "$PRESERVE_REF" >/dev/null 2>&1; then + refuse "deterministic preservation ref is symbolic" +elif repo_git show-ref --verify --quiet "$PRESERVE_REF"; then + EXISTING_PRESERVE=$(repo_git rev-parse --verify "$PRESERVE_REF" 2>/dev/null) \ + || refuse "cannot resolve existing preservation ref" + [ "$EXISTING_PRESERVE" = "$LOCAL_OID" ] \ + || refuse "deterministic preservation ref conflicts with the old local commit" + PRESERVE_MODE=verify +fi + +FETCH_HEAD_PATH=$(repo_git rev-parse --path-format=absolute --git-path FETCH_HEAD 2>/dev/null) \ + || refuse "cannot resolve FETCH_HEAD" +snapshot_fetch_head +snapshot_refs "$TMP_ROOT/refs.initial" || refuse "cannot snapshot repository refs" + +attempt=1 +REMOTE_OID= +REMOTE_TREE= +while [ "$attempt" -le "$MAX_SOURCE_ATTEMPTS" ]; do + if ! repo_git fetch --quiet --no-write-fetch-head --no-tags \ + --no-recurse-submodules --refmap= "$AUTHORITATIVE_REMOTE" "$REMOTE_REF" \ + >"$TMP_ROOT/fetch.out" 2>"$TMP_ROOT/fetch.err"; then + refuse "source-only fetch from origin failed; inspect remote access without exposing credentials" + fi + snapshot_refs "$TMP_ROOT/refs.after-fetch" \ + || refuse "cannot verify refs after source-only fetch" + cmp -s "$TMP_ROOT/refs.initial" "$TMP_ROOT/refs.after-fetch" \ + || refuse "source acquisition changed a repository ref" + fetch_head_unchanged || refuse "source acquisition changed FETCH_HEAD" + + observe_remote_default \ + || refuse "cannot observe origin's default branch after source-only fetch" + [ "$OBSERVED_REMOTE_REF" = "$REMOTE_REF" ] \ + || refuse "origin's default branch identity changed during reconciliation" + candidate=$OBSERVED_REMOTE_OID + if ! resolved_candidate=$(repo_git rev-parse --verify "$candidate^{commit}" 2>/dev/null); then + if [ "$attempt" -lt "$MAX_SOURCE_ATTEMPTS" ]; then + attempt=$((attempt + 1)) + continue + fi + refuse "origin's default tip advanced beyond the fetched object in $MAX_SOURCE_ATTEMPTS attempts" + fi + if [ "$resolved_candidate" != "$candidate" ]; then + refuse "origin's default branch does not directly name a commit" + fi + + if [ "$LOCAL_OID" = "$candidate" ]; then + candidate_tree=$(repo_git rev-parse --verify "$candidate^{tree}" 2>/dev/null) \ + || refuse "cannot resolve the current root tree" + else + if repo_git merge-base --is-ancestor "$LOCAL_OID" "$candidate" 2>/dev/null; then + refuse "local default branch is only behind origin; use an ordinary fast-forward" + fi + if repo_git merge-base --is-ancestor "$candidate" "$LOCAL_OID" 2>/dev/null; then + refuse "local default branch is ahead of origin" + fi + BASE_COUNT=$(merge_base_count "$LOCAL_OID" "$candidate") \ + || refuse "cannot determine the merge-base set" + [ "$BASE_COUNT" -gt 0 ] || refuse "local and origin histories are unrelated" + [ "$BASE_COUNT" -eq 1 ] || refuse "local and origin histories have an ambiguous merge-base set" + LOCAL_TREE=$(repo_git rev-parse --verify "$LOCAL_OID^{tree}" 2>/dev/null) \ + || refuse "cannot resolve the local root tree" + candidate_tree=$(repo_git rev-parse --verify "$candidate^{tree}" 2>/dev/null) \ + || refuse "cannot resolve origin's root tree" + [ "$LOCAL_TREE" = "$candidate_tree" ] \ + || refuse "local and origin root trees are not identical" + fi + + observe_remote_default \ + || refuse "cannot re-observe origin immediately before reconciliation" + [ "$OBSERVED_REMOTE_REF" = "$REMOTE_REF" ] \ + || refuse "origin's default branch identity changed during reconciliation" + if [ "$OBSERVED_REMOTE_OID" != "$candidate" ]; then + if [ "$attempt" -lt "$MAX_SOURCE_ATTEMPTS" ]; then + attempt=$((attempt + 1)) + continue + fi + refuse "origin's default tip did not stabilize in $MAX_SOURCE_ATTEMPTS attempts" + fi + REMOTE_OID=$candidate + REMOTE_TREE=$candidate_tree + break +done +[ -n "$REMOTE_OID" ] || refuse "could not obtain a stable source-backed origin tip" + +if [ "$LOCAL_OID" = "$REMOTE_OID" ]; then + anchor_namespace_valid || refuse "$ANCHOR_ERROR" + find_idempotent_anchor "$LOCAL_OID" "$REMOTE_TREE" \ + || refuse "local default branch already equals origin without valid prior reconciliation evidence" + snapshot_refs "$TMP_ROOT/refs.idempotent" \ + || refuse "cannot verify refs for repeat-run convergence" + cmp -s "$TMP_ROOT/refs.initial" "$TMP_ROOT/refs.idempotent" \ + || refuse "repository refs changed during repeat-run verification" + fetch_head_unchanged || refuse "FETCH_HEAD changed during repeat-run verification" + require_clean || refuse "worktree or index changed during repeat-run verification" + COUNT=$(worktree_count) || refuse "cannot re-enumerate repository worktrees" + [ "$COUNT" -eq 1 ] || refuse "repository worktree count changed during repeat-run verification" + active_operation_present && refuse "an active Git operation appeared during repeat-run verification" + printf '%s: already reconciled: %s is at source-backed origin commit %s; preserved prior head evidence at %s\n' \ + "$PROGRAM" "$HEAD_REF" "$REMOTE_OID" "$IDEMPOTENT_REF" + exit 0 +fi + +# Re-prove every local precondition immediately before the sole ref mutation. +CURRENT_HEAD_REF=$(repo_git symbolic-ref --no-recurse -q HEAD 2>/dev/null) \ + || refuse "HEAD detached before the atomic ref transaction" +[ "$CURRENT_HEAD_REF" = "$HEAD_REF" ] \ + || refuse "checked-out branch changed before the atomic ref transaction" +repo_git symbolic-ref -q "$HEAD_REF" >/dev/null 2>&1 \ + && refuse "local default branch became symbolic before the atomic ref transaction" +CURRENT_LOCAL=$(repo_git rev-parse --verify "$HEAD_REF" 2>/dev/null) \ + || refuse "cannot re-read the local default branch" +[ "$CURRENT_LOCAL" = "$LOCAL_OID" ] \ + || refuse "local default branch changed before the atomic ref transaction" +require_clean || refuse "worktree or index changed before the atomic ref transaction" +COUNT=$(worktree_count) || refuse "cannot re-enumerate repository worktrees" +[ "$COUNT" -eq 1 ] || refuse "repository worktree count changed before the atomic ref transaction" +active_operation_present && refuse "an active Git operation appeared before the atomic ref transaction" +anchor_namespace_valid || refuse "$ANCHOR_ERROR" +if [ "$PRESERVE_MODE" = create ]; then + repo_git show-ref --verify --quiet "$PRESERVE_REF" \ + && refuse "deterministic preservation ref appeared before the atomic ref transaction" + repo_git symbolic-ref -q "$PRESERVE_REF" >/dev/null 2>&1 \ + && refuse "deterministic preservation ref became symbolic before the atomic ref transaction" +else + repo_git symbolic-ref -q "$PRESERVE_REF" >/dev/null 2>&1 \ + && refuse "deterministic preservation ref became symbolic before the atomic ref transaction" + CURRENT_PRESERVE=$(repo_git rev-parse --verify "$PRESERVE_REF" 2>/dev/null) \ + || refuse "existing preservation ref disappeared before the atomic ref transaction" + [ "$CURRENT_PRESERVE" = "$LOCAL_OID" ] \ + || refuse "existing preservation ref changed before the atomic ref transaction" +fi +snapshot_refs "$TMP_ROOT/refs.pre-transaction" \ + || refuse "cannot verify refs before the atomic ref transaction" +cmp -s "$TMP_ROOT/refs.initial" "$TMP_ROOT/refs.pre-transaction" \ + || refuse "a repository ref changed before the atomic ref transaction" +fetch_head_unchanged || refuse "FETCH_HEAD changed before the atomic ref transaction" +observe_remote_default \ + || refuse "cannot observe origin at the atomic transaction boundary" +[ "$OBSERVED_REMOTE_REF" = "$REMOTE_REF" ] && [ "$OBSERVED_REMOTE_OID" = "$REMOTE_OID" ] \ + || refuse "origin changed at the atomic transaction boundary; rerun after quiescing writers" + +{ + printf 'option no-deref\n' + printf 'start\n' + if [ "$PRESERVE_MODE" = create ]; then + printf 'create %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" + else + printf 'verify %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" + fi + printf 'update %s %s %s\n' "$HEAD_REF" "$REMOTE_OID" "$LOCAL_OID" + printf 'prepare\n' + printf 'commit\n' +} > "$TMP_ROOT/update-ref.stdin" + +if ! repo_git update-ref --stdin < "$TMP_ROOT/update-ref.stdin" \ + >"$TMP_ROOT/update-ref.out" 2>"$TMP_ROOT/update-ref.err"; then + refuse "atomic ref transaction was rejected, likely because a concurrent claimant changed evidence; no reconciliation transaction was committed" +fi +printf '%s: committed: moved %s from %s to %s; preserved old head at %s\n' \ + "$PROGRAM" "$HEAD_REF" "$LOCAL_OID" "$REMOTE_OID" "$PRESERVE_REF" + +# Never roll back below this line. A new claimant owns any post-commit change. +CURRENT_HEAD_REF=$(repo_git symbolic-ref --no-recurse -q HEAD 2>/dev/null) \ + || post_commit_fail "HEAD detached after the transaction" +[ "$CURRENT_HEAD_REF" = "$HEAD_REF" ] \ + || post_commit_fail "checked-out branch identity changed after the transaction" +repo_git symbolic-ref -q "$HEAD_REF" >/dev/null 2>&1 \ + && post_commit_fail "local default branch became symbolic after the transaction" +CURRENT_LOCAL=$(repo_git rev-parse --verify "$HEAD_REF" 2>/dev/null) \ + || post_commit_fail "local default branch became unreadable after the transaction" +[ "$CURRENT_LOCAL" = "$REMOTE_OID" ] \ + || post_commit_fail "local default branch no longer names the committed remote target" +CURRENT_HEAD=$(repo_git rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || post_commit_fail "HEAD became unreadable after the transaction" +[ "$CURRENT_HEAD" = "$REMOTE_OID" ] \ + || post_commit_fail "HEAD no longer names the committed remote target" +require_clean || post_commit_fail "worktree or index changed after the transaction" +COUNT=$(worktree_count) || post_commit_fail "repository worktrees became unreadable after the transaction" +[ "$COUNT" -eq 1 ] || post_commit_fail "repository worktree count changed after the transaction" +active_operation_present && post_commit_fail "an active Git operation appeared after the transaction" +repo_git symbolic-ref -q "$PRESERVE_REF" >/dev/null 2>&1 \ + && post_commit_fail "preservation ref became symbolic after the transaction" +CURRENT_PRESERVE=$(repo_git rev-parse --verify "$PRESERVE_REF" 2>/dev/null) \ + || post_commit_fail "preservation ref became unreadable after the transaction" +[ "$CURRENT_PRESERVE" = "$LOCAL_OID" ] \ + || post_commit_fail "preservation ref no longer names the old local head" +CURRENT_TREE=$(repo_git rev-parse --verify "$REMOTE_OID^{tree}" 2>/dev/null) \ + || post_commit_fail "committed remote root tree became unreadable" +[ "$CURRENT_TREE" = "$REMOTE_TREE" ] \ + || post_commit_fail "committed remote root tree identity changed" +fetch_head_unchanged || post_commit_fail "FETCH_HEAD changed after the transaction" +snapshot_refs "$TMP_ROOT/refs.post-transaction" \ + || post_commit_fail "repository refs became unreadable after the transaction" +snapshot_unrelated_refs "$TMP_ROOT/refs.initial" "$TMP_ROOT/refs.initial-unrelated" +snapshot_unrelated_refs "$TMP_ROOT/refs.post-transaction" "$TMP_ROOT/refs.post-unrelated" +cmp -s "$TMP_ROOT/refs.initial-unrelated" "$TMP_ROOT/refs.post-unrelated" \ + || post_commit_fail "an unrelated repository ref changed after the transaction" +observe_remote_default \ + || post_commit_fail "origin could not be observed after the transaction" +[ "$OBSERVED_REMOTE_REF" = "$REMOTE_REF" ] && [ "$OBSERVED_REMOTE_OID" = "$REMOTE_OID" ] \ + || post_commit_fail "origin's source-backed default branch changed after the transaction" +anchor_namespace_valid || post_commit_fail "$ANCHOR_ERROR" +find_idempotent_anchor "$REMOTE_OID" "$REMOTE_TREE" \ + || post_commit_fail "repeat-run convergence evidence is incomplete" + +printf '%s: verified: HEAD and %s are clean at %s; prior head %s remains at %s; a repeat invocation will converge without mutation\n' \ + "$PROGRAM" "$HEAD_REF" "$REMOTE_OID" "$LOCAL_OID" "$PRESERVE_REF" diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d1310ae..d013d00024a 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -12,6 +12,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | +| `fm-reconcile-identical-squash.sh` | Explicitly preserve and reconcile one quiesced default branch when its divergent source-backed origin has an identical root tree | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh new file mode 100755 index 00000000000..f7ae58ea3b3 --- /dev/null +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -0,0 +1,586 @@ +#!/usr/bin/env bash +# Executable-interface regressions for fm-reconcile-identical-squash.sh. +# Every repository and remote is isolated under one temporary root. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_git_identity fm-reconcile-tests fm-reconcile-tests@example.invalid + +TMP_ROOT=$(fm_test_tmproot fm-reconcile-identical-squash-tests) +HELPER="$ROOT/bin/fm-reconcile-identical-squash.sh" +CASE_N=0 +RUN_RC=0 +RUN_OUT= +RUN_ERR= + +new_case_root() { + CASE_N=$((CASE_N + 1)) + CASE_ROOT="$TMP_ROOT/case-$CASE_N-$1" + mkdir -p "$CASE_ROOT" +} + +commit_file() { + local repo=$1 path=$2 content=$3 message=$4 + mkdir -p "$(dirname "$repo/$path")" + printf '%s\n' "$content" > "$repo/$path" + git -C "$repo" add "$path" + git -C "$repo" commit -qm "$message" +} + +commit_same_tree_child() { + local repo=$1 parent=$2 message=$3 tree + tree=$(git -C "$repo" rev-parse "$parent^{tree}") + printf '%s\n' "$message" | git -C "$repo" commit-tree "$tree" -p "$parent" +} + +new_base_fixture() { + local name=$1 remote_abs + new_case_root "$name" + SEED="$CASE_ROOT/seed" + REMOTE="$CASE_ROOT/origin.git" + REPO="$CASE_ROOT/repo" + git init -q "$SEED" + git -C "$SEED" symbolic-ref HEAD refs/heads/main + commit_file "$SEED" tracked.txt stable base + git clone --quiet --bare "$SEED" "$REMOTE" + remote_abs=$(cd "$REMOTE" && pwd -P) + git clone --quiet "file://$remote_abs" "$REPO" + BASE_OID=$(git -C "$REPO" rev-parse HEAD) +} + +# Create two child histories from BASE_OID. Each history adds and then removes a +# private file, so LOCAL_OLD and REMOTE_NEW genuinely diverge with one merge base +# while their exact root trees equal BASE_OID's tree. +make_divergent_identical() { + commit_file "$REPO" local-only.txt local local-add + rm "$REPO/local-only.txt" + git -C "$REPO" add -u + git -C "$REPO" commit -qm local-remove + LOCAL_OLD=$(git -C "$REPO" rev-parse HEAD) + + commit_file "$SEED" remote-only.txt remote remote-add + rm "$SEED/remote-only.txt" + git -C "$SEED" add -u + git -C "$SEED" commit -qm remote-remove + REMOTE_NEW=$(git -C "$SEED" rev-parse HEAD) + git -C "$SEED" push -q "file://$REMOTE" main + PRESERVE_REF="refs/firstmate/identical-squash/6d61696e/$LOCAL_OLD" +} + +new_divergent_fixture() { + new_base_fixture "$1" + make_divergent_identical +} + +new_fast_forward_fixture() { + new_base_fixture "$1" + LOCAL_OLD=$(git -C "$REPO" rev-parse HEAD) + commit_file "$SEED" remote.txt remote remote-ahead + REMOTE_NEW=$(git -C "$SEED" rev-parse HEAD) + git -C "$SEED" push -q "file://$REMOTE" main + PRESERVE_REF="refs/firstmate/identical-squash/6d61696e/$LOCAL_OLD" +} + +run_helper() { + local repo=$1 + shift + RUN_OUT="$CASE_ROOT/run.out" + RUN_ERR="$CASE_ROOT/run.err" + set +e + "$@" "$HELPER" "$repo" > "$RUN_OUT" 2> "$RUN_ERR" + RUN_RC=$? + set -e +} + +run_helper_plain() { + run_helper "$1" env +} + +assert_direct_ref() { + local repo=$1 ref=$2 expected=$3 label=$4 + ! git -C "$repo" symbolic-ref -q "$ref" >/dev/null 2>&1 \ + || fail "$label: $ref is symbolic" + [ "$(git -C "$repo" rev-parse --verify "$ref")" = "$expected" ] \ + || fail "$label: $ref does not name $expected" +} + +assert_no_preservation_namespace() { + local repo=$1 label=$2 + [ -z "$(git -C "$repo" for-each-ref --format='%(refname)' refs/firstmate/identical-squash)" ] \ + || fail "$label: helper created an unauthorized preservation ref" +} + +assert_refused_without_move() { + local repo=$1 before=$2 label=$3 + [ "$RUN_RC" -ne 0 ] || fail "$label: expected refusal" + [ "$(git -C "$repo" rev-parse HEAD)" = "$before" ] \ + || fail "$label: checked-out branch moved" + assert_no_preservation_namespace "$repo" "$label" +} + +snapshot_refs() { + git -C "$1" for-each-ref --format='%(refname)%09%(objectname)%09%(symref)' > "$2" +} + +# The main success regression also proves the empirically selected source-only +# fetch surface: configured destination refspecs, FETCH_HEAD, tags, and unrelated +# refs remain untouched while the exact source-backed remote object is acquired. +test_divergent_identical_succeeds_and_repeats() { + local old_tracking old_fetch_hash unrelated_oid local_tag_oid refs_before_second refs_after_second + new_divergent_fixture success + + printf 'FETCH_HEAD sentinel\n' > "$REPO/.git/FETCH_HEAD" + old_fetch_hash=$(git hash-object "$REPO/.git/FETCH_HEAD") + old_tracking=$(git -C "$REPO" rev-parse refs/remotes/origin/main) + git -C "$REPO" update-ref refs/unrelated/keep "$BASE_OID" + unrelated_oid=$(git -C "$REPO" rev-parse refs/unrelated/keep) + git -C "$REPO" tag local-keep "$LOCAL_OLD" + local_tag_oid=$(git -C "$REPO" rev-parse refs/tags/local-keep) + git --git-dir="$REMOTE" tag remote-must-not-follow "$REMOTE_NEW" + + run_helper_plain "$REPO" + + expect_code 0 "$RUN_RC" "identical divergent histories" + assert_grep "committed: moved refs/heads/main from $LOCAL_OLD to $REMOTE_NEW" "$RUN_OUT" \ + "success did not report the exact branch movement" + assert_grep "repeat invocation will converge without mutation" "$RUN_OUT" \ + "success did not report repeat-run convergence" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "success branch" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$LOCAL_OLD" "success preservation" + [ "$(git -C "$REPO" rev-parse HEAD)" = "$REMOTE_NEW" ] \ + || fail "success: HEAD did not follow the moved default branch" + [ "$(git -C "$REPO" rev-parse "$LOCAL_OLD^{tree}")" = "$(git -C "$REPO" rev-parse "$REMOTE_NEW^{tree}")" ] \ + || fail "success fixture does not have identical exact root trees" + [ "$(git hash-object "$REPO/.git/FETCH_HEAD")" = "$old_fetch_hash" ] \ + || fail "success: FETCH_HEAD bytes changed" + [ "$(git -C "$REPO" rev-parse refs/remotes/origin/main)" = "$old_tracking" ] \ + || fail "success: configured remote-tracking ref changed" + [ "$(git -C "$REPO" rev-parse refs/unrelated/keep)" = "$unrelated_oid" ] \ + || fail "success: unrelated ref changed" + [ "$(git -C "$REPO" rev-parse refs/tags/local-keep)" = "$local_tag_oid" ] \ + || fail "success: local tag changed" + ! git -C "$REPO" show-ref --verify --quiet refs/tags/remote-must-not-follow \ + || fail "success: remote tag was fetched" + [ -z "$(git -C "$REPO" status --porcelain=v1)" ] || fail "success: repository is not clean" + + refs_before_second="$CASE_ROOT/refs.before-second" + refs_after_second="$CASE_ROOT/refs.after-second" + snapshot_refs "$REPO" "$refs_before_second" + run_helper_plain "$REPO" + expect_code 0 "$RUN_RC" "repeat invocation" + assert_grep "already reconciled" "$RUN_OUT" "repeat invocation did not converge" + snapshot_refs "$REPO" "$refs_after_second" + cmp -s "$refs_before_second" "$refs_after_second" \ + || fail "repeat invocation changed refs" + [ "$(git hash-object "$REPO/.git/FETCH_HEAD")" = "$old_fetch_hash" ] \ + || fail "repeat invocation changed FETCH_HEAD" + pass "divergent identical trees reconcile atomically, preserve all unrelated state, and repeat safely" +} + +# Even when repository config requests recursive submodule fetches, the helper's +# public behavior must leave an uninitialized submodule untouched. +test_submodules_are_not_fetched() { + local sub_work sub_remote sub_oid module_url + new_base_fixture no-submodules + sub_work="$CASE_ROOT/sub-work" + sub_remote="$CASE_ROOT/sub.git" + git init -q "$sub_work" + git -C "$sub_work" symbolic-ref HEAD refs/heads/main + commit_file "$sub_work" payload.txt submodule sub-base + sub_oid=$(git -C "$sub_work" rev-parse HEAD) + git clone --quiet --bare "$sub_work" "$sub_remote" + module_url="file://$(cd "$sub_remote" && pwd -P)" + + printf '[submodule "deps/sub"]\n\tpath = deps/sub\n\turl = %s\n' "$module_url" > "$REPO/.gitmodules" + git -C "$REPO" add .gitmodules + git -C "$REPO" update-index --add --cacheinfo "160000,$sub_oid,deps/sub" + git -C "$REPO" commit -qm local-submodule-tree + LOCAL_OLD=$(git -C "$REPO" rev-parse HEAD) + mkdir -p "$REPO/deps/sub" + + cp "$REPO/.gitmodules" "$SEED/.gitmodules" + git -C "$SEED" add .gitmodules + git -C "$SEED" update-index --add --cacheinfo "160000,$sub_oid,deps/sub" + git -C "$SEED" commit -qm remote-submodule-tree + REMOTE_NEW=$(git -C "$SEED" rev-parse HEAD) + git -C "$SEED" push -q "file://$REMOTE" main + git -C "$REPO" config fetch.recurseSubmodules true + + run_helper_plain "$REPO" + + expect_code 0 "$RUN_RC" "no submodule recursion" + [ ! -e "$REPO/.git/modules/deps/sub" ] \ + || fail "submodule repository was fetched despite --no-recurse-submodules" + [ ! -e "$REPO/deps/sub/.git" ] \ + || fail "submodule worktree was initialized" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "submodule success branch" + pass "configured recursive submodule fetching is suppressed" +} + +test_dirty_refuses() { + new_divergent_fixture dirty + printf 'staged dirty\n' >> "$REPO/tracked.txt" + git -C "$REPO" add tracked.txt + printf 'unstaged dirty\n' >> "$REPO/tracked.txt" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" dirty + assert_grep "worktree or index is not clean" "$RUN_ERR" "dirty refusal was not actionable" + pass "dirty worktree and index refuse without ref mutation" +} + +test_detached_refuses() { + new_divergent_fixture detached + git -C "$REPO" checkout --detach --quiet + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" detached + assert_grep "HEAD is detached" "$RUN_ERR" "detached refusal was not actionable" + pass "detached HEAD refuses without ref mutation" +} + +test_off_default_refuses() { + new_divergent_fixture off-default + git -C "$REPO" switch -q -c feature + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" off-default + [ "$(git -C "$REPO" symbolic-ref --short HEAD)" = feature ] \ + || fail "off-default refusal changed the checked-out branch" + assert_grep "not origin's source-backed default branch" "$RUN_ERR" \ + "off-default refusal was not actionable" + pass "off-default branch refuses without ref mutation" +} + +test_active_operation_refuses() { + local merge_head_path + new_divergent_fixture active-operation + merge_head_path=$(git -C "$REPO" rev-parse --git-path MERGE_HEAD) + printf '%s\n' "$BASE_OID" > "$REPO/$merge_head_path" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" active-operation + assert_grep "active Git operation or lock" "$RUN_ERR" "active-operation refusal was not actionable" + pass "active Git operation refuses without ref mutation" +} + +test_multiple_worktrees_refuse() { + new_divergent_fixture multiple-worktrees + git -C "$REPO" worktree add --quiet --detach "$CASE_ROOT/other-worktree" "$BASE_OID" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" multiple-worktrees + assert_grep "exactly one is required" "$RUN_ERR" "multi-worktree refusal was not actionable" + pass "multiple registered worktrees refuse without ref mutation" +} + +test_symbolic_local_ref_refuses() { + new_divergent_fixture symbolic-local + git -C "$REPO" update-ref refs/heads/direct-local "$LOCAL_OLD" + git -C "$REPO" symbolic-ref refs/heads/main refs/heads/direct-local + run_helper_plain "$REPO" + [ "$RUN_RC" -ne 0 ] || fail "symbolic local ref: expected refusal" + [ "$(git -C "$REPO" symbolic-ref refs/heads/main)" = refs/heads/direct-local ] \ + || fail "symbolic local ref was rewritten" + assert_no_preservation_namespace "$REPO" "symbolic local ref" + assert_grep "local branch ref is symbolic" "$RUN_ERR" "symbolic-local refusal was not actionable" + pass "symbolic local default ref refuses without write-through" +} + +test_symbolic_remote_tracking_ref_refuses() { + new_divergent_fixture symbolic-remote-tracking + git -C "$REPO" update-ref refs/remotes/origin/direct-target "$BASE_OID" + git -C "$REPO" symbolic-ref refs/remotes/origin/main refs/remotes/origin/direct-target + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" symbolic-remote-tracking + [ "$(git -C "$REPO" symbolic-ref refs/remotes/origin/main)" = refs/remotes/origin/direct-target ] \ + || fail "symbolic remote-tracking ref was rewritten" + assert_grep "remote-tracking default ref is symbolic" "$RUN_ERR" \ + "symbolic remote-tracking refusal was not actionable" + pass "symbolic remote-tracking default ref refuses without mutation" +} + +test_existing_exact_preservation_ref_is_reused() { + new_divergent_fixture existing-exact-preservation + git -C "$REPO" update-ref "$PRESERVE_REF" "$LOCAL_OLD" + run_helper_plain "$REPO" + expect_code 0 "$RUN_RC" "existing exact preservation" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "existing preservation branch" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$LOCAL_OLD" "existing preservation" + pass "existing exact direct preservation ref is reused idempotently" +} + +test_conflicting_preservation_ref_refuses() { + new_divergent_fixture conflicting-preservation + git -C "$REPO" update-ref "$PRESERVE_REF" "$BASE_OID" + run_helper_plain "$REPO" + [ "$RUN_RC" -ne 0 ] || fail "conflicting preservation: expected refusal" + [ "$(git -C "$REPO" rev-parse HEAD)" = "$LOCAL_OLD" ] \ + || fail "conflicting preservation moved the branch" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$BASE_OID" "conflicting preservation" + assert_grep "conflicting preservation ref" "$RUN_ERR" \ + "conflicting preservation refusal was not actionable" + pass "conflicting deterministic preservation ref is never overwritten" +} + +test_symbolic_preservation_ref_refuses() { + new_divergent_fixture symbolic-preservation + git -C "$REPO" update-ref refs/heads/preserved-target "$LOCAL_OLD" + git -C "$REPO" symbolic-ref "$PRESERVE_REF" refs/heads/preserved-target + run_helper_plain "$REPO" + [ "$RUN_RC" -ne 0 ] || fail "symbolic preservation: expected refusal" + [ "$(git -C "$REPO" rev-parse HEAD)" = "$LOCAL_OLD" ] \ + || fail "symbolic preservation moved the branch" + [ "$(git -C "$REPO" symbolic-ref "$PRESERVE_REF")" = refs/heads/preserved-target ] \ + || fail "symbolic preservation ref was rewritten" + assert_grep "symbolic preservation ref" "$RUN_ERR" \ + "symbolic preservation refusal was not actionable" + pass "symbolic preservation ref refuses without write-through" +} + +test_fast_forward_refuses() { + new_fast_forward_fixture fast-forward + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" fast-forward + assert_grep "ordinary fast-forward" "$RUN_ERR" "fast-forward refusal was not actionable" + pass "ordinary fast-forward case refuses without branch movement" +} + +test_ahead_only_refuses() { + new_base_fixture ahead-only + commit_file "$REPO" local.txt ahead local-ahead + LOCAL_OLD=$(git -C "$REPO" rev-parse HEAD) + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" ahead-only + assert_grep "ahead of origin" "$RUN_ERR" "ahead-only refusal was not actionable" + pass "ahead-only case refuses without branch movement" +} + +test_unequal_tree_refuses() { + new_divergent_fixture unequal-tree + commit_file "$SEED" unequal.txt unequal unequal-tree + REMOTE_NEW=$(git -C "$SEED" rev-parse HEAD) + git -C "$SEED" push -q "file://$REMOTE" main + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" unequal-tree + assert_grep "root trees are not identical" "$RUN_ERR" "unequal-tree refusal was not actionable" + pass "divergent unequal trees refuse without ref mutation" +} + +test_unrelated_identical_tree_refuses() { + local tree unrelated + new_divergent_fixture unrelated + tree=$(git -C "$REPO" rev-parse "$LOCAL_OLD^{tree}") + unrelated=$(printf 'unrelated root\n' | git --git-dir="$REMOTE" commit-tree "$tree") + git --git-dir="$REMOTE" update-ref refs/heads/main "$unrelated" "$REMOTE_NEW" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" unrelated + assert_grep "histories are unrelated" "$RUN_ERR" "unrelated-history refusal was not actionable" + pass "unrelated histories with identical trees refuse without ref mutation" +} + +test_ambiguous_merge_base_refuses() { + local tree a b local_merge remote_merge + new_base_fixture ambiguous-base + tree=$(git -C "$REPO" rev-parse "$BASE_OID^{tree}") + a=$(printf 'side A\n' | git -C "$REPO" commit-tree "$tree" -p "$BASE_OID") + b=$(printf 'side B\n' | git -C "$REPO" commit-tree "$tree" -p "$BASE_OID") + local_merge=$(printf 'local criss-cross merge\n' | git -C "$REPO" commit-tree "$tree" -p "$a" -p "$b") + remote_merge=$(printf 'remote criss-cross merge\n' | git -C "$REPO" commit-tree "$tree" -p "$b" -p "$a") + git -C "$REPO" update-ref refs/heads/main "$local_merge" "$BASE_OID" + git -C "$REPO" push -q "file://$REMOTE" "$remote_merge:refs/heads/main" + LOCAL_OLD=$local_merge + + [ "$(git -C "$REPO" merge-base --all "$local_merge" "$remote_merge" | wc -l | tr -d ' ')" -eq 2 ] \ + || fail "ambiguous fixture did not produce two merge bases" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" ambiguous-base + assert_grep "ambiguous merge-base set" "$RUN_ERR" "ambiguous-base refusal was not actionable" + pass "ambiguous merge-base set refuses without ref mutation" +} + +test_missing_remote_ref_refuses() { + new_divergent_fixture missing-remote-ref + git --git-dir="$REMOTE" update-ref -d refs/heads/main "$REMOTE_NEW" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" missing-remote-ref + assert_grep "cannot confidently resolve origin's source-backed default branch" "$RUN_ERR" \ + "missing-remote-ref refusal was not actionable" + pass "missing authoritative remote default ref refuses without ref mutation" +} + +test_fetch_failure_refuses() { + local fakebin real_git + new_divergent_fixture fetch-failure + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +for arg in "$@"; do + if [ "$arg" = fetch ]; then + printf 'simulated credential-bearing transport failure is intentionally hidden\n' >&2 + exit 19 + fi +done +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" + assert_refused_without_move "$REPO" "$LOCAL_OLD" fetch-failure + assert_grep "source-only fetch from origin failed" "$RUN_ERR" "fetch failure was not actionable" + assert_no_grep "credential-bearing" "$RUN_ERR" "fetch diagnostic exposed transport details" + pass "fetch failure refuses without mutation or credential-bearing diagnostics" +} + +test_source_advancement_is_bounded() { + local fakebin real_git tree next parent sequence counter i + new_divergent_fixture advancing-source + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + tree=$(git --git-dir="$REMOTE" rev-parse "$REMOTE_NEW^{tree}") + sequence="$CASE_ROOT/advance-sequence" + : > "$sequence" + parent=$REMOTE_NEW + i=1 + while [ "$i" -le 3 ]; do + next=$(printf 'remote advance %s\n' "$i" | git --git-dir="$REMOTE" commit-tree "$tree" -p "$parent") + printf '%s\n' "$next" >> "$sequence" + parent=$next + i=$((i + 1)) + done + counter="$CASE_ROOT/fetch-count" + printf '0\n' > "$counter" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_fetch=0 +for arg in "$@"; do [ "$arg" = fetch ] && is_fetch=1; done +if [ "$is_fetch" -eq 1 ]; then + "${REAL_GIT_FOR_TEST:?}" "$@" || exit $? + n=$(cat "${ADVANCE_COUNTER:?}") + n=$((n + 1)) + printf '%s\n' "$n" > "$ADVANCE_COUNTER" + next=$(sed -n "${n}p" "${ADVANCE_SEQUENCE:?}") + [ -n "$next" ] || exit 97 + "${REAL_GIT_FOR_TEST:?}" --git-dir="${ADVANCE_REMOTE:?}" \ + update-ref refs/heads/main "$next" + exit 0 +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + ADVANCE_COUNTER="$counter" ADVANCE_SEQUENCE="$sequence" ADVANCE_REMOTE="$REMOTE" + + assert_refused_without_move "$REPO" "$LOCAL_OLD" advancing-source + [ "$(cat "$counter")" -eq 3 ] || fail "advancing source was not bounded to three fetch attempts" + assert_grep "advanced beyond the fetched object in 3 attempts" "$RUN_ERR" \ + "bounded source-advance refusal was not actionable" + pass "source advancement retries to a fixed bound and then refuses" +} + +test_expected_old_race_is_atomic() { + local fakebin real_git race_oid + new_divergent_fixture expected-old-race + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + race_oid=$(commit_same_tree_child "$REPO" "$LOCAL_OLD" external-claimant) + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_update=0 +for arg in "$@"; do [ "$arg" = update-ref ] && is_update=1; done +if [ "$is_update" -eq 1 ] && [ "${RACE_INNER:-0}" != 1 ]; then + RACE_INNER=1 "${REAL_GIT_FOR_TEST:?}" -C "${RACE_REPO:?}" \ + update-ref refs/heads/main "${RACE_OID:?}" "${RACE_OLD:?}" || exit $? +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + RACE_REPO="$REPO" RACE_OID="$race_oid" RACE_OLD="$LOCAL_OLD" + + [ "$RUN_RC" -ne 0 ] || fail "expected-old race: expected refusal" + [ "$(git -C "$REPO" rev-parse refs/heads/main)" = "$race_oid" ] \ + || fail "expected-old race overwrote the external claimant" + [ "$(git -C "$REPO" rev-parse refs/heads/main)" != "$REMOTE_NEW" ] \ + || fail "expected-old race moved the branch to the remote target" + assert_no_preservation_namespace "$REPO" "expected-old race" + assert_grep "atomic ref transaction was rejected" "$RUN_ERR" \ + "expected-old race refusal was not actionable" + pass "expected-old race commits neither branch movement nor preservation ref" +} + +test_post_commit_claimant_is_never_rolled_back() { + local fakebin real_git claimant + new_divergent_fixture post-commit-claimant + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + claimant=$(commit_same_tree_child "$REPO" "$LOCAL_OLD" post-commit-claimant) + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_update=0 +for arg in "$@"; do [ "$arg" = update-ref ] && is_update=1; done +if [ "$is_update" -eq 1 ] && [ "${POST_INNER:-0}" != 1 ]; then + "${REAL_GIT_FOR_TEST:?}" "$@" || exit $? + POST_INNER=1 "${REAL_GIT_FOR_TEST:?}" -C "${POST_REPO:?}" \ + update-ref refs/heads/main "${POST_CLAIMANT:?}" "${POST_REMOTE:?}" || exit $? + exit 0 +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + POST_REPO="$REPO" POST_CLAIMANT="$claimant" POST_REMOTE="$REMOTE_NEW" + + [ "$RUN_RC" -ne 0 ] || fail "post-commit claimant: expected evidence-change failure" + [ "$(git -C "$REPO" rev-parse refs/heads/main)" = "$claimant" ] \ + || fail "post-commit claimant was rolled back or overwritten" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$LOCAL_OLD" "post-commit preservation" + assert_grep "committed: moved refs/heads/main from $LOCAL_OLD to $REMOTE_NEW" "$RUN_OUT" \ + "post-commit change did not retain committed outcome" + assert_grep "committed outcome: refs/heads/main moved from $LOCAL_OLD to $REMOTE_NEW" "$RUN_ERR" \ + "post-commit diagnostic omitted exact committed state" + assert_grep "current branch ref is $claimant" "$RUN_ERR" \ + "post-commit diagnostic omitted the new claimant" + assert_grep "no rollback was attempted" "$RUN_ERR" \ + "post-commit diagnostic did not state the no-rollback boundary" + pass "post-commit claimant is reported with exact evidence and never rolled back" +} + +test_non_repository_refuses() { + new_case_root non-repository + REPO="$CASE_ROOT/not-a-repo" + mkdir -p "$REPO" + run_helper_plain "$REPO" + [ "$RUN_RC" -ne 0 ] || fail "non-repository: expected refusal" + assert_grep "not an existing Git worktree" "$RUN_ERR" "non-repository refusal was not actionable" + pass "non-repository target refuses" +} + +test_divergent_identical_succeeds_and_repeats +test_submodules_are_not_fetched +test_dirty_refuses +test_detached_refuses +test_off_default_refuses +test_active_operation_refuses +test_multiple_worktrees_refuse +test_symbolic_local_ref_refuses +test_symbolic_remote_tracking_ref_refuses +test_existing_exact_preservation_ref_is_reused +test_conflicting_preservation_ref_refuses +test_symbolic_preservation_ref_refuses +test_fast_forward_refuses +test_ahead_only_refuses +test_unequal_tree_refuses +test_unrelated_identical_tree_refuses +test_ambiguous_merge_base_refuses +test_missing_remote_ref_refuses +test_fetch_failure_refuses +test_source_advancement_is_bounded +test_expected_old_race_is_atomic +test_post_commit_claimant_is_never_rolled_back +test_non_repository_refuses From 8014dc3cda9fa4d0b0f8d3aa4999c7179a61ccbe Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:47:38 -0400 Subject: [PATCH 2/6] no-mistakes(review): Harden identical-squash reconciliation against unsafe Git state --- bin/fm-reconcile-identical-squash.sh | 91 +++++++++++++++-- tests/fm-reconcile-identical-squash.test.sh | 107 ++++++++++++++++++++ 2 files changed, 189 insertions(+), 9 deletions(-) diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh index c219bdd9463..5b7b1f24bbe 100755 --- a/bin/fm-reconcile-identical-squash.sh +++ b/bin/fm-reconcile-identical-squash.sh @@ -10,18 +10,19 @@ # branch ref. It never changes ordinary fm-fleet-sync.sh behavior. # # Source acquisition uses an empty --refmap together with a source-only refspec, -# --no-write-fetch-head, --no-tags, and --no-recurse-submodules. This suppresses -# configured destination refspecs, remote-tracking updates, FETCH_HEAD writes, -# tag following, and submodule recursion. A fixed three-attempt loop tolerates a -# source tip advancing between fetch and source-backed observation. +# --no-write-fetch-head, --no-tags, --no-prune, --no-prune-tags, and +# --no-recurse-submodules. This suppresses configured destination refspecs, +# remote-tracking updates, FETCH_HEAD writes, tag following, pruning, and +# submodule recursion. A fixed three-attempt loop tolerates a source tip +# advancing between fetch and source-backed observation. # # Preservation refs have this form: # refs/firstmate/identical-squash// # The branch encoding and complete old object identity make the name # deterministic. Existing exact direct refs are reused; conflicting or symbolic # refs are refused. Ref creation/verification and branch movement share one -# `git update-ref --stdin` transaction with `option no-deref` and expected-old -# verification. +# `git update-ref --stdin` transaction with per-command `option no-deref` and +# expected-old verification. # # A successful second invocation is recognized only when the local branch still # equals the source-backed remote tip and a valid preservation ref proves a prior @@ -53,6 +54,32 @@ refuse() { exit 1 } +reject_ambient_git_overrides() { + local name + for name in \ + GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_CONFIG \ + GIT_CONFIG_PARAMETERS \ + GIT_CONFIG_COUNT \ + GIT_DIR \ + GIT_WORK_TREE \ + GIT_IMPLICIT_WORK_TREE \ + GIT_COMMON_DIR \ + GIT_INDEX_FILE \ + GIT_OBJECT_DIRECTORY \ + GIT_GRAFT_FILE \ + GIT_SHALLOW_FILE \ + GIT_NO_REPLACE_OBJECTS \ + GIT_REPLACE_REF_BASE \ + GIT_NAMESPACE \ + GIT_QUARANTINE_PATH + do + if declare -p "$name" >/dev/null 2>&1; then + refuse "ambient Git environment override $name must be unset" + fi + done +} + if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then usage exit 0 @@ -60,6 +87,7 @@ fi [ "$#" -eq 1 ] || { usage; exit 2; } REPO_ARG=$1 [ -d "$REPO_ARG" ] || refuse "repository directory does not exist" +reject_ambient_git_overrides if ! inside=$(git -C "$REPO_ARG" rev-parse --is-inside-work-tree 2>/dev/null); then refuse "target is not an existing Git worktree" @@ -91,6 +119,43 @@ repo_git() { git -C "$REPO" "$@" } +history_state_valid() { + local shallow grafts replacements + HISTORY_ERROR= + if ! shallow=$(repo_git rev-parse --is-shallow-repository 2>/dev/null); then + HISTORY_ERROR="cannot determine whether repository history is shallow" + return 1 + fi + case "$shallow" in + false) ;; + true) + HISTORY_ERROR="repository history is shallow" + return 1 + ;; + *) + HISTORY_ERROR="repository shallow-history state is ambiguous" + return 1 + ;; + esac + if ! grafts=$(repo_git rev-parse --path-format=absolute --git-path info/grafts 2>/dev/null); then + HISTORY_ERROR="cannot resolve the legacy graft path" + return 1 + fi + if [ -e "$grafts" ] || [ -L "$grafts" ]; then + HISTORY_ERROR="legacy graft history is present" + return 1 + fi + if ! replacements=$(repo_git for-each-ref --format='%(refname)' refs/replace 2>/dev/null); then + HISTORY_ERROR="cannot inspect replacement object refs" + return 1 + fi + if [ -n "$replacements" ]; then + HISTORY_ERROR="replacement object history is present" + return 1 + fi + return 0 +} + require_clean() { local status_output status_output=$(repo_git status --porcelain=v1 --untracked-files=normal 2>/dev/null) \ @@ -306,6 +371,7 @@ post_commit_fail() { exit 1 } +history_state_valid || refuse "$HISTORY_ERROR" if ! HEAD_REF=$(repo_git symbolic-ref --no-recurse -q HEAD 2>/dev/null); then refuse "HEAD is detached" fi @@ -375,8 +441,9 @@ attempt=1 REMOTE_OID= REMOTE_TREE= while [ "$attempt" -le "$MAX_SOURCE_ATTEMPTS" ]; do - if ! repo_git fetch --quiet --no-write-fetch-head --no-tags \ - --no-recurse-submodules --refmap= "$AUTHORITATIVE_REMOTE" "$REMOTE_REF" \ + if ! repo_git fetch --quiet --no-write-fetch-head --no-tags --no-prune \ + --no-prune-tags --no-recurse-submodules --refmap= \ + "$AUTHORITATIVE_REMOTE" "$REMOTE_REF" \ >"$TMP_ROOT/fetch.out" 2>"$TMP_ROOT/fetch.err"; then refuse "source-only fetch from origin failed; inspect remote access without exposing credentials" fi @@ -385,6 +452,7 @@ while [ "$attempt" -le "$MAX_SOURCE_ATTEMPTS" ]; do cmp -s "$TMP_ROOT/refs.initial" "$TMP_ROOT/refs.after-fetch" \ || refuse "source acquisition changed a repository ref" fetch_head_unchanged || refuse "source acquisition changed FETCH_HEAD" + history_state_valid || refuse "$HISTORY_ERROR" observe_remote_default \ || refuse "cannot observe origin's default branch after source-only fetch" @@ -442,6 +510,7 @@ done [ -n "$REMOTE_OID" ] || refuse "could not obtain a stable source-backed origin tip" if [ "$LOCAL_OID" = "$REMOTE_OID" ]; then + history_state_valid || refuse "$HISTORY_ERROR" anchor_namespace_valid || refuse "$ANCHOR_ERROR" find_idempotent_anchor "$LOCAL_OID" "$REMOTE_TREE" \ || refuse "local default branch already equals origin without valid prior reconciliation evidence" @@ -474,6 +543,7 @@ require_clean || refuse "worktree or index changed before the atomic ref transac COUNT=$(worktree_count) || refuse "cannot re-enumerate repository worktrees" [ "$COUNT" -eq 1 ] || refuse "repository worktree count changed before the atomic ref transaction" active_operation_present && refuse "an active Git operation appeared before the atomic ref transaction" +history_state_valid || refuse "$HISTORY_ERROR" anchor_namespace_valid || refuse "$ANCHOR_ERROR" if [ "$PRESERVE_MODE" = create ]; then repo_git show-ref --verify --quiet "$PRESERVE_REF" \ @@ -499,13 +569,15 @@ observe_remote_default \ || refuse "origin changed at the atomic transaction boundary; rerun after quiescing writers" { - printf 'option no-deref\n' printf 'start\n' if [ "$PRESERVE_MODE" = create ]; then + printf 'option no-deref\n' printf 'create %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" else + printf 'option no-deref\n' printf 'verify %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" fi + printf 'option no-deref\n' printf 'update %s %s %s\n' "$HEAD_REF" "$REMOTE_OID" "$LOCAL_OID" printf 'prepare\n' printf 'commit\n' @@ -543,6 +615,7 @@ CURRENT_PRESERVE=$(repo_git rev-parse --verify "$PRESERVE_REF" 2>/dev/null) \ || post_commit_fail "preservation ref became unreadable after the transaction" [ "$CURRENT_PRESERVE" = "$LOCAL_OID" ] \ || post_commit_fail "preservation ref no longer names the old local head" +history_state_valid || post_commit_fail "$HISTORY_ERROR" CURRENT_TREE=$(repo_git rev-parse --verify "$REMOTE_OID^{tree}" 2>/dev/null) \ || post_commit_fail "committed remote root tree became unreadable" [ "$CURRENT_TREE" = "$REMOTE_TREE" ] \ diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh index f7ae58ea3b3..1807db46c1b 100755 --- a/tests/fm-reconcile-identical-squash.test.sh +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -219,6 +219,79 @@ test_submodules_are_not_fetched() { pass "configured recursive submodule fetching is suppressed" } +test_configured_pruning_is_suppressed() { + local local_tag_oid + new_divergent_fixture configured-pruning + git -C "$REPO" tag local-must-not-prune "$LOCAL_OLD" + local_tag_oid=$(git -C "$REPO" rev-parse refs/tags/local-must-not-prune) + git --git-dir="$REMOTE" tag remote-must-not-fetch "$REMOTE_NEW" + git -C "$REPO" config fetch.prune true + git -C "$REPO" config fetch.pruneTags true + git -C "$REPO" config remote.origin.prune true + git -C "$REPO" config remote.origin.pruneTags true + + run_helper_plain "$REPO" + + expect_code 0 "$RUN_RC" "configured pruning" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "configured pruning branch" + [ "$(git -C "$REPO" rev-parse refs/tags/local-must-not-prune)" = "$local_tag_oid" ] \ + || fail "configured pruning changed or deleted a local tag" + ! git -C "$REPO" show-ref --verify --quiet refs/tags/remote-must-not-fetch \ + || fail "configured pruning fetched a remote tag" + pass "configured branch and tag pruning are suppressed" +} + +test_ambient_git_overrides_refuse() { + local name + new_divergent_fixture ambient-git-overrides + for name in \ + GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_CONFIG \ + GIT_CONFIG_PARAMETERS \ + GIT_CONFIG_COUNT \ + GIT_DIR \ + GIT_WORK_TREE \ + GIT_IMPLICIT_WORK_TREE \ + GIT_COMMON_DIR \ + GIT_INDEX_FILE \ + GIT_OBJECT_DIRECTORY \ + GIT_GRAFT_FILE \ + GIT_SHALLOW_FILE \ + GIT_NO_REPLACE_OBJECTS \ + GIT_REPLACE_REF_BASE \ + GIT_NAMESPACE \ + GIT_QUARANTINE_PATH + do + run_helper "$REPO" env "$name=$CASE_ROOT/ambient-override" + assert_refused_without_move "$REPO" "$LOCAL_OLD" "ambient $name" + assert_grep "ambient Git environment override $name must be unset" "$RUN_ERR" \ + "ambient $name refusal did not precede repository resolution" + done + pass "ambient repository, object, index, and graph overrides refuse before resolution" +} + +test_virtual_or_incomplete_history_refuses() { + new_divergent_fixture shallow-history + printf '%s\n' "$BASE_OID" > "$REPO/.git/shallow" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" shallow-history + assert_grep "repository history is shallow" "$RUN_ERR" "shallow-history refusal was not actionable" + + new_divergent_fixture graft-history + printf '%s %s\n' "$LOCAL_OLD" "$BASE_OID" > "$REPO/.git/info/grafts" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" graft-history + assert_grep "legacy graft history is present" "$RUN_ERR" "graft-history refusal was not actionable" + + new_divergent_fixture replacement-history + git -C "$REPO" replace "$LOCAL_OLD" "$BASE_OID" + run_helper_plain "$REPO" + assert_refused_without_move "$REPO" "$LOCAL_OLD" replacement-history + assert_grep "replacement object history is present" "$RUN_ERR" \ + "replacement-history refusal was not actionable" + pass "shallow, grafted, and replacement-overlaid histories refuse" +} + test_dirty_refuses() { new_divergent_fixture dirty printf 'staged dirty\n' >> "$REPO/tracked.txt" @@ -512,6 +585,36 @@ SH pass "expected-old race commits neither branch movement nor preservation ref" } +test_symbolic_branch_race_is_atomic() { + local fakebin real_git claimant_ref + new_divergent_fixture symbolic-branch-race + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + claimant_ref=refs/heads/direct-claimant + git -C "$REPO" update-ref "$claimant_ref" "$LOCAL_OLD" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_update=0 +for arg in "$@"; do [ "$arg" = update-ref ] && is_update=1; done +if [ "$is_update" -eq 1 ] && [ "${RACE_INNER:-0}" != 1 ]; then + RACE_INNER=1 "${REAL_GIT_FOR_TEST:?}" -C "${RACE_REPO:?}" \ + symbolic-ref refs/heads/main "${RACE_CLAIMANT_REF:?}" || exit $? +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + RACE_REPO="$REPO" RACE_CLAIMANT_REF="$claimant_ref" + + expect_code 0 "$RUN_RC" "symbolic branch race" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "symbolic branch race branch" + assert_direct_ref "$REPO" "$claimant_ref" "$LOCAL_OLD" "symbolic branch race claimant" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$LOCAL_OLD" "symbolic branch race preservation" + pass "symbolic branch race cannot write through to its claimant" +} + test_post_commit_claimant_is_never_rolled_back() { local fakebin real_git claimant new_divergent_fixture post-commit-claimant @@ -563,6 +666,9 @@ test_non_repository_refuses() { test_divergent_identical_succeeds_and_repeats test_submodules_are_not_fetched +test_configured_pruning_is_suppressed +test_ambient_git_overrides_refuse +test_virtual_or_incomplete_history_refuses test_dirty_refuses test_detached_refuses test_off_default_refuses @@ -582,5 +688,6 @@ test_missing_remote_ref_refuses test_fetch_failure_refuses test_source_advancement_is_bounded test_expected_old_race_is_atomic +test_symbolic_branch_race_is_atomic test_post_commit_claimant_is_never_rolled_back test_non_repository_refuses From 018b4f7dc4e933fb51120be69bc228a78c9b5578 Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:00:13 -0400 Subject: [PATCH 3/6] no-mistakes(review): Enforce direct preservation refs during atomic reuse --- bin/fm-reconcile-identical-squash.sh | 2 +- tests/fm-reconcile-identical-squash.test.sh | 33 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh index 5b7b1f24bbe..31f19a57ad0 100755 --- a/bin/fm-reconcile-identical-squash.sh +++ b/bin/fm-reconcile-identical-squash.sh @@ -575,7 +575,7 @@ observe_remote_default \ printf 'create %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" else printf 'option no-deref\n' - printf 'verify %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" + printf 'update %s %s %s\n' "$PRESERVE_REF" "$LOCAL_OID" "$LOCAL_OID" fi printf 'option no-deref\n' printf 'update %s %s %s\n' "$HEAD_REF" "$REMOTE_OID" "$LOCAL_OID" diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh index 1807db46c1b..0fe0605b695 100755 --- a/tests/fm-reconcile-identical-squash.test.sh +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -615,6 +615,38 @@ SH pass "symbolic branch race cannot write through to its claimant" } +test_symbolic_preservation_race_retains_direct_anchor() { + local fakebin real_git claimant_ref + new_divergent_fixture symbolic-preservation-race + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + claimant_ref=refs/heads/preservation-claimant + git -C "$REPO" update-ref "$PRESERVE_REF" "$LOCAL_OLD" + git -C "$REPO" update-ref "$claimant_ref" "$LOCAL_OLD" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_update=0 +for arg in "$@"; do [ "$arg" = update-ref ] && is_update=1; done +if [ "$is_update" -eq 1 ] && [ "${RACE_INNER:-0}" != 1 ]; then + RACE_INNER=1 "${REAL_GIT_FOR_TEST:?}" -C "${RACE_REPO:?}" \ + symbolic-ref "${RACE_PRESERVE_REF:?}" "${RACE_CLAIMANT_REF:?}" || exit $? +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + RACE_REPO="$REPO" RACE_PRESERVE_REF="$PRESERVE_REF" \ + RACE_CLAIMANT_REF="$claimant_ref" + + expect_code 0 "$RUN_RC" "symbolic preservation race" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "symbolic preservation race branch" + assert_direct_ref "$REPO" "$PRESERVE_REF" "$LOCAL_OLD" "symbolic preservation race anchor" + assert_direct_ref "$REPO" "$claimant_ref" "$LOCAL_OLD" "symbolic preservation race claimant" + pass "symbolic preservation race cannot commit without a direct anchor" +} + test_post_commit_claimant_is_never_rolled_back() { local fakebin real_git claimant new_divergent_fixture post-commit-claimant @@ -689,5 +721,6 @@ test_fetch_failure_refuses test_source_advancement_is_bounded test_expected_old_race_is_atomic test_symbolic_branch_race_is_atomic +test_symbolic_preservation_race_retains_direct_anchor test_post_commit_claimant_is_never_rolled_back test_non_repository_refuses From 2113f70440063d754199a1278a6fbd1e53f65a9c Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:20:30 -0400 Subject: [PATCH 4/6] no-mistakes: apply CI fixes --- bin/fm-reconcile-identical-squash.sh | 1 + docs/scripts.md | 2 +- tests/fm-reconcile-identical-squash.test.sh | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh index 31f19a57ad0..21142afb2ca 100755 --- a/bin/fm-reconcile-identical-squash.sh +++ b/bin/fm-reconcile-identical-squash.sh @@ -47,6 +47,7 @@ export GIT_TERMINAL_PROMPT=0 usage() { printf 'usage: fm-reconcile-identical-squash.sh \n' >&2 + printf 'requires: quiesce every other Git claimant before invocation\n' >&2 } refuse() { diff --git a/docs/scripts.md b/docs/scripts.md index d013d00024a..b3e22167107 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -12,7 +12,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | -| `fm-reconcile-identical-squash.sh` | Explicitly preserve and reconcile one quiesced default branch when its divergent source-backed origin has an identical root tree | +| `fm-reconcile-identical-squash.sh` | Explicitly preserve and reconcile one operator-quiesced default branch when its divergent source-backed origin has an identical root tree | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh index 0fe0605b695..d241b930efd 100755 --- a/tests/fm-reconcile-identical-squash.test.sh +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -124,6 +124,15 @@ snapshot_refs() { git -C "$1" for-each-ref --format='%(refname)%09%(objectname)%09%(symref)' > "$2" } +test_help_documents_operator_quiescence() { + new_case_root help + run_helper --help env + expect_code 0 "$RUN_RC" "help" + assert_grep "quiesce every other Git claimant before invocation" "$RUN_ERR" \ + "help omitted the operator quiescence prerequisite" + pass "help documents the operator quiescence prerequisite" +} + # The main success regression also proves the empirically selected source-only # fetch surface: configured destination refspecs, FETCH_HEAD, tags, and unrelated # refs remain untouched while the exact source-backed remote object is acquired. @@ -696,6 +705,7 @@ test_non_repository_refuses() { pass "non-repository target refuses" } +test_help_documents_operator_quiescence test_divergent_identical_succeeds_and_repeats test_submodules_are_not_fetched test_configured_pruning_is_suppressed From fb32adb4990375e280de79c82a28bb03b904c2cf Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:53:08 -0400 Subject: [PATCH 5/6] no-mistakes(review): Refuse unsafe object storage and disable fetch maintenance --- bin/fm-reconcile-identical-squash.sh | 92 +++++++++++++++++++-- docs/scripts.md | 2 +- tests/fm-reconcile-identical-squash.test.sh | 88 ++++++++++++++++++++ 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh index 21142afb2ca..69c8b615d83 100755 --- a/bin/fm-reconcile-identical-squash.sh +++ b/bin/fm-reconcile-identical-squash.sh @@ -4,17 +4,19 @@ # identical root trees. # # The operator must quiesce every other Git claimant before invoking this helper. -# The helper proves the local repository shape, obtains origin's current default -# branch object with a source-only fetch, and atomically creates or verifies a -# deterministic preservation ref while moving only the direct local default -# branch ref. It never changes ordinary fm-fleet-sync.sh behavior. +# The helper proves the local repository shape, refuses repository-local object +# alternates and partial or promisor object storage, obtains origin's current +# default branch object with a source-only fetch, and atomically creates or +# verifies a deterministic preservation ref while moving only the direct local +# default branch ref. It never changes ordinary fm-fleet-sync.sh behavior. # # Source acquisition uses an empty --refmap together with a source-only refspec, # --no-write-fetch-head, --no-tags, --no-prune, --no-prune-tags, and -# --no-recurse-submodules. This suppresses configured destination refspecs, -# remote-tracking updates, FETCH_HEAD writes, tag following, pruning, and -# submodule recursion. A fixed three-attempt loop tolerates a source tip -# advancing between fetch and source-backed observation. +# --no-recurse-submodules, and --no-auto-maintenance. This suppresses configured +# destination refspecs, remote-tracking updates, FETCH_HEAD writes, tag +# following, pruning, submodule recursion, and automatic maintenance. A fixed +# three-attempt loop tolerates a source tip advancing between fetch and +# source-backed observation. # # Preservation refs have this form: # refs/firstmate/identical-squash// @@ -120,6 +122,77 @@ repo_git() { git -C "$REPO" "$@" } +object_storage_state_valid() { + local alternates object_dir config_output rc line value marker + OBJECT_STORAGE_ERROR= + if ! alternates=$(repo_git rev-parse --path-format=absolute \ + --git-path objects/info/alternates 2>/dev/null); then + OBJECT_STORAGE_ERROR="cannot resolve the repository-local object alternates path" + return 1 + fi + if [ -e "$alternates" ] || [ -L "$alternates" ]; then + OBJECT_STORAGE_ERROR="repository-local object alternates are present" + return 1 + fi + set +e + repo_git config --local --get extensions.partialClone >/dev/null 2>&1 + rc=$? + set -e + case "$rc" in + 0) + OBJECT_STORAGE_ERROR="partial or promisor object storage is configured" + return 1 + ;; + 1) ;; + *) + OBJECT_STORAGE_ERROR="cannot determine partial-clone repository state" + return 1 + ;; + esac + set +e + config_output=$(repo_git config --type=bool --get-regexp \ + '^remote\..*\.promisor$' 2>/dev/null) + rc=$? + set -e + case "$rc" in + 0) + while IFS= read -r line || [ -n "${line:-}" ]; do + value=${line##* } + case "$value" in + true) + OBJECT_STORAGE_ERROR="partial or promisor object storage is configured" + return 1 + ;; + false) ;; + *) + OBJECT_STORAGE_ERROR="promisor remote state is ambiguous" + return 1 + ;; + esac + done </dev/null); then + OBJECT_STORAGE_ERROR="cannot resolve the repository object directory" + return 1 + fi + for marker in "$object_dir"/pack/*.promisor; do + if [ -e "$marker" ] || [ -L "$marker" ]; then + OBJECT_STORAGE_ERROR="partial or promisor object storage is present" + return 1 + fi + done + return 0 +} + history_state_valid() { local shallow grafts replacements HISTORY_ERROR= @@ -372,6 +445,7 @@ post_commit_fail() { exit 1 } +object_storage_state_valid || refuse "$OBJECT_STORAGE_ERROR" history_state_valid || refuse "$HISTORY_ERROR" if ! HEAD_REF=$(repo_git symbolic-ref --no-recurse -q HEAD 2>/dev/null); then refuse "HEAD is detached" @@ -443,7 +517,7 @@ REMOTE_OID= REMOTE_TREE= while [ "$attempt" -le "$MAX_SOURCE_ATTEMPTS" ]; do if ! repo_git fetch --quiet --no-write-fetch-head --no-tags --no-prune \ - --no-prune-tags --no-recurse-submodules --refmap= \ + --no-prune-tags --no-recurse-submodules --no-auto-maintenance --refmap= \ "$AUTHORITATIVE_REMOTE" "$REMOTE_REF" \ >"$TMP_ROOT/fetch.out" 2>"$TMP_ROOT/fetch.err"; then refuse "source-only fetch from origin failed; inspect remote access without exposing credentials" diff --git a/docs/scripts.md b/docs/scripts.md index b3e22167107..7d47d38f8e0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -12,7 +12,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | -| `fm-reconcile-identical-squash.sh` | Explicitly preserve and reconcile one operator-quiesced default branch when its divergent source-backed origin has an identical root tree | +| `fm-reconcile-identical-squash.sh` | Explicitly preserve and reconcile one operator-quiesced default branch with self-contained local objects when its divergent source-backed origin has an identical root tree | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh index d241b930efd..779459263d7 100755 --- a/tests/fm-reconcile-identical-squash.test.sh +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -124,6 +124,13 @@ snapshot_refs() { git -C "$1" for-each-ref --format='%(refname)%09%(objectname)%09%(symref)' > "$2" } +snapshot_object_files() { + local object_dir + object_dir=$(git -C "$1" rev-parse --path-format=absolute --git-path objects) \ + || fail "cannot resolve object directory for snapshot" + find "$object_dir" \( -type f -o -type l \) -print | LC_ALL=C sort > "$2" +} + test_help_documents_operator_quiescence() { new_case_root help run_helper --help env @@ -250,6 +257,39 @@ test_configured_pruning_is_suppressed() { pass "configured branch and tag pruning are suppressed" } +test_source_fetch_disables_auto_maintenance() { + local fakebin real_git marker + new_divergent_fixture no-auto-maintenance + fakebin="$CASE_ROOT/fakebin" + mkdir -p "$fakebin" + real_git=$(command -v git) + marker="$CASE_ROOT/fetches" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +is_fetch=0 +no_auto_maintenance=0 +for arg in "$@"; do + [ "$arg" = fetch ] && is_fetch=1 + [ "$arg" = --no-auto-maintenance ] && no_auto_maintenance=1 +done +if [ "$is_fetch" -eq 1 ]; then + [ "$no_auto_maintenance" -eq 1 ] || exit 91 + printf 'fetch\n' >> "${FETCH_MARKER:?}" +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + + run_helper "$REPO" env PATH="$fakebin:$PATH" REAL_GIT_FOR_TEST="$real_git" \ + FETCH_MARKER="$marker" + + expect_code 0 "$RUN_RC" "no auto maintenance" + [ "$(wc -l < "$marker" | tr -d ' ')" -eq 1 ] \ + || fail "source acquisition did not use exactly one guarded fetch" + assert_direct_ref "$REPO" refs/heads/main "$REMOTE_NEW" "no auto maintenance branch" + pass "source-only fetch explicitly disables automatic maintenance" +} + test_ambient_git_overrides_refuse() { local name new_divergent_fixture ambient-git-overrides @@ -279,6 +319,51 @@ test_ambient_git_overrides_refuse() { pass "ambient repository, object, index, and graph overrides refuse before resolution" } +test_repository_local_alternates_refuse() { + local refs_before refs_after + new_divergent_fixture repository-local-alternates + refs_before="$CASE_ROOT/refs.before" + refs_after="$CASE_ROOT/refs.after" + printf '%s\n' "$SEED/.git/objects" > "$REPO/.git/objects/info/alternates" + snapshot_refs "$REPO" "$refs_before" + + run_helper_plain "$REPO" + + assert_refused_without_move "$REPO" "$LOCAL_OLD" repository-local-alternates + snapshot_refs "$REPO" "$refs_after" + cmp -s "$refs_before" "$refs_after" \ + || fail "repository-local alternates refusal changed a ref" + assert_grep "repository-local object alternates are present" "$RUN_ERR" \ + "repository-local alternates refusal was not actionable" + pass "repository-local object alternates refuse without ref movement" +} + +test_partial_or_promisor_storage_refuses_without_acquisition() { + local refs_before refs_after objects_before objects_after + new_divergent_fixture partial-or-promisor + refs_before="$CASE_ROOT/refs.before" + refs_after="$CASE_ROOT/refs.after" + objects_before="$CASE_ROOT/objects.before" + objects_after="$CASE_ROOT/objects.after" + git -C "$REPO" config remote.origin.promisor true + git -C "$REPO" config remote.origin.partialclonefilter tree:0 + snapshot_refs "$REPO" "$refs_before" + snapshot_object_files "$REPO" "$objects_before" + + run_helper_plain "$REPO" + + assert_refused_without_move "$REPO" "$LOCAL_OLD" partial-or-promisor + snapshot_refs "$REPO" "$refs_after" + snapshot_object_files "$REPO" "$objects_after" + cmp -s "$refs_before" "$refs_after" \ + || fail "partial or promisor refusal changed a ref" + cmp -s "$objects_before" "$objects_after" \ + || fail "partial or promisor refusal acquired an object" + assert_grep "partial or promisor object storage is configured" "$RUN_ERR" \ + "partial or promisor refusal was not actionable" + pass "partial or promisor storage refuses without object acquisition or ref movement" +} + test_virtual_or_incomplete_history_refuses() { new_divergent_fixture shallow-history printf '%s\n' "$BASE_OID" > "$REPO/.git/shallow" @@ -709,7 +794,10 @@ test_help_documents_operator_quiescence test_divergent_identical_succeeds_and_repeats test_submodules_are_not_fetched test_configured_pruning_is_suppressed +test_source_fetch_disables_auto_maintenance test_ambient_git_overrides_refuse +test_repository_local_alternates_refuse +test_partial_or_promisor_storage_refuses_without_acquisition test_virtual_or_incomplete_history_refuses test_dirty_refuses test_detached_refuses From 6df0dd673a93944a60b558aa3332f024d27fc857 Mon Sep 17 00:00:00 2001 From: homestills <90728616+homestills@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:18:37 -0400 Subject: [PATCH 6/6] no-mistakes(review): Refuse filter-only partial clone configurations before proof --- bin/fm-reconcile-identical-squash.sh | 16 ++++++++++ tests/fm-reconcile-identical-squash.test.sh | 34 +++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/bin/fm-reconcile-identical-squash.sh b/bin/fm-reconcile-identical-squash.sh index 69c8b615d83..b0bb4974332 100755 --- a/bin/fm-reconcile-identical-squash.sh +++ b/bin/fm-reconcile-identical-squash.sh @@ -150,6 +150,22 @@ object_storage_state_valid() { ;; esac set +e + repo_git config --get-regexp \ + '^remote\..*\.partialclonefilter$' >/dev/null 2>&1 + rc=$? + set -e + case "$rc" in + 0) + OBJECT_STORAGE_ERROR="partial or promisor object storage is configured" + return 1 + ;; + 1) ;; + *) + OBJECT_STORAGE_ERROR="cannot determine partial-clone filter state" + return 1 + ;; + esac + set +e config_output=$(repo_git config --type=bool --get-regexp \ '^remote\..*\.promisor$' 2>/dev/null) rc=$? diff --git a/tests/fm-reconcile-identical-squash.test.sh b/tests/fm-reconcile-identical-squash.test.sh index 779459263d7..a7d881e387c 100755 --- a/tests/fm-reconcile-identical-squash.test.sh +++ b/tests/fm-reconcile-identical-squash.test.sh @@ -364,6 +364,39 @@ test_partial_or_promisor_storage_refuses_without_acquisition() { pass "partial or promisor storage refuses without object acquisition or ref movement" } +test_partial_clone_filter_only_refuses_without_acquisition() { + local refs_before refs_after objects_before objects_after + new_divergent_fixture partial-clone-filter-only + refs_before="$CASE_ROOT/refs.before" + refs_after="$CASE_ROOT/refs.after" + objects_before="$CASE_ROOT/objects.before" + objects_after="$CASE_ROOT/objects.after" + git -C "$REPO" config remote.origin.partialCloneFilter tree:0 + ! git -C "$REPO" config --get remote.origin.promisor >/dev/null 2>&1 \ + || fail "partial-clone filter-only fixture configured a promisor boolean" + ! git -C "$REPO" config --local --get extensions.partialClone >/dev/null 2>&1 \ + || fail "partial-clone filter-only fixture configured the partial-clone extension" + [ ! -e "$REPO/.git/objects/info/alternates" ] \ + || fail "partial-clone filter-only fixture configured object alternates" + [ -z "$(find "$REPO/.git/objects/pack" -name '*.promisor' -print)" ] \ + || fail "partial-clone filter-only fixture contains a promisor pack marker" + snapshot_refs "$REPO" "$refs_before" + snapshot_object_files "$REPO" "$objects_before" + + run_helper_plain "$REPO" + + assert_refused_without_move "$REPO" "$LOCAL_OLD" partial-clone-filter-only + snapshot_refs "$REPO" "$refs_after" + snapshot_object_files "$REPO" "$objects_after" + cmp -s "$refs_before" "$refs_after" \ + || fail "partial-clone filter-only refusal changed a ref" + cmp -s "$objects_before" "$objects_after" \ + || fail "partial-clone filter-only refusal acquired an object" + assert_grep "partial or promisor object storage is configured" "$RUN_ERR" \ + "partial-clone filter-only refusal was not actionable" + pass "partial-clone filter-only storage refuses without object acquisition or ref movement" +} + test_virtual_or_incomplete_history_refuses() { new_divergent_fixture shallow-history printf '%s\n' "$BASE_OID" > "$REPO/.git/shallow" @@ -798,6 +831,7 @@ test_source_fetch_disables_auto_maintenance test_ambient_git_overrides_refuse test_repository_local_alternates_refuse test_partial_or_promisor_storage_refuses_without_acquisition +test_partial_clone_filter_only_refuses_without_acquisition test_virtual_or_incomplete_history_refuses test_dirty_refuses test_detached_refuses