diff --git a/.github/workflows/reusable-docs-governance.yml b/.github/workflows/reusable-docs-governance.yml index 97a69c3077..9c03baf200 100644 --- a/.github/workflows/reusable-docs-governance.yml +++ b/.github/workflows/reusable-docs-governance.yml @@ -20,6 +20,14 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate worktree guard expectation contract + shell: bash + run: bash scripts/worktree_guard.expectation.tests.sh + + - name: Validate shell guard export path contract + shell: bash + run: bash scripts/worktree_guard.path-flavor.tests.sh + - name: Validate detached-first worktree helper shell: powershell run: powershell -NoLogo -NoProfile -NonInteractive -File scripts/git/Test-New-CodexIssueWorktree.ps1 @@ -48,6 +56,14 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate worktree guard contracts + shell: bash + run: | + bash scripts/worktree_guard.tests.sh + bash scripts/worktree_guard.submodule.tests.sh + bash scripts/worktree_guard.expectation.tests.sh + bash scripts/worktree_guard.path-flavor.tests.sh + - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/scripts/worktree_guard.expectation.tests.sh b/scripts/worktree_guard.expectation.tests.sh new file mode 100644 index 0000000000..4e09486ca0 --- /dev/null +++ b/scripts/worktree_guard.expectation.tests.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# Cross-shell regression contract for malformed or contradictory worktree HEAD +# expectations. These are caller setup errors, not conditions either guard may +# silently resolve, normalize differently, or treat as ordinary HEAD mismatch. + +set -euo pipefail + +_tests_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +SH_GUARD="${WT_GUARD_SH:-$_tests_dir/worktree_guard.sh}" +PS_GUARD="${WT_GUARD_PS:-$_tests_dir/worktree_guard.ps1}" +PS_GUARD_NATIVE="$PS_GUARD" +if command -v cygpath >/dev/null 2>&1; then + PS_GUARD_NATIVE="$(cygpath -w "$PS_GUARD" 2>/dev/null || printf '%s' "$PS_GUARD")" +fi + +PS_EXE="" +if command -v powershell >/dev/null 2>&1; then + PS_EXE="powershell" +elif command -v pwsh >/dev/null 2>&1; then + PS_EXE="pwsh" +fi + +FIXTURE_ROOT="$(mktemp -d)" +cleanup() { + if [ -d "$FIXTURE_ROOT/primary" ]; then + git -C "$FIXTURE_ROOT/primary" worktree remove "$FIXTURE_ROOT/detached" >/dev/null 2>&1 || true + fi + rm -rf -- "$FIXTURE_ROOT" 2>/dev/null || true +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf ' PASS: %s\n' "$1" +} + +normalize_assertion_output() { + printf '%s' "$1" | + tr '\r\n\t' ' ' | + sed 's/[[:space:]][[:space:]]*/ /g' +} + +assert_setup_error() { + local name="$1" + local code="$2" + local output="$3" + local expected="$4" + local normalized_output + + if [ "$code" -ne 2 ]; then + printf '%s\n' "$output" >&2 + fail "$name must exit 2 for invalid expectations (got $code)" + fi + + normalized_output="$(normalize_assertion_output "$output")" + if ! printf '%s' "$normalized_output" | grep -qF -- "$expected"; then + printf '%s\n' "$output" >&2 + fail "$name did not explain the invalid expectations (missing '$expected')" + fi + pass "$name rejects invalid expectations as a setup error" +} + +assert_named_branch_mismatch() { + local name="$1" + local code="$2" + local output="$3" + local normalized_output + + if [ "$code" -ne 1 ]; then + printf '%s\n' "$output" >&2 + fail "$name must treat the value as a named branch expectation (got $code)" + fi + + normalized_output="$(normalize_assertion_output "$output")" + if ! printf '%s' "$normalized_output" | grep -qF -- "detached but a branch was required"; then + printf '%s\n' "$output" >&2 + fail "$name did not reach the ordinary named-branch mismatch" + fi + pass "$name treats the value as a named branch expectation" +} + +assert_setup_error "formatter-wrapped setup error" \ + 2 $'ERROR: cannot be\r\ncombined with -ExpectedBranch' "cannot be combined" + +if [ ! -f "$SH_GUARD" ] || [ ! -f "$PS_GUARD" ]; then + fail "guard scripts were not found beside the contract" +fi + +git init -q -b main "$FIXTURE_ROOT/primary" +git -C "$FIXTURE_ROOT/primary" \ + -c user.email=t@example.com \ + -c user.name=t \ + commit -q --allow-empty -m "seed" --no-gpg-sign +git -C "$FIXTURE_ROOT/primary" worktree add -q --detach "$FIXTURE_ROOT/detached" HEAD + +set +e +sh_output="$( + cd -- "$FIXTURE_ROOT/detached" + WT_EXPECT_HEAD=detached \ + WT_EXPECT_BRANCH=guard-test-branch \ + bash -c 'source "$1"' bash "$SH_GUARD" 2>&1 +)" +sh_code=$? +set -e +assert_setup_error "shell guard contradictory expectation" "$sh_code" "$sh_output" "cannot be combined" + +set +e +sh_whitespace_output="$( + cd -- "$FIXTURE_ROOT/detached" + WT_EXPECT_HEAD=any \ + WT_EXPECT_BRANCH=' ' \ + bash -c 'source "$1"' bash "$SH_GUARD" 2>&1 +)" +sh_whitespace_code=$? +set -e +assert_setup_error "shell guard whitespace-only branch" \ + "$sh_whitespace_code" "$sh_whitespace_output" "cannot be whitespace-only" + +unicode_branch=$'\u00a0' +set +e +sh_unicode_output="$( + cd -- "$FIXTURE_ROOT/detached" + WT_EXPECT_HEAD=any \ + WT_EXPECT_BRANCH="$unicode_branch" \ + bash -c 'source "$1"' bash "$SH_GUARD" 2>&1 +)" +sh_unicode_code=$? +set -e +assert_named_branch_mismatch "shell guard Unicode branch" \ + "$sh_unicode_code" "$sh_unicode_output" + +# The space character alone cannot distinguish a correctly built ASCII-whitespace +# class from a mis-built one, so exercise a non-space member (tab) and a control +# made only of the letters that a literally-taken "\t\n\r\v\f" escape would +# contain. A guard whose class degraded to those letters rejects "fnrtv" as +# whitespace-only, which the two cases below catch in opposite directions. +tab_branch=$'\t' +literal_escape_branch="fnrtv" +set +e +sh_tab_output="$( + cd -- "$FIXTURE_ROOT/detached" + WT_EXPECT_HEAD=any \ + WT_EXPECT_BRANCH="$tab_branch" \ + bash -c 'source "$1"' bash "$SH_GUARD" 2>&1 +)" +sh_tab_code=$? +set -e +assert_setup_error "shell guard tab-only branch" \ + "$sh_tab_code" "$sh_tab_output" "cannot be whitespace-only" + +set +e +sh_literal_escape_output="$( + cd -- "$FIXTURE_ROOT/detached" + WT_EXPECT_HEAD=any \ + WT_EXPECT_BRANCH="$literal_escape_branch" \ + bash -c 'source "$1"' bash "$SH_GUARD" 2>&1 +)" +sh_literal_escape_code=$? +set -e +assert_named_branch_mismatch "shell guard escape-letter branch" \ + "$sh_literal_escape_code" "$sh_literal_escape_output" + +if [ -z "$PS_EXE" ]; then + printf ' SKIP: PowerShell guard contract (no powershell/pwsh on PATH)\n' +else + set +e + ps_output="$( + cd -- "$FIXTURE_ROOT/detached" + "$PS_EXE" -NoLogo -NoProfile -NonInteractive -File "$PS_GUARD_NATIVE" \ + -ExpectHead Detached \ + -ExpectedBranch guard-test-branch 2>&1 + )" + ps_code=$? + set -e + assert_setup_error "PowerShell guard contradictory expectation" \ + "$ps_code" "$ps_output" "cannot be combined" + + set +e + ps_whitespace_output="$( + cd -- "$FIXTURE_ROOT/detached" + "$PS_EXE" -NoLogo -NoProfile -NonInteractive -File "$PS_GUARD_NATIVE" \ + -ExpectHead Any \ + -ExpectedBranch ' ' 2>&1 + )" + ps_whitespace_code=$? + set -e + assert_setup_error "PowerShell guard whitespace-only branch" \ + "$ps_whitespace_code" "$ps_whitespace_output" "cannot be whitespace-only" + + set +e + ps_unicode_output="$( + cd -- "$FIXTURE_ROOT/detached" + "$PS_EXE" -NoLogo -NoProfile -NonInteractive -File "$PS_GUARD_NATIVE" \ + -ExpectHead Any \ + -ExpectedBranch "$unicode_branch" 2>&1 + )" + ps_unicode_code=$? + set -e + assert_named_branch_mismatch "PowerShell guard Unicode branch" \ + "$ps_unicode_code" "$ps_unicode_output" + + set +e + ps_tab_output="$( + cd -- "$FIXTURE_ROOT/detached" + "$PS_EXE" -NoLogo -NoProfile -NonInteractive -File "$PS_GUARD_NATIVE" \ + -ExpectHead Any \ + -ExpectedBranch "$tab_branch" 2>&1 + )" + ps_tab_code=$? + set -e + assert_setup_error "PowerShell guard tab-only branch" \ + "$ps_tab_code" "$ps_tab_output" "cannot be whitespace-only" + + set +e + ps_literal_escape_output="$( + cd -- "$FIXTURE_ROOT/detached" + "$PS_EXE" -NoLogo -NoProfile -NonInteractive -File "$PS_GUARD_NATIVE" \ + -ExpectHead Any \ + -ExpectedBranch "$literal_escape_branch" 2>&1 + )" + ps_literal_escape_code=$? + set -e + assert_named_branch_mismatch "PowerShell guard escape-letter branch" \ + "$ps_literal_escape_code" "$ps_literal_escape_output" +fi + +printf 'worktree_guard expectation contract passed.\n' diff --git a/scripts/worktree_guard.path-flavor.tests.sh b/scripts/worktree_guard.path-flavor.tests.sh new file mode 100644 index 0000000000..454ca213f8 --- /dev/null +++ b/scripts/worktree_guard.path-flavor.tests.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Cross-platform contract for the shell guard's exported path variables. +# Internal validation may use an MSYS/POSIX physical path, but the public +# WT_REPO_ROOT and WT_GIT_DIR values must share one flavour so a caller can hand +# either value to the same native tool without a platform-specific conversion. + +set -euo pipefail + +_tests_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +GUARD="${WT_GUARD_SH:-$_tests_dir/worktree_guard.sh}" +FIXTURE_ROOT="$(mktemp -d)" + +cleanup() { + if [ -d "$FIXTURE_ROOT/primary" ]; then + git -C "$FIXTURE_ROOT/primary" worktree remove "$FIXTURE_ROOT/linked" >/dev/null 2>&1 || true + fi + rm -rf -- "$FIXTURE_ROOT" 2>/dev/null || true +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +path_flavor() { + case "$1" in + [A-Za-z]:[\\/]*) printf 'windows\n' ;; + /*) printf 'posix\n' ;; + *) printf 'unknown\n' ;; + esac +} + +if [ ! -f "$GUARD" ]; then + fail "guard script not found: $GUARD" +fi + +git init -q -b main "$FIXTURE_ROOT/primary" +git -C "$FIXTURE_ROOT/primary" \ + -c user.email=t@example.com \ + -c user.name=t \ + commit -q --allow-empty -m "seed" --no-gpg-sign +git -C "$FIXTURE_ROOT/primary" worktree add -q --detach "$FIXTURE_ROOT/linked" HEAD + +mapfile -t exported < <( + cd -- "$FIXTURE_ROOT/linked" + bash -c ' + source "$1" >/dev/null + printf "%s\n%s\n" "$WT_REPO_ROOT" "$WT_GIT_DIR" + ' bash "$GUARD" +) + +if [ "${#exported[@]}" -ne 2 ]; then + fail "guard did not emit exactly the two exported paths" +fi + +repo_root="${exported[0]}" +git_dir="${exported[1]}" +repo_flavor="$(path_flavor "$repo_root")" +git_flavor="$(path_flavor "$git_dir")" + +if [ "$repo_flavor" = "unknown" ] || [ "$git_flavor" = "unknown" ]; then + printf 'WT_REPO_ROOT=%s\nWT_GIT_DIR=%s\n' "$repo_root" "$git_dir" >&2 + fail "guard exported an unrecognized path flavour" +fi +if [ "$repo_flavor" != "$git_flavor" ]; then + printf 'WT_REPO_ROOT=%s (%s)\nWT_GIT_DIR=%s (%s)\n' \ + "$repo_root" "$repo_flavor" "$git_dir" "$git_flavor" >&2 + fail "WT_REPO_ROOT and WT_GIT_DIR use different path flavours" +fi + +printf 'worktree_guard path-flavour contract passed: %s.\n' "$repo_flavor" diff --git a/scripts/worktree_guard.ps1 b/scripts/worktree_guard.ps1 index 1deaf18899..25fb6d6919 100644 --- a/scripts/worktree_guard.ps1 +++ b/scripts/worktree_guard.ps1 @@ -13,7 +13,7 @@ # Exit codes (unchanged contract): # 0 - inside a valid linked worktree # 1 - FATAL: main checkout / not a linked worktree / HEAD expectation unmet -# 2 - ERROR: setup failure, not inside a git repository, layout unreadable +# 2 - ERROR: setup/configuration failure, repository/layout unreadable # # -AllowedMarkers is retained for invocation compatibility and is ADVISORY # only: a root outside those markers is reported but not rejected. @@ -87,6 +87,17 @@ if (-not $layoutInvocationSucceeded -or $layoutExitCode -ne 0 -or $layoutOutput. exit 2 } +function Test-GuardAsciiWhitespaceOnly { + param([AllowEmptyString()][string]$Value) + + # Git branch names are byte-preserving Unicode strings, and the shell guard + # rejects only ASCII horizontal/vertical whitespace ([[:space:]] in the C + # locale). .NET's IsNullOrWhiteSpace also classifies valid branch characters + # such as U+00A0 NBSP, which made the two guards disagree on the same name. + return (-not [string]::IsNullOrEmpty($Value)) -and + [System.Text.RegularExpressions.Regex]::IsMatch($Value, "^[\x09-\x0D\x20]+$") +} + $invocationDirectory = (Get-Location).Path function Resolve-GuardGitPath { param([string]$Path) @@ -239,7 +250,16 @@ if ($symbolicHead.Succeeded -and $symbolicHead.ExitCode -eq 0 -and $symbolicHead $headState = if ([string]::IsNullOrEmpty($headBranch)) { "detached" } else { "branch" } $effectiveExpectHead = $ExpectHead -if (-not [string]::IsNullOrWhiteSpace($ExpectedBranch) -and $effectiveExpectHead -eq "Any") { +$hasExpectedBranch = -not [string]::IsNullOrEmpty($ExpectedBranch) +if ($hasExpectedBranch -and (Test-GuardAsciiWhitespaceOnly $ExpectedBranch)) { + Write-Error "ERROR [worktree_guard]: -ExpectedBranch cannot be whitespace-only." -ErrorAction Continue + exit 2 +} +if ($hasExpectedBranch -and $effectiveExpectHead -eq "Detached") { + Write-Error "ERROR [worktree_guard]: -ExpectHead Detached cannot be combined with -ExpectedBranch." -ErrorAction Continue + exit 2 +} +if ($hasExpectedBranch -and $effectiveExpectHead -eq "Any") { $effectiveExpectHead = "Branch" } @@ -256,11 +276,11 @@ if ($effectiveExpectHead -eq "Branch") { Write-Error -ErrorAction Continue @" FATAL [worktree_guard]: Worktree HEAD is detached but a branch was required. toplevel: $topLevel - expected: $(if ([string]::IsNullOrWhiteSpace($ExpectedBranch)) { "" } else { $ExpectedBranch }) + expected: $(if (-not $hasExpectedBranch) { "" } else { $ExpectedBranch }) "@ exit 1 } - if (-not [string]::IsNullOrWhiteSpace($ExpectedBranch) -and $headBranch -ne $ExpectedBranch) { + if ($hasExpectedBranch -and $headBranch -ne $ExpectedBranch) { Write-Error -ErrorAction Continue @" FATAL [worktree_guard]: Worktree HEAD is on the wrong branch. toplevel: $topLevel diff --git a/scripts/worktree_guard.sh b/scripts/worktree_guard.sh index 7245419648..af23f67fa4 100644 --- a/scripts/worktree_guard.sh +++ b/scripts/worktree_guard.sh @@ -20,12 +20,12 @@ # Exit/return codes (unchanged contract): # 0 - inside a valid linked worktree # 1 - FATAL: main checkout / not a linked worktree / HEAD expectation unmet -# 2 - ERROR: not inside a git repository, or the layout could not be read +# 2 - ERROR: setup/configuration failure, or repository layout unreadable # # Exports on success: # WT_REPO_ROOT - absolute path to the worktree's git toplevel # WT_PROJECT_DIR - same as WT_REPO_ROOT for Taskdeck's single-repo layout -# WT_GIT_DIR - this worktree's git dir (/.git/worktrees/) +# WT_GIT_DIR - linked git dir, in the same path flavour as WT_REPO_ROOT # WT_HEAD_STATE - "detached" or "branch" # WT_HEAD_BRANCH - branch name when WT_HEAD_STATE=branch, otherwise empty @@ -37,9 +37,10 @@ _wt_realdir() { } _wt_cleanup() { - unset -v _wt_toplevel _wt_gitdir _wt_common _wt_worktrees_dir _wt_pointer \ - _wt_pointer_line _wt_pointer_dir _wt_head_branch _wt_head_state \ - _wt_expect_head _wt_expect_branch _wt_conventional 2>/dev/null || true + unset -v _wt_toplevel _wt_gitdir _wt_gitdir_export _wt_common \ + _wt_worktrees_dir _wt_pointer _wt_pointer_line _wt_pointer_dir \ + _wt_head_branch _wt_head_state _wt_expect_head _wt_expect_branch \ + _wt_conventional 2>/dev/null || true unset -f _wt_realdir _wt_fatal 2>/dev/null || true unset -f _wt_cleanup 2>/dev/null || true } @@ -87,8 +88,15 @@ if [ -z "$_wt_gitdir" ] || [ -z "$_wt_common" ]; then return 2 2>/dev/null || exit 2 fi +# Preserve Git's own absolute spelling for the public export. Git for Windows +# reports both this path and --show-toplevel as C:/..., whereas pwd -P below +# deliberately normalizes the internal comparison path to /c/.... Mixing those +# representations in the public contract made callers perform ad-hoc conversion. +_wt_gitdir_export="$_wt_gitdir" + # --git-common-dir may be relative to the current directory; normalize both so -# they are comparable regardless of separator style or symlinks. +# they are comparable regardless of separator style or symlinks. These physical +# paths are private validation values and are not exported. _wt_gitdir="$(_wt_realdir "$_wt_gitdir")" || _wt_gitdir="" _wt_common="$(_wt_realdir "$_wt_common")" || _wt_common="" if [ -z "$_wt_gitdir" ] || [ -z "$_wt_common" ]; then @@ -177,6 +185,22 @@ fi _wt_expect_head="${WT_EXPECT_HEAD:-any}" _wt_expect_branch="${WT_EXPECT_BRANCH:-}" +# Reject an expectation made only of ASCII whitespace. The class is spelled out +# instead of [[:space:]] because that class is locale- and platform-dependent: +# MSYS2/Git-for-Windows classifies U+00A0 NBSP as space while glibc does not, so +# the same branch name would be a setup error on one runner and a branch name on +# another. Git branch names are byte-preserving Unicode, so only ASCII +# whitespace is treated as "no branch was really given". +if [ -n "$_wt_expect_branch" ] && [ -z "${_wt_expect_branch//[$' \t\n\r\v\f']/}" ]; then + echo "ERROR [worktree_guard]: WT_EXPECT_BRANCH cannot be whitespace-only." >&2 + _wt_cleanup + return 2 2>/dev/null || exit 2 +fi +if [ -n "$_wt_expect_branch" ] && [ "$_wt_expect_head" = "detached" ]; then + echo "ERROR [worktree_guard]: WT_EXPECT_HEAD=detached cannot be combined with WT_EXPECT_BRANCH." >&2 + _wt_cleanup + return 2 2>/dev/null || exit 2 +fi if [ -n "$_wt_expect_branch" ] && [ "$_wt_expect_head" = "any" ]; then _wt_expect_head="branch" fi @@ -218,7 +242,7 @@ esac export WT_REPO_ROOT="$_wt_toplevel" export WT_PROJECT_DIR="$_wt_toplevel" -export WT_GIT_DIR="$_wt_gitdir" +export WT_GIT_DIR="$_wt_gitdir_export" export WT_HEAD_STATE="$_wt_head_state" export WT_HEAD_BRANCH="$_wt_head_branch" diff --git a/scripts/worktree_guard.submodule.tests.sh b/scripts/worktree_guard.submodule.tests.sh new file mode 100644 index 0000000000..a257df8f01 --- /dev/null +++ b/scripts/worktree_guard.submodule.tests.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Isolating contract for worktree_guard.sh's linked-worktree containment check. +# +# A Git submodule is the load-bearing fixture: its work tree has a real .git +# pointer file that resolves exactly to its own git dir, so it satisfies the +# guard's pointer check. It is still not a linked worktree because that git dir +# does not live under /worktrees/. Removing only the +# containment block must therefore turn this fixture from rejected to accepted. + +set -euo pipefail + +_tests_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +GUARD="${WT_GUARD_SH:-$_tests_dir/worktree_guard.sh}" +FIXTURE_ROOT="$(mktemp -d)" + +cleanup() { + rm -rf -- "$FIXTURE_ROOT" 2>/dev/null || true +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf ' PASS: %s\n' "$1" +} + +run_guard() { + local guard="$1" + ( + cd -- "$SUBMODULE_ROOT" + bash -c 'source "$1"' bash "$guard" + ) 2>&1 +} + +if [ ! -f "$GUARD" ]; then + fail "guard script not found: $GUARD" +fi + +# A committed source repository is required because submodule add checks out +# HEAD into the nested work tree. +git init -q -b main "$FIXTURE_ROOT/module-source" +git -C "$FIXTURE_ROOT/module-source" \ + -c user.email=t@example.com \ + -c user.name=t \ + commit -q --allow-empty -m "seed module" --no-gpg-sign + +git init -q -b main "$FIXTURE_ROOT/superproject" +git -C "$FIXTURE_ROOT/superproject" \ + -c user.email=t@example.com \ + -c user.name=t \ + commit -q --allow-empty -m "seed superproject" --no-gpg-sign + +# Git disables local file transport for submodules by default. Scope the +# exception to this fixture command; no global or repository configuration is +# changed. +git -C "$FIXTURE_ROOT/superproject" \ + -c protocol.file.allow=always \ + submodule add -q "$FIXTURE_ROOT/module-source" vendor/module +git -C "$FIXTURE_ROOT/superproject" \ + -c user.email=t@example.com \ + -c user.name=t \ + commit -q -m "add module" --no-gpg-sign + +SUBMODULE_ROOT="$FIXTURE_ROOT/superproject/vendor/module" +if [ ! -f "$SUBMODULE_ROOT/.git" ]; then + fail "fixture is not a submodule work tree with a .git pointer file" +fi + +set +e +output="$(run_guard "$GUARD")" +code=$? +set -e + +if [ "$code" -ne 1 ]; then + printf '%s\n' "$output" >&2 + fail "submodule must be rejected with exit 1 (got $code)" +fi +if ! printf '%s' "$output" | grep -qF -- "main checkout or an unrecognized worktree"; then + printf '%s\n' "$output" >&2 + fail "submodule rejection did not come from linked-worktree containment" +fi +pass "submodule is rejected by linked-worktree containment" + +# Mutation control: remove exactly substance check 1 while leaving the pointer +# and HEAD checks byte-for-byte. The same submodule must then reach success; +# otherwise the fixture does not isolate the containment invariant. +MUTATED_GUARD="$FIXTURE_ROOT/worktree_guard-without-containment.sh" +awk ' + /^# Substance check 1:/ { skipping = 1; next } + /^# Substance check 2:/ { skipping = 0 } + !skipping { print } +' "$GUARD" > "$MUTATED_GUARD" + +if grep -qF -- "# Substance check 1:" "$MUTATED_GUARD"; then + fail "mutation control did not remove substance check 1" +fi +if ! grep -qF -- "# Substance check 2:" "$MUTATED_GUARD"; then + fail "mutation control removed the pointer check as well" +fi + +set +e +mutated_output="$(run_guard "$MUTATED_GUARD")" +mutated_code=$? +set -e + +if [ "$mutated_code" -ne 0 ]; then + printf '%s\n' "$mutated_output" >&2 + fail "submodule must pass when only linked-worktree containment is removed (got $mutated_code)" +fi +if ! printf '%s' "$mutated_output" | grep -qF -- "OK [worktree_guard]: Running in an isolated worktree."; then + printf '%s\n' "$mutated_output" >&2 + fail "mutated guard did not reach its success path" +fi +pass "removing only containment makes the isolating submodule pass" + +printf 'worktree_guard submodule contract passed: 2 checks.\n'