From 489fbcdf369499250b754a2c1c8c9200cd688362 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:51:41 -0400 Subject: [PATCH 01/16] plan: change 0466 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction.md --- ...ck-under-its-60s-budget-row-transaction.md | 2107 +++++++++++++++++ 1 file changed, 2107 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction.md diff --git a/docs/superpowers/plans/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction.md b/docs/superpowers/plans/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction.md new file mode 100644 index 000000000..d281c0495 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction.md @@ -0,0 +1,2107 @@ + +> ↩ **[Change 0466 — Bring test_go_race back under its 60s budget row (transaction, workspace, gatedrive)](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0466-bring-test-go-race-back-under-its-60s-budget-row-transaction.md)** + +# Partition transaction, workspace, and gatedrive out of the race gate — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. (In this repository the build runs through `docket-build`: one `### Task N` heading is one worker and one commit.) + +**Goal:** Bring `tests/test_go_race.sh` back under its 60s row and `tests/test_go_toolchain.sh` under its 55s row (cold test cache), without raising any row, by moving the real-git and real-process tests of `internal/repository/transaction`, `internal/workspace`, and `internal/gatedrive` behind `//go:build integration`. + +**Architecture:** This extends change 0333/0465's integration partition to three more packages. Real-git and real-process tests move into `*_integration_test.go` / `*_race_integration_test.go` files, get `TestIntegration…` / `TestRaceIntegration…` names, and run in eight new shard runners on the unchanged `tests/lib/go-integration-shard.sh`. 0465's no-real-git PATH-shim guard is hoisted from `internal/app` into `internal/testsupport` (`InstallNoGitGuard`, with the build-tag split living there once). It is installed from `TestMain` in `internal/app` (byte-identical behavior), `internal/repository/transaction`, and `internal/workspace`. `internal/gatedrive` is process-bound, not git-bound, so it gets no guard, and its budget row stays the growth detector. + +**Tech Stack:** Go 1.27 (`testing`, `os/exec`), bash shard runners over `tests/lib/go-integration-shard.sh`, the contract `tests/test_go_integration_contract.sh`, the Go suite runner (`internal/suiterunner`), `tests/runtime-budgets.tsv`. + +**Spec:** `docs/superpowers/specs/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-design.md` (on the `docket` metadata branch; read-only copy at `/Users/homer/dev/docket/.docket/docs/superpowers/specs/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-design.md`). + +**Feature worktree:** `/Users/homer/dev/docket/.worktrees/bring-test-go-race-back-under-its-60s-budget-row-transaction` (branch `chore/bring-test-go-race-back-under-its-60s-budget-row-transaction`). Every command below runs from this directory unless it says otherwise. + +## Global Constraints + +- **Partition, don't parallelize.** No `t.Parallel()` anywhere in the three packages. The transaction and workspace harnesses set `GIT_CONFIG_GLOBAL` process-wide via `t.Setenv`, which is safe only because nothing runs in parallel. +- **Do not raise** the `tests/test_go_race.sh` (60) or `tests/test_go_toolchain.sh` (55) rows, and do not touch `RACE_TIMEOUT` (8m) or `internal/repoguard/race_gate_timeout_test.go`'s `raceBackstopFloor`. +- **Do not edit** `tests/test_go_integration_contract.sh` or `tests/lib/go-integration-shard.sh`. The contract discovers the new packages from the `*_integration_test.go` census (change 0362). It must pass with no allowlist edit. +- Every `*_integration_test.go` / `*_race_integration_test.go` file has line 1 exactly `//go:build integration` and line 2 blank (contract check (1)). +- **What moves is keyed on shape, not timing.** In transaction and workspace, every test that starts real git moves. The guard is the definition. In gatedrive, every test that drives the real `internal/process.Service` supervisor or real git moves, including every test in the four existing untagged files `integration_test.go`, `integration_history_test.go`, `integration_sequence_test.go`, and `integration_takeover_test.go`. +- **Race vs normal.** A moved test goes to a `mode=race` shard (`TestRaceIntegration…` prefix) when its body starts more than one goroutine or process against shared state (`go func`/`go f(`, `sync.WaitGroup`, channels coordinating simultaneous work, or two live processes/launches against one store or slot). When in doubt, it goes to race. Everything else goes to a `mode=normal` shard (`TestIntegration…` prefix). Each race-classified test gets a one-line comment directly above its `func`: `// Race shard (change 0466): .` The plan pre-classifies every test (see each task). A worker who disagrees with a classification reports it and does not silently re-route. +- **Renaming rule.** New name = shard prefix + the old name with its leading `Test` removed. For gatedrive names that already start `TestIntegration` or `TestRace`, strip that whole leading word instead (see Task 9's explicit table). Examples: `TestEngineAppliesHappyPath` becomes `TestIntegrationTxnApplyEngineAppliesHappyPath`, and `TestIntegrationDeadlineExpiryStopsOwnedTree` becomes `TestIntegrationGatedriveDeadlineExpiryStopsOwnedTree`. +- **Prefixes (fixed by this plan; none is a string prefix of another in the same package):** + - transaction: `TestIntegrationTxnApply` (normal), `TestIntegrationTxnRecovery` (normal), `TestRaceIntegrationTxn` (race) + - workspace: `TestIntegrationWorkspaceSetup` (normal), `TestIntegrationWorkspaceLifecycle` (normal), `TestRaceIntegrationWorkspace` (race) + - gatedrive: `TestIntegrationGatedrive` (normal), `TestRaceIntegrationGatedrive` (race) +- **Runner names:** `tests/test_go_integration_transaction_{apply,recovery,race}.sh`, `tests/test_go_integration_workspace_{setup,lifecycle,race}.sh`, `tests/test_go_integration_gatedrive_{process,race}.sh`. +- **Budget rows.** Every new `tests/test_*.sh` gets exactly one row in `tests/runtime-budgets.tsv` (`internal/repoguard` `TestRuntimeBudgetsCorrespondence`). Row = the solo (serial) measured `real` seconds, rounded up to the next multiple of 5, plus 5, minimum 10. That is the table's own rule, and it guarantees at least 5s of headroom, never parity (learning `budget-headroom-is-spent-before-it-is-breached`). Format: `parallel`. Insert new rows directly above the `tests/test_go_integration_release.sh` row. A computed row above 40 is a stop: return NEEDS_ESCALATION with the measurement so the shard can be split into two runners with disjoint new prefixes. Record every measurement and margin as numbers in your task report, never as "under budget". +- **Helpers.** Fixture helpers used by both corpora stay in a file with no build tag. Helpers used only by the tagged corpus move behind the tag, so each build compiles exactly what it uses. `go vet .//` and `go vet -tags integration .//` must both pass after every task. +- **Never hand-list sites.** Offenders come from the census (`census.sh`, then the guard). References to renamed tests come from a whole-repo `git grep` (`rename-tests.sh`). Point-in-time records keep old names: never rewrite `docs/results/**`, `docs/changes/**`, `docs/superpowers/**`, or `docs/adrs/**`. +- **Cache.** Every run that observes a verdict defeats Go's test cache (`-count=1`; learning `cached-runner-serves-a-mutated-tree`). Mutation probes back up the file with `cp`, prove the mutation landed (`cmp` must report a difference) before reading the result, and restore by copying the backup back. Never `git checkout --` an uncommitted edit (learning `mutation-restore-needs-a-backup-copy`). +- **Shell rules (AGENTS.md):** never pipe a producer into `grep -q`/`head`. Capture into a variable first. Write grep patterns as `grep -E -e`. Template every `mktemp` as `"${TMPDIR:-/tmp}/.XXXXXX"`. Use `mv -f`. The Bash tool's shell is zsh, so run multi-line snippets through `bash -c '…'` or a script file. +- Stage only the paths your task names (`git add -- `, never `git add -A`). Check `git status --porcelain` before every commit. +- Cross-references in maintained source anchor on symbol names or quoted clauses, never line numbers (ADR-0054). +- **Every task leaves the tree green.** Tests move first, and each package's guard is installed only after all of that package's offenders have moved (Tasks 5 and 8). There is no expected-red intermediate state. If a task's default package goes red, that is a defect in that task. +- The whole-suite gate (`build.test_command`) runs once at the end of the build (docket-build). Its budget report must print no `SERIAL CONFIRMED OVER BUDGET` line for any file this change created or touched (spec acceptance 5). Read the report even when it is green. + +## Shared tools + +Tasks 2–9 use four throwaway helper scripts. They are **not committed**. Each task that needs them writes them to `${TMPDIR:-/tmp}/docket-0466-tools/` if that directory does not already hold them, by copying the four blocks below verbatim. All four were executed against this tree at plan time (a module copy for the mover and import pruner, the live tree for the census). Still, prove each one on your first use (learning `plan-supplied-test-code-is-unverified`): the mover must report `not found` for a bogus name and change nothing, and the census must report a non-zero count for a test you know starts git. + +`movefuncs.sh` moves named top-level funcs, each with its leading comment block, from one file to another. It creates the tagged destination when absent: + +```bash +#!/usr/bin/env bash +# movefuncs.sh ... — move each top-level `func (` +# declaration, with its contiguous leading `//` comment block, from to the end of +# . When does not exist it is created as an integration-tagged file: line 1 +# `//go:build integration`, line 2 blank, then 's package clause and import block +# (prune-imports.sh removes the imports that turn out unused). Fails, changing +# nothing, when a name is not found exactly once or has no closing `}` at column 0. +set -euo pipefail +src="$1"; dst="$2"; shift 2 +[ -f "$src" ] || { echo "movefuncs: $src does not exist" >&2; exit 2; } +names="$*" +tmp_keep="$(mktemp "${TMPDIR:-/tmp}/movefuncs-keep.XXXXXX")" +tmp_move="$(mktemp "${TMPDIR:-/tmp}/movefuncs-move.XXXXXX")" +awk -v names="$names" -v keep="$tmp_keep" -v move="$tmp_move" ' +BEGIN { n = split(names, want, " "); for (i = 1; i <= n; i++) wanted[want[i]] = 1 } +{ line[NR] = $0 } +END { + for (i = 1; i <= NR; i++) { + if (match(line[i], /^func [A-Za-z0-9_]+\(/)) { + nm = substr(line[i], 6, RLENGTH - 6) + if (nm in wanted) { + if (nm in found) { print "movefuncs: " nm " declared twice" > "/dev/stderr"; exit 3 } + found[nm] = 1 + s = i; while (s > 1 && line[s-1] ~ /^\/\//) s-- + e = i; while (e <= NR && line[e] != "}") e++ + if (e > NR) { print "movefuncs: no closing brace for " nm > "/dev/stderr"; exit 3 } + for (j = s; j <= e; j++) moved[j] = 1 + } + } + } + for (k in wanted) if (!(k in found)) { print "movefuncs: " k " not found in source" > "/dev/stderr"; exit 3 } + for (i = 1; i <= NR; i++) { + if (i in moved) { print line[i] > move; if (!((i + 1) in moved)) print "" > move } + else print line[i] > keep + } +}' "$src" +if [ ! -e "$dst" ]; then + { + printf '//go:build integration\n\n' + awk '/^package /{print; print ""; next} /^import \($/{inimp=1} inimp{print} inimp && /^\)$/{exit} /^import "/{print; exit}' "$src" + } > "$dst" +fi +{ printf '\n'; cat "$tmp_move"; } >> "$dst" +mv -f "$tmp_keep" "$src" +rm -f "$tmp_move" +gofmt -w "$src" "$dst" +``` + +`prune-imports.sh` deletes compiler-reported unused imports in both builds until vet is clean: + +```bash +#!/usr/bin/env bash +# prune-imports.sh — delete import lines the compiler reports as unused, in +# both the default and the integration build, until neither reports any (max 20 rounds; +# go vet reports one type-check error at a time). Any OTHER compile error ends the +# script with exit 1 and the vet output: fix that by hand, then re-run. +# Run from the module root. +set -uo pipefail +pkg="$1" +re='([^ :]+\.go):([0-9]+):[0-9]+: "[^"]+" imported (as [A-Za-z_][A-Za-z0-9_]* )?and not used' +for round in $(seq 1 20); do + changed=0 + for tags in "" "-tags integration"; do + out="$(go vet $tags "./$pkg" 2>&1)" + hits="$(grep -o -E -e "$re" <<<"$out" || true)" + [ -n "$hits" ] || continue + while IFS= read -r h; do + file="$(sed -E "s/$re/\\1/" <<<"$h")"; line="$(sed -E "s/$re/\\2/" <<<"$h")" + printf '%s %s\n' "$file" "$line" + done <<<"$hits" | LC_ALL=C sort -u | LC_ALL=C sort -k1,1 -k2,2nr > "${TMPDIR:-/tmp}/prune-imports.list" + while read -r file line; do + sed -i.bak "${line}d" "$file" && rm -f "$file.bak" + done < "${TMPDIR:-/tmp}/prune-imports.list" + rm -f "${TMPDIR:-/tmp}/prune-imports.list" + changed=1 + done + [ "$changed" -eq 1 ] || break +done +gofmt -w "$pkg" +for tags in "" "-tags integration"; do + out="$(go vet $tags "./$pkg" 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || { printf 'go vet %s ./%s still fails:\n%s\n' "$tags" "$pkg" "$out"; exit 1; } +done +echo "prune-imports: go vet clean (default and integration) for $pkg" +``` + +`census.sh` runs each default-build test in the named files alone with a logging git shim, and prints how many git execs it made: + +```bash +#!/usr/bin/env bash +# census.sh ... — for every `func Test…` declared in the named +# default-build test files, run that test ALONE (default tags, -count=1) with a +# logging git shim first on PATH, and print " ". +# git-execs > 0 means the test starts real git and must move behind the tag. +set -uo pipefail +pkg="$1"; shift +work="$(mktemp -d "${TMPDIR:-/tmp}/census.XXXXXX")" +real_git="$(command -v git)" +mkdir -p "$work/shim" +printf '#!/bin/sh\nprintf "%%s\\n" "$*" >> "%s/git.log"\nexec "%s" "$@"\n' "$work" "$real_git" > "$work/shim/git" +chmod 755 "$work/shim/git" +( cd "$pkg" && go test -c -o "$work/pkg.test" . ) || { echo "census: build failed" >&2; exit 2; } +for f in "$@"; do + names="$(grep -E -e '^func Test[A-Za-z0-9_]*\(' "$pkg/$f" | sed -E 's/^func (Test[A-Za-z0-9_]*)\(.*/\1/')" + for t in $names; do + [ "$t" = "TestMain" ] && continue + : > "$work/git.log" + ( cd "$pkg" && PATH="$work/shim:$PATH" "$work/pkg.test" -test.run "^${t}\$" -test.count=1 >/dev/null 2>&1 ); rc=$? + n="$(wc -l < "$work/git.log" | tr -d ' ')" + printf '%s %s %s %s\n' "$f" "$t" "$n" "$rc" + done +done +rm -rf "$work" +``` + +(`census.sh` only works **before** a package's guard is installed, because the guard's shim shadows the logging shim. After Tasks 5 and 8, the guard itself is the census.) + +`rename-tests.sh` rewrites whole-word old names to new names across maintained files, tracked and untracked. **One moved name is declared in three packages:** `TestHarnessBuildersProduceExpectedTopology` exists in `internal/gitcli`, `internal/repository/transaction`, and `internal/workspace`. Always rename it with `--scope `, never repo-wide. Tasks 2 and 6 do this. Before any repo-wide rename, check that the old name is declared in only one package: `git grep -l -E -e "^func \(" -- internal` must list one file. + +```bash +#!/usr/bin/env bash +# rename-tests.sh [--scope ] OLD=NEW... — rewrite every whole-word OLD to NEW across +# maintained files, tracked AND untracked (a file movefuncs.sh just created is untracked), +# with point-in-time records excluded, and fail if any OLD survives in that scope. Default +# scope is the whole repository; pass --scope for a test name that another +# package also declares. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 2 +scope="." +if [ "${1-}" = "--scope" ]; then scope="$2"; shift 2; fi +excl=(-- "$scope" ':!docs/results' ':!docs/changes' ':!docs/superpowers' ':!docs/adrs') +for pair in "$@"; do + old="${pair%%=*}"; new="${pair#*=}" + if [ -z "$old" ] || [ -z "$new" ] || [ "$old" = "$pair" ]; then echo "rename-tests: bad pair $pair" >&2; exit 2; fi + hits="$(git grep --untracked -l -w -e "$old" "${excl[@]}")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep --untracked -n -w -e "$old" "${excl[@]}")" + [ -z "$left" ] || { printf 'STILL REFERENCED %s:\n%s\n' "$old" "$left"; exit 1; } +done +``` + +`defaultonly-unused.sh` lists untagged top-level helpers that no untagged test file references, meaning helpers only the tagged corpus uses: + +```bash +#!/usr/bin/env bash +# defaultonly-unused.sh — list top-level funcs/types/vars/consts declared in +# the package's UNTAGGED *_test.go files that no untagged *_test.go file references +# anywhere except on the declaring line. Those are helpers only the tagged corpus +# (or nothing) uses. Iterate: after moving them, re-run until it prints nothing new. +set -uo pipefail +pkg="$1" +untagged=() +for f in "$pkg"/*_test.go; do + first="$(sed -n '1p' "$f")" + case "$first" in //go:build*) continue;; esac + untagged+=("$f") +done +[ "${#untagged[@]}" -gt 0 ] || exit 0 +decls="$(grep -h -o -E -e '^(func|type|var|const) [A-Za-z_][A-Za-z0-9_]*' "${untagged[@]}" | awk '{print $2}' | LC_ALL=C sort -u)" +for name in $decls; do + case "$name" in Test*|Benchmark*|Example*|Fuzz*) continue;; esac + uses="$(grep -h -w -e "$name" "${untagged[@]}" | grep -v -E -e "^(func|type|var|const) ${name}\b" | wc -l | tr -d ' ')" + [ "$uses" -eq 0 ] && printf '%s\t%s\n' "$name" "$(grep -l -E -e "^(func|type|var|const) ${name}\b" "${untagged[@]}" | xargs -n1 basename | tr '\n' ' ')" +done +exit 0 +``` + +(Its known limits: methods and names declared inside grouped `const (`/`var (` blocks are not listed, and a helper referenced only by another tagged-only untagged helper surfaces on the next iteration. Both builds' `go vet` stays the authority on correctness. This script only finds candidates.) + +## Plan-time census (snapshot) + +Taken on `ef4a341d2` with `go test -race -c` binaries, each top-level test run alone with a logging git shim on `PATH` (three packages concurrently, so wall times are inflated by load, about 1s of per-run overhead subtracted): + +| package | tests | real-git tests | time in real-git tests | stays in default corpus | +|---|---|---|---|---| +| transaction | 102 | 68 | ~61s | 34 tests, ~1.2s | +| workspace | 91 | 59 | ~59s | 32 tests, ~1.3s | +| gatedrive | 290 | 15 (fingerprint 9, handoff 2, `integration_sequence` 4) | ~6s | 265 tests after moving 25 (~15s of the real-process tests leave with them) | + +The per-task lists below are this snapshot. Each task's Step 1 re-takes the census live, and **the live census wins**. + +## Review Focus + +1. **A helper a default test still needs ends up behind the tag** (or the reverse: a tagged-only helper stays untagged and the tagged file compiles only by accident). Every move task runs `prune-imports.sh`, which fails unless `go vet` passes in **both** builds. The guard tasks (5, 8) and the gatedrive task (9) run `defaultonly-unused.sh` and relocate the tagged-only helpers. +2. **`internal/app` must behave byte-identically after the hoist**: same diagnostic text, same shim script bytes and remedy line, same exit code 97, same probe argument, same self-probe env var, same `docket-app-nogit-*` temp dir. Pinned by Task 1's golden test `TestNoGitShimScriptKeepsInternalAppBytes`, whose literal is first proven against the **pre-hoist** function (Task 1 Step 1), and by `internal/app`'s three proving tests still passing. +3. **A package name or shard glob that the shim's single-quoted `printf` cannot carry** (a `'`, `%`, `\`, or newline). It would produce a shim that is a shell syntax error, one that exits non-zero on every call but never logs, so a tolerant test would pass. Pinned by `TestValidateNoGitGuardArgs` (Task 1), and `InstallNoGitGuard` refuses such input and exits 1 before writing the shim. +4. **The tagged builds must not get the shim.** The integration shards of transaction and workspace run real git from the same `TestMain`, so an installed guard would redden every shard. Pinned by Task 5 Step 7 and Task 8 Step 7, which run every shard of the package after the guard is installed, and by `go vet -tags e2e ./internal/app/` in Task 1. +5. **A real-process test left in gatedrive's default corpus.** It has no guard to catch it. Pinned by Task 9 Step 8's shape check: no untagged gatedrive test file references `process.NewService`, `mustService(`, `mustExe(`, or `intChildMarker`. The census over every remaining untagged gatedrive file must also report 0 git execs. + +--- + +### Task 1: Hoist the no-real-git guard into `internal/testsupport` + +**Build profile:** premium + +The named risk: `internal/app`'s `TestMain` also routes the supervisor and guardian re-exec roles. A hoist that changes where or when the guard installs, or that compiles it into the wrong build, breaks every real `GateLaunch`/guardian test or the integration/e2e corpora. `internal/app` must behave byte-identically. + +**Files:** +- Create: `internal/testsupport/nogit.go` (untagged: constants, diagnostic, shim renderer, verdict, argument validation, proving-test helpers) +- Create: `internal/testsupport/nogit_install.go` (`//go:build !integration && !e2e`: the real `InstallNoGitGuard`) +- Create: `internal/testsupport/nogit_install_off.go` (`//go:build integration || e2e`: the identity twin) +- Create: `internal/testsupport/nogit_test.go` (untagged: golden bytes, verdict table, validation table, executed-shim test) +- Modify: `internal/app/nogit_guard_test.go` (becomes three thin proving tests) +- Delete: `internal/app/nogit_guard_off_test.go` (its no-op twin now lives in testsupport) +- Modify: `internal/app/gate_test.go` (`TestMain` call site, two constants, comment) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces (Tasks 5, 8, and 10 rely on these exact names): + - `const testsupport.NoGitGuardProbeArg = "docket-nogit-guard-probe"`, `const testsupport.NoGitGuardExit = 97`, `const testsupport.NoGitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE"` + - `func testsupport.NoGitGuardDiagnostic(pkg string) string`, which returns `"docket nogit guard: default " + pkg + " tests must not run real git"` + - `func testsupport.NoGitShimScript(pkg, shardGlob, logPath string) string` + - `func testsupport.NoGitVerdict(pkg, logPath string, code int, w io.Writer) int` + - `func testsupport.InstallNoGitGuard(pkg, shardGlob string) func(code int) int`: installs the guard in the default build; the identity in `integration`/`e2e` builds + - `func testsupport.NoGitGuardDir() string`: `""` when not installed + - `func testsupport.AssertNoGitGuardShadowsGit(t testing.TB)`, `func testsupport.AssertNoGitGuardRefusesBareExec(t testing.TB, pkg string)`, `func testsupport.NoGitGuardTolerantProbe(t *testing.T, pkg, testName string)` + - Per-package convention: an untagged file holding `TestMain` declares `const nogitPkg = ""` and `const nogitShardGlob = "tests/test_go_integration__*.sh"`. A default-only `nogit_guard_test.go` holds `TestNoGitGuardShadowsGitOnPath`, `TestNoGitGuardRefusesBareExec`, and `TestNoGitGuardFailsTolerantTest`. + +- [ ] **Step 1: Prove the golden literal against the pre-hoist function** + +Create the throwaway file `internal/app/nogit_golden_probe_test.go`: + +```go +//go:build !integration && !e2e + +package app + +import "testing" + +func TestNoGitShimGoldenProbe(t *testing.T) { + const want = "#!/bin/sh\n" + + "if [ \"${1-}\" != 'docket-nogit-guard-probe' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '/x/violations.log'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' 'docket nogit guard: default internal/app tests must not run real git' \"$*\" >&2\n" + + "exit 97\n" + if got := nogitShimScript("/x/violations.log"); got != want { + t.Fatalf("golden drifted from the pre-hoist shim:\n got %q\nwant %q", got, want) + } +} +``` + +Run: `go test -count=1 -run '^TestNoGitShimGoldenProbe$' ./internal/app/` +Expected: PASS. This proves the literal Step 2 uses is the pre-hoist bytes. Then delete the file: `rm -f internal/app/nogit_golden_probe_test.go`. + +- [ ] **Step 2: Write the failing testsupport tests** + +Create `internal/testsupport/nogit_test.go`: + +```go +package testsupport + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestNoGitShimScriptKeepsInternalAppBytes pins the hoist as byte-identical for +// internal/app (change 0466): the literal was proven against the pre-hoist +// internal/app nogitShimScript before that function was deleted. +func TestNoGitShimScriptKeepsInternalAppBytes(t *testing.T) { + const want = "#!/bin/sh\n" + + "if [ \"${1-}\" != 'docket-nogit-guard-probe' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '/x/violations.log'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' 'docket nogit guard: default internal/app tests must not run real git' \"$*\" >&2\n" + + "exit 97\n" + if got := NoGitShimScript("internal/app", "tests/test_go_integration_app_*.sh", "/x/violations.log"); got != want { + t.Fatalf("internal/app shim bytes changed:\n got %q\nwant %q", got, want) + } + if got, want := NoGitGuardDiagnostic("internal/app"), "docket nogit guard: default internal/app tests must not run real git"; got != want { + t.Fatalf("NoGitGuardDiagnostic(internal/app) = %q, want %q", got, want) + } +} + +// TestNoGitShimScriptRefusesAndLogs EXECUTES a rendered shim: a non-probe call is +// logged as "\t" and refused with the package's diagnostic, its shard +// glob, and exit NoGitGuardExit; a probe call is refused but not logged. +func TestNoGitShimScriptRefusesAndLogs(t *testing.T) { + dir := TempDir(t) + logPath := filepath.Join(dir, "violations.log") + shim := filepath.Join(dir, "git") + pkg, glob := "internal/repository/transaction", "tests/test_go_integration_transaction_*.sh" + if err := os.WriteFile(shim, []byte(NoGitShimScript(pkg, glob, logPath)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(shim, 0o755); err != nil { + t.Fatal(err) + } + out, err := exec.Command(shim, "status", "--porcelain").CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != NoGitGuardExit { + t.Fatalf("shim must exit %d, got err=%v output=%q", NoGitGuardExit, err, out) + } + for _, want := range []string{NoGitGuardDiagnostic(pkg), "(git status --porcelain)", glob} { + if !strings.Contains(string(out), want) { + t.Fatalf("shim stderr must contain %q, got %q", want, out) + } + } + logged, err := os.ReadFile(logPath) + if err != nil || !strings.Contains(string(logged), "\tstatus --porcelain") { + t.Fatalf("violation log must record the call, got %q (err %v)", logged, err) + } + if err := os.Remove(logPath); err != nil { + t.Fatal(err) + } + if _, err := exec.Command(shim, NoGitGuardProbeArg).CombinedOutput(); err == nil { + t.Fatalf("a probe call must still be refused") + } + if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a probe call must not be logged (stat err %v)", err) + } +} + +// TestNoGitVerdict covers the post-m.Run verdict over the violation log (moved +// from internal/app's TestNoGitGuardVerdict by change 0466). +func TestNoGitVerdict(t *testing.T) { + dir := TempDir(t) + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + cases := []struct { + name string + logPath string + code int + want int + wantText string + }{ + {"missing log is clean", filepath.Join(dir, "absent.log"), 0, 0, ""}, + {"empty log is clean", write("empty.log", ""), 0, 0, ""}, + {"one violation fails a green run", write("one.log", "/tmp/x\tstatus --porcelain\n"), 0, 1, "1 real-git exec attempt(s)"}, + {"violation keeps an existing failure code", write("keep.log", "/tmp/x\tlog\n"), 2, 2, "/tmp/x\tlog"}, + {"unreadable log fails closed", dir, 0, 1, "cannot read the violation log"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + got := NoGitVerdict("internal/app", tc.logPath, tc.code, &buf) + if got != tc.want { + t.Fatalf("NoGitVerdict(%q, %d) = %d, want %d; output:\n%s", tc.logPath, tc.code, got, tc.want, buf.String()) + } + if tc.wantText == "" && buf.Len() != 0 { + t.Fatalf("clean verdict must print nothing, got:\n%s", buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), tc.wantText) { + t.Fatalf("verdict output must contain %q, got:\n%s", tc.wantText, buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), NoGitGuardDiagnostic("internal/app")) { + t.Fatalf("verdict output must carry the package diagnostic, got:\n%s", buf.String()) + } + }) + } +} + +// TestValidateNoGitGuardArgs: the shim embeds pkg and shardGlob inside a +// single-quoted printf, so a quote, percent sign, backslash, or newline (or an +// empty value) is refused rather than rendered into a broken shim. +func TestValidateNoGitGuardArgs(t *testing.T) { + okGlob := "tests/test_go_integration_app_*.sh" + cases := []struct { + name, pkg, glob string + ok bool + }{ + {"app", "internal/app", okGlob, true}, + {"transaction", "internal/repository/transaction", "tests/test_go_integration_transaction_*.sh", true}, + {"empty package", "", okGlob, false}, + {"empty glob", "internal/app", "", false}, + {"quote in package", "internal/a'pp", okGlob, false}, + {"percent in glob", "internal/app", "tests/%s.sh", false}, + {"backslash in glob", "internal/app", `tests\x.sh`, false}, + {"newline in package", "internal/app\nx", okGlob, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateNoGitGuardArgs(tc.pkg, tc.glob) + if tc.ok && err != nil { + t.Fatalf("validateNoGitGuardArgs(%q, %q) = %v, want nil", tc.pkg, tc.glob, err) + } + if !tc.ok && err == nil { + t.Fatalf("validateNoGitGuardArgs(%q, %q) = nil, want a refusal", tc.pkg, tc.glob) + } + }) + } +} +``` + +- [ ] **Step 3: Run them to verify they fail** + +Run: `go test -count=1 ./internal/testsupport/` +Expected: FAIL to compile with `undefined: NoGitShimScript` (and the other new names). + +- [ ] **Step 4: Implement the shared guard** + +Create `internal/testsupport/nogit.go`: + +```go +package testsupport + +// The default-build no-real-git guard (change 0465, hoisted here from internal/app +// by change 0466). Change 0333 moved the slow real-git, subprocess, and +// process-lifecycle corpus behind `//go:build integration`; this guard makes that +// partition an enforced invariant for a package: its default-tag test corpus never +// starts a real `git`. Installed from the TestMain of internal/app, +// internal/repository/transaction, and internal/workspace. +// +// Mechanism (keyed on the exec itself, never on spellings): InstallNoGitGuard, +// called from TestMain before m.Run, puts a directory holding a refusing `git` shim +// at the FRONT of PATH. Every PATH-resolved route to git (gitcli.NewClient's +// exec.LookPath, a bare exec.Command("git", …), a fixture helper, a child process +// inheriting PATH) resolves the shim. Known limits, none used by default tests +// today: a client built with gitcli.WithExecutable(), a test that +// replaces PATH wholesale rather than prepending to it, and a detached child that +// runs git after m.Run returns (once the shim dir is removed) all bypass the shim. +// The shim exits NoGitGuardExit with NoGitGuardDiagnostic(pkg) on stderr AND +// appends "\t" to a violation log, so a test that tolerates the failure +// still turns the package red when NoGitVerdict reads the log after m.Run. +// +// Only a package's own proving tests may call the shim without recording a +// violation, by passing NoGitGuardProbeArg as the first argument. +// +// Build split: InstallNoGitGuard is real only in the default build +// (nogit_install.go, `//go:build !integration && !e2e`); the tagged corpora exist +// to run real git and get the identity finisher (nogit_install_off.go). A build +// tag applies to every package compiled into the test binary, testsupport +// included, so the split lives here once instead of as a twin file in each +// guarded package. + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + // NoGitGuardProbeArg as git's first argument marks a proving-test call the + // shim refuses without logging a violation. + NoGitGuardProbeArg = "docket-nogit-guard-probe" + // NoGitGuardExit is the shim's exit code. + NoGitGuardExit = 97 + // NoGitSelfProbeEnv routes NoGitGuardTolerantProbe's re-exec'd child. + NoGitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE" +) + +// noGitGuardDir is the installed shim directory ("" when the guard is not installed). +var noGitGuardDir string + +// NoGitGuardDir returns the installed shim directory, or "" when no guard is installed. +func NoGitGuardDir() string { return noGitGuardDir } + +// NoGitGuardDiagnostic is the guard's stderr diagnostic for package pkg. +func NoGitGuardDiagnostic(pkg string) string { + return "docket nogit guard: default " + pkg + " tests must not run real git" +} + +// validateNoGitGuardArgs refuses a pkg or shardGlob the shim's single-quoted printf +// cannot carry verbatim: empty, or holding a quote, percent sign, backslash, or newline. +func validateNoGitGuardArgs(pkg, shardGlob string) error { + for _, v := range []struct{ name, val string }{{"package", pkg}, {"shard glob", shardGlob}} { + if v.val == "" { + return fmt.Errorf("the %s is empty", v.name) + } + if strings.ContainsAny(v.val, "'%\\\n") { + return fmt.Errorf("the %s %q contains a quote, percent sign, backslash, or newline, which the shim's single-quoted printf cannot carry", v.name, v.val) + } + } + return nil +} + +// NoGitShimScript renders the refusing git for package pkg: it records every +// non-probe invocation as "\t" in logPath and always exits +// NoGitGuardExit with the diagnostic and the remedy (naming shardGlob) on stderr. +func NoGitShimScript(pkg, shardGlob, logPath string) string { + return "#!/bin/sh\n" + + "if [ \"${1-}\" != '" + NoGitGuardProbeArg + "' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '" + logPath + "'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a " + shardGlob + " shard (change 0465; partition from change 0333)\\n' '" + + NoGitGuardDiagnostic(pkg) + "' \"$*\" >&2\n" + + fmt.Sprintf("exit %d\n", NoGitGuardExit) +} + +// NoGitVerdict folds the violation log into m.Run's exit code. A missing or empty +// log is clean and leaves code unchanged. Any recorded attempt, or a log that exists +// but cannot be read, fails the package: a probe error is never clean absence. +func NoGitVerdict(pkg, logPath string, code int, w io.Writer) int { + diag := NoGitGuardDiagnostic(pkg) + raw, err := os.ReadFile(logPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Fprintf(w, "%s: cannot read the violation log %s: %v\n", diag, logPath, err) + return noGitFailCode(code) + } + var lines []string + for _, l := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) == 0 { + return code + } + fmt.Fprintf(w, "%s: %d real-git exec attempt(s) reached the guard shim (a test that tolerated the failure still counts); \\t:\n", diag, len(lines)) + for _, l := range lines { + fmt.Fprintf(w, " %s\n", l) + } + return noGitFailCode(code) +} + +func noGitFailCode(code int) int { + if code == 0 { + return 1 + } + return code +} + +// AssertNoGitGuardShadowsGit: every PATH lookup of `git` (gitcli.NewClient uses +// exec.LookPath) resolves the installed shim, not a real git. +func AssertNoGitGuardShadowsGit(t testing.TB) { + t.Helper() + if noGitGuardDir == "" { + t.Fatalf("the no-real-git guard is not installed (NoGitGuardDir empty); TestMain must call testsupport.InstallNoGitGuard before m.Run") + } + p, err := exec.LookPath("git") + if err != nil { + t.Fatalf("LookPath(git): %v", err) + } + if filepath.Dir(p) != noGitGuardDir { + t.Fatalf("git resolves to %q, want the guard shim in %q", p, noGitGuardDir) + } +} + +// AssertNoGitGuardRefusesBareExec pins the MECHANISM, not just "it failed": real +// git also fails on an unknown subcommand, so the assert is the guard's exit code +// AND pkg's diagnostic (learning assert-pins-outcome-not-mechanism). +func AssertNoGitGuardRefusesBareExec(t testing.TB, pkg string) { + t.Helper() + out, err := exec.Command("git", NoGitGuardProbeArg).CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != NoGitGuardExit { + t.Fatalf("git exec must exit %d from the guard shim, got err=%v output=%q", NoGitGuardExit, err, out) + } + if !strings.Contains(string(out), NoGitGuardDiagnostic(pkg)) { + t.Fatalf("git exec output must carry the guard diagnostic %q, got %q", NoGitGuardDiagnostic(pkg), out) + } +} + +// NoGitGuardTolerantProbe proves a test that SWALLOWS the git failure still fails +// the package. testName must be the calling test's exact name: the helper re-execs +// the test binary running only that test in child mode, where it runs `git status` +// and ignores the error, then asserts the child binary exits non-zero and lists the +// violation. +func NoGitGuardTolerantProbe(t *testing.T, pkg, testName string) { + t.Helper() + if os.Getenv(NoGitSelfProbeEnv) == "1" { + _ = exec.Command("git", "status").Run() // tolerated on purpose + return + } + cmd := exec.Command(os.Args[0], "-test.run=^"+testName+"$", "-test.count=1") + cmd.Env = append(os.Environ(), NoGitSelfProbeEnv+"=1") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() == 0 { + t.Fatalf("a package whose test tolerated a git exec must exit non-zero, got err=%v output:\n%s", err, out) + } + for _, want := range []string{NoGitGuardDiagnostic(pkg), "1 real-git exec attempt(s)", "\tstatus"} { + if !strings.Contains(string(out), want) { + t.Fatalf("child output must contain %q, got:\n%s", want, out) + } + } +} +``` + +Create `internal/testsupport/nogit_install.go`: + +```go +//go:build !integration && !e2e + +package testsupport + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// InstallNoGitGuard installs the refusing git shim for package pkg (its +// module-relative dir, e.g. "internal/app") at the front of PATH and returns the +// finisher TestMain wraps around m.Run. shardGlob names the package's integration +// shard runners in the remedy text. Setup failure, or a pkg/shardGlob the shim +// cannot carry, exits the binary non-zero: a guard that silently failed to install +// would certify nothing. +func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { + diag := NoGitGuardDiagnostic(pkg) + if err := validateNoGitGuardArgs(pkg, shardGlob); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", diag, err) + os.Exit(1) + } + // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. + dir, err := os.MkdirTemp("", "docket-"+path.Base(pkg)+"-nogit-") + if err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", diag, err) + os.Exit(1) + } + logPath := filepath.Join(dir, "violations.log") + if strings.ContainsAny(logPath, "'\n") { + fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", diag, logPath) + os.Exit(1) + } + shim := filepath.Join(dir, "git") + if err := os.WriteFile(shim, []byte(NoGitShimScript(pkg, shardGlob, logPath)), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", diag, err) + os.Exit(1) + } + // Explicit chmod: a create-time mode is masked by the umask. + if err := os.Chmod(shim, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", diag, err) + os.Exit(1) + } + if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", diag, err) + os.Exit(1) + } + noGitGuardDir = dir + return func(code int) int { + verdict := NoGitVerdict(pkg, logPath, code, os.Stderr) + _ = os.RemoveAll(dir) + return verdict + } +} +``` + +Create `internal/testsupport/nogit_install_off.go`: + +```go +//go:build integration || e2e + +package testsupport + +// InstallNoGitGuard is the tagged builds' no-op twin of the default-build guard in +// nogit_install.go (change 0466, formerly internal/app/nogit_guard_off_test.go). +// The integration and e2e corpora exist to run real git, so they install no shim +// and the finisher returns m.Run's code as-is. Exactly one of the two files +// compiles for any tag set, so every guarded TestMain stays single-sourced. +func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { + return func(code int) int { return code } +} +``` + +- [ ] **Step 5: Run the testsupport tests to verify they pass** + +Run: `gofmt -l internal/testsupport && go vet ./internal/testsupport/ && go vet -tags integration ./internal/testsupport/ && go test -count=1 -v -run 'NoGit' ./internal/testsupport/` +Expected: `gofmt -l` prints nothing, both vets are clean, and `TestNoGitShimScriptKeepsInternalAppBytes`, `TestNoGitShimScriptRefusesAndLogs`, `TestNoGitVerdict`, and `TestValidateNoGitGuardArgs` PASS. + +- [ ] **Step 6: Switch `internal/app` to the shared guard** + +(a) `git rm -q internal/app/nogit_guard_off_test.go`. + +(b) Replace the whole of `internal/app/nogit_guard_test.go` with: + +```go +//go:build !integration && !e2e + +package app + +// The no-real-git guard's proving tests for internal/app (change 0465). The guard +// itself lives in internal/testsupport (InstallNoGitGuard, hoisted there by change +// 0466) and is installed from TestMain in gate_test.go. These tests prove it is +// installed in THIS package's binary and fails the package on any real-git exec, +// even one a test tolerates. The default-tag internal/app test corpus never starts +// a real `git`; real-git tests live behind //go:build integration in the +// tests/test_go_integration_app_*.sh shards. + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + testsupport.AssertNoGitGuardShadowsGit(t) +} + +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. +func TestNoGitGuardRefusesBareExec(t *testing.T) { + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) +} + +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") +} +``` + +(c) In `internal/app/gate_test.go`, add directly above `func TestMain`: + +```go +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (testsupport.InstallNoGitGuard): its diagnostic and its remedy text. +const ( + nogitPkg = "internal/app" + nogitShardGlob = "tests/test_go_integration_app_*.sh" +) +``` + +Then in `TestMain` replace + +```go + // Change 0465: the default build installs the no-real-git guard (nogit_guard_test.go) + // AFTER the re-exec routing above, so the supervisor and guardian roles behave + // exactly as before; tagged builds get the no-op twin (nogit_guard_off_test.go). + finish := installNoGitGuard() +``` + +with + +```go + // Change 0465 (hoisted by change 0466): the default build installs the no-real-git + // guard (testsupport.InstallNoGitGuard) AFTER the re-exec routing above, so the + // supervisor and guardian roles behave exactly as before; tagged builds get + // testsupport's no-op twin. Its proving tests are in nogit_guard_test.go. + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) +``` + +Also update the doc comment line above `TestMain` that reads `// Ordinary runs then install the default-build no-real-git guard (change 0465) around m.Run.` so it names `testsupport.InstallNoGitGuard`. + +(d) Find every other maintained reference to the removed names and fix it (comments included): + +```bash +bash -c 'git grep -n -w -E -e "installNoGitGuard|nogitVerdict|nogitShimScript|nogitGuardDir|nogitGuardProbeArg|nogitGuardDiagnostic|nogitGuardExit|nogitSelfProbeEnv|nogitFailCode|nogit_guard_off_test" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs" || echo "no stale references"' +``` +Expected after your edits: `no stale references`, except the historical prose in `tests/test_go_race.sh`'s header, which Task 10 rewrites. Leave that one for Task 10. + +- [ ] **Step 7: Verify `internal/app` behaves identically** + +```bash +gofmt -l internal/app internal/testsupport +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +go test -count=1 -v -run '^TestNoGitGuard' ./internal/app/ +bash -c 'out="$(go test -count=1 ./internal/app/ 2>&1)"; rc=$?; printf "%s\n" "$out" | tail -3; grep -E -e "real-git exec attempt\(s\)|docket nogit guard" <<<"$out" || echo "guard: silent"; echo "rc=$rc"' +bash tests/test_go_finalize_e2e.sh >/dev/null 2>&1; echo "e2e rc=$?" +bash tests/test_go_integration_app_reposetup.sh >/dev/null 2>&1; echo "app shard rc=$?" +``` +Expected: no gofmt output, three clean vets, three `TestNoGitGuard*` PASS, `ok github.com/danielhanold/docket/internal/app`, `guard: silent`, `rc=0`, `e2e rc=0`, `app shard rc=0`. The e2e and shard runs prove the tagged builds still get no shim. + +- [ ] **Step 8: Mutation probes** + +```bash +bash -c ' +f=internal/testsupport/nogit_install.go; g=internal/testsupport/nogit.go +bf="$(mktemp "${TMPDIR:-/tmp}/nogit-install.XXXXXX")"; bg="$(mktemp "${TMPDIR:-/tmp}/nogit-shared.XXXXXX")"; cp "$f" "$bf"; cp "$g" "$bg" +# (a) strip the PATH prepend: shadow + bare-exec + tolerant-child must go red in internal/app +perl -0pi -e "s/os\.Setenv\(\"PATH\", dir\+string\(os\.PathListSeparator\)\+os\.Getenv\(\"PATH\"\)\)/os.Setenv(\"DOCKET_NOGIT_MUTATED\", dir)/" "$f" +cmp -s "$f" "$bf" && { echo "mutation a did not land"; exit 1; } +go test -count=1 -run "^TestNoGitGuard" ./internal/app/ >/dev/null 2>&1; echo "mutation-a rc=$?" +cp "$bf" "$f" +# (b) make the verdict ignore violations: TestNoGitVerdict + the app tolerant child must go red +perl -0pi -e "s/if len\(lines\) == 0 \{\n\t\treturn code\n\t\}/if len(lines) >= 0 {\n\t\treturn code\n\t}/" "$g" +cmp -s "$g" "$bg" && { echo "mutation b did not land"; exit 1; } +go test -count=1 -run "^TestNoGitVerdict$" ./internal/testsupport/ >/dev/null 2>&1; echo "mutation-b testsupport rc=$?" +go test -count=1 -run "^TestNoGitGuardFailsTolerantTest$" ./internal/app/ >/dev/null 2>&1; echo "mutation-b app rc=$?" +cp "$bg" "$g" +go test -count=1 -run "NoGit" ./internal/testsupport/ ./internal/app/ >/dev/null 2>&1; echo "restored rc=$?" +rm -f "$bf" "$bg" +' +``` +Expected: `mutation-a rc=1`, `mutation-b testsupport rc=1`, `mutation-b app rc=1`, `restored rc=0`, and no `did not land` line. If a `did not land` line prints, the perl pattern no longer matches the file: fix the probe, never read the run as a result. Report all four codes. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/testsupport/nogit.go internal/testsupport/nogit_install.go internal/testsupport/nogit_install_off.go internal/testsupport/nogit_test.go internal/app/nogit_guard_test.go internal/app/nogit_guard_off_test.go internal/app/gate_test.go +git status --porcelain +git commit -m "test(testsupport): hoist the no-real-git guard out of internal/app (change 0466)" +``` + +--- + +### Task 2: Move transaction's apply-path real-git tests into the `TestIntegrationTxnApply` shard + +**Build profile:** standard + +**Files:** +- Modify: `internal/repository/transaction/{engine,engine_scope,candidate,loader,harness,preserve}_test.go` (tests move out; helpers stay) +- Create: `internal/repository/transaction/{engine,engine_scope,candidate,loader,harness,preserve}_integration_test.go` +- Create: `tests/test_go_integration_transaction_apply.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row) +- Modify: any maintained file `rename-tests.sh` rewrites + +**Interfaces:** +- Consumes: the Shared tools; `tests/lib/go-integration-shard.sh` and `tests/test_go_integration_contract.sh`, unchanged. +- Produces: runner `tests/test_go_integration_transaction_apply.sh` (`SHARD_PKG="./internal/repository/transaction"`, `SHARD_PREFIX="TestIntegrationTxnApply"`, `SHARD_MODE="normal"`). Transaction test files named `_integration_test.go` exist for Tasks 3–5 to append to. + +**Context.** Moving tests out one shard at a time keeps the package green. The guard is installed only in Task 5, after all transaction offenders have moved. Helpers stay untagged until Task 5 relocates the tagged-only ones. + +**This task's tests (snapshot, all `normal`, all start real git): 25** +- `engine_test.go` (10): `TestEngineAfterGateRefusesInvalidPlan`, `TestEngineAppliesHappyPath`, `TestEngineBeforeGateRefusesInvalidBase`, `TestEngineEvolutionGateBlocksFrozenADRRewrite`, `TestEngineExpectationMatrix`, `TestEngineFailsOnMissingTargetBranch`, `TestEngineNoOpOnEmptyPlan`, `TestEngineRefusalFromOperation`, `TestEngineRejectsNonBranchTargetRef`, `TestEngineRetriesLeaseLoss` +- `engine_scope_test.go` (10): `TestEngineScopeAfterGateVolatility`, `TestEngineScopeAppliesDespiteUnrelatedError`, `TestEngineScopeCandidateSubjectsAreResolved`, `TestEngineScopeKeyedReplay`, `TestEngineScopeLeaseRetryRecomputes`, `TestEngineScopeNoOpSurfacesUnrelatedError`, `TestEngineScopePostPlanRecheckRefusesPlanTouchedError`, `TestEngineScopeRefusesRelevantBeforeErrors`, `TestEngineScopeUnresolvableCandidateIsStrict`, `TestEngineScopeUnresolvableScopeIsStrict` +- `candidate_test.go` (2): `TestAllocateCandidateStructureAndManifest`, `TestCandidateModesUnderUmask` +- `loader_test.go` (1): `TestLoaderBuildsCleanStateFromCorpus` +- `harness_test.go` (1): `TestHarnessBuildersProduceExpectedTopology` +- `preserve_test.go` (1): `TestTransactionPreservesDirtyCheckout` + +Stays in the default corpus (no git): `TestNewEngineRejectsNilDependencies`, `TestLoaderErrorsOnUnparseableRecord`, `TestLiveLockExcludesSecondNonBlocking`, `TestRegistryLockMutualExclusion`. `TestEngineConcurrentExecuteIsRaceFree`, `TestSetPhaseAtomicUnderConcurrentReads`, and `TestRegistryLockAllocationExcludesConcurrentAllocation` also start git, but they belong to Task 4's race shard. Do not touch them here. + +- [ ] **Step 1: Confirm the live census** + +Write the Shared tools to `T="${TMPDIR:-/tmp}/docket-0466-tools"` if absent (`mkdir -p "$T"`, one file per block, `chmod +x "$T"/*.sh`). Then: +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/census.sh" internal/repository/transaction engine_test.go engine_scope_test.go candidate_test.go loader_test.go harness_test.go preserve_test.go' +``` +Expected: every test listed above prints a git-exec count `> 0`. The four "stays" tests print `0`. The three race-shard tests print `> 0` and stay untouched. If the live census differs (a test added or renamed since the snapshot), the live census wins: move every live `> 0` test of these files that is not race-classified under the Global Constraints criterion, and report the difference. + +- [ ] **Step 2: Move the tests behind the tag** + +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/repository/transaction +"$T/movefuncs.sh" $P/engine_test.go $P/engine_integration_test.go TestEngineAfterGateRefusesInvalidPlan TestEngineAppliesHappyPath TestEngineBeforeGateRefusesInvalidBase TestEngineEvolutionGateBlocksFrozenADRRewrite TestEngineExpectationMatrix TestEngineFailsOnMissingTargetBranch TestEngineNoOpOnEmptyPlan TestEngineRefusalFromOperation TestEngineRejectsNonBranchTargetRef TestEngineRetriesLeaseLoss +"$T/movefuncs.sh" $P/engine_scope_test.go $P/engine_scope_integration_test.go TestEngineScopeAfterGateVolatility TestEngineScopeAppliesDespiteUnrelatedError TestEngineScopeCandidateSubjectsAreResolved TestEngineScopeKeyedReplay TestEngineScopeLeaseRetryRecomputes TestEngineScopeNoOpSurfacesUnrelatedError TestEngineScopePostPlanRecheckRefusesPlanTouchedError TestEngineScopeRefusesRelevantBeforeErrors TestEngineScopeUnresolvableCandidateIsStrict TestEngineScopeUnresolvableScopeIsStrict +"$T/movefuncs.sh" $P/candidate_test.go $P/candidate_integration_test.go TestAllocateCandidateStructureAndManifest TestCandidateModesUnderUmask +"$T/movefuncs.sh" $P/loader_test.go $P/loader_integration_test.go TestLoaderBuildsCleanStateFromCorpus +"$T/movefuncs.sh" $P/harness_test.go $P/harness_integration_test.go TestHarnessBuildersProduceExpectedTopology +"$T/movefuncs.sh" $P/preserve_test.go $P/preserve_integration_test.go TestTransactionPreservesDirtyCheckout +"$T/prune-imports.sh" $P +for f in $P/*_integration_test.go; do printf "%s: [%s] [%s]\n" "$f" "$(sed -n 1p "$f")" "$(sed -n 2p "$f")"; done +' +``` +Expected: `prune-imports: go vet clean (default and integration) for internal/repository/transaction`, and every listed file shows `[//go:build integration] []`. An untagged file left holding only a package clause and imports (for example `preserve_test.go` if it has no helpers) is deleted with `git rm -q -f `. It carries nothing. Re-run `prune-imports.sh` after any deletion. + +- [ ] **Step 3: Rename the moved tests and every maintained reference** + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; X=TestIntegrationTxnApply; pairs="" +for old in TestEngineAfterGateRefusesInvalidPlan TestEngineAppliesHappyPath TestEngineBeforeGateRefusesInvalidBase TestEngineEvolutionGateBlocksFrozenADRRewrite TestEngineExpectationMatrix TestEngineFailsOnMissingTargetBranch TestEngineNoOpOnEmptyPlan TestEngineRefusalFromOperation TestEngineRejectsNonBranchTargetRef TestEngineRetriesLeaseLoss TestEngineScopeAfterGateVolatility TestEngineScopeAppliesDespiteUnrelatedError TestEngineScopeCandidateSubjectsAreResolved TestEngineScopeKeyedReplay TestEngineScopeLeaseRetryRecomputes TestEngineScopeNoOpSurfacesUnrelatedError TestEngineScopePostPlanRecheckRefusesPlanTouchedError TestEngineScopeRefusesRelevantBeforeErrors TestEngineScopeUnresolvableCandidateIsStrict TestEngineScopeUnresolvableScopeIsStrict TestAllocateCandidateStructureAndManifest TestCandidateModesUnderUmask TestLoaderBuildsCleanStateFromCorpus TestTransactionPreservesDirtyCheckout; do pairs="$pairs $old=$X${old#Test}"; done +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +# declared in internal/gitcli and internal/workspace too: rename ONLY this package copy +"$T/rename-tests.sh" --scope internal/repository/transaction TestHarnessBuildersProduceExpectedTopology=${X}HarnessBuildersProduceExpectedTopology; echo "scoped rename rc=$?" +git grep -l -E -e "^func TestHarnessBuildersProduceExpectedTopology\(" -- internal +git diff --stat -- . ":!internal/repository/transaction" +' +``` +Expected: `rename rc=0` and `scoped rename rc=0`, with no `STILL REFERENCED` line. The declaration grep still lists the `internal/gitcli` and `internal/workspace` copies, untouched. The `\b` word boundary keeps a name from matching inside a longer one (e.g. `TestEngineScopeKeyedReplay`), so list order does not matter. Inspect every file the `--stat` lists outside the package. Where a comment or doc called the test a default-corpus test, correct the wording, not only the name. + +- [ ] **Step 4: Create the shard runner** + +Create `tests/test_go_integration_transaction_apply.sh` with exactly this content, then `chmod +x tests/test_go_integration_transaction_apply.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_apply.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's apply-path real-git tests (engine, scope +# gates, candidate allocation, loader, harness topology, dirty-checkout preservation) — moved +# out of the default internal/repository/transaction corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationTxnApply. Declarations only — execution and +# inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestIntegrationTxnApply" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 5: Verify: default package green, shard green, contract green, no prefix collision** + +```bash +go test -count=1 ./internal/repository/transaction/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/repository/transaction/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +bash tests/test_go_integration_transaction_apply.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/repository/transaction"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: `ok` for the package, `no leak into the default corpus`, the shard prints only `ok - ` lines with `shard rc=0`, `contract rc=0`, and no `COLLISION` line. The contract now discovers `internal/repository/transaction` by itself, and the transaction package's runner set is exactly this one runner. The contract's check (10) passes because this runner declares the package. + +- [ ] **Step 6: Measure and register the budget row** + +Run the shard once to warm the build cache, then measure the second, solo run: `bash -c 'bash tests/test_go_integration_transaction_apply.sh >/dev/null 2>&1; time bash tests/test_go_integration_transaction_apply.sh >/dev/null' 2>&1 | tail -4`. Take `real` = S seconds. The row is S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert `tests/test_go_integration_transaction_apply.shparallel` directly above the `tests/test_go_integration_release.sh` row, with a literal tab. A row above 40: stop and return NEEDS_ESCALATION with S (see Global Constraints). Then: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. Report S, the row, and the margin (row − S). + +- [ ] **Step 7: Commit** + +```bash +git add -- internal/repository/transaction tests/test_go_integration_transaction_apply.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 3 rewrote; list it explicitly +git status --porcelain +git commit -m "test(transaction): move apply-path real-git tests behind the integration tag (TestIntegrationTxnApply, change 0466)" +``` + +--- + +### Task 3: Move transaction's replay and recovery real-git tests into the `TestIntegrationTxnRecovery` shard + +**Build profile:** standard + +**Files:** +- Modify: `internal/repository/transaction/{idempotency,materialize,cleanup,interrupt,recovery}_test.go` +- Create: `internal/repository/transaction/{idempotency,materialize,cleanup,interrupt,recovery}_integration_test.go` +- Create: `tests/test_go_integration_transaction_recovery.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row) +- Modify: any maintained file `rename-tests.sh` rewrites + +**Interfaces:** +- Consumes: the Shared tools; Task 2's runner (for the prefix check). +- Produces: runner `tests/test_go_integration_transaction_recovery.sh` (`SHARD_PKG="./internal/repository/transaction"`, `SHARD_PREFIX="TestIntegrationTxnRecovery"`, `SHARD_MODE="normal"`). + +**This task's tests (snapshot, all `normal`, all start real git): 31** +- `idempotency_test.go` (7): `TestDuplicateRequestIDIsInvalidState`, `TestKeyedCommitCarriesFiveTrailers`, `TestKeyedReplayFoundDeepInHistory`, `TestKeyedReplayReturnsOriginalReceipt`, `TestMalformedResultIsInvalidState`, `TestRequestIDInProseDoesNotMatch`, `TestRequestIDReusedDifferentDigest` +- `materialize_test.go` (10): `TestMaterializeCreateEmptyFile`, `TestMaterializeCreateReplaceDeleteByteExact`, `TestMaterializeHostilePathsByteExact`, `TestMaterializeRefusesReplaceTargetSymlink`, `TestMaterializeRefusesSymlinkParentComponent`, `TestMaterializeReplacePreservesExecutableMode`, `TestVerifyActualDeltaExactMatch`, `TestVerifyActualDeltaRejectsDeclaredUnchanged`, `TestVerifyActualDeltaRejectsUndeclaredChange`, `TestVerifyMaterializedDetectsCorruption` +- `cleanup_test.go` (4): `TestCleanupRetainsRegisteredCandidateOnListError`, `TestPruneDocketModeIgnoresLinkedWorktree`, `TestPruneReportDeterministicOrder`, `TestPruneReportEmptyOnCleanRoot` +- `interrupt_test.go` (4): `TestInterruptContainmentFailureDoesNotPush`, `TestInterruptDeltaMismatchDoesNotPush`, `TestInterruptLostResponseReplaysOriginalOnce`, `TestInterruptPreCancelledContext` +- `recovery_test.go` (6): `TestPruneHeldLockBeatsAncientPIDAndTimestamp`, `TestPruneLeavesMalformedAndForeignByteUntouched`, `TestPruneNeverGlobalPrunesOrTouchesUserCheckout`, `TestPrunePrunesAbandonedCandidate`, `TestPruneReportsCleanupFailedOnForcedRemovalFailure`, `TestPruneReportsLiveCandidatesUntouched` + +Belong to Task 4 (race), do not touch: `TestInterruptAmbiguousPushLandedIsApplied`, `TestInterruptCancelBetweenCommitAndPush`, `TestInterruptCancelInsidePlan`, `TestInterruptLiteralLeaseRejectsFresherTrackingRef` (each runs `eng.Execute` in a goroutine while the test goroutine cancels or races it), and `TestPruneRegistryLockSerializesAllocation` (a goroutine holds the registry lock while another runs `PruneAbandoned`). + +- [ ] **Step 1: Confirm the live census** + +Write the Shared tools to `${TMPDIR:-/tmp}/docket-0466-tools` if absent. Then: +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/census.sh" internal/repository/transaction idempotency_test.go materialize_test.go cleanup_test.go interrupt_test.go recovery_test.go' +``` +Expected: all 36 tests print `> 0` git execs (31 here plus Task 4's five). The live census wins, as in Task 2. + +- [ ] **Step 2: Move the tests behind the tag** + +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/repository/transaction +"$T/movefuncs.sh" $P/idempotency_test.go $P/idempotency_integration_test.go TestDuplicateRequestIDIsInvalidState TestKeyedCommitCarriesFiveTrailers TestKeyedReplayFoundDeepInHistory TestKeyedReplayReturnsOriginalReceipt TestMalformedResultIsInvalidState TestRequestIDInProseDoesNotMatch TestRequestIDReusedDifferentDigest +"$T/movefuncs.sh" $P/materialize_test.go $P/materialize_integration_test.go TestMaterializeCreateEmptyFile TestMaterializeCreateReplaceDeleteByteExact TestMaterializeHostilePathsByteExact TestMaterializeRefusesReplaceTargetSymlink TestMaterializeRefusesSymlinkParentComponent TestMaterializeReplacePreservesExecutableMode TestVerifyActualDeltaExactMatch TestVerifyActualDeltaRejectsDeclaredUnchanged TestVerifyActualDeltaRejectsUndeclaredChange TestVerifyMaterializedDetectsCorruption +"$T/movefuncs.sh" $P/cleanup_test.go $P/cleanup_integration_test.go TestCleanupRetainsRegisteredCandidateOnListError TestPruneDocketModeIgnoresLinkedWorktree TestPruneReportDeterministicOrder TestPruneReportEmptyOnCleanRoot +"$T/movefuncs.sh" $P/interrupt_test.go $P/interrupt_integration_test.go TestInterruptContainmentFailureDoesNotPush TestInterruptDeltaMismatchDoesNotPush TestInterruptLostResponseReplaysOriginalOnce TestInterruptPreCancelledContext +"$T/movefuncs.sh" $P/recovery_test.go $P/recovery_integration_test.go TestPruneHeldLockBeatsAncientPIDAndTimestamp TestPruneLeavesMalformedAndForeignByteUntouched TestPruneNeverGlobalPrunesOrTouchesUserCheckout TestPrunePrunesAbandonedCandidate TestPruneReportsCleanupFailedOnForcedRemovalFailure TestPruneReportsLiveCandidatesUntouched +"$T/prune-imports.sh" $P +for f in $P/*_integration_test.go; do printf "%s: [%s] [%s]\n" "$f" "$(sed -n 1p "$f")" "$(sed -n 2p "$f")"; done +' +``` +Expected: `prune-imports: go vet clean …`, every file `[//go:build integration] []`. Delete any untagged file left with only a package clause and imports (`git rm -q -f`), then re-run `prune-imports.sh`. + +- [ ] **Step 3: Rename the moved tests and every maintained reference** + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; X=TestIntegrationTxnRecovery; pairs="" +for old in TestDuplicateRequestIDIsInvalidState TestKeyedCommitCarriesFiveTrailers TestKeyedReplayFoundDeepInHistory TestKeyedReplayReturnsOriginalReceipt TestMalformedResultIsInvalidState TestRequestIDInProseDoesNotMatch TestRequestIDReusedDifferentDigest TestMaterializeCreateEmptyFile TestMaterializeCreateReplaceDeleteByteExact TestMaterializeHostilePathsByteExact TestMaterializeRefusesReplaceTargetSymlink TestMaterializeRefusesSymlinkParentComponent TestMaterializeReplacePreservesExecutableMode TestVerifyActualDeltaExactMatch TestVerifyActualDeltaRejectsDeclaredUnchanged TestVerifyActualDeltaRejectsUndeclaredChange TestVerifyMaterializedDetectsCorruption TestCleanupRetainsRegisteredCandidateOnListError TestPruneDocketModeIgnoresLinkedWorktree TestPruneReportDeterministicOrder TestPruneReportEmptyOnCleanRoot TestInterruptContainmentFailureDoesNotPush TestInterruptDeltaMismatchDoesNotPush TestInterruptLostResponseReplaysOriginalOnce TestInterruptPreCancelledContext TestPruneHeldLockBeatsAncientPIDAndTimestamp TestPruneLeavesMalformedAndForeignByteUntouched TestPruneNeverGlobalPrunesOrTouchesUserCheckout TestPrunePrunesAbandonedCandidate TestPruneReportsCleanupFailedOnForcedRemovalFailure TestPruneReportsLiveCandidatesUntouched; do pairs="$pairs $old=$X${old#Test}"; done +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +git diff --stat -- . ":!internal/repository/transaction" +' +``` +Expected: `rename rc=0`, no `STILL REFERENCED`. Correct the wording of any reference outside the package, as in Task 2 Step 3. + +- [ ] **Step 4: Create the shard runner** + +Create `tests/test_go_integration_transaction_recovery.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_recovery.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's replay, materialization, interruption, +# cleanup, and abandoned-candidate recovery real-git tests — moved out of the default +# internal/repository/transaction corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationTxnRecovery. Declarations only — execution and +# inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestIntegrationTxnRecovery" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +- [ ] **Step 5: Verify: default package green, shards green, contract green, no prefix collision** + +```bash +go test -count=1 ./internal/repository/transaction/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/repository/transaction/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +bash tests/test_go_integration_transaction_recovery.sh; echo "shard rc=$?" +bash tests/test_go_integration_transaction_apply.sh >/dev/null; echo "apply shard rc=$?" +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/repository/transaction"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: package `ok`, `no leak…`, both shards `rc=0` with only `ok - ` lines, `contract rc=0`, no `COLLISION`, and `prefixes: TestIntegrationTxnApply TestIntegrationTxnRecovery` (in either order). + +- [ ] **Step 6: Measure and register the budget row** + +Warm, then measure the second solo run: `bash -c 'bash tests/test_go_integration_transaction_recovery.sh >/dev/null 2>&1; time bash tests/test_go_integration_transaction_recovery.sh >/dev/null' 2>&1 | tail -4`. Row = S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert `tests/test_go_integration_transaction_recovery.shparallel` directly above the `tests/test_go_integration_release.sh` row. A row above 40 means NEEDS_ESCALATION. Then run `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. Report S, the row, and the margin. + +- [ ] **Step 7: Commit** + +```bash +git add -- internal/repository/transaction tests/test_go_integration_transaction_recovery.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 3 rewrote +git status --porcelain +git commit -m "test(transaction): move replay and recovery real-git tests behind the integration tag (TestIntegrationTxnRecovery, change 0466)" +``` + +--- + +### Task 4: Move transaction's concurrency real-git tests into the `TestRaceIntegrationTxn` race shard + +**Build profile:** standard + +**Files:** +- Modify: `internal/repository/transaction/{concurrency,candidate,engine,interrupt,recovery}_test.go` +- Create: `internal/repository/transaction/{concurrency,candidate,engine,interrupt,recovery}_race_integration_test.go` +- Create: `tests/test_go_integration_transaction_race.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row) +- Modify: any maintained file `rename-tests.sh` rewrites + +**Interfaces:** +- Consumes: the Shared tools; Tasks 2–3's runners (for the prefix check). +- Produces: runner `tests/test_go_integration_transaction_race.sh` (`SHARD_PKG="./internal/repository/transaction"`, `SHARD_PREFIX="TestRaceIntegrationTxn"`, `SHARD_MODE="race"`). After this task no transaction test starts real git in the default build, which is Task 5's precondition. + +**This task's tests (snapshot, all `race`, all start real git): 12.** Each gets its rationale comment: +- `concurrency_test.go` (4): `TestConcurrencyDerivedOverlapReplansView`, `TestConcurrencyFourLeaseLossesContend`, `TestConcurrencySameEntityContends`, `TestConcurrencyUnrelatedWritersConverge`. Rationale: `concurrent writers contend on one target branch`. +- `candidate_test.go` (2): `TestSetPhaseAtomicUnderConcurrentReads` (`a reader goroutine polls the manifest while the phase is rewritten`), `TestRegistryLockAllocationExcludesConcurrentAllocation` (`two goroutines allocate candidates under the registry lock`) +- `engine_test.go` (1): `TestEngineConcurrentExecuteIsRaceFree` (`concurrent Execute calls share one engine`) +- `interrupt_test.go` (4): `TestInterruptAmbiguousPushLandedIsApplied`, `TestInterruptCancelBetweenCommitAndPush`, `TestInterruptCancelInsidePlan`, `TestInterruptLiteralLeaseRejectsFresherTrackingRef`. Rationale: `Execute runs in a goroutine while the test goroutine cancels or races it through shared hooks`. +- `recovery_test.go` (1): `TestPruneRegistryLockSerializesAllocation` (`a goroutine holds the registry lock while PruneAbandoned contends for it`) + +- [ ] **Step 1: Confirm the live census** + +Write the Shared tools if absent. Then run `bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/census.sh" internal/repository/transaction concurrency_test.go candidate_test.go engine_test.go interrupt_test.go recovery_test.go'`. +Expected: the 12 tests above print `> 0`. `TestLiveLockExcludesSecondNonBlocking`, `TestRegistryLockMutualExclusion`, and `TestNewEngineRejectsNilDependencies` print `0` and stay. Then prove no other transaction default test still starts git: +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; cd internal/repository/transaction && files="$(for f in *_test.go; do case "$(sed -n 1p "$f")" in //go:build*) ;; *) printf "%s " "$f";; esac; done)"; cd - >/dev/null; out="$("$T/census.sh" internal/repository/transaction $files)"; awk "\$3 > 0" <<<"$out"' +``` +Expected: exactly the 12 tests above. Any other line is a live offender. Classify it under the Global Constraints criterion, move it in this task (race) or into Task 3's recovery shard file with the `TestIntegrationTxnRecovery` prefix (normal), and report it. + +- [ ] **Step 2: Move the tests behind the tag and add the rationale comments** + +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/repository/transaction +"$T/movefuncs.sh" $P/concurrency_test.go $P/concurrency_race_integration_test.go TestConcurrencyDerivedOverlapReplansView TestConcurrencyFourLeaseLossesContend TestConcurrencySameEntityContends TestConcurrencyUnrelatedWritersConverge +"$T/movefuncs.sh" $P/candidate_test.go $P/candidate_race_integration_test.go TestSetPhaseAtomicUnderConcurrentReads TestRegistryLockAllocationExcludesConcurrentAllocation +"$T/movefuncs.sh" $P/engine_test.go $P/engine_race_integration_test.go TestEngineConcurrentExecuteIsRaceFree +"$T/movefuncs.sh" $P/interrupt_test.go $P/interrupt_race_integration_test.go TestInterruptAmbiguousPushLandedIsApplied TestInterruptCancelBetweenCommitAndPush TestInterruptCancelInsidePlan TestInterruptLiteralLeaseRejectsFresherTrackingRef +"$T/movefuncs.sh" $P/recovery_test.go $P/recovery_race_integration_test.go TestPruneRegistryLockSerializesAllocation +"$T/prune-imports.sh" $P +' +``` +Then, directly above each moved `func` line (below its doc comment), add the one-line comment `// Race shard (change 0466): .` Re-run `gofmt -l internal/repository/transaction`. Expected: no output. Delete any untagged file left holding only a package clause and imports, and re-run `prune-imports.sh`. + +- [ ] **Step 3: Rename the moved tests and every maintained reference** + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; X=TestRaceIntegrationTxn; pairs="" +for old in TestConcurrencyDerivedOverlapReplansView TestConcurrencyFourLeaseLossesContend TestConcurrencySameEntityContends TestConcurrencyUnrelatedWritersConverge TestSetPhaseAtomicUnderConcurrentReads TestRegistryLockAllocationExcludesConcurrentAllocation TestEngineConcurrentExecuteIsRaceFree TestInterruptAmbiguousPushLandedIsApplied TestInterruptCancelBetweenCommitAndPush TestInterruptCancelInsidePlan TestInterruptLiteralLeaseRejectsFresherTrackingRef TestPruneRegistryLockSerializesAllocation; do pairs="$pairs $old=$X${old#Test}"; done +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +git diff --stat -- . ":!internal/repository/transaction" +' +``` +Expected: `rename rc=0`. + +- [ ] **Step 4: Create the race shard runner** + +Create `tests/test_go_integration_transaction_race.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's real-concurrency real-git tests (writers +# contending on one target branch, concurrent Execute, interrupt/cancel races, registry-lock +# contention) — moved out of the default internal/repository/transaction corpus, which must +# never start real git (testsupport.InstallNoGitGuard, installed from the package's TestMain) — +# behind the `integration` build tag, prefix ^TestRaceIntegrationTxn, run in RACE mode. +# Declarations only — execution and inspection live in tests/lib/go-integration-shard.sh; the +# completeness contract is tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestRaceIntegrationTxn" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +- [ ] **Step 5: Verify: default package green, all three shards green, contract green, no collision** + +```bash +go test -count=1 ./internal/repository/transaction/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/repository/transaction/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +bash tests/test_go_integration_transaction_race.sh; echo "race shard rc=$?" +bash -c 'DOCKET_SHARD_INSPECT=1 bash tests/test_go_integration_transaction_race.sh' +for r in apply recovery; do bash tests/test_go_integration_transaction_$r.sh >/dev/null; echo "$r shard rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/repository/transaction"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: package `ok`, `no leak…`, every shard `rc=0`, the inspection prints `mode=race` and `race=-race`, `contract rc=0` (its checks (5) and (9) prove that the race prefix maps to the race runner and that the runner really passes `-race`), no `COLLISION`, and three transaction prefixes. + +- [ ] **Step 6: Measure and register the budget row** + +Warm, then measure: `bash -c 'bash tests/test_go_integration_transaction_race.sh >/dev/null 2>&1; time bash tests/test_go_integration_transaction_race.sh >/dev/null' 2>&1 | tail -4`. Row = S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert `tests/test_go_integration_transaction_race.shparallel` directly above the `tests/test_go_integration_release.sh` row. A row above 40 means NEEDS_ESCALATION. Then `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/` must PASS. Report S, the row, and the margin. + +- [ ] **Step 7: Commit** + +```bash +git add -- internal/repository/transaction tests/test_go_integration_transaction_race.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 3 rewrote +git status --porcelain +git commit -m "test(transaction): move concurrency real-git tests into a race shard (TestRaceIntegrationTxn, change 0466)" +``` + +--- + +### Task 5: Install the no-real-git guard in `internal/repository/transaction` + +**Build profile:** standard + +**Files:** +- Create: `internal/repository/transaction/main_test.go` (untagged: `TestMain`, `nogitPkg`, `nogitShardGlob`) +- Create: `internal/repository/transaction/nogit_guard_test.go` (`//go:build !integration && !e2e`: the three proving tests) +- Modify/Move: untagged transaction `*_test.go` helper declarations that only the tagged corpus uses, relocated behind the tag +- Modify: `internal/repository/transaction/harness_test.go` (the `useBackgroundOffGit` comment, only if its wording needs to name where the harness now runs) + +**Interfaces:** +- Consumes: Task 1's `testsupport.InstallNoGitGuard`, `AssertNoGitGuardShadowsGit`, `AssertNoGitGuardRefusesBareExec`, `NoGitGuardTolerantProbe`, and `NoGitGuardDiagnostic`. Tasks 2–4 must have moved every real-git transaction test. +- Produces: the invariant "the default-tag `internal/repository/transaction` test corpus never starts a real git", enforced. Task 10 records this package's default `-race` wall time. + +- [ ] **Step 1: Write the failing proving tests** + +Create `internal/repository/transaction/nogit_guard_test.go`: + +```go +//go:build !integration && !e2e + +package transaction + +// The no-real-git guard's proving tests for internal/repository/transaction (change +// 0466). The guard lives in internal/testsupport (InstallNoGitGuard) and is +// installed from TestMain in main_test.go. These tests prove it is installed in +// THIS package's binary and fails the package on any real-git exec, even one a test +// tolerates. Real-git tests live behind //go:build integration in the +// tests/test_go_integration_transaction_*.sh shards. + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + testsupport.AssertNoGitGuardShadowsGit(t) +} + +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. +func TestNoGitGuardRefusesBareExec(t *testing.T) { + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) +} + +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `go test -count=1 -run '^TestNoGitGuard' ./internal/repository/transaction/` +Expected: FAIL to compile with `undefined: nogitPkg`. + +- [ ] **Step 3: Install the guard from `TestMain`** + +Create `internal/repository/transaction/main_test.go`: + +```go +package transaction + +import ( + "os" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (change 0466): the default-tag internal/repository/transaction test corpus never +// starts a real git; real-git tests live behind //go:build integration in the +// tests/test_go_integration_transaction_*.sh shards. +const ( + nogitPkg = "internal/repository/transaction" + nogitShardGlob = "tests/test_go_integration_transaction_*.sh" +) + +// TestMain installs the no-real-git guard (testsupport.InstallNoGitGuard) around +// m.Run in the default build; the integration build gets testsupport's identity +// finisher, so the tagged shards run real git as before. +func TestMain(m *testing.M) { + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + os.Exit(finish(m.Run())) +} +``` + +- [ ] **Step 4: The whole default corpus is green with the guard on** + +```bash +bash -c 'out="$(go test -count=1 -v ./internal/repository/transaction/ 2>&1)"; rc=$?; grep -E -e "^--- (FAIL|PASS): TestNoGitGuard" <<<"$out"; grep -E -e "^--- FAIL" <<<"$out"; grep -E -e "real-git exec attempt\(s\)|docket nogit guard" <<<"$out" || echo "guard: silent"; echo "rc=$rc"' +``` +Expected: three `--- PASS: TestNoGitGuard…` lines, no other `--- FAIL`, `guard: silent`, `rc=0`. If a test fails with the guard diagnostic, it is a live offender the census missed. Move it behind the tag into the matching shard file with the Tasks 2–4 procedure (movefuncs, prune-imports, rename with that shard's prefix), then re-run, and report it. + +- [ ] **Step 5: Relocate helpers that only the tagged corpus uses** + +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/defaultonly-unused.sh" internal/repository/transaction' +``` +Each printed `\t` is a declaration no untagged test file uses. Move each func with `movefuncs.sh _integration_test.go …` (same base name as its file). Move `type`/`var`/`const` declarations by hand into the same tagged file. Then run `prune-imports.sh internal/repository/transaction` and repeat `defaultonly-unused.sh` until it prints nothing. An untagged file left with no declarations is deleted (`git rm -q -f`). Finally: +```bash +go vet ./internal/repository/transaction/ && go vet -tags integration ./internal/repository/transaction/ && gofmt -l internal/repository/transaction +``` +Expected: both vets clean and no gofmt output. Report which helpers moved. If a helper comment such as `useBackgroundOffGit`'s ("safe because this package runs no test in parallel") now sits in a tagged file, keep it verbatim, because it is still true. + +- [ ] **Step 6: Mutation probes (spec acceptance 4)** + +```bash +bash -c ' +P=internal/repository/transaction +# (a) a throwaway default-tag test that runs git must redden the package with the guard diagnostic +printf "package transaction\n\nimport (\n\t\"os/exec\"\n\t\"testing\"\n)\n\nfunc TestZZNoGitMutationProbe(t *testing.T) { _ = exec.Command(\"git\", \"status\").Run() }\n" > $P/zz_nogit_mutation_test.go +out="$(go test -count=1 ./$P/ 2>&1)"; rc=$?; echo "mutation-a rc=$rc" +grep -E -e "docket nogit guard: default internal/repository/transaction tests must not run real git: 1 real-git exec attempt" <<<"$out" || echo "mutation-a: DIAGNOSTIC MISSING" +rm -f $P/zz_nogit_mutation_test.go +# (b) drop the install from TestMain: the proving tests must go red +f=$P/main_test.go; b="$(mktemp "${TMPDIR:-/tmp}/txn-main.XXXXXX")"; cp "$f" "$b" +perl -0pi -e "s/finish := testsupport\.InstallNoGitGuard\(nogitPkg, nogitShardGlob\)/finish := func(code int) int { return code }; _ = testsupport.NoGitGuardDir/" "$f" +cmp -s "$f" "$b" && echo "mutation b did not land" +go test -count=1 -run "^TestNoGitGuard" ./$P/ >/dev/null 2>&1; echo "mutation-b rc=$?" +cp "$b" "$f"; rm -f "$b" +go test -count=1 ./$P/ >/dev/null 2>&1; echo "restored rc=$?" +' +``` +Expected: `mutation-a rc=1` with the diagnostic line printed (not `DIAGNOSTIC MISSING`), `mutation-b rc=1` with no `did not land`, `restored rc=0`. Report all three. + +- [ ] **Step 7: The tagged shards still run real git (the guard stays out of the integration build)** + +```bash +for r in apply recovery race; do bash tests/test_go_integration_transaction_$r.sh >/dev/null 2>&1; echo "$r rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +``` +Expected: every line `rc=0`. + +- [ ] **Step 8: Record the default-corpus `-race` wall time (spec acceptance 2)** + +Run: `bash -c 'time go test -race -count=1 ./internal/repository/transaction/' 2>&1 | tail -4` +Expected: single-digit seconds of `real` (grooming baseline: ~46s). Report it as `transaction default -race: before ~46s, after s`. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/repository/transaction +git status --porcelain +git commit -m "test(transaction): install the no-real-git guard in the default corpus (change 0466)" +``` + +--- + +### Task 6: Move workspace's prepare real-git tests into the `TestIntegrationWorkspaceSetup` shard and its two concurrent ones into the `TestRaceIntegrationWorkspace` race shard + +**Build profile:** standard + +**Files:** +- Modify: `internal/workspace/{prepare,harness}_test.go` +- Create: `internal/workspace/{prepare,harness}_integration_test.go`, `internal/workspace/prepare_race_integration_test.go` +- Create: `tests/test_go_integration_workspace_setup.sh`, `tests/test_go_integration_workspace_race.sh` +- Modify: `tests/runtime-budgets.tsv` (two new rows) +- Modify: any maintained file `rename-tests.sh` rewrites + +**Interfaces:** +- Consumes: the Shared tools. +- Produces: runners `tests/test_go_integration_workspace_setup.sh` (`SHARD_PKG="./internal/workspace"`, `SHARD_PREFIX="TestIntegrationWorkspaceSetup"`, `SHARD_MODE="normal"`) and `tests/test_go_integration_workspace_race.sh` (`SHARD_PREFIX="TestRaceIntegrationWorkspace"`, `SHARD_MODE="race"`). + +**This task's tests (snapshot, all start real git): 19** +- normal, `prepare_test.go` (16): `TestPrepareBlockedMatrix`, `TestPrepareExistingIdempotent`, `TestPrepareFetchFailureCreatesNothing`, `TestPrepareFreshBlockedByStaleRegistration`, `TestPrepareFreshDoneParent`, `TestPrepareFreshLiveParentStack`, `TestPrepareFreshStackedMergedRecurse`, `TestPrepareFreshUnstacked`, `TestPrepareInvocationMatrix`, `TestPrepareProbeFailureCreatesNothing`, `TestPrepareRejectsMismatchedRepository`, `TestPrepareResumeAttach`, `TestPrepareResumeBranchOffBaseBlocked`, `TestPrepareResumeCreateBoth`, `TestPrepareResumeVerifyOnly`, `TestPrepareReturnsReinspectedFacts` +- normal, `harness_test.go` (1): `TestHarnessBuildersProduceExpectedTopology` +- race, `prepare_test.go` (2): `TestPrepareConcurrentDistinctTargets` (rationale: `concurrent Prepare calls for distinct targets share one primary clone`), `TestPrepareConcurrentSameTarget` (`concurrent Prepare calls race for one target`) + +Stays (no git): `TestClassifyRegistrationAbsence`. + +- [ ] **Step 1: Confirm the live census** + +Write the Shared tools if absent. Run: `bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/census.sh" internal/workspace prepare_test.go harness_test.go'` +Expected: the 19 tests above print `> 0`, and `TestClassifyRegistrationAbsence` prints `0`. The live census wins. + +- [ ] **Step 2: Move the tests behind the tag and add the race rationale comments** + +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/workspace +"$T/movefuncs.sh" $P/prepare_test.go $P/prepare_integration_test.go TestPrepareBlockedMatrix TestPrepareExistingIdempotent TestPrepareFetchFailureCreatesNothing TestPrepareFreshBlockedByStaleRegistration TestPrepareFreshDoneParent TestPrepareFreshLiveParentStack TestPrepareFreshStackedMergedRecurse TestPrepareFreshUnstacked TestPrepareInvocationMatrix TestPrepareProbeFailureCreatesNothing TestPrepareRejectsMismatchedRepository TestPrepareResumeAttach TestPrepareResumeBranchOffBaseBlocked TestPrepareResumeCreateBoth TestPrepareResumeVerifyOnly TestPrepareReturnsReinspectedFacts +"$T/movefuncs.sh" $P/prepare_test.go $P/prepare_race_integration_test.go TestPrepareConcurrentDistinctTargets TestPrepareConcurrentSameTarget +"$T/movefuncs.sh" $P/harness_test.go $P/harness_integration_test.go TestHarnessBuildersProduceExpectedTopology +"$T/prune-imports.sh" $P +for f in $P/*_integration_test.go; do printf "%s: [%s] [%s]\n" "$f" "$(sed -n 1p "$f")" "$(sed -n 2p "$f")"; done +' +``` +Then add `// Race shard (change 0466): .` directly above each of the two race `func` lines, and confirm `gofmt -l internal/workspace` prints nothing. + +- [ ] **Step 3: Rename the moved tests and every maintained reference** + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; X=TestIntegrationWorkspaceSetup; R=TestRaceIntegrationWorkspace; pairs="" +for old in TestPrepareBlockedMatrix TestPrepareExistingIdempotent TestPrepareFetchFailureCreatesNothing TestPrepareFreshBlockedByStaleRegistration TestPrepareFreshDoneParent TestPrepareFreshLiveParentStack TestPrepareFreshStackedMergedRecurse TestPrepareFreshUnstacked TestPrepareInvocationMatrix TestPrepareProbeFailureCreatesNothing TestPrepareRejectsMismatchedRepository TestPrepareResumeAttach TestPrepareResumeBranchOffBaseBlocked TestPrepareResumeCreateBoth TestPrepareResumeVerifyOnly TestPrepareReturnsReinspectedFacts; do pairs="$pairs $old=$X${old#Test}"; done +for old in TestPrepareConcurrentDistinctTargets TestPrepareConcurrentSameTarget; do pairs="$pairs $old=$R${old#Test}"; done +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +# declared in internal/gitcli too: rename ONLY this package copy +"$T/rename-tests.sh" --scope internal/workspace TestHarnessBuildersProduceExpectedTopology=${X}HarnessBuildersProduceExpectedTopology; echo "scoped rename rc=$?" +git grep -l -E -e "^func TestHarnessBuildersProduceExpectedTopology\(" -- internal +git diff --stat -- . ":!internal/workspace" +' +``` +Expected: `rename rc=0` and `scoped rename rc=0`. The declaration grep lists only the `internal/gitcli` copy, untouched. + +- [ ] **Step 4: Create both shard runners** + +Create `tests/test_go_integration_workspace_setup.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_setup.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace Prepare real-git tests (fresh, stacked, resume, +# blocked, and probe-failure preparation, plus the fixture-topology proof) — moved out of the +# default internal/workspace corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationWorkspaceSetup. Declarations only — execution +# and inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestIntegrationWorkspaceSetup" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +Create `tests/test_go_integration_workspace_race.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace real-concurrency real-git tests (concurrent Prepare +# for one target and for distinct targets over one primary clone) — moved out of the default +# internal/workspace corpus, which must never start real git (testsupport.InstallNoGitGuard, +# installed from the package's TestMain) — behind the `integration` build tag, prefix +# ^TestRaceIntegrationWorkspace, run in RACE mode. Declarations only — execution and inspection +# live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestRaceIntegrationWorkspace" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +- [ ] **Step 5: Verify: default package green, shards green, contract green, no collision** + +```bash +go test -count=1 ./internal/workspace/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/workspace/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +for r in setup race; do bash tests/test_go_integration_workspace_$r.sh; echo "$r shard rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/workspace"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: package `ok`, `no leak…`, both shards `rc=0` with only `ok - ` lines, `contract rc=0`, no `COLLISION`. + +- [ ] **Step 6: Measure and register both budget rows** + +For each of `tests/test_go_integration_workspace_setup.sh` and `tests/test_go_integration_workspace_race.sh`: warm, then measure `bash -c 'bash >/dev/null 2>&1; time bash >/dev/null' 2>&1 | tail -4`. Row = S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert `parallel` directly above the `tests/test_go_integration_release.sh` row. A row above 40 means NEEDS_ESCALATION. Then `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/` must PASS. Report S, the row, and the margin for each. + +- [ ] **Step 7: Commit** + +```bash +git add -- internal/workspace tests/test_go_integration_workspace_setup.sh tests/test_go_integration_workspace_race.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 3 rewrote +git status --porcelain +git commit -m "test(workspace): move Prepare real-git tests behind the integration tag (TestIntegrationWorkspaceSetup, TestRaceIntegrationWorkspace, change 0466)" +``` + +--- + +### Task 7: Move workspace's inspect, publish, rewrite, and cleanup real-git tests into the `TestIntegrationWorkspaceLifecycle` shard + +**Build profile:** standard + +**Files:** +- Modify: `internal/workspace/{inspect,publish,rewrite,cleanup}_test.go` +- Create: `internal/workspace/{inspect,publish,rewrite,cleanup}_integration_test.go` +- Create: `tests/test_go_integration_workspace_lifecycle.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row) +- Modify: any maintained file `rename-tests.sh` rewrites + +**Interfaces:** +- Consumes: the Shared tools; Task 6's runners (for the prefix check). +- Produces: runner `tests/test_go_integration_workspace_lifecycle.sh` (`SHARD_PKG="./internal/workspace"`, `SHARD_PREFIX="TestIntegrationWorkspaceLifecycle"`, `SHARD_MODE="normal"`). After this task no workspace test starts real git in the default build, which is Task 8's precondition. + +**This task's tests (snapshot, all `normal`, all start real git): 40.** None starts goroutines: `TestPublishRewriteContention` and `TestPublishDivergentContended` stage their contention sequentially through a writer clone. +- `cleanup_test.go` (6): `TestCleanupBlockedMatrix`, `TestCleanupDirtyNeverRemoved`, `TestCleanupNeverPrunes`, `TestCleanupProbeFailureIsFailedNotClean`, `TestCleanupReadyClean`, `TestCleanupRetryAlreadyClean` +- `inspect_test.go` (13): `TestInspectAbsent`, `TestInspectAbsentBlockedByLeftovers`, `TestInspectAbsentSlotStatErrorIsError`, `TestInspectBranchGone`, `TestInspectCleaned`, `TestInspectDirty`, `TestInspectForeignMalformed`, `TestInspectForeignUnownedCommonDir`, `TestInspectMismatch`, `TestInspectReady`, `TestInspectReadyAfterParentRebase`, `TestInspectResumable`, `TestInspectUnreadableIsError` +- `publish_test.go` (13): `TestPublishAbsentRefCreates`, `TestPublishDetachedRefused`, `TestPublishDirtyRefused`, `TestPublishDivergentContended`, `TestPublishExpectedHeadMatches`, `TestPublishExpectedHeadMoved`, `TestPublishFastForward`, `TestPublishLocalProxiesNotConsulted`, `TestPublishLostResponseAdopted`, `TestPublishPushFailsRefAbsentFailed`, `TestPublishReadyAfterParentRebase`, `TestPublishRepeatAlreadyPublished`, `TestPublishUnprobeableRemoteUnknown` +- `rewrite_test.go` (8): `TestGeneralPublishStillRefusesRewrite`, `TestPublishRewriteContention`, `TestPublishRewriteLease`, `TestPublishRewriteLeaseWithGatePair`, `TestPublishRewriteLeaseWithPublishCheckpoint`, `TestPublishRewriteNoop`, `TestPublishRewriteRefusesWithoutReceipt`, `TestPublishRewriteUnknownRetains` + +- [ ] **Step 1: Confirm the live census, including every other untagged workspace file** + +Write the Shared tools if absent. Then: +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; cd internal/workspace && files="$(for f in *_test.go; do case "$(sed -n 1p "$f")" in //go:build*) ;; *) printf "%s " "$f";; esac; done)"; cd - >/dev/null; out="$("$T/census.sh" internal/workspace $files)"; awk "\$3 > 0" <<<"$out"' +``` +Expected: exactly the 40 tests above. Any other line is a live offender. Classify it (race goes to Task 6's race file and prefix, normal to this shard) and move it here. Report it. + +- [ ] **Step 2: Move the tests behind the tag** + +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/workspace +"$T/movefuncs.sh" $P/cleanup_test.go $P/cleanup_integration_test.go TestCleanupBlockedMatrix TestCleanupDirtyNeverRemoved TestCleanupNeverPrunes TestCleanupProbeFailureIsFailedNotClean TestCleanupReadyClean TestCleanupRetryAlreadyClean +"$T/movefuncs.sh" $P/inspect_test.go $P/inspect_integration_test.go TestInspectAbsent TestInspectAbsentBlockedByLeftovers TestInspectAbsentSlotStatErrorIsError TestInspectBranchGone TestInspectCleaned TestInspectDirty TestInspectForeignMalformed TestInspectForeignUnownedCommonDir TestInspectMismatch TestInspectReady TestInspectReadyAfterParentRebase TestInspectResumable TestInspectUnreadableIsError +"$T/movefuncs.sh" $P/publish_test.go $P/publish_integration_test.go TestPublishAbsentRefCreates TestPublishDetachedRefused TestPublishDirtyRefused TestPublishDivergentContended TestPublishExpectedHeadMatches TestPublishExpectedHeadMoved TestPublishFastForward TestPublishLocalProxiesNotConsulted TestPublishLostResponseAdopted TestPublishPushFailsRefAbsentFailed TestPublishReadyAfterParentRebase TestPublishRepeatAlreadyPublished TestPublishUnprobeableRemoteUnknown +"$T/movefuncs.sh" $P/rewrite_test.go $P/rewrite_integration_test.go TestGeneralPublishStillRefusesRewrite TestPublishRewriteContention TestPublishRewriteLease TestPublishRewriteLeaseWithGatePair TestPublishRewriteLeaseWithPublishCheckpoint TestPublishRewriteNoop TestPublishRewriteRefusesWithoutReceipt TestPublishRewriteUnknownRetains +"$T/prune-imports.sh" $P +for f in $P/*_integration_test.go; do printf "%s: [%s] [%s]\n" "$f" "$(sed -n 1p "$f")" "$(sed -n 2p "$f")"; done +' +``` +Expected: `prune-imports: go vet clean …`, and every file `[//go:build integration] []`. Delete any untagged file left with only a package clause and imports, then re-run `prune-imports.sh`. + +- [ ] **Step 3: Rename the moved tests and every maintained reference** + +The list includes both `TestPublishRewriteLease` and `TestPublishRewriteLeaseWithGatePair`. The `\b` word boundary keeps the shorter one from rewriting inside the longer one. Order still does not matter. + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; X=TestIntegrationWorkspaceLifecycle; pairs="" +for old in TestCleanupBlockedMatrix TestCleanupDirtyNeverRemoved TestCleanupNeverPrunes TestCleanupProbeFailureIsFailedNotClean TestCleanupReadyClean TestCleanupRetryAlreadyClean TestInspectAbsent TestInspectAbsentBlockedByLeftovers TestInspectAbsentSlotStatErrorIsError TestInspectBranchGone TestInspectCleaned TestInspectDirty TestInspectForeignMalformed TestInspectForeignUnownedCommonDir TestInspectMismatch TestInspectReady TestInspectReadyAfterParentRebase TestInspectResumable TestInspectUnreadableIsError TestPublishAbsentRefCreates TestPublishDetachedRefused TestPublishDirtyRefused TestPublishDivergentContended TestPublishExpectedHeadMatches TestPublishExpectedHeadMoved TestPublishFastForward TestPublishLocalProxiesNotConsulted TestPublishLostResponseAdopted TestPublishPushFailsRefAbsentFailed TestPublishReadyAfterParentRebase TestPublishRepeatAlreadyPublished TestPublishUnprobeableRemoteUnknown TestGeneralPublishStillRefusesRewrite TestPublishRewriteContention TestPublishRewriteLease TestPublishRewriteLeaseWithGatePair TestPublishRewriteLeaseWithPublishCheckpoint TestPublishRewriteNoop TestPublishRewriteRefusesWithoutReceipt TestPublishRewriteUnknownRetains; do pairs="$pairs $old=$X${old#Test}"; done +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +git diff --stat -- . ":!internal/workspace" +' +``` +Expected: `rename rc=0`. Then prove the boundary held: `bash -c 'out="$(git grep -n -E -e "TestIntegrationWorkspaceLifecycleTestIntegration|LifecyclePublishRewriteLeaseWith" -- internal/workspace)"; printf "%s\n" "$out"'` must list exactly `TestIntegrationWorkspaceLifecyclePublishRewriteLeaseWithGatePair` and `…WithPublishCheckpoint` (one declaration each, plus any references), and never a doubled prefix. + +- [ ] **Step 4: Create the shard runner** + +Create `tests/test_go_integration_workspace_lifecycle.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_lifecycle.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace lifecycle real-git tests after Prepare (Inspect, +# Publish, rewrite-lease publication, and Cleanup) — moved out of the default +# internal/workspace corpus, which must never start real git (testsupport.InstallNoGitGuard, +# installed from the package's TestMain) — behind the `integration` build tag, prefix +# ^TestIntegrationWorkspaceLifecycle. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestIntegrationWorkspaceLifecycle" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +- [ ] **Step 5: Verify: default package green, all three shards green, contract green, no collision** + +```bash +go test -count=1 ./internal/workspace/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/workspace/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +for r in lifecycle setup race; do bash tests/test_go_integration_workspace_$r.sh >/dev/null; echo "$r shard rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/workspace"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: package `ok`, `no leak…`, all shards `rc=0`, `contract rc=0`, no `COLLISION`, and three workspace prefixes. + +- [ ] **Step 6: Measure and register the budget row** + +Warm, then measure: `bash -c 'bash tests/test_go_integration_workspace_lifecycle.sh >/dev/null 2>&1; time bash tests/test_go_integration_workspace_lifecycle.sh >/dev/null' 2>&1 | tail -4`. Row = S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert it directly above the `tests/test_go_integration_release.sh` row. A row above 40 means NEEDS_ESCALATION. Then `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/` must PASS. Report S, the row, and the margin. + +- [ ] **Step 7: Commit** + +```bash +git add -- internal/workspace tests/test_go_integration_workspace_lifecycle.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 3 rewrote +git status --porcelain +git commit -m "test(workspace): move inspect/publish/rewrite/cleanup real-git tests behind the integration tag (TestIntegrationWorkspaceLifecycle, change 0466)" +``` + +--- + +### Task 8: Install the no-real-git guard in `internal/workspace` + +**Build profile:** standard + +**Files:** +- Create: `internal/workspace/main_test.go` (untagged: `TestMain`, `nogitPkg`, `nogitShardGlob`) +- Create: `internal/workspace/nogit_guard_test.go` (`//go:build !integration && !e2e`) +- Modify/Move: untagged workspace `*_test.go` helper declarations that only the tagged corpus uses + +**Interfaces:** +- Consumes: Task 1's testsupport guard API. Tasks 6–7 must have moved every real-git workspace test. +- Produces: the invariant "the default-tag `internal/workspace` test corpus never starts a real git", enforced. + +- [ ] **Step 1: Write the failing proving tests** + +Create `internal/workspace/nogit_guard_test.go`: + +```go +//go:build !integration && !e2e + +package workspace + +// The no-real-git guard's proving tests for internal/workspace (change 0466). The +// guard lives in internal/testsupport (InstallNoGitGuard) and is installed from +// TestMain in main_test.go. These tests prove it is installed in THIS package's +// binary and fails the package on any real-git exec, even one a test tolerates. +// Real-git tests live behind //go:build integration in the +// tests/test_go_integration_workspace_*.sh shards. + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + testsupport.AssertNoGitGuardShadowsGit(t) +} + +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. +func TestNoGitGuardRefusesBareExec(t *testing.T) { + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) +} + +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `go test -count=1 -run '^TestNoGitGuard' ./internal/workspace/` +Expected: FAIL to compile with `undefined: nogitPkg`. + +- [ ] **Step 3: Install the guard from `TestMain`** + +Create `internal/workspace/main_test.go`: + +```go +package workspace + +import ( + "os" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (change 0466): the default-tag internal/workspace test corpus never starts a real +// git; real-git tests live behind //go:build integration in the +// tests/test_go_integration_workspace_*.sh shards. +const ( + nogitPkg = "internal/workspace" + nogitShardGlob = "tests/test_go_integration_workspace_*.sh" +) + +// TestMain installs the no-real-git guard (testsupport.InstallNoGitGuard) around +// m.Run in the default build; the integration build gets testsupport's identity +// finisher, so the tagged shards run real git as before. +func TestMain(m *testing.M) { + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + os.Exit(finish(m.Run())) +} +``` + +- [ ] **Step 4: The whole default corpus is green with the guard on** + +```bash +bash -c 'out="$(go test -count=1 -v ./internal/workspace/ 2>&1)"; rc=$?; grep -E -e "^--- (FAIL|PASS): TestNoGitGuard" <<<"$out"; grep -E -e "^--- FAIL" <<<"$out"; grep -E -e "real-git exec attempt\(s\)|docket nogit guard" <<<"$out" || echo "guard: silent"; echo "rc=$rc"' +``` +Expected: three `--- PASS: TestNoGitGuard…` lines, no other `--- FAIL`, `guard: silent`, `rc=0`. A test failing with the guard diagnostic is a live offender. Move it with the Tasks 6–7 procedure into the matching shard and report it. + +- [ ] **Step 5: Relocate helpers that only the tagged corpus uses** + +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/defaultonly-unused.sh" internal/workspace' +``` +Move each listed func with `movefuncs.sh _integration_test.go …`, and each listed type/var/const by hand into the same tagged file. Run `prune-imports.sh internal/workspace`, and repeat until `defaultonly-unused.sh` prints nothing. Delete untagged files left with no declarations. Finally: `go vet ./internal/workspace/ && go vet -tags integration ./internal/workspace/ && gofmt -l internal/workspace`. Expected: clean. Report which helpers moved. + +- [ ] **Step 6: Mutation probes (spec acceptance 4)** + +```bash +bash -c ' +P=internal/workspace +printf "package workspace\n\nimport (\n\t\"os/exec\"\n\t\"testing\"\n)\n\nfunc TestZZNoGitMutationProbe(t *testing.T) { _ = exec.Command(\"git\", \"status\").Run() }\n" > $P/zz_nogit_mutation_test.go +out="$(go test -count=1 ./$P/ 2>&1)"; rc=$?; echo "mutation-a rc=$rc" +grep -E -e "docket nogit guard: default internal/workspace tests must not run real git: 1 real-git exec attempt" <<<"$out" || echo "mutation-a: DIAGNOSTIC MISSING" +rm -f $P/zz_nogit_mutation_test.go +f=$P/main_test.go; b="$(mktemp "${TMPDIR:-/tmp}/ws-main.XXXXXX")"; cp "$f" "$b" +perl -0pi -e "s/finish := testsupport\.InstallNoGitGuard\(nogitPkg, nogitShardGlob\)/finish := func(code int) int { return code }; _ = testsupport.NoGitGuardDir/" "$f" +cmp -s "$f" "$b" && echo "mutation b did not land" +go test -count=1 -run "^TestNoGitGuard" ./$P/ >/dev/null 2>&1; echo "mutation-b rc=$?" +cp "$b" "$f"; rm -f "$b" +go test -count=1 ./$P/ >/dev/null 2>&1; echo "restored rc=$?" +' +``` +Expected: `mutation-a rc=1` with the diagnostic line printed, `mutation-b rc=1`, no `did not land`, `restored rc=0`. + +- [ ] **Step 7: The tagged shards still run real git** + +```bash +for r in setup lifecycle race; do bash tests/test_go_integration_workspace_$r.sh >/dev/null 2>&1; echo "$r rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +``` +Expected: every line `rc=0`. + +- [ ] **Step 8: Record the default-corpus `-race` wall time (spec acceptance 2)** + +Run: `bash -c 'time go test -race -count=1 ./internal/workspace/' 2>&1 | tail -4` +Expected: single-digit seconds (grooming baseline: ~42s). Report `workspace default -race: before ~42s, after s`. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/workspace +git status --porcelain +git commit -m "test(workspace): install the no-real-git guard in the default corpus (change 0466)" +``` + +--- + +### Task 9: Move gatedrive's real-supervisor and real-git tests behind the tag (`TestIntegrationGatedrive` / `TestRaceIntegrationGatedrive`) + +**Build profile:** premium + +The named risk: `integration_test.go` owns the package's `TestMain`, which routes the test binary's supervisor and child re-exec roles. It must move into the tagged build intact, since every real-supervisor test depends on it, and the default build must compile without it. The four files are also untagged today, so once the contract discovers `internal/gatedrive`, its check (6) reddens on any `TestIntegration…` name left visible to the default corpus. + +**Files:** +- Move (`git mv`, whole file): `internal/gatedrive/integration_test.go` → `internal/gatedrive/supervisor_integration_test.go` +- Move (`git mv`, whole file): `internal/gatedrive/integration_takeover_test.go` → `internal/gatedrive/takeover_integration_test.go` +- Move (`git mv`, whole file): `internal/gatedrive/integration_sequence_test.go` → `internal/gatedrive/sequence_integration_test.go` +- Move (`git mv`, whole file): `internal/gatedrive/integration_history_test.go` → `internal/gatedrive/history_integration_test.go` +- Create: `internal/gatedrive/{takeover,sequence,history}_race_integration_test.go` +- Modify: `internal/gatedrive/{fingerprint,handoff}_test.go`; Create: `internal/gatedrive/{fingerprint,handoff}_integration_test.go` +- Create: `tests/test_go_integration_gatedrive_process.sh`, `tests/test_go_integration_gatedrive_race.sh` +- Modify: `tests/runtime-budgets.tsv` (two new rows) +- Modify: any maintained file the rename or file-reference greps find + +**Interfaces:** +- Consumes: the Shared tools. +- Produces: runners `tests/test_go_integration_gatedrive_process.sh` (`SHARD_PKG="./internal/gatedrive"`, `SHARD_PREFIX="TestIntegrationGatedrive"`, `SHARD_MODE="normal"`) and `tests/test_go_integration_gatedrive_race.sh` (`SHARD_PREFIX="TestRaceIntegrationGatedrive"`, `SHARD_MODE="race"`). gatedrive gets **no** no-real-git guard (spec decision 3): its slow tests start supervised processes, which a git shim cannot see, so its budget row stays the growth detector. + +**This task's tests (snapshot): 25** + +| old name | file today | shard | new name | +|---|---|---|---| +| `TestIntegrationDeadlineExpiryStopsOwnedTree` | `integration_test.go` | normal | `TestIntegrationGatedriveDeadlineExpiryStopsOwnedTree` | +| `TestIntegrationDriverSlicesAcrossLiveChildThenPasses` | `integration_test.go` | normal | `TestIntegrationGatedriveDriverSlicesAcrossLiveChildThenPasses` | +| `TestIntegrationFreshProcessResumesAndChildSurvives` | `integration_test.go` | normal | `TestIntegrationGatedriveFreshProcessResumesAndChildSurvives` | +| `TestIntegrationProcessDeathPermitsAtMostOneRelaunch` | `integration_test.go` | normal | `TestIntegrationGatedriveProcessDeathPermitsAtMostOneRelaunch` | +| `TestIntegrationFastCompletionReturnsImmediately` | `integration_takeover_test.go` | normal | `TestIntegrationGatedriveFastCompletionReturnsImmediately` | +| `TestIntegrationSliceBoundIsProductionThirtySeconds` | `integration_takeover_test.go` | normal | `TestIntegrationGatedriveSliceBoundIsProductionThirtySeconds` | +| `TestIntegrationTerminalConsumedFromFreshProcess` | `integration_takeover_test.go` | normal | `TestIntegrationGatedriveTerminalConsumedFromFreshProcess` | +| `TestIntegrationTakeoverKeepsRunIdentity` | `integration_takeover_test.go` | **race** (`a parent takeover supersedes the owner of a live supervised child mid-run`) | `TestRaceIntegrationGatedriveTakeoverKeepsRunIdentity` | +| `TestIntegrationSequenceRealGitBaselineRedGreen` | `integration_sequence_test.go` | normal | `TestIntegrationGatedriveSequenceRealGitBaselineRedGreen` | +| `TestIntegrationSequenceCredentialTheftRejected` | `integration_sequence_test.go` | normal | `TestIntegrationGatedriveSequenceCredentialTheftRejected` | +| `TestIntegrationSequenceConcurrentScopesResolveOwnWork` | `integration_sequence_test.go` | **race** (`two goroutines drive distinct scopes to terminal concurrently`) | `TestRaceIntegrationGatedriveSequenceConcurrentScopesResolveOwnWork` | +| `TestIntegrationSameWorktreeGenerations` | `integration_sequence_test.go` | **race** (`a live incumbent run holds the worktree slot while a start through its alias contends for it`) | `TestRaceIntegrationGatedriveSameWorktreeGenerations` | +| `TestIntegrationOutcomeTriggersNoLegacyCensusOrSecondStart` | `integration_history_test.go` | normal | `TestIntegrationGatedriveOutcomeTriggersNoLegacyCensusOrSecondStart` | +| `TestRaceConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe` | `integration_history_test.go` | **race** (`concurrent Starts and a CleanupHistory race over one legacy-seeded store`) | `TestRaceIntegrationGatedriveConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe` | +| `TestFingerprintDanglingSymlinkHashedByValue` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintDanglingSymlinkHashedByValue` | +| `TestFingerprintDetectsModeChange` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintDetectsModeChange` | +| `TestFingerprintFileDeleted` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintFileDeleted` | +| `TestFingerprintFileRenamed` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintFileRenamed` | +| `TestFingerprintIdenticalDirtyStateEqual` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintIdenticalDirtyStateEqual` | +| `TestFingerprintStagedByteChange` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintStagedByteChange` | +| `TestFingerprintSymlinkTargetChanged` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintSymlinkTargetChanged` | +| `TestFingerprintUnstagedByteChange` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintUnstagedByteChange` | +| `TestFingerprintUntrackedFileAdded` | `fingerprint_test.go` | normal | `TestIntegrationGatedriveFingerprintUntrackedFileAdded` | +| `TestRepoHandoffPerDimensionDriftRejectsClaim` | `handoff_test.go` | normal | `TestIntegrationGatedriveRepoHandoffPerDimensionDriftRejectsClaim` | +| `TestDirtyHandoffIdenticalStateClaimsWithoutWIPCommit` | `handoff_test.go` | normal | `TestIntegrationGatedriveDirtyHandoffIdenticalStateClaimsWithoutWIPCommit` | + +The spec names the whole of the four `integration*_test.go` files as moving. That includes `integration_history_test.go`'s two tests, which use scripted procs, not the real supervisor: they carry the integration naming and belong to the partition. + +- [ ] **Step 1: Confirm the live census** + +Write the Shared tools if absent. Then: +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; cd internal/gatedrive && files="$(for f in *_test.go; do case "$(sed -n 1p "$f")" in //go:build*) ;; *) printf "%s " "$f";; esac; done)"; cd - >/dev/null; out="$("$T/census.sh" internal/gatedrive $files)"; awk "\$3 > 0" <<<"$out"' +grep -c -E -e '^func Test' internal/gatedrive/integration_test.go internal/gatedrive/integration_takeover_test.go internal/gatedrive/integration_sequence_test.go internal/gatedrive/integration_history_test.go +``` +Expected: git-exec lines exactly for the 9 fingerprint tests, the 2 handoff tests, and the 4 `integration_sequence_test.go` tests. The four files hold 4 + 4 + 4 + 2 test functions. The live census and the live file contents win: a new test in one of the four files moves with its file, and a new git-using test elsewhere moves into the normal shard. Report any difference. + +- [ ] **Step 2: Move the four integration files and tag them** + +```bash +bash -c ' +set -euo pipefail +P=internal/gatedrive +git mv $P/integration_test.go $P/supervisor_integration_test.go +git mv $P/integration_takeover_test.go $P/takeover_integration_test.go +git mv $P/integration_sequence_test.go $P/sequence_integration_test.go +git mv $P/integration_history_test.go $P/history_integration_test.go +for f in $P/supervisor_integration_test.go $P/takeover_integration_test.go $P/sequence_integration_test.go $P/history_integration_test.go; do + case "$(sed -n 1p "$f")" in //go:build*) echo "UNEXPECTED existing constraint in $f"; exit 1;; esac + tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat "$f"; } > "$tmp" && mv -f "$tmp" "$f" +done +' +``` +Then split the race tests out of the tagged files and move the git-using tests out of `fingerprint_test.go`/`handoff_test.go`: +```bash +bash -c ' +set -euo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; P=internal/gatedrive +"$T/movefuncs.sh" $P/takeover_integration_test.go $P/takeover_race_integration_test.go TestIntegrationTakeoverKeepsRunIdentity +"$T/movefuncs.sh" $P/sequence_integration_test.go $P/sequence_race_integration_test.go TestIntegrationSequenceConcurrentScopesResolveOwnWork TestIntegrationSameWorktreeGenerations +"$T/movefuncs.sh" $P/history_integration_test.go $P/history_race_integration_test.go TestRaceConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe +"$T/movefuncs.sh" $P/fingerprint_test.go $P/fingerprint_integration_test.go TestFingerprintDanglingSymlinkHashedByValue TestFingerprintDetectsModeChange TestFingerprintFileDeleted TestFingerprintFileRenamed TestFingerprintIdenticalDirtyStateEqual TestFingerprintStagedByteChange TestFingerprintSymlinkTargetChanged TestFingerprintUnstagedByteChange TestFingerprintUntrackedFileAdded +"$T/movefuncs.sh" $P/handoff_test.go $P/handoff_integration_test.go TestRepoHandoffPerDimensionDriftRejectsClaim TestDirtyHandoffIdenticalStateClaimsWithoutWIPCommit +"$T/prune-imports.sh" $P +for f in $P/*_integration_test.go; do printf "%s: [%s] [%s]\n" "$f" "$(sed -n 1p "$f")" "$(sed -n 2p "$f")"; done +' +``` +Expected: `prune-imports: go vet clean …` and every file `[//go:build integration] []`. `movefuncs.sh` created the three `*_race_integration_test.go` files from their tagged sources, so their line 1 is already the tag. Add `// Race shard (change 0466): .` directly above each of the four race `func` lines. + +**`TestMain` stays whole in `supervisor_integration_test.go`.** The default build then has no `TestMain` and uses Go's default runner. That is correct because no default gatedrive test re-execs the test binary (Step 8 proves it). Append this paragraph to the `TestMain` doc comment: + +```go +// Change 0466 moved this file (formerly integration_test.go) behind the integration +// tag. The default gatedrive build has no TestMain: none of its tests re-execs the +// test binary as a supervisor or child (only this tagged corpus drives the real +// process.Service), so Go's default m.Run is exactly right there. +``` + +- [ ] **Step 3: Relocate helpers so each build compiles exactly what it uses** + +```bash +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; "$T/defaultonly-unused.sh" internal/gatedrive' +``` +Move each listed func with `movefuncs.sh _integration_test.go …` (for example `fingerprint_test.go`'s `newDirtyRepo`/`git`/`gitInit`/`gitAdd`/`gitCommit` if the census-moved tests are their only users), and each listed type/var/const by hand. Run `prune-imports.sh internal/gatedrive`, and repeat until the script prints nothing. Delete any untagged file left with no declarations (`git rm -q -f`). Finally: `go vet ./internal/gatedrive/ && go vet -tags integration ./internal/gatedrive/ && gofmt -l internal/gatedrive`. Expected: clean. + +- [ ] **Step 4: Rename the tests and every maintained reference** + +```bash +bash -c ' +set -uo pipefail +T="${TMPDIR:-/tmp}/docket-0466-tools"; N=TestIntegrationGatedrive; R=TestRaceIntegrationGatedrive; pairs="" +for old in TestIntegrationDeadlineExpiryStopsOwnedTree TestIntegrationDriverSlicesAcrossLiveChildThenPasses TestIntegrationFreshProcessResumesAndChildSurvives TestIntegrationProcessDeathPermitsAtMostOneRelaunch TestIntegrationFastCompletionReturnsImmediately TestIntegrationSliceBoundIsProductionThirtySeconds TestIntegrationTerminalConsumedFromFreshProcess TestIntegrationSequenceRealGitBaselineRedGreen TestIntegrationSequenceCredentialTheftRejected TestIntegrationOutcomeTriggersNoLegacyCensusOrSecondStart; do pairs="$pairs $old=$N${old#TestIntegration}"; done +for old in TestFingerprintDanglingSymlinkHashedByValue TestFingerprintDetectsModeChange TestFingerprintFileDeleted TestFingerprintFileRenamed TestFingerprintIdenticalDirtyStateEqual TestFingerprintStagedByteChange TestFingerprintSymlinkTargetChanged TestFingerprintUnstagedByteChange TestFingerprintUntrackedFileAdded TestRepoHandoffPerDimensionDriftRejectsClaim TestDirtyHandoffIdenticalStateClaimsWithoutWIPCommit; do pairs="$pairs $old=$N${old#Test}"; done +for old in TestIntegrationTakeoverKeepsRunIdentity TestIntegrationSequenceConcurrentScopesResolveOwnWork TestIntegrationSameWorktreeGenerations; do pairs="$pairs $old=$R${old#TestIntegration}"; done +pairs="$pairs TestRaceConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe=${R}ConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe" +"$T/rename-tests.sh" $pairs; echo "rename rc=$?" +git diff --stat -- . ":!internal/gatedrive" +' +``` +Expected: `rename rc=0`. Check a sample against the table, e.g. `git grep -n -e "^func TestIntegrationGatedriveDeadlineExpiryStopsOwnedTree(" -- internal/gatedrive` finds exactly one declaration. + +Then fix the old **file-name** references (comments such as `integration_test.go: mustService, …` or `TestMain (integration_test.go)`): +```bash +bash -c 'git grep -n -E -e "(^|[^_A-Za-z])integration(_takeover|_sequence|_history)?_test\.go" -- internal/gatedrive internal/repoguard skills internal/assets docs/*.md tests || echo "no stale file references"' +``` +Rewrite each hit to the new file name (`integration_test.go` → `supervisor_integration_test.go`, `integration_takeover_test.go` → `takeover_integration_test.go` or `takeover_race_integration_test.go`, and likewise for sequence and history), naming wherever the referenced symbol now actually lives. Expected afterwards: `no stale file references`. + +- [ ] **Step 5: Create both shard runners** + +Create `tests/test_go_integration_gatedrive_process.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_gatedrive_process.sh — Go integration shard (change 0466, extending +# change 0333's partition): the gate driver's real-process and real-git tests (driving the REAL +# native supervisor internal/process.Service across slices, fresh-process resume, deadline and +# death handling, real-git sequences, and the worktree fingerprint/handoff proofs over real +# repositories) — moved out of the default internal/gatedrive corpus behind the `integration` +# build tag, prefix ^TestIntegrationGatedrive. internal/gatedrive has no no-real-git guard (its +# slow tests are process-bound, not git-bound): the budget row of tests/test_go_race.sh is its +# growth detector. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/gatedrive" +SHARD_PREFIX="TestIntegrationGatedrive" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +Create `tests/test_go_integration_gatedrive_race.sh` with exactly this content, then `chmod +x` it: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_gatedrive_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the gate driver's real-concurrency integration tests (takeover of a +# live supervised run, concurrent scopes driven to terminal, same-worktree generations against a +# live incumbent, concurrent Starts over a legacy-seeded store) — moved out of the default +# internal/gatedrive corpus behind the `integration` build tag, prefix +# ^TestRaceIntegrationGatedrive, run in RACE mode. Declarations only — execution and inspection +# live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/gatedrive" +SHARD_PREFIX="TestRaceIntegrationGatedrive" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +- [ ] **Step 6: Verify: default package green, shards green, contract green, no collision** + +```bash +go test -count=1 ./internal/gatedrive/ +bash -c 'leak="$(go test -list "^Test(Race)?Integration" ./internal/gatedrive/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus"' +for r in process race; do bash tests/test_go_integration_gatedrive_$r.sh; echo "$r shard rc=$?"; done +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +bash -c 'pk="./internal/gatedrive"; p=""; for r in tests/test_go_integration_*.sh; do [ "$r" = tests/test_go_integration_contract.sh ] && continue; d="$(DOCKET_SHARD_INSPECT=1 bash "$r")"; case "$d" in *"package=$pk"*) p="$p $(sed -n "s/^prefix=//p" <<<"$d")";; esac; done; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo "prefixes:$p"; echo prefix-check-done' +``` +Expected: package `ok`, `no leak…`, both shards `rc=0` (21 and 4 tests), `contract rc=0`, no `COLLISION`. + +- [ ] **Step 7: Measure and register both budget rows** + +For each of `tests/test_go_integration_gatedrive_process.sh` and `tests/test_go_integration_gatedrive_race.sh`: warm, then measure `bash -c 'bash >/dev/null 2>&1; time bash >/dev/null' 2>&1 | tail -4`. Row = S rounded up to the next multiple of 5, plus 5 (minimum 10). Insert it directly above the `tests/test_go_integration_release.sh` row. A row above 40 means NEEDS_ESCALATION. Then `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/` must PASS. Report S, the row, and the margin for each. + +- [ ] **Step 8: No real-process or real-git test is left in gatedrive's default corpus (Review Focus 5)** + +```bash +bash -c 'hits=""; for f in internal/gatedrive/*_test.go; do case "$(sed -n 1p "$f")" in //go:build*) continue;; esac; h="$(grep -n -E -e "process\.NewService|mustService\(|mustExe\(|intChildMarker" "$f")"; [ -z "$h" ] || hits="$hits$f: $h +"; done; [ -z "$hits" ] && echo "no real-supervisor use in the default corpus" || printf "%s" "$hits"' +bash -c 'T="${TMPDIR:-/tmp}/docket-0466-tools"; cd internal/gatedrive && files="$(for f in *_test.go; do case "$(sed -n 1p "$f")" in //go:build*) ;; *) printf "%s " "$f";; esac; done)"; cd - >/dev/null; out="$("$T/census.sh" internal/gatedrive $files)"; bad="$(awk "\$3 > 0" <<<"$out")"; [ -z "$bad" ] && echo "census: no default gatedrive test runs git" || printf "%s\n" "$bad"' +``` +Expected: `no real-supervisor use in the default corpus` and `census: no default gatedrive test runs git`. (The `driver_test.go` line `var _ ProcessSeam = (*process.Service)(nil)` is a compile-time assertion, not a `NewService` call, so it does not match.) + +- [ ] **Step 9: Record the default-corpus `-race` wall time (spec acceptance 2)** + +Run: `bash -c 'time go test -race -count=1 ./internal/gatedrive/' 2>&1 | tail -4` +Report `gatedrive default -race: before ~42s, after s`. About 15s of real-process time leaves with this task, so expect a large drop but not single digits. The package keeps 265 scripted-proc tests. + +- [ ] **Step 10: Commit** + +```bash +git add -- internal/gatedrive tests/test_go_integration_gatedrive_process.sh tests/test_go_integration_gatedrive_race.sh tests/runtime-budgets.tsv # plus any file outside the package that Step 4 rewrote +git status --porcelain +git commit -m "test(gatedrive): move real-supervisor and real-git tests behind the integration tag (TestIntegrationGatedrive, TestRaceIntegrationGatedrive, change 0466)" +``` + +--- + +### Task 10: Update the race gate's header, serial-confirm both gates, and record the numbers + +**Build profile:** standard + +**Files:** +- Modify: `tests/test_go_race.sh` (header comment only: the "PARTITION AND LANE" and "BACKSTOP TIMEOUT" paragraphs) +- Modify: `tests/runtime-budgets.tsv` (only the eight new rows, and only if a fresh measurement's rule value is higher) + +**Interfaces:** +- Consumes: every earlier task. Specifically Task 1's `testsupport.InstallNoGitGuard`, the eight runners from Tasks 2–4, 6–7, and 9, and the guards installed by Tasks 5 and 8. +- Produces: the measurement record the results file cites (spec acceptance 1, 2, 3, and the margins). + +- [ ] **Step 1: Rank the default corpus's slowest packages** + +Run on an idle machine (record `uptime` first): +```bash +bash -c ' +uptime +out="$(GOMAXPROCS=2 go test -race -count=1 -p 2 -json ./... 2>/dev/null)" +jq -r "select((.Action==\"pass\" or .Action==\"fail\") and .Test==null) | \"\(.Elapsed)\t\(.Action)\t\(.Package)\"" <<<"$out" | LC_ALL=C sort -rn > "${TMPDIR:-/tmp}/race-pkg-rank.tsv" +sed -n "1,6p" "${TMPDIR:-/tmp}/race-pkg-rank.tsv" +grep -c -E -e " fail " "${TMPDIR:-/tmp}/race-pkg-rank.tsv" +' +``` +Expected: no `fail` rows (the count prints `0`). The top row names the new worst default-corpus package W at S_W seconds. That is the same `-p 2` shape 0465's 48.7s transaction figure was measured with. Report the top six rows, plus the transaction, workspace, and gatedrive rows. + +- [ ] **Step 2: Update the race gate's header prose** + +In `tests/test_go_race.sh`, in the "PARTITION AND LANE" paragraph, replace + +```bash +# starts a real `git` process. installNoGitGuard (internal/app/nogit_guard_test.go) +# shadows git on PATH in the default build and fails the package on any attempt, +# so a new real-git test cannot land here unnoticed. With that tail gone, `go test +``` + +with + +```bash +# starts a real `git` process. testsupport.InstallNoGitGuard (internal/testsupport, +# installed from the package's TestMain) shadows git on PATH in the default build and +# fails the package on any attempt, so a new real-git test cannot land here unnoticed. +# Change 0466 extended the partition and the guard to internal/repository/transaction +# and internal/workspace, and moved internal/gatedrive's real-supervisor and real-git +# tests behind the tag too. gatedrive is process-bound rather than git-bound, so it +# has no git guard; this file's budget row is its growth detector. With that tail gone, `go test +``` + +Then re-wrap the paragraph's lines to the file's existing width without changing any other words. In the "BACKSTOP TIMEOUT" paragraph, directly after the sentence ending `internal/repository/transaction at 48.7s (local, idle, -p 2).`, insert: + +```bash +# Change 0466 then partitioned that package; the measured worst default-corpus package +# is now at s (same shape). The backstop and its floor in internal/repoguard +# keep 0465's larger 48.7s input, so the margin only grew. +``` + +Fill in `` and `` from Step 1. Do not change `RACE_TIMEOUT` or any executable line. Then: +```bash +go test -count=1 -run 'TestRaceGate' ./internal/repoguard/ && go test -count=1 ./internal/repoguard/ +bash -c 'git grep -n -w -E -e "installNoGitGuard|nogit_guard_off_test" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs" || echo "no stale guard references"' +``` +Expected: both PASS, and `no stale guard references`. + +- [ ] **Step 3: Serial-confirm the race gate (spec acceptance 1)** + +On an idle machine, run it solo twice and take the worse reading: +```bash +bash -c 'for i in 1 2; do uptime; ( time bash tests/test_go_race.sh >/dev/null 2>&1; echo "rc=$?" ) 2>&1 | grep -E -e "^(real|rc=)"; done' +``` +Expected: both `rc=0`, and the worse `real` R is under 60s. The margin is 60 − R. If R ≥ 60, **do not raise the row**. Write `FINDING: test_go_race s ≥ 60` with Step 1's ranking and return NEEDS_ESCALATION. + +- [ ] **Step 4: Serial-confirm the toolchain gate on a cold test cache (spec acceptance 1)** + +The runner uses `$GOCACHE` when set, else `/docket-go-cache/build`. Clear the test cache in that same location before each run: +```bash +bash -c ' +c="$(git rev-parse --git-common-dir)"; case "$c" in /*) ;; *) c="$PWD/$c";; esac +gc="${GOCACHE:-$c/docket-go-cache/build}" +for i in 1 2; do GOCACHE="$gc" go clean -testcache; uptime; ( time bash tests/test_go_toolchain.sh >/dev/null 2>&1; echo "rc=$?" ) 2>&1 | grep -E -e "^(real|rc=)"; done +' +``` +Expected: both `rc=0`, and the worse `real` K is under 55s. The margin is 55 − K. If K ≥ 55, do not raise the row: write `FINDING: test_go_toolchain cold s ≥ 55` and return NEEDS_ESCALATION. + +- [ ] **Step 5: Re-confirm every new shard row** + +Measure each of the eight new runners solo once more (warm the build cache, then `time bash >/dev/null`): +`tests/test_go_integration_transaction_{apply,recovery,race}.sh`, `tests/test_go_integration_workspace_{setup,lifecycle,race}.sh`, `tests/test_go_integration_gatedrive_{process,race}.sh`. +Compute each rule value (next multiple of 5, plus 5, minimum 10). If a rule value is **higher** than the row the move task registered, raise that new row to it. These rows are new in this change, so this is their honest sizing, not a relaxation. Never lower a row below its rule value, and never touch the 60/55 rows. Then run `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 6: Contract and every new shard green together** + +```bash +bash -c 'bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?"; for r in tests/test_go_integration_transaction_*.sh tests/test_go_integration_workspace_*.sh tests/test_go_integration_gatedrive_*.sh; do bash "$r" >/dev/null 2>&1; echo "$r rc=$?"; done' +bash -c 'out="$(git diff --name-only ef4a341d20e392a0c6ac09dd0bb49fce97a61f73 -- tests/test_go_integration_contract.sh tests/lib/go-integration-shard.sh)"; [ -z "$out" ] && echo "contract and shard executor untouched" || printf "EDITED: %s\n" "$out"' +``` +Expected: every `rc=0`, and `contract and shard executor untouched` (spec acceptance 3: new packages discovered with no allowlist edit). + +- [ ] **Step 7: Commit and report** + +```bash +git add -- tests/test_go_race.sh tests/runtime-budgets.tsv +git status --porcelain +git commit -m "test(race): record the post-partition worst package and re-confirm budgets (change 0466)" +``` + +Your report must carry, as numbers, for the results file: +- `test_go_race.sh`: both solo readings, the worse one R, and the margin 60 − R (with `uptime` load) +- `test_go_toolchain.sh` (cold test cache): both readings, the worse one K, and the margin 55 − K +- the Step 1 top-six package ranking, W, and S_W +- the default-corpus `-race` wall times of transaction, workspace, and gatedrive (from Tasks 5, 8, and 9, plus Step 1's `-p 2` rows) +- every new row: measured S, row, and margin +- any `FINDING:` line + +State also that acceptance 5 (whole suite green, no `SERIAL CONFIRMED OVER BUDGET` line for any file this change touched or created) is verified at docket-build's final suite gate. That gate's budget report must be read even on a green run. From 5ad26c7b34c283cd05da2665a0e61e557daec2e3 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:57:00 -0400 Subject: [PATCH 02/16] test(testsupport): hoist the no-real-git guard out of internal/app (change 0466) --- internal/app/gate_test.go | 19 +- internal/app/nogit_guard_off_test.go | 10 - internal/app/nogit_guard_test.go | 215 ++-------------------- internal/testsupport/nogit.go | 179 ++++++++++++++++++ internal/testsupport/nogit_install.go | 56 ++++++ internal/testsupport/nogit_install_off.go | 12 ++ internal/testsupport/nogit_test.go | 142 ++++++++++++++ 7 files changed, 416 insertions(+), 217 deletions(-) delete mode 100644 internal/app/nogit_guard_off_test.go create mode 100644 internal/testsupport/nogit.go create mode 100644 internal/testsupport/nogit_install.go create mode 100644 internal/testsupport/nogit_install_off.go create mode 100644 internal/testsupport/nogit_test.go diff --git a/internal/app/gate_test.go b/internal/app/gate_test.go index d05f7b343..d920e29dc 100644 --- a/internal/app/gate_test.go +++ b/internal/app/gate_test.go @@ -11,11 +11,19 @@ import ( "testing" ) +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (testsupport.InstallNoGitGuard): its diagnostic and its remedy text. +const ( + nogitPkg = "internal/app" + nogitShardGlob = "tests/test_go_integration_app_*.sh" +) + // TestMain routes the supervisor re-exec role of the app test binary: a real // GateLaunch re-executes this binary with the private supervisor env var set, // and it must become the supervisor rather than re-running the test suite. // Ordinary `go test` runs set neither and fall through to m.Run. -// Ordinary runs then install the default-build no-real-git guard (change 0465) around m.Run. +// Ordinary runs then install the default-build no-real-git guard (change 0465, +// testsupport.InstallNoGitGuard since change 0466) around m.Run. func TestMain(m *testing.M) { if process.SupervisorRequested() { os.Exit(process.RunSupervisorFromEnv()) @@ -26,10 +34,11 @@ func TestMain(m *testing.M) { if GuardianRequested() { os.Exit(RunAgentGuardianFromEnv()) } - // Change 0465: the default build installs the no-real-git guard (nogit_guard_test.go) - // AFTER the re-exec routing above, so the supervisor and guardian roles behave - // exactly as before; tagged builds get the no-op twin (nogit_guard_off_test.go). - finish := installNoGitGuard() + // Change 0465 (hoisted by change 0466): the default build installs the no-real-git + // guard (testsupport.InstallNoGitGuard) AFTER the re-exec routing above, so the + // supervisor and guardian roles behave exactly as before; tagged builds get + // testsupport's no-op twin. Its proving tests are in nogit_guard_test.go. + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) os.Exit(finish(m.Run())) } diff --git a/internal/app/nogit_guard_off_test.go b/internal/app/nogit_guard_off_test.go deleted file mode 100644 index cd10e429a..000000000 --- a/internal/app/nogit_guard_off_test.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build integration || e2e - -package app - -// installNoGitGuard is the tagged builds' no-op twin of the default-build guard in -// nogit_guard_test.go (change 0465). The integration and e2e corpora exist to run -// real git, so they install no shim and the finisher returns m.Run's code as-is. -// Exactly one of the two files compiles for any tag set, so TestMain stays -// single-sourced. -func installNoGitGuard() func(code int) int { return func(code int) int { return code } } diff --git a/internal/app/nogit_guard_test.go b/internal/app/nogit_guard_test.go index 0ef84bdc0..2a3b0ccfa 100644 --- a/internal/app/nogit_guard_test.go +++ b/internal/app/nogit_guard_test.go @@ -2,220 +2,31 @@ package app -// The default-build no-real-git guard (change 0465). Change 0333 moved the slow -// real-git, subprocess, and process-lifecycle corpus behind `//go:build integration`, -// but nothing stopped new real-git tests landing in the default corpus, which -// tests/test_go_race.sh instruments. This guard makes the partition an enforced -// invariant: the default-tag internal/app test corpus never starts a real `git`. -// -// Mechanism (keyed on the exec itself, never on spellings): installNoGitGuard, called -// from TestMain before m.Run, puts a directory holding a refusing `git` shim at the -// FRONT of PATH. Every PATH-resolved route to git (gitcli.NewClient's exec.LookPath, -// a bare exec.Command("git", …), a fixture helper, a child process inheriting PATH) -// resolves the shim. Known limits, none used by default tests today: a client built -// with gitcli.WithExecutable(), a test that replaces PATH wholesale -// rather than prepending to it, and a detached child that runs git after m.Run -// returns (once the shim dir is removed) all bypass the shim. The shim exits nogitGuardExit with the nogitGuardDiagnostic on stderr AND -// appends "\t" to a violation log, so a test that tolerates the failure -// still turns the package red when nogitVerdict reads the log after m.Run. -// -// Only this guard's own proving tests may call the shim without recording a -// violation, by passing nogitGuardProbeArg as the first argument. +// The no-real-git guard's proving tests for internal/app (change 0465). The guard +// itself lives in internal/testsupport (InstallNoGitGuard, hoisted there by change +// 0466) and is installed from TestMain in gate_test.go. These tests prove it is +// installed in THIS package's binary and fails the package on any real-git exec, +// even one a test tolerates. The default-tag internal/app test corpus never starts +// a real `git`; real-git tests live behind //go:build integration in the +// tests/test_go_integration_app_*.sh shards. import ( - "errors" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - "path/filepath" - "strings" "testing" "github.com/danielhanold/docket/internal/testsupport" ) -const ( - nogitGuardProbeArg = "docket-nogit-guard-probe" - nogitGuardDiagnostic = "docket nogit guard: default internal/app tests must not run real git" - nogitGuardExit = 97 - nogitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE" -) - -// nogitGuardDir is the installed shim directory ("" when the guard is not installed). -var nogitGuardDir string - -// installNoGitGuard installs the refusing git shim at the front of PATH and returns -// the finisher TestMain wraps around m.Run. Setup failure exits the binary non-zero: -// a guard that silently failed to install would certify nothing. -func installNoGitGuard() func(code int) int { - // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. - dir, err := os.MkdirTemp("", "docket-app-nogit-") - if err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", nogitGuardDiagnostic, err) - os.Exit(1) - } - logPath := filepath.Join(dir, "violations.log") - if strings.ContainsAny(logPath, "'\n") { - fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", nogitGuardDiagnostic, logPath) - os.Exit(1) - } - shim := filepath.Join(dir, "git") - if err := os.WriteFile(shim, []byte(nogitShimScript(logPath)), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", nogitGuardDiagnostic, err) - os.Exit(1) - } - // Explicit chmod: a create-time mode is masked by the umask. - if err := os.Chmod(shim, 0o755); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", nogitGuardDiagnostic, err) - os.Exit(1) - } - if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", nogitGuardDiagnostic, err) - os.Exit(1) - } - nogitGuardDir = dir - return func(code int) int { - verdict := nogitVerdict(logPath, code, os.Stderr) - _ = os.RemoveAll(dir) - return verdict - } -} - -// nogitShimScript renders the refusing git: it records every non-probe invocation -// as "\t" in logPath and always exits nogitGuardExit with the diagnostic -// and the remedy on stderr. -func nogitShimScript(logPath string) string { - return "#!/bin/sh\n" + - "if [ \"${1-}\" != '" + nogitGuardProbeArg + "' ]; then\n" + - " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '" + logPath + "'\n" + - "fi\n" + - "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' '" + - nogitGuardDiagnostic + "' \"$*\" >&2\n" + - fmt.Sprintf("exit %d\n", nogitGuardExit) -} - -// nogitVerdict folds the violation log into m.Run's exit code. A missing or empty -// log is clean and leaves code unchanged. Any recorded attempt, or a log that exists -// but cannot be read, fails the package: a probe error is never clean absence. -func nogitVerdict(logPath string, code int, w io.Writer) int { - raw, err := os.ReadFile(logPath) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - fmt.Fprintf(w, "%s: cannot read the violation log %s: %v\n", nogitGuardDiagnostic, logPath, err) - return nogitFailCode(code) - } - var lines []string - for _, l := range strings.Split(string(raw), "\n") { - if strings.TrimSpace(l) != "" { - lines = append(lines, l) - } - } - if len(lines) == 0 { - return code - } - fmt.Fprintf(w, "%s: %d real-git exec attempt(s) reached the guard shim (a test that tolerated the failure still counts); \\t:\n", nogitGuardDiagnostic, len(lines)) - for _, l := range lines { - fmt.Fprintf(w, " %s\n", l) - } - return nogitFailCode(code) -} - -func nogitFailCode(code int) int { - if code == 0 { - return 1 - } - return code -} - -// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` (gitcli.NewClient uses -// exec.LookPath) resolves the shim, not a real git. +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. func TestNoGitGuardShadowsGitOnPath(t *testing.T) { - if nogitGuardDir == "" { - t.Fatalf("the no-real-git guard is not installed (nogitGuardDir empty); TestMain must call installNoGitGuard before m.Run") - } - p, err := exec.LookPath("git") - if err != nil { - t.Fatalf("LookPath(git): %v", err) - } - if filepath.Dir(p) != nogitGuardDir { - t.Fatalf("git resolves to %q, want the guard shim in %q", p, nogitGuardDir) - } + testsupport.AssertNoGitGuardShadowsGit(t) } -// TestNoGitGuardRefusesBareExec pins the MECHANISM, not just "it failed": real git -// also fails on an unknown subcommand, so the assert is the guard's exit code AND -// its diagnostic (learning assert-pins-outcome-not-mechanism). +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. func TestNoGitGuardRefusesBareExec(t *testing.T) { - out, err := exec.Command("git", nogitGuardProbeArg).CombinedOutput() - var ee *exec.ExitError - if !errors.As(err, &ee) || ee.ExitCode() != nogitGuardExit { - t.Fatalf("git exec must exit %d from the guard shim, got err=%v output=%q", nogitGuardExit, err, out) - } - if !strings.Contains(string(out), nogitGuardDiagnostic) { - t.Fatalf("git exec output must carry the guard diagnostic %q, got %q", nogitGuardDiagnostic, out) - } -} - -// TestNoGitGuardVerdict covers the post-m.Run verdict over the violation log. -func TestNoGitGuardVerdict(t *testing.T) { - dir := testsupport.TempDir(t) // bare X.TempDir() is banned by internal/repoguard tempdir_fixture_test.go - write := func(name, body string) string { - p := filepath.Join(dir, name) - if err := os.WriteFile(p, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - return p - } - cases := []struct { - name string - logPath string - code int - want int - wantText string - }{ - {"missing log is clean", filepath.Join(dir, "absent.log"), 0, 0, ""}, - {"empty log is clean", write("empty.log", ""), 0, 0, ""}, - {"one violation fails a green run", write("one.log", "/tmp/x\tstatus --porcelain\n"), 0, 1, "1 real-git exec attempt(s)"}, - {"violation keeps an existing failure code", write("keep.log", "/tmp/x\tlog\n"), 2, 2, "/tmp/x\tlog"}, - {"unreadable log fails closed", dir, 0, 1, "cannot read the violation log"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var buf strings.Builder - got := nogitVerdict(tc.logPath, tc.code, &buf) - if got != tc.want { - t.Fatalf("nogitVerdict(%q, %d) = %d, want %d; output:\n%s", tc.logPath, tc.code, got, tc.want, buf.String()) - } - if tc.wantText == "" && buf.Len() != 0 { - t.Fatalf("clean verdict must print nothing, got:\n%s", buf.String()) - } - if tc.wantText != "" && !strings.Contains(buf.String(), tc.wantText) { - t.Fatalf("verdict output must contain %q, got:\n%s", tc.wantText, buf.String()) - } - }) - } + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) } -// TestNoGitGuardFailsTolerantTest proves a test that SWALLOWS the git failure still -// fails the package: it re-execs this test binary running only itself in child -// mode, where it runs `git status` and ignores the error, then asserts the child -// binary exits non-zero and lists the violation. +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. func TestNoGitGuardFailsTolerantTest(t *testing.T) { - if os.Getenv(nogitSelfProbeEnv) == "1" { - _ = exec.Command("git", "status").Run() // tolerated on purpose - return - } - cmd := exec.Command(os.Args[0], "-test.run=^TestNoGitGuardFailsTolerantTest$", "-test.count=1") - cmd.Env = append(os.Environ(), nogitSelfProbeEnv+"=1") - out, err := cmd.CombinedOutput() - var ee *exec.ExitError - if !errors.As(err, &ee) || ee.ExitCode() == 0 { - t.Fatalf("a package whose test tolerated a git exec must exit non-zero, got err=%v output:\n%s", err, out) - } - for _, want := range []string{nogitGuardDiagnostic, "1 real-git exec attempt(s)", "\tstatus"} { - if !strings.Contains(string(out), want) { - t.Fatalf("child output must contain %q, got:\n%s", want, out) - } - } + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") } diff --git a/internal/testsupport/nogit.go b/internal/testsupport/nogit.go new file mode 100644 index 000000000..34dc070fe --- /dev/null +++ b/internal/testsupport/nogit.go @@ -0,0 +1,179 @@ +package testsupport + +// The default-build no-real-git guard (change 0465, hoisted here from internal/app +// by change 0466). Change 0333 moved the slow real-git, subprocess, and +// process-lifecycle corpus behind `//go:build integration`; this guard makes that +// partition an enforced invariant for a package: its default-tag test corpus never +// starts a real `git`. Installed from the TestMain of internal/app, +// internal/repository/transaction, and internal/workspace. +// +// Mechanism (keyed on the exec itself, never on spellings): InstallNoGitGuard, +// called from TestMain before m.Run, puts a directory holding a refusing `git` shim +// at the FRONT of PATH. Every PATH-resolved route to git (gitcli.NewClient's +// exec.LookPath, a bare exec.Command("git", …), a fixture helper, a child process +// inheriting PATH) resolves the shim. Known limits, none used by default tests +// today: a client built with gitcli.WithExecutable(), a test that +// replaces PATH wholesale rather than prepending to it, and a detached child that +// runs git after m.Run returns (once the shim dir is removed) all bypass the shim. +// The shim exits NoGitGuardExit with NoGitGuardDiagnostic(pkg) on stderr AND +// appends "\t" to a violation log, so a test that tolerates the failure +// still turns the package red when NoGitVerdict reads the log after m.Run. +// +// Only a package's own proving tests may call the shim without recording a +// violation, by passing NoGitGuardProbeArg as the first argument. +// +// Build split: InstallNoGitGuard is real only in the default build +// (nogit_install.go, `//go:build !integration && !e2e`); the tagged corpora exist +// to run real git and get the identity finisher (nogit_install_off.go). A build +// tag applies to every package compiled into the test binary, testsupport +// included, so the split lives here once instead of as a twin file in each +// guarded package. + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + // NoGitGuardProbeArg as git's first argument marks a proving-test call the + // shim refuses without logging a violation. + NoGitGuardProbeArg = "docket-nogit-guard-probe" + // NoGitGuardExit is the shim's exit code. + NoGitGuardExit = 97 + // NoGitSelfProbeEnv routes NoGitGuardTolerantProbe's re-exec'd child. + NoGitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE" +) + +// noGitGuardDir is the installed shim directory ("" when the guard is not installed). +var noGitGuardDir string + +// NoGitGuardDir returns the installed shim directory, or "" when no guard is installed. +func NoGitGuardDir() string { return noGitGuardDir } + +// NoGitGuardDiagnostic is the guard's stderr diagnostic for package pkg. +func NoGitGuardDiagnostic(pkg string) string { + return "docket nogit guard: default " + pkg + " tests must not run real git" +} + +// validateNoGitGuardArgs refuses a pkg or shardGlob the shim's single-quoted printf +// cannot carry verbatim: empty, or holding a quote, percent sign, backslash, or newline. +func validateNoGitGuardArgs(pkg, shardGlob string) error { + for _, v := range []struct{ name, val string }{{"package", pkg}, {"shard glob", shardGlob}} { + if v.val == "" { + return fmt.Errorf("the %s is empty", v.name) + } + if strings.ContainsAny(v.val, "'%\\\n") { + return fmt.Errorf("the %s %q contains a quote, percent sign, backslash, or newline, which the shim's single-quoted printf cannot carry", v.name, v.val) + } + } + return nil +} + +// NoGitShimScript renders the refusing git for package pkg: it records every +// non-probe invocation as "\t" in logPath and always exits +// NoGitGuardExit with the diagnostic and the remedy (naming shardGlob) on stderr. +func NoGitShimScript(pkg, shardGlob, logPath string) string { + return "#!/bin/sh\n" + + "if [ \"${1-}\" != '" + NoGitGuardProbeArg + "' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '" + logPath + "'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a " + shardGlob + " shard (change 0465; partition from change 0333)\\n' '" + + NoGitGuardDiagnostic(pkg) + "' \"$*\" >&2\n" + + fmt.Sprintf("exit %d\n", NoGitGuardExit) +} + +// NoGitVerdict folds the violation log into m.Run's exit code. A missing or empty +// log is clean and leaves code unchanged. Any recorded attempt, or a log that exists +// but cannot be read, fails the package: a probe error is never clean absence. +func NoGitVerdict(pkg, logPath string, code int, w io.Writer) int { + diag := NoGitGuardDiagnostic(pkg) + raw, err := os.ReadFile(logPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Fprintf(w, "%s: cannot read the violation log %s: %v\n", diag, logPath, err) + return noGitFailCode(code) + } + var lines []string + for _, l := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) == 0 { + return code + } + fmt.Fprintf(w, "%s: %d real-git exec attempt(s) reached the guard shim (a test that tolerated the failure still counts); \\t:\n", diag, len(lines)) + for _, l := range lines { + fmt.Fprintf(w, " %s\n", l) + } + return noGitFailCode(code) +} + +func noGitFailCode(code int) int { + if code == 0 { + return 1 + } + return code +} + +// AssertNoGitGuardShadowsGit: every PATH lookup of `git` (gitcli.NewClient uses +// exec.LookPath) resolves the installed shim, not a real git. +func AssertNoGitGuardShadowsGit(t testing.TB) { + t.Helper() + if noGitGuardDir == "" { + t.Fatalf("the no-real-git guard is not installed (NoGitGuardDir empty); TestMain must call testsupport.InstallNoGitGuard before m.Run") + } + p, err := exec.LookPath("git") + if err != nil { + t.Fatalf("LookPath(git): %v", err) + } + if filepath.Dir(p) != noGitGuardDir { + t.Fatalf("git resolves to %q, want the guard shim in %q", p, noGitGuardDir) + } +} + +// AssertNoGitGuardRefusesBareExec pins the MECHANISM, not just "it failed": real +// git also fails on an unknown subcommand, so the assert is the guard's exit code +// AND pkg's diagnostic (learning assert-pins-outcome-not-mechanism). +func AssertNoGitGuardRefusesBareExec(t testing.TB, pkg string) { + t.Helper() + out, err := exec.Command("git", NoGitGuardProbeArg).CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != NoGitGuardExit { + t.Fatalf("git exec must exit %d from the guard shim, got err=%v output=%q", NoGitGuardExit, err, out) + } + if !strings.Contains(string(out), NoGitGuardDiagnostic(pkg)) { + t.Fatalf("git exec output must carry the guard diagnostic %q, got %q", NoGitGuardDiagnostic(pkg), out) + } +} + +// NoGitGuardTolerantProbe proves a test that SWALLOWS the git failure still fails +// the package. testName must be the calling test's exact name: the helper re-execs +// the test binary running only that test in child mode, where it runs `git status` +// and ignores the error, then asserts the child binary exits non-zero and lists the +// violation. +func NoGitGuardTolerantProbe(t *testing.T, pkg, testName string) { + t.Helper() + if os.Getenv(NoGitSelfProbeEnv) == "1" { + _ = exec.Command("git", "status").Run() // tolerated on purpose + return + } + cmd := exec.Command(os.Args[0], "-test.run=^"+testName+"$", "-test.count=1") + cmd.Env = append(os.Environ(), NoGitSelfProbeEnv+"=1") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() == 0 { + t.Fatalf("a package whose test tolerated a git exec must exit non-zero, got err=%v output:\n%s", err, out) + } + for _, want := range []string{NoGitGuardDiagnostic(pkg), "1 real-git exec attempt(s)", "\tstatus"} { + if !strings.Contains(string(out), want) { + t.Fatalf("child output must contain %q, got:\n%s", want, out) + } + } +} diff --git a/internal/testsupport/nogit_install.go b/internal/testsupport/nogit_install.go new file mode 100644 index 000000000..d9697d2a8 --- /dev/null +++ b/internal/testsupport/nogit_install.go @@ -0,0 +1,56 @@ +//go:build !integration && !e2e + +package testsupport + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// InstallNoGitGuard installs the refusing git shim for package pkg (its +// module-relative dir, e.g. "internal/app") at the front of PATH and returns the +// finisher TestMain wraps around m.Run. shardGlob names the package's integration +// shard runners in the remedy text. Setup failure, or a pkg/shardGlob the shim +// cannot carry, exits the binary non-zero: a guard that silently failed to install +// would certify nothing. +func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { + diag := NoGitGuardDiagnostic(pkg) + if err := validateNoGitGuardArgs(pkg, shardGlob); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", diag, err) + os.Exit(1) + } + // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. + dir, err := os.MkdirTemp("", "docket-"+path.Base(pkg)+"-nogit-") + if err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", diag, err) + os.Exit(1) + } + logPath := filepath.Join(dir, "violations.log") + if strings.ContainsAny(logPath, "'\n") { + fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", diag, logPath) + os.Exit(1) + } + shim := filepath.Join(dir, "git") + if err := os.WriteFile(shim, []byte(NoGitShimScript(pkg, shardGlob, logPath)), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", diag, err) + os.Exit(1) + } + // Explicit chmod: a create-time mode is masked by the umask. + if err := os.Chmod(shim, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", diag, err) + os.Exit(1) + } + if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", diag, err) + os.Exit(1) + } + noGitGuardDir = dir + return func(code int) int { + verdict := NoGitVerdict(pkg, logPath, code, os.Stderr) + _ = os.RemoveAll(dir) + return verdict + } +} diff --git a/internal/testsupport/nogit_install_off.go b/internal/testsupport/nogit_install_off.go new file mode 100644 index 000000000..b8e4289f8 --- /dev/null +++ b/internal/testsupport/nogit_install_off.go @@ -0,0 +1,12 @@ +//go:build integration || e2e + +package testsupport + +// InstallNoGitGuard is the tagged builds' no-op twin of the default-build guard in +// nogit_install.go (change 0466, formerly internal/app/nogit_guard_off_test.go). +// The integration and e2e corpora exist to run real git, so they install no shim +// and the finisher returns m.Run's code as-is. Exactly one of the two files +// compiles for any tag set, so every guarded TestMain stays single-sourced. +func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { + return func(code int) int { return code } +} diff --git a/internal/testsupport/nogit_test.go b/internal/testsupport/nogit_test.go new file mode 100644 index 000000000..ddd371a65 --- /dev/null +++ b/internal/testsupport/nogit_test.go @@ -0,0 +1,142 @@ +package testsupport + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestNoGitShimScriptKeepsInternalAppBytes pins the hoist as byte-identical for +// internal/app (change 0466): the literal was proven against the pre-hoist +// internal/app nogitShimScript before that function was deleted. +func TestNoGitShimScriptKeepsInternalAppBytes(t *testing.T) { + const want = "#!/bin/sh\n" + + "if [ \"${1-}\" != 'docket-nogit-guard-probe' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '/x/violations.log'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' 'docket nogit guard: default internal/app tests must not run real git' \"$*\" >&2\n" + + "exit 97\n" + if got := NoGitShimScript("internal/app", "tests/test_go_integration_app_*.sh", "/x/violations.log"); got != want { + t.Fatalf("internal/app shim bytes changed:\n got %q\nwant %q", got, want) + } + if got, want := NoGitGuardDiagnostic("internal/app"), "docket nogit guard: default internal/app tests must not run real git"; got != want { + t.Fatalf("NoGitGuardDiagnostic(internal/app) = %q, want %q", got, want) + } +} + +// TestNoGitShimScriptRefusesAndLogs EXECUTES a rendered shim: a non-probe call is +// logged as "\t" and refused with the package's diagnostic, its shard +// glob, and exit NoGitGuardExit; a probe call is refused but not logged. +func TestNoGitShimScriptRefusesAndLogs(t *testing.T) { + dir := TempDir(t) + logPath := filepath.Join(dir, "violations.log") + shim := filepath.Join(dir, "git") + pkg, glob := "internal/repository/transaction", "tests/test_go_integration_transaction_*.sh" + if err := os.WriteFile(shim, []byte(NoGitShimScript(pkg, glob, logPath)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(shim, 0o755); err != nil { + t.Fatal(err) + } + out, err := exec.Command(shim, "status", "--porcelain").CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != NoGitGuardExit { + t.Fatalf("shim must exit %d, got err=%v output=%q", NoGitGuardExit, err, out) + } + for _, want := range []string{NoGitGuardDiagnostic(pkg), "(git status --porcelain)", glob} { + if !strings.Contains(string(out), want) { + t.Fatalf("shim stderr must contain %q, got %q", want, out) + } + } + logged, err := os.ReadFile(logPath) + if err != nil || !strings.Contains(string(logged), "\tstatus --porcelain") { + t.Fatalf("violation log must record the call, got %q (err %v)", logged, err) + } + if err := os.Remove(logPath); err != nil { + t.Fatal(err) + } + if _, err := exec.Command(shim, NoGitGuardProbeArg).CombinedOutput(); err == nil { + t.Fatalf("a probe call must still be refused") + } + if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a probe call must not be logged (stat err %v)", err) + } +} + +// TestNoGitVerdict covers the post-m.Run verdict over the violation log (moved +// from internal/app's TestNoGitGuardVerdict by change 0466). +func TestNoGitVerdict(t *testing.T) { + dir := TempDir(t) + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + cases := []struct { + name string + logPath string + code int + want int + wantText string + }{ + {"missing log is clean", filepath.Join(dir, "absent.log"), 0, 0, ""}, + {"empty log is clean", write("empty.log", ""), 0, 0, ""}, + {"one violation fails a green run", write("one.log", "/tmp/x\tstatus --porcelain\n"), 0, 1, "1 real-git exec attempt(s)"}, + {"violation keeps an existing failure code", write("keep.log", "/tmp/x\tlog\n"), 2, 2, "/tmp/x\tlog"}, + {"unreadable log fails closed", dir, 0, 1, "cannot read the violation log"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + got := NoGitVerdict("internal/app", tc.logPath, tc.code, &buf) + if got != tc.want { + t.Fatalf("NoGitVerdict(%q, %d) = %d, want %d; output:\n%s", tc.logPath, tc.code, got, tc.want, buf.String()) + } + if tc.wantText == "" && buf.Len() != 0 { + t.Fatalf("clean verdict must print nothing, got:\n%s", buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), tc.wantText) { + t.Fatalf("verdict output must contain %q, got:\n%s", tc.wantText, buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), NoGitGuardDiagnostic("internal/app")) { + t.Fatalf("verdict output must carry the package diagnostic, got:\n%s", buf.String()) + } + }) + } +} + +// TestValidateNoGitGuardArgs: the shim embeds pkg and shardGlob inside a +// single-quoted printf, so a quote, percent sign, backslash, or newline (or an +// empty value) is refused rather than rendered into a broken shim. +func TestValidateNoGitGuardArgs(t *testing.T) { + okGlob := "tests/test_go_integration_app_*.sh" + cases := []struct { + name, pkg, glob string + ok bool + }{ + {"app", "internal/app", okGlob, true}, + {"transaction", "internal/repository/transaction", "tests/test_go_integration_transaction_*.sh", true}, + {"empty package", "", okGlob, false}, + {"empty glob", "internal/app", "", false}, + {"quote in package", "internal/a'pp", okGlob, false}, + {"percent in glob", "internal/app", "tests/%s.sh", false}, + {"backslash in glob", "internal/app", `tests\x.sh`, false}, + {"newline in package", "internal/app\nx", okGlob, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateNoGitGuardArgs(tc.pkg, tc.glob) + if tc.ok && err != nil { + t.Fatalf("validateNoGitGuardArgs(%q, %q) = %v, want nil", tc.pkg, tc.glob, err) + } + if !tc.ok && err == nil { + t.Fatalf("validateNoGitGuardArgs(%q, %q) = nil, want a refusal", tc.pkg, tc.glob) + } + }) + } +} From 88ead9fa06df06487c820426d6b810e2b030ab31 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:02:40 -0400 Subject: [PATCH 03/16] test(transaction): move apply-path real-git tests behind the integration tag (TestIntegrationTxnApply, change 0466) --- .../transaction/candidate_integration_test.go | 123 +++++ .../repository/transaction/candidate_test.go | 111 ----- .../transaction/engine_integration_test.go | 384 ++++++++++++++++ .../engine_scope_integration_test.go | 431 ++++++++++++++++++ .../transaction/engine_scope_test.go | 419 ----------------- .../repository/transaction/engine_test.go | 373 --------------- .../transaction/harness_integration_test.go | 46 ++ .../repository/transaction/harness_test.go | 38 -- .../transaction/loader_integration_test.go | 57 +++ .../repository/transaction/loader_test.go | 48 -- .../transaction/preserve_integration_test.go | 72 +++ .../repository/transaction/preserve_test.go | 65 --- tests/runtime-budgets.tsv | 1 + .../test_go_integration_transaction_apply.sh | 24 + 14 files changed, 1138 insertions(+), 1054 deletions(-) create mode 100644 internal/repository/transaction/candidate_integration_test.go create mode 100644 internal/repository/transaction/engine_integration_test.go create mode 100644 internal/repository/transaction/engine_scope_integration_test.go create mode 100644 internal/repository/transaction/harness_integration_test.go create mode 100644 internal/repository/transaction/loader_integration_test.go create mode 100644 internal/repository/transaction/preserve_integration_test.go create mode 100755 tests/test_go_integration_transaction_apply.sh diff --git a/internal/repository/transaction/candidate_integration_test.go b/internal/repository/transaction/candidate_integration_test.go new file mode 100644 index 000000000..7e345ca99 --- /dev/null +++ b/internal/repository/transaction/candidate_integration_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package transaction + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "syscall" + "testing" + "time" +) + +// TestIntegrationTxnApplyAllocateCandidateStructureAndManifest proves allocation lays down the +// private candidate tree, mints a 32-hex id, publishes a fully populated +// manifest, and stays invisible to the primary checkout's status. +func TestIntegrationTxnApplyAllocateCandidateStructureAndManifest(t *testing.T) { + client, repo := newTxnRepo(t) + + c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + if err != nil { + t.Fatalf("allocateCandidate: %v", err) + } + defer func() { _ = c.live.release() }() + + // Directory shape: //{worktree not yet, hooks empty}. + wantRoot := filepath.Join(transactionsRoot(repo), c.id) + if c.root != wantRoot { + t.Errorf("c.root = %q, want %q", c.root, wantRoot) + } + if c.worktree != filepath.Join(wantRoot, "worktree") { + t.Errorf("c.worktree = %q", c.worktree) + } + if c.hooks != filepath.Join(wantRoot, "hooks") { + t.Errorf("c.hooks = %q", c.hooks) + } + if fi, err := os.Stat(c.root); err != nil || !fi.IsDir() { + t.Fatalf("candidate root not a dir: %v", err) + } + entries, err := os.ReadDir(c.hooks) + if err != nil { + t.Fatalf("read hooks dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("hooks dir not empty: %v", entries) + } + if _, err := os.Stat(filepath.Join(c.root, "manifest.json")); err != nil { + t.Errorf("manifest.json missing: %v", err) + } + if _, err := os.Stat(filepath.Join(c.root, "live.lock")); err != nil { + t.Errorf("live.lock missing: %v", err) + } + + // ID shape. + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(c.id) { + t.Errorf("id %q does not match ^[0-9a-f]{32}$", c.id) + } + + // Two allocations differ. + c2, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + if err != nil { + t.Fatalf("second allocateCandidate: %v", err) + } + defer func() { _ = c2.live.release() }() + if c.id == c2.id { + t.Errorf("two allocations produced the same id %q", c.id) + } + + // Manifest round-trips with every field populated. + m := readManifestFile(t, c.root) + wantStamp := txnTestClock.Now().UTC().Format(time.RFC3339) + want := manifest{ + Schema: manifestSchemaVersion, + TransactionID: c.id, + CommonDir: repo.CommonDir, + Remote: "origin", + TargetRef: "refs/heads/main", + BaseCommit: fixedBase, + WorktreeRel: "worktree", + Phase: phaseAllocating, + CreatedUTC: wantStamp, + UpdatedUTC: wantStamp, + PID: os.Getpid(), + } + if m != want { + t.Errorf("manifest =\n%+v\nwant\n%+v", m, want) + } + + // The transactions tree lives under the common dir, invisible to status. + changes, err := client.ChangedPaths(context.Background(), repo.PrimaryWorktree) + if err != nil { + t.Fatalf("ChangedPaths: %v", err) + } + if len(changes) != 0 { + t.Errorf("primary checkout dirty after allocation: %v", changes) + } +} + +// TestIntegrationTxnApplyCandidateModesUnderUmask proves the promised modes are enforced with an +// explicit chmod: under a permissive umask (0o022) an unchmodded lock/dir would +// land at 0644/0755, so passing under BOTH umasks can only mean the chmod ran. +func TestIntegrationTxnApplyCandidateModesUnderUmask(t *testing.T) { + for _, um := range []int{0o077, 0o022} { + t.Run(fmt.Sprintf("umask_%04o", um), func(t *testing.T) { + old := syscall.Umask(um) + defer syscall.Umask(old) + + _, repo := newTxnRepo(t) + c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + if err != nil { + t.Fatalf("allocateCandidate: %v", err) + } + defer func() { _ = c.live.release() }() + + assertPerm(t, c.root, 0o700) + assertPerm(t, c.hooks, 0o700) + assertPerm(t, filepath.Join(c.root, "manifest.json"), 0o600) + assertPerm(t, filepath.Join(c.root, "live.lock"), 0o600) + }) + } +} diff --git a/internal/repository/transaction/candidate_test.go b/internal/repository/transaction/candidate_test.go index 5e65ffbad..f7cc65e30 100644 --- a/internal/repository/transaction/candidate_test.go +++ b/internal/repository/transaction/candidate_test.go @@ -9,8 +9,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" - "syscall" "testing" "time" @@ -93,115 +91,6 @@ func readManifestFile(t *testing.T, root string) manifest { return m } -// TestAllocateCandidateStructureAndManifest proves allocation lays down the -// private candidate tree, mints a 32-hex id, publishes a fully populated -// manifest, and stays invisible to the primary checkout's status. -func TestAllocateCandidateStructureAndManifest(t *testing.T) { - client, repo := newTxnRepo(t) - - c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - if err != nil { - t.Fatalf("allocateCandidate: %v", err) - } - defer func() { _ = c.live.release() }() - - // Directory shape: //{worktree not yet, hooks empty}. - wantRoot := filepath.Join(transactionsRoot(repo), c.id) - if c.root != wantRoot { - t.Errorf("c.root = %q, want %q", c.root, wantRoot) - } - if c.worktree != filepath.Join(wantRoot, "worktree") { - t.Errorf("c.worktree = %q", c.worktree) - } - if c.hooks != filepath.Join(wantRoot, "hooks") { - t.Errorf("c.hooks = %q", c.hooks) - } - if fi, err := os.Stat(c.root); err != nil || !fi.IsDir() { - t.Fatalf("candidate root not a dir: %v", err) - } - entries, err := os.ReadDir(c.hooks) - if err != nil { - t.Fatalf("read hooks dir: %v", err) - } - if len(entries) != 0 { - t.Errorf("hooks dir not empty: %v", entries) - } - if _, err := os.Stat(filepath.Join(c.root, "manifest.json")); err != nil { - t.Errorf("manifest.json missing: %v", err) - } - if _, err := os.Stat(filepath.Join(c.root, "live.lock")); err != nil { - t.Errorf("live.lock missing: %v", err) - } - - // ID shape. - if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(c.id) { - t.Errorf("id %q does not match ^[0-9a-f]{32}$", c.id) - } - - // Two allocations differ. - c2, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - if err != nil { - t.Fatalf("second allocateCandidate: %v", err) - } - defer func() { _ = c2.live.release() }() - if c.id == c2.id { - t.Errorf("two allocations produced the same id %q", c.id) - } - - // Manifest round-trips with every field populated. - m := readManifestFile(t, c.root) - wantStamp := txnTestClock.Now().UTC().Format(time.RFC3339) - want := manifest{ - Schema: manifestSchemaVersion, - TransactionID: c.id, - CommonDir: repo.CommonDir, - Remote: "origin", - TargetRef: "refs/heads/main", - BaseCommit: fixedBase, - WorktreeRel: "worktree", - Phase: phaseAllocating, - CreatedUTC: wantStamp, - UpdatedUTC: wantStamp, - PID: os.Getpid(), - } - if m != want { - t.Errorf("manifest =\n%+v\nwant\n%+v", m, want) - } - - // The transactions tree lives under the common dir, invisible to status. - changes, err := client.ChangedPaths(context.Background(), repo.PrimaryWorktree) - if err != nil { - t.Fatalf("ChangedPaths: %v", err) - } - if len(changes) != 0 { - t.Errorf("primary checkout dirty after allocation: %v", changes) - } -} - -// TestCandidateModesUnderUmask proves the promised modes are enforced with an -// explicit chmod: under a permissive umask (0o022) an unchmodded lock/dir would -// land at 0644/0755, so passing under BOTH umasks can only mean the chmod ran. -func TestCandidateModesUnderUmask(t *testing.T) { - for _, um := range []int{0o077, 0o022} { - t.Run(fmt.Sprintf("umask_%04o", um), func(t *testing.T) { - old := syscall.Umask(um) - defer syscall.Umask(old) - - _, repo := newTxnRepo(t) - c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - if err != nil { - t.Fatalf("allocateCandidate: %v", err) - } - defer func() { _ = c.live.release() }() - - assertPerm(t, c.root, 0o700) - assertPerm(t, c.hooks, 0o700) - assertPerm(t, filepath.Join(c.root, "manifest.json"), 0o600) - assertPerm(t, filepath.Join(c.root, "live.lock"), 0o600) - }) - } -} - // TestSetPhaseAtomicUnderConcurrentReads rewrites the phase many times while a // reader goroutine parses the manifest in a tight loop. Because publication is a // same-directory temp+rename, every read observes a complete document — a naive diff --git a/internal/repository/transaction/engine_integration_test.go b/internal/repository/transaction/engine_integration_test.go new file mode 100644 index 000000000..bc3437d4e --- /dev/null +++ b/internal/repository/transaction/engine_integration_test.go @@ -0,0 +1,384 @@ +//go:build integration + +package transaction + +import ( + "context" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationTxnApplyEngineAppliesHappyPath runs one full apply on both topologies and proves the +// commit landed on origin with exactly the planned path, the engine trailer block, +// a populated Result, and a fully cleaned transactions root. +func TestIntegrationTxnApplyEngineAppliesHappyPath(t *testing.T) { + for _, topo := range topologies() { + t.Run(topo.name, func(t *testing.T) { + r := topo.build(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + op := createOp(thirdChangePath, thirdChange()) + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) + } + if res.Attempts != 1 { + t.Errorf("attempts = %d, want 1", res.Attempts) + } + if res.Operation != "test.op" { + t.Errorf("operation = %q, want test.op", res.Operation) + } + if res.BaseCommit != base { + t.Errorf("base commit = %q, want %q", res.BaseCommit, base) + } + tip := r.originTip(t) + if res.AppliedCommit != tip { + t.Errorf("applied commit = %q, origin tip = %q", res.AppliedCommit, tip) + } + if res.RemoteCommit != tip { + t.Errorf("remote commit = %q, want %q", res.RemoteCommit, tip) + } + if string(res.Receipt) != string(validReceipt()) { + t.Errorf("receipt = %q, want %q", res.Receipt, validReceipt()) + } + if len(res.CleanupWarnings) != 0 { + t.Errorf("cleanup warnings = %v, want none", res.CleanupWarnings) + } + + paths := diffTreePaths(t, r.Origin, res.AppliedCommit) + if len(paths) != 1 || paths[0] != thirdChangePath { + t.Errorf("committed paths = %v, want [%s]", paths, thirdChangePath) + } + trailers := hgitOut(t, r.Origin, "log", "-1", "--format=%(trailers:only,unfold)", string(res.AppliedCommit)) + for _, want := range []string{"Docket-Transaction-ID: ", "Docket-Operation: test.op", "Docket-Result: "} { + if !strings.Contains(trailers, want) { + t.Errorf("trailer block missing %q:\n%s", want, trailers) + } + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after apply") + } + }) + } +} + +// TestIntegrationTxnApplyEngineNoOpOnEmptyPlan proves an empty-Files plan is a no-op: no commit lands +// and the disposition is no-op. +func TestIntegrationTxnApplyEngineNoOpOnEmptyPlan(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + op := &scriptedOp{files: nil} // empty plan (valid subject/receipt supplied by defaults) + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionNoOp { + t.Fatalf("disposition = %q, want no-op", res.Disposition) + } + if r.originTip(t) != base { + t.Error("origin advanced on a no-op") + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after no-op") + } +} + +// TestIntegrationTxnApplyEngineRefusalFromOperation proves an operation refusal produces a refused +// disposition carrying its findings, with no commit. +func TestIntegrationTxnApplyEngineRefusalFromOperation(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + op := &scriptedOp{refuse: true, findings: []domain.Finding{ + {Code: "op-refused", Severity: domain.SeverityError, Entity: domain.EntityRef{Kind: domain.EntityRepo}}, + }} + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionRefused { + t.Fatalf("disposition = %q, want refused", res.Disposition) + } + if len(res.Findings) == 0 { + t.Error("refusal carried no findings") + } + if r.originTip(t) != base { + t.Error("origin advanced on a refusal") + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after refusal") + } +} + +// TestIntegrationTxnApplyEngineBeforeGateRefusesInvalidBase proves the before-gate refuses when the +// loaded base has error findings — before the operation is ever consulted. +func TestIntegrationTxnApplyEngineBeforeGateRefusesInvalidBase(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + // A record whose filename disagrees with its frontmatter slug is a base error. + r.advanceOrigin(t, "docs/changes/active/0007-mismatch.md", corpusChange(7, "different-slug", "proposed")) + base := r.originTip(t) + + op := createOp(thirdChangePath, thirdChange()) + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionRefused { + t.Fatalf("disposition = %q, want refused", res.Disposition) + } + if op.calls != 0 { + t.Errorf("operation was consulted %d times before the before-gate refused", op.calls) + } + if len(res.Findings) == 0 { + t.Error("before-gate refusal carried no findings") + } + if r.originTip(t) != base { + t.Error("origin advanced on a before-gate refusal") + } +} + +// TestIntegrationTxnApplyEngineAfterGateRefusesInvalidPlan proves the after-gate refuses when the +// operation plans a domain-invalid record; nothing is pushed. +func TestIntegrationTxnApplyEngineAfterGateRefusesInvalidPlan(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + // Filename slug "bad" disagrees with frontmatter slug "wrong-slug": an error. + op := createOp("docs/changes/active/0004-bad.md", corpusChange(4, "wrong-slug", "proposed")) + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionRefused { + t.Fatalf("disposition = %q, want refused", res.Disposition) + } + if len(res.Findings) == 0 { + t.Error("after-gate refusal carried no findings") + } + if r.originTip(t) != base { + t.Error("origin advanced on an after-gate refusal") + } +} + +// TestIntegrationTxnApplyEngineEvolutionGateBlocksFrozenADRRewrite proves the evolution gate blocks a +// plan that rewrites an already-published ADR's frozen body. +func TestIntegrationTxnApplyEngineEvolutionGateBlocksFrozenADRRewrite(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + rewritten := strings.Replace(corpusADR(1, "first-decision"), "Body.", "Rewritten body.", 1) + op := &scriptedOp{files: []FileMutation{ + {Path: "docs/adrs/0001-first-decision.md", Kind: MutationReplace, Bytes: []byte(rewritten)}, + }} + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionRefused { + t.Fatalf("disposition = %q, want refused (findings %v)", res.Disposition, res.Findings) + } + if r.originTip(t) != base { + t.Error("origin advanced on an evolution-gate refusal") + } +} + +// TestIntegrationTxnApplyEngineExpectationMatrix proves matching/stale blob and present/absent +// expectations gate the transaction correctly. +func TestIntegrationTxnApplyEngineExpectationMatrix(t *testing.T) { + firstPath := "docs/changes/active/0001-first-change.md" + + t.Run("matching blob applies", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), + Version: ExpectedVersion{Kind: VersionBlob, ObjectID: r.blobID(t, firstPath)}}} + res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied", res.Disposition) + } + }) + + t.Run("stale blob contends first attempt", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), + Version: ExpectedVersion{Kind: VersionBlob, ObjectID: "0000000000000000000000000000000000000000"}}} + op := createOp(thirdChangePath, thirdChange()) + res := mustExecute(t, eng, r, repo, exp, op) + if res.Disposition != DispositionContended { + t.Fatalf("disposition = %q, want contended", res.Disposition) + } + if len(res.ContendedPaths) != 1 || res.ContendedPaths[0] != gitcli.RepoPath(firstPath) { + t.Errorf("contended paths = %v, want [%s]", res.ContendedPaths, firstPath) + } + if res.Attempts != 1 { + t.Errorf("attempts = %d, want 1", res.Attempts) + } + if op.calls != 0 { + t.Errorf("operation consulted %d times despite a first-attempt mismatch", op.calls) + } + if r.originTip(t) != base { + t.Error("origin advanced on a contended expectation") + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after contention") + } + }) + + t.Run("absent expectation passes when path is absent", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + exp := []EntityExpectation{{Path: "docs/changes/active/9999-none.md", + Version: ExpectedVersion{Kind: VersionAbsent}}} + res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied", res.Disposition) + } + }) + + t.Run("absent expectation contends when path is present", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), + Version: ExpectedVersion{Kind: VersionAbsent}}} + res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) + if res.Disposition != DispositionContended { + t.Fatalf("disposition = %q, want contended", res.Disposition) + } + }) +} + +// TestIntegrationTxnApplyEngineRejectsNonBranchTargetRef proves a tag ref or a short name is a +// call-shape failure: a Go *Failure of kind invalid-input, before any Git work. +func TestIntegrationTxnApplyEngineRejectsNonBranchTargetRef(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + for _, ref := range []gitcli.RefName{"refs/tags/v1", "main", "refs/remotes/origin/main"} { + _, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: ref, + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err == nil { + t.Fatalf("target ref %q: want error", ref) + } + assertFailureKind(t, err, KindInvalidInput) + } +} + +// TestIntegrationTxnApplyEngineFailsOnMissingTargetBranch proves a missing target branch is a typed +// failure and never triggers branch creation. +func TestIntegrationTxnApplyEngineFailsOnMissingTargetBranch(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: "refs/heads/nope", + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err == nil { + t.Fatal("missing target branch: want error") + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error is not *Failure: %v", err) + } + if f.Stage != StageFetch { + t.Errorf("failure stage = %q, want fetch", f.Stage) + } + if res.Disposition != DispositionFailed { + t.Errorf("disposition = %q, want failed", res.Disposition) + } + if _, gerr := hgitTry(r.Origin, "rev-parse", "--verify", "--quiet", "refs/heads/nope"); gerr == nil { + t.Error("engine created the missing target branch on origin") + } +} + +// TestIntegrationTxnApplyEngineRetriesLeaseLoss proves a structurally proven lease loss retries from +// a fresh fetch and a fresh plan, applying on the next attempt — the reused first +// plan never appears in the winning commit. +func TestIntegrationTxnApplyEngineRetriesLeaseLoss(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + op := createOp(thirdChangePath, thirdChange()) + op.beforePlan = func(call int) { + if call == 1 { + // Advance origin between fetch and push so attempt 1 loses the lease. + r.advanceOrigin(t, "docs/changes/active/0005-writer-change.md", + corpusChange(5, "writer-change", "proposed")) + } + } + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied", res.Disposition) + } + if res.Attempts != 2 { + t.Errorf("attempts = %d, want 2 (one lease loss then apply)", res.Attempts) + } + if op.calls != 2 { + t.Errorf("operation re-planned %d times, want 2", op.calls) + } + // Final origin carries both the writer's change and the engine's. + names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) + for _, want := range []string{thirdChangePath, "docs/changes/active/0005-writer-change.md"} { + if !strings.Contains(names, want) { + t.Errorf("final origin tree missing %q:\n%s", want, names) + } + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after a retried apply") + } +} diff --git a/internal/repository/transaction/engine_scope_integration_test.go b/internal/repository/transaction/engine_scope_integration_test.go new file mode 100644 index 000000000..e7cc921ed --- /dev/null +++ b/internal/repository/transaction/engine_scope_integration_test.go @@ -0,0 +1,431 @@ +//go:build integration + +package transaction + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationTxnApplyEngineScopeAppliesDespiteUnrelatedError is the progress case: an +// unrelated invalid record A does not veto a scoped write to B, A's bytes stay +// identical on the target ref, and the unrelated finding is not a refusal. +func TestIntegrationTxnApplyEngineScopeAppliesDespiteUnrelatedError(t *testing.T) { + for _, topo := range topologies() { + t.Run(topo.name, func(t *testing.T) { + r := topo.build(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + aBlob := r.blobID(t, scopeUnrelatedPath) + + loader := &scopeLoader{} + res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, editSubjectOp()) + if loader.beforeErrors == 0 { + t.Fatal("fixture is vacuous: the before state carried no error finding") + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) + } + assertGrandfatheredSurfaced(t, res.Findings) + if got := r.blobID(t, scopeUnrelatedPath); got != aBlob { + t.Errorf("unrelated record blob changed: %q -> %q", aBlob, got) + } + paths := diffTreePaths(t, r.Origin, res.AppliedCommit) + if len(paths) != 1 || paths[0] != scopeSubjectPath { + t.Errorf("committed paths = %v, want [%s]", paths, scopeSubjectPath) + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopeNoOpSurfacesUnrelatedError is the no-op sibling of the +// progress case: an empty plan beside the unrelated invalid A is still no-op, +// and still reports A's grandfathered error finding. +func TestIntegrationTxnApplyEngineScopeNoOpSurfacesUnrelatedError(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + res := execScoped(t, eng, r, repo, &scopeLoader{}, staticScope(nil, scopeSubjectPath), nil, &scriptedOp{}) + if res.Disposition != DispositionNoOp { + t.Fatalf("disposition = %q, want no-op (findings %v)", res.Disposition, res.Findings) + } + assertGrandfatheredSurfaced(t, res.Findings) + if r.originTip(t) != base { + t.Error("origin advanced on a no-op") + } +} + +// TestIntegrationTxnApplyEngineScopeRefusesRelevantBeforeErrors proves an error on a subject — the +// root itself, a resolved dependency, or a path the request pins in Expected — +// refuses before the operation is ever consulted. +func TestIntegrationTxnApplyEngineScopeRefusesRelevantBeforeErrors(t *testing.T) { + cases := []struct { + name string + seed func(t *testing.T, r *testRepos) + scope []string + pinA bool + wantAt string + wantNot string + }{ + { + name: "error on root", + seed: func(t *testing.T, r *testRepos) { + r.advanceOrigin(t, scopeSubjectPath, corpusChange(1, "other-slug", "proposed")) + }, + scope: []string{scopeSubjectPath}, + wantAt: scopeSubjectPath, + }, + { + name: "error on resolved dependency", + seed: func(t *testing.T, r *testRepos) { + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + r.advanceOrigin(t, scopeDependencyPath, corpusChange(2, "other-slug", "proposed")) + }, + scope: []string{scopeSubjectPath, scopeDependencyPath}, + wantAt: scopeDependencyPath, + wantNot: scopeUnrelatedPath, + }, + { + name: "error on an expectation-pinned path", + seed: func(t *testing.T, r *testRepos) { + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + }, + scope: []string{scopeSubjectPath}, + pinA: true, + wantAt: scopeUnrelatedPath, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + c.seed(t, r) + base := r.originTip(t) + var exp []EntityExpectation + if c.pinA { + exp = []EntityExpectation{{Path: scopeUnrelatedPath, + Version: ExpectedVersion{Kind: VersionBlob, ObjectID: r.blobID(t, scopeUnrelatedPath)}}} + } + + op := editSubjectOp() + res := execScoped(t, eng, r, repo, &scopeLoader{}, staticScope(nil, c.scope...), exp, op) + assertScopedRefusal(t, r, res, base) + if op.calls != 0 { + t.Errorf("operation consulted %d times before the scoped before-gate refused", op.calls) + } + if !hasFindingAt(res.Findings, c.wantAt) { + t.Errorf("refusal findings %v lack the relevant error at %s", res.Findings, c.wantAt) + } + if c.wantNot != "" && hasFindingAt(res.Findings, c.wantNot) { + t.Errorf("refusal findings %v carry the unrelated error at %s", res.Findings, c.wantNot) + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopeUnresolvableScopeIsStrict proves an empty resolved subject set, +// a resolver error, and a nil resolver inside a non-nil scope all fall back to +// strict whole-corpus validation: the unrelated error refuses, never applies. +func TestIntegrationTxnApplyEngineScopeUnresolvableScopeIsStrict(t *testing.T) { + cases := []struct { + name string + scope *ValidationScope + }{ + {"empty resolved set", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { + return map[gitcli.RepoPath]bool{}, nil + }}}, + {"only unusable entries resolved", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { + return map[gitcli.RepoPath]bool{"": true, scopeSubjectPath: false}, nil + }}}, + {"resolver error", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { + return map[gitcli.RepoPath]bool{scopeSubjectPath: true}, errors.New("resolver failed") + }}}, + {"nil resolver", &ValidationScope{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + op := editSubjectOp() + res := execScoped(t, eng, r, repo, &scopeLoader{}, c.scope, nil, op) + assertScopedRefusal(t, r, res, base) + if op.calls != 0 { + t.Errorf("operation consulted %d times despite a strict before-gate refusal", op.calls) + } + if !hasFindingAt(res.Findings, scopeUnrelatedPath) { + t.Errorf("strict refusal findings %v lack the corpus error at %s", res.Findings, scopeUnrelatedPath) + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopeUnresolvableCandidateIsStrict proves the after-gate falls back +// to strict on its own: when the candidate state resolves to nothing (or the +// resolver errors on it), the unrelated pre-existing error refuses instead of +// being grandfathered under the before state's subjects. +func TestIntegrationTxnApplyEngineScopeUnresolvableCandidateIsStrict(t *testing.T) { + cases := []struct { + name string + after func() (map[gitcli.RepoPath]bool, error) + }{ + {"candidate resolves empty", func() (map[gitcli.RepoPath]bool, error) { + return map[gitcli.RepoPath]bool{}, nil + }}, + {"candidate resolves only unusable entries", func() (map[gitcli.RepoPath]bool, error) { + return map[gitcli.RepoPath]bool{"": true, scopeSubjectPath: false}, nil + }}, + {"candidate resolver error", func() (map[gitcli.RepoPath]bool, error) { + return nil, errors.New("resolver failed on candidate") + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + calls := 0 + scope := &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { + calls++ + if calls == 1 { // the before state + return map[gitcli.RepoPath]bool{scopeSubjectPath: true}, nil + } + return c.after() + }} + op := editSubjectOp() + loader := &scopeLoader{} + res := execScoped(t, eng, r, repo, loader, scope, nil, op) + assertScopedRefusal(t, r, res, base) + if op.calls != 1 || loader.afters != 1 { + t.Errorf("operation consulted %d / candidate loaded %d times, want 1 / 1 (an after-gate refusal)", op.calls, loader.afters) + } + if !hasFindingAt(res.Findings, scopeUnrelatedPath) { + t.Errorf("strict after-gate findings %v lack the corpus error at %s", res.Findings, scopeUnrelatedPath) + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopePostPlanRecheckRefusesPlanTouchedError proves a plan that +// declares a path whose record already carried an error widens the scope and +// refuses at the post-plan recheck — before the candidate is ever loaded — even +// when the plan would repair that record. +func TestIntegrationTxnApplyEngineScopePostPlanRecheckRefusesPlanTouchedError(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + op := &scriptedOp{files: []FileMutation{ + {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(editedSubject())}, + {Path: scopeUnrelatedPath, Kind: MutationReplace, Bytes: []byte(corpusChange(7, "mismatch", "proposed"))}, + }} + loader := &scopeLoader{} + res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, op) + assertScopedRefusal(t, r, res, base) + if op.calls != 1 { + t.Errorf("operation consulted %d times, want 1", op.calls) + } + if loader.afters != 0 { + t.Errorf("candidate loaded %d times; the post-plan recheck must refuse before the after load", loader.afters) + } + if !hasFindingAt(res.Findings, scopeUnrelatedPath) { + t.Errorf("recheck findings %v lack the plan-touched error at %s", res.Findings, scopeUnrelatedPath) + } +} + +// TestIntegrationTxnApplyEngineScopeCandidateSubjectsAreResolved proves the resolver runs against +// the candidate too: a plan that makes B newly depend on the invalid A pulls A +// into scope, so A's unchanged error refuses rather than being grandfathered. +func TestIntegrationTxnApplyEngineScopeCandidateSubjectsAreResolved(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + dependent := strings.Replace(corpusChange(1, "first-change", "proposed"), + "type: feat\n", "type: feat\ndepends_on: [7]\n", 1) + op := &scriptedOp{files: []FileMutation{ + {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(dependent)}, + }} + loader := &scopeLoader{} + res := execScoped(t, eng, r, repo, loader, dependencyScope(), nil, op) + assertScopedRefusal(t, r, res, base) + if loader.afters != 1 { + t.Errorf("candidate loaded %d times, want 1 (the after-gate refusal)", loader.afters) + } + if !hasFindingAt(res.Findings, scopeUnrelatedPath) { + t.Errorf("after-gate findings %v lack the newly required record's error at %s", res.Findings, scopeUnrelatedPath) + } +} + +// TestIntegrationTxnApplyEngineScopeAfterGateVolatility proves the after-gate grandfathers only an +// exact, unchanged pre-existing unrelated error: a changed blob id for its path, +// a new error (even one sharing the old code), or a count increase all refuse. +func TestIntegrationTxnApplyEngineScopeAfterGateVolatility(t *testing.T) { + // withFindings rebuilds st's report with extra findings appended. + withFindings := func(st *LoadedState, extra ...domain.Finding) { + st.Report = domain.NewValidationReport(append(st.Report.Findings(), extra...)) + } + unrelatedFinding := func(st *LoadedState) domain.Finding { + for _, f := range errorFindings(st.Report.Findings()) { + if f.Entity.Path == scopeUnrelatedPath { + return f + } + } + t.Fatal("after state lacks the unrelated finding") + return domain.Finding{} + } + cases := []struct { + name string + tamper func(st *LoadedState) + wantAt string + }{ + {"unrelated path blob id changed", func(st *LoadedState) { + st.Blobs[scopeUnrelatedPath] = "1111111111111111111111111111111111111111" + }, scopeUnrelatedPath}, + {"new unrelated error with an existing code", func(st *LoadedState) { + f := unrelatedFinding(st) + f.Entity = domain.EntityRef{Kind: domain.EntityChange, ID: 2, Slug: "second-change", Path: scopeDependencyPath} + withFindings(st, f) + }, scopeDependencyPath}, + {"count increase of an existing key", func(st *LoadedState) { + withFindings(st, unrelatedFinding(st)) + }, scopeUnrelatedPath}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + base := r.originTip(t) + + loader := &scopeLoader{tamperAfter: c.tamper} + res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, editSubjectOp()) + assertScopedRefusal(t, r, res, base) + if loader.afters != 1 { + t.Errorf("candidate loaded %d times, want 1", loader.afters) + } + if !hasFindingAt(res.Findings, c.wantAt) { + t.Errorf("after-gate findings %v lack the volatile error at %s", res.Findings, c.wantAt) + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopeLeaseRetryRecomputes proves scope and baseline are derived +// fresh from each attempt's base: after a lease loss, the second base decides — +// a newly relevant error refuses, and a newly present unrelated error is +// grandfathered against the fresh baseline rather than a stale one. +func TestIntegrationTxnApplyEngineScopeLeaseRetryRecomputes(t *testing.T) { + const newUnrelatedPath = "docs/changes/active/0008-other-mismatch.md" + cases := []struct { + name string + advancePath string + advanceBody string + want Disposition + }{ + {"second base carries a relevant error", scopeDependencyPath, corpusChange(2, "other-slug", "proposed"), DispositionRefused}, + {"second base carries a new unrelated error", newUnrelatedPath, corpusChange(8, "yet-another-slug", "proposed"), DispositionApplied}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + + op := editSubjectOp() + op.beforePlan = func(call int) { + if call == 1 { + r.advanceOrigin(t, c.advancePath, c.advanceBody) + } + } + var resolverCalls int + loader := &scopeLoader{} + res := execScoped(t, eng, r, repo, loader, + staticScope(&resolverCalls, scopeSubjectPath, scopeDependencyPath), nil, op) + if res.Disposition != c.want { + t.Fatalf("disposition = %q, want %q (findings %v)", res.Disposition, c.want, res.Findings) + } + if res.Attempts != 2 { + t.Errorf("attempts = %d, want 2", res.Attempts) + } + if loader.befores != 2 { + t.Errorf("before state loaded %d times, want once per attempt (2)", loader.befores) + } + if resolverCalls < 3 { + t.Errorf("resolver consulted %d times; want it re-run on the second attempt's base", resolverCalls) + } + if c.want == DispositionRefused { + if op.calls != 1 { + t.Errorf("operation consulted %d times, want 1 (second attempt refuses before planning)", op.calls) + } + if !hasFindingAt(res.Findings, scopeDependencyPath) { + t.Errorf("refusal findings %v lack the second base's relevant error", res.Findings) + } + } + }) + } +} + +// TestIntegrationTxnApplyEngineScopeKeyedReplay proves a scoped keyed request replays +// already-applied through the idempotency scan, before any load or resolution. +func TestIntegrationTxnApplyEngineScopeKeyedReplay(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) + + var resolverCalls int + scope := staticScope(&resolverCalls, scopeSubjectPath) + loader := &scopeLoader{} + first, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: loader, Scope: scope, Operation: editSubjectOp(), + }) + if err != nil || first.Disposition != DispositionApplied { + t.Fatalf("first Execute: disposition %q err %v (findings %v)", first.Disposition, err, first.Findings) + } + loadsAfterFirst, callsAfterFirst := loader.befores+loader.afters, resolverCalls + + replayOp := editSubjectOp() + second, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: loader, Scope: scope, Operation: replayOp, + }) + if err != nil { + t.Fatalf("replay Execute: %v", err) + } + if second.Disposition != DispositionAlreadyApplied { + t.Fatalf("replay disposition = %q, want already-applied", second.Disposition) + } + if second.AppliedCommit != first.AppliedCommit { + t.Errorf("replay commit = %q, want original %q", second.AppliedCommit, first.AppliedCommit) + } + if got := loader.befores + loader.afters; got != loadsAfterFirst { + t.Errorf("replay loaded state %d more times, want 0", got-loadsAfterFirst) + } + if resolverCalls != callsAfterFirst || replayOp.calls != 0 { + t.Errorf("replay consulted resolver %d / operation %d times, want 0", resolverCalls-callsAfterFirst, replayOp.calls) + } +} diff --git a/internal/repository/transaction/engine_scope_test.go b/internal/repository/transaction/engine_scope_test.go index 9087cea54..8b8cecde5 100644 --- a/internal/repository/transaction/engine_scope_test.go +++ b/internal/repository/transaction/engine_scope_test.go @@ -2,7 +2,6 @@ package transaction import ( "context" - "errors" "strings" "sync" "testing" @@ -158,38 +157,6 @@ func assertScopedRefusal(t *testing.T, r *testRepos, res Result, base gitcli.Obj } } -// TestEngineScopeAppliesDespiteUnrelatedError is the progress case: an -// unrelated invalid record A does not veto a scoped write to B, A's bytes stay -// identical on the target ref, and the unrelated finding is not a refusal. -func TestEngineScopeAppliesDespiteUnrelatedError(t *testing.T) { - for _, topo := range topologies() { - t.Run(topo.name, func(t *testing.T) { - r := topo.build(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - aBlob := r.blobID(t, scopeUnrelatedPath) - - loader := &scopeLoader{} - res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, editSubjectOp()) - if loader.beforeErrors == 0 { - t.Fatal("fixture is vacuous: the before state carried no error finding") - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) - } - assertGrandfatheredSurfaced(t, res.Findings) - if got := r.blobID(t, scopeUnrelatedPath); got != aBlob { - t.Errorf("unrelated record blob changed: %q -> %q", aBlob, got) - } - paths := diffTreePaths(t, r.Origin, res.AppliedCommit) - if len(paths) != 1 || paths[0] != scopeSubjectPath { - t.Errorf("committed paths = %v, want [%s]", paths, scopeSubjectPath) - } - }) - } -} - // assertGrandfatheredSurfaced proves an applied or no-op scoped result carries // the unrelated record A's grandfathered error finding — and only it, at its // error severity (spec §1 step 5: unrelated health findings travel through the @@ -206,389 +173,3 @@ func assertGrandfatheredSurfaced(t *testing.T, findings []domain.Finding) { } } } - -// TestEngineScopeNoOpSurfacesUnrelatedError is the no-op sibling of the -// progress case: an empty plan beside the unrelated invalid A is still no-op, -// and still reports A's grandfathered error finding. -func TestEngineScopeNoOpSurfacesUnrelatedError(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - res := execScoped(t, eng, r, repo, &scopeLoader{}, staticScope(nil, scopeSubjectPath), nil, &scriptedOp{}) - if res.Disposition != DispositionNoOp { - t.Fatalf("disposition = %q, want no-op (findings %v)", res.Disposition, res.Findings) - } - assertGrandfatheredSurfaced(t, res.Findings) - if r.originTip(t) != base { - t.Error("origin advanced on a no-op") - } -} - -// TestEngineScopeRefusesRelevantBeforeErrors proves an error on a subject — the -// root itself, a resolved dependency, or a path the request pins in Expected — -// refuses before the operation is ever consulted. -func TestEngineScopeRefusesRelevantBeforeErrors(t *testing.T) { - cases := []struct { - name string - seed func(t *testing.T, r *testRepos) - scope []string - pinA bool - wantAt string - wantNot string - }{ - { - name: "error on root", - seed: func(t *testing.T, r *testRepos) { - r.advanceOrigin(t, scopeSubjectPath, corpusChange(1, "other-slug", "proposed")) - }, - scope: []string{scopeSubjectPath}, - wantAt: scopeSubjectPath, - }, - { - name: "error on resolved dependency", - seed: func(t *testing.T, r *testRepos) { - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - r.advanceOrigin(t, scopeDependencyPath, corpusChange(2, "other-slug", "proposed")) - }, - scope: []string{scopeSubjectPath, scopeDependencyPath}, - wantAt: scopeDependencyPath, - wantNot: scopeUnrelatedPath, - }, - { - name: "error on an expectation-pinned path", - seed: func(t *testing.T, r *testRepos) { - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - }, - scope: []string{scopeSubjectPath}, - pinA: true, - wantAt: scopeUnrelatedPath, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - c.seed(t, r) - base := r.originTip(t) - var exp []EntityExpectation - if c.pinA { - exp = []EntityExpectation{{Path: scopeUnrelatedPath, - Version: ExpectedVersion{Kind: VersionBlob, ObjectID: r.blobID(t, scopeUnrelatedPath)}}} - } - - op := editSubjectOp() - res := execScoped(t, eng, r, repo, &scopeLoader{}, staticScope(nil, c.scope...), exp, op) - assertScopedRefusal(t, r, res, base) - if op.calls != 0 { - t.Errorf("operation consulted %d times before the scoped before-gate refused", op.calls) - } - if !hasFindingAt(res.Findings, c.wantAt) { - t.Errorf("refusal findings %v lack the relevant error at %s", res.Findings, c.wantAt) - } - if c.wantNot != "" && hasFindingAt(res.Findings, c.wantNot) { - t.Errorf("refusal findings %v carry the unrelated error at %s", res.Findings, c.wantNot) - } - }) - } -} - -// TestEngineScopeUnresolvableScopeIsStrict proves an empty resolved subject set, -// a resolver error, and a nil resolver inside a non-nil scope all fall back to -// strict whole-corpus validation: the unrelated error refuses, never applies. -func TestEngineScopeUnresolvableScopeIsStrict(t *testing.T) { - cases := []struct { - name string - scope *ValidationScope - }{ - {"empty resolved set", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { - return map[gitcli.RepoPath]bool{}, nil - }}}, - {"only unusable entries resolved", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { - return map[gitcli.RepoPath]bool{"": true, scopeSubjectPath: false}, nil - }}}, - {"resolver error", &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { - return map[gitcli.RepoPath]bool{scopeSubjectPath: true}, errors.New("resolver failed") - }}}, - {"nil resolver", &ValidationScope{}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - op := editSubjectOp() - res := execScoped(t, eng, r, repo, &scopeLoader{}, c.scope, nil, op) - assertScopedRefusal(t, r, res, base) - if op.calls != 0 { - t.Errorf("operation consulted %d times despite a strict before-gate refusal", op.calls) - } - if !hasFindingAt(res.Findings, scopeUnrelatedPath) { - t.Errorf("strict refusal findings %v lack the corpus error at %s", res.Findings, scopeUnrelatedPath) - } - }) - } -} - -// TestEngineScopeUnresolvableCandidateIsStrict proves the after-gate falls back -// to strict on its own: when the candidate state resolves to nothing (or the -// resolver errors on it), the unrelated pre-existing error refuses instead of -// being grandfathered under the before state's subjects. -func TestEngineScopeUnresolvableCandidateIsStrict(t *testing.T) { - cases := []struct { - name string - after func() (map[gitcli.RepoPath]bool, error) - }{ - {"candidate resolves empty", func() (map[gitcli.RepoPath]bool, error) { - return map[gitcli.RepoPath]bool{}, nil - }}, - {"candidate resolves only unusable entries", func() (map[gitcli.RepoPath]bool, error) { - return map[gitcli.RepoPath]bool{"": true, scopeSubjectPath: false}, nil - }}, - {"candidate resolver error", func() (map[gitcli.RepoPath]bool, error) { - return nil, errors.New("resolver failed on candidate") - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - calls := 0 - scope := &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { - calls++ - if calls == 1 { // the before state - return map[gitcli.RepoPath]bool{scopeSubjectPath: true}, nil - } - return c.after() - }} - op := editSubjectOp() - loader := &scopeLoader{} - res := execScoped(t, eng, r, repo, loader, scope, nil, op) - assertScopedRefusal(t, r, res, base) - if op.calls != 1 || loader.afters != 1 { - t.Errorf("operation consulted %d / candidate loaded %d times, want 1 / 1 (an after-gate refusal)", op.calls, loader.afters) - } - if !hasFindingAt(res.Findings, scopeUnrelatedPath) { - t.Errorf("strict after-gate findings %v lack the corpus error at %s", res.Findings, scopeUnrelatedPath) - } - }) - } -} - -// TestEngineScopePostPlanRecheckRefusesPlanTouchedError proves a plan that -// declares a path whose record already carried an error widens the scope and -// refuses at the post-plan recheck — before the candidate is ever loaded — even -// when the plan would repair that record. -func TestEngineScopePostPlanRecheckRefusesPlanTouchedError(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - op := &scriptedOp{files: []FileMutation{ - {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(editedSubject())}, - {Path: scopeUnrelatedPath, Kind: MutationReplace, Bytes: []byte(corpusChange(7, "mismatch", "proposed"))}, - }} - loader := &scopeLoader{} - res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, op) - assertScopedRefusal(t, r, res, base) - if op.calls != 1 { - t.Errorf("operation consulted %d times, want 1", op.calls) - } - if loader.afters != 0 { - t.Errorf("candidate loaded %d times; the post-plan recheck must refuse before the after load", loader.afters) - } - if !hasFindingAt(res.Findings, scopeUnrelatedPath) { - t.Errorf("recheck findings %v lack the plan-touched error at %s", res.Findings, scopeUnrelatedPath) - } -} - -// TestEngineScopeCandidateSubjectsAreResolved proves the resolver runs against -// the candidate too: a plan that makes B newly depend on the invalid A pulls A -// into scope, so A's unchanged error refuses rather than being grandfathered. -func TestEngineScopeCandidateSubjectsAreResolved(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - dependent := strings.Replace(corpusChange(1, "first-change", "proposed"), - "type: feat\n", "type: feat\ndepends_on: [7]\n", 1) - op := &scriptedOp{files: []FileMutation{ - {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(dependent)}, - }} - loader := &scopeLoader{} - res := execScoped(t, eng, r, repo, loader, dependencyScope(), nil, op) - assertScopedRefusal(t, r, res, base) - if loader.afters != 1 { - t.Errorf("candidate loaded %d times, want 1 (the after-gate refusal)", loader.afters) - } - if !hasFindingAt(res.Findings, scopeUnrelatedPath) { - t.Errorf("after-gate findings %v lack the newly required record's error at %s", res.Findings, scopeUnrelatedPath) - } -} - -// TestEngineScopeAfterGateVolatility proves the after-gate grandfathers only an -// exact, unchanged pre-existing unrelated error: a changed blob id for its path, -// a new error (even one sharing the old code), or a count increase all refuse. -func TestEngineScopeAfterGateVolatility(t *testing.T) { - // withFindings rebuilds st's report with extra findings appended. - withFindings := func(st *LoadedState, extra ...domain.Finding) { - st.Report = domain.NewValidationReport(append(st.Report.Findings(), extra...)) - } - unrelatedFinding := func(st *LoadedState) domain.Finding { - for _, f := range errorFindings(st.Report.Findings()) { - if f.Entity.Path == scopeUnrelatedPath { - return f - } - } - t.Fatal("after state lacks the unrelated finding") - return domain.Finding{} - } - cases := []struct { - name string - tamper func(st *LoadedState) - wantAt string - }{ - {"unrelated path blob id changed", func(st *LoadedState) { - st.Blobs[scopeUnrelatedPath] = "1111111111111111111111111111111111111111" - }, scopeUnrelatedPath}, - {"new unrelated error with an existing code", func(st *LoadedState) { - f := unrelatedFinding(st) - f.Entity = domain.EntityRef{Kind: domain.EntityChange, ID: 2, Slug: "second-change", Path: scopeDependencyPath} - withFindings(st, f) - }, scopeDependencyPath}, - {"count increase of an existing key", func(st *LoadedState) { - withFindings(st, unrelatedFinding(st)) - }, scopeUnrelatedPath}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - base := r.originTip(t) - - loader := &scopeLoader{tamperAfter: c.tamper} - res := execScoped(t, eng, r, repo, loader, staticScope(nil, scopeSubjectPath), nil, editSubjectOp()) - assertScopedRefusal(t, r, res, base) - if loader.afters != 1 { - t.Errorf("candidate loaded %d times, want 1", loader.afters) - } - if !hasFindingAt(res.Findings, c.wantAt) { - t.Errorf("after-gate findings %v lack the volatile error at %s", res.Findings, c.wantAt) - } - }) - } -} - -// TestEngineScopeLeaseRetryRecomputes proves scope and baseline are derived -// fresh from each attempt's base: after a lease loss, the second base decides — -// a newly relevant error refuses, and a newly present unrelated error is -// grandfathered against the fresh baseline rather than a stale one. -func TestEngineScopeLeaseRetryRecomputes(t *testing.T) { - const newUnrelatedPath = "docs/changes/active/0008-other-mismatch.md" - cases := []struct { - name string - advancePath string - advanceBody string - want Disposition - }{ - {"second base carries a relevant error", scopeDependencyPath, corpusChange(2, "other-slug", "proposed"), DispositionRefused}, - {"second base carries a new unrelated error", newUnrelatedPath, corpusChange(8, "yet-another-slug", "proposed"), DispositionApplied}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - - op := editSubjectOp() - op.beforePlan = func(call int) { - if call == 1 { - r.advanceOrigin(t, c.advancePath, c.advanceBody) - } - } - var resolverCalls int - loader := &scopeLoader{} - res := execScoped(t, eng, r, repo, loader, - staticScope(&resolverCalls, scopeSubjectPath, scopeDependencyPath), nil, op) - if res.Disposition != c.want { - t.Fatalf("disposition = %q, want %q (findings %v)", res.Disposition, c.want, res.Findings) - } - if res.Attempts != 2 { - t.Errorf("attempts = %d, want 2", res.Attempts) - } - if loader.befores != 2 { - t.Errorf("before state loaded %d times, want once per attempt (2)", loader.befores) - } - if resolverCalls < 3 { - t.Errorf("resolver consulted %d times; want it re-run on the second attempt's base", resolverCalls) - } - if c.want == DispositionRefused { - if op.calls != 1 { - t.Errorf("operation consulted %d times, want 1 (second attempt refuses before planning)", op.calls) - } - if !hasFindingAt(res.Findings, scopeDependencyPath) { - t.Errorf("refusal findings %v lack the second base's relevant error", res.Findings) - } - } - }) - } -} - -// TestEngineScopeKeyedReplay proves a scoped keyed request replays -// already-applied through the idempotency scan, before any load or resolution. -func TestEngineScopeKeyedReplay(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - r.advanceOrigin(t, scopeUnrelatedPath, scopeUnrelatedRecord()) - - var resolverCalls int - scope := staticScope(&resolverCalls, scopeSubjectPath) - loader := &scopeLoader{} - first, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: loader, Scope: scope, Operation: editSubjectOp(), - }) - if err != nil || first.Disposition != DispositionApplied { - t.Fatalf("first Execute: disposition %q err %v (findings %v)", first.Disposition, err, first.Findings) - } - loadsAfterFirst, callsAfterFirst := loader.befores+loader.afters, resolverCalls - - replayOp := editSubjectOp() - second, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: loader, Scope: scope, Operation: replayOp, - }) - if err != nil { - t.Fatalf("replay Execute: %v", err) - } - if second.Disposition != DispositionAlreadyApplied { - t.Fatalf("replay disposition = %q, want already-applied", second.Disposition) - } - if second.AppliedCommit != first.AppliedCommit { - t.Errorf("replay commit = %q, want original %q", second.AppliedCommit, first.AppliedCommit) - } - if got := loader.befores + loader.afters; got != loadsAfterFirst { - t.Errorf("replay loaded state %d more times, want 0", got-loadsAfterFirst) - } - if resolverCalls != callsAfterFirst || replayOp.calls != 0 { - t.Errorf("replay consulted resolver %d / operation %d times, want 0", resolverCalls-callsAfterFirst, replayOp.calls) - } -} diff --git a/internal/repository/transaction/engine_test.go b/internal/repository/transaction/engine_test.go index fcf28a739..bce323ab0 100644 --- a/internal/repository/transaction/engine_test.go +++ b/internal/repository/transaction/engine_test.go @@ -2,7 +2,6 @@ package transaction import ( "context" - "strings" "sync" "testing" "time" @@ -89,378 +88,6 @@ func TestNewEngineRejectsNilDependencies(t *testing.T) { } } -// TestEngineAppliesHappyPath runs one full apply on both topologies and proves the -// commit landed on origin with exactly the planned path, the engine trailer block, -// a populated Result, and a fully cleaned transactions root. -func TestEngineAppliesHappyPath(t *testing.T) { - for _, topo := range topologies() { - t.Run(topo.name, func(t *testing.T) { - r := topo.build(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - op := createOp(thirdChangePath, thirdChange()) - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) - } - if res.Attempts != 1 { - t.Errorf("attempts = %d, want 1", res.Attempts) - } - if res.Operation != "test.op" { - t.Errorf("operation = %q, want test.op", res.Operation) - } - if res.BaseCommit != base { - t.Errorf("base commit = %q, want %q", res.BaseCommit, base) - } - tip := r.originTip(t) - if res.AppliedCommit != tip { - t.Errorf("applied commit = %q, origin tip = %q", res.AppliedCommit, tip) - } - if res.RemoteCommit != tip { - t.Errorf("remote commit = %q, want %q", res.RemoteCommit, tip) - } - if string(res.Receipt) != string(validReceipt()) { - t.Errorf("receipt = %q, want %q", res.Receipt, validReceipt()) - } - if len(res.CleanupWarnings) != 0 { - t.Errorf("cleanup warnings = %v, want none", res.CleanupWarnings) - } - - paths := diffTreePaths(t, r.Origin, res.AppliedCommit) - if len(paths) != 1 || paths[0] != thirdChangePath { - t.Errorf("committed paths = %v, want [%s]", paths, thirdChangePath) - } - trailers := hgitOut(t, r.Origin, "log", "-1", "--format=%(trailers:only,unfold)", string(res.AppliedCommit)) - for _, want := range []string{"Docket-Transaction-ID: ", "Docket-Operation: test.op", "Docket-Result: "} { - if !strings.Contains(trailers, want) { - t.Errorf("trailer block missing %q:\n%s", want, trailers) - } - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after apply") - } - }) - } -} - -// TestEngineNoOpOnEmptyPlan proves an empty-Files plan is a no-op: no commit lands -// and the disposition is no-op. -func TestEngineNoOpOnEmptyPlan(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - op := &scriptedOp{files: nil} // empty plan (valid subject/receipt supplied by defaults) - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionNoOp { - t.Fatalf("disposition = %q, want no-op", res.Disposition) - } - if r.originTip(t) != base { - t.Error("origin advanced on a no-op") - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after no-op") - } -} - -// TestEngineRefusalFromOperation proves an operation refusal produces a refused -// disposition carrying its findings, with no commit. -func TestEngineRefusalFromOperation(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - op := &scriptedOp{refuse: true, findings: []domain.Finding{ - {Code: "op-refused", Severity: domain.SeverityError, Entity: domain.EntityRef{Kind: domain.EntityRepo}}, - }} - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionRefused { - t.Fatalf("disposition = %q, want refused", res.Disposition) - } - if len(res.Findings) == 0 { - t.Error("refusal carried no findings") - } - if r.originTip(t) != base { - t.Error("origin advanced on a refusal") - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after refusal") - } -} - -// TestEngineBeforeGateRefusesInvalidBase proves the before-gate refuses when the -// loaded base has error findings — before the operation is ever consulted. -func TestEngineBeforeGateRefusesInvalidBase(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - // A record whose filename disagrees with its frontmatter slug is a base error. - r.advanceOrigin(t, "docs/changes/active/0007-mismatch.md", corpusChange(7, "different-slug", "proposed")) - base := r.originTip(t) - - op := createOp(thirdChangePath, thirdChange()) - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionRefused { - t.Fatalf("disposition = %q, want refused", res.Disposition) - } - if op.calls != 0 { - t.Errorf("operation was consulted %d times before the before-gate refused", op.calls) - } - if len(res.Findings) == 0 { - t.Error("before-gate refusal carried no findings") - } - if r.originTip(t) != base { - t.Error("origin advanced on a before-gate refusal") - } -} - -// TestEngineAfterGateRefusesInvalidPlan proves the after-gate refuses when the -// operation plans a domain-invalid record; nothing is pushed. -func TestEngineAfterGateRefusesInvalidPlan(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - // Filename slug "bad" disagrees with frontmatter slug "wrong-slug": an error. - op := createOp("docs/changes/active/0004-bad.md", corpusChange(4, "wrong-slug", "proposed")) - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionRefused { - t.Fatalf("disposition = %q, want refused", res.Disposition) - } - if len(res.Findings) == 0 { - t.Error("after-gate refusal carried no findings") - } - if r.originTip(t) != base { - t.Error("origin advanced on an after-gate refusal") - } -} - -// TestEngineEvolutionGateBlocksFrozenADRRewrite proves the evolution gate blocks a -// plan that rewrites an already-published ADR's frozen body. -func TestEngineEvolutionGateBlocksFrozenADRRewrite(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - rewritten := strings.Replace(corpusADR(1, "first-decision"), "Body.", "Rewritten body.", 1) - op := &scriptedOp{files: []FileMutation{ - {Path: "docs/adrs/0001-first-decision.md", Kind: MutationReplace, Bytes: []byte(rewritten)}, - }} - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionRefused { - t.Fatalf("disposition = %q, want refused (findings %v)", res.Disposition, res.Findings) - } - if r.originTip(t) != base { - t.Error("origin advanced on an evolution-gate refusal") - } -} - -// TestEngineExpectationMatrix proves matching/stale blob and present/absent -// expectations gate the transaction correctly. -func TestEngineExpectationMatrix(t *testing.T) { - firstPath := "docs/changes/active/0001-first-change.md" - - t.Run("matching blob applies", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), - Version: ExpectedVersion{Kind: VersionBlob, ObjectID: r.blobID(t, firstPath)}}} - res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied", res.Disposition) - } - }) - - t.Run("stale blob contends first attempt", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), - Version: ExpectedVersion{Kind: VersionBlob, ObjectID: "0000000000000000000000000000000000000000"}}} - op := createOp(thirdChangePath, thirdChange()) - res := mustExecute(t, eng, r, repo, exp, op) - if res.Disposition != DispositionContended { - t.Fatalf("disposition = %q, want contended", res.Disposition) - } - if len(res.ContendedPaths) != 1 || res.ContendedPaths[0] != gitcli.RepoPath(firstPath) { - t.Errorf("contended paths = %v, want [%s]", res.ContendedPaths, firstPath) - } - if res.Attempts != 1 { - t.Errorf("attempts = %d, want 1", res.Attempts) - } - if op.calls != 0 { - t.Errorf("operation consulted %d times despite a first-attempt mismatch", op.calls) - } - if r.originTip(t) != base { - t.Error("origin advanced on a contended expectation") - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after contention") - } - }) - - t.Run("absent expectation passes when path is absent", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - exp := []EntityExpectation{{Path: "docs/changes/active/9999-none.md", - Version: ExpectedVersion{Kind: VersionAbsent}}} - res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied", res.Disposition) - } - }) - - t.Run("absent expectation contends when path is present", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - exp := []EntityExpectation{{Path: gitcli.RepoPath(firstPath), - Version: ExpectedVersion{Kind: VersionAbsent}}} - res := mustExecute(t, eng, r, repo, exp, createOp(thirdChangePath, thirdChange())) - if res.Disposition != DispositionContended { - t.Fatalf("disposition = %q, want contended", res.Disposition) - } - }) -} - -// TestEngineRejectsNonBranchTargetRef proves a tag ref or a short name is a -// call-shape failure: a Go *Failure of kind invalid-input, before any Git work. -func TestEngineRejectsNonBranchTargetRef(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - for _, ref := range []gitcli.RefName{"refs/tags/v1", "main", "refs/remotes/origin/main"} { - _, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: ref, - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err == nil { - t.Fatalf("target ref %q: want error", ref) - } - assertFailureKind(t, err, KindInvalidInput) - } -} - -// TestEngineFailsOnMissingTargetBranch proves a missing target branch is a typed -// failure and never triggers branch creation. -func TestEngineFailsOnMissingTargetBranch(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: "refs/heads/nope", - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err == nil { - t.Fatal("missing target branch: want error") - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error is not *Failure: %v", err) - } - if f.Stage != StageFetch { - t.Errorf("failure stage = %q, want fetch", f.Stage) - } - if res.Disposition != DispositionFailed { - t.Errorf("disposition = %q, want failed", res.Disposition) - } - if _, gerr := hgitTry(r.Origin, "rev-parse", "--verify", "--quiet", "refs/heads/nope"); gerr == nil { - t.Error("engine created the missing target branch on origin") - } -} - -// TestEngineRetriesLeaseLoss proves a structurally proven lease loss retries from -// a fresh fetch and a fresh plan, applying on the next attempt — the reused first -// plan never appears in the winning commit. -func TestEngineRetriesLeaseLoss(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - op := createOp(thirdChangePath, thirdChange()) - op.beforePlan = func(call int) { - if call == 1 { - // Advance origin between fetch and push so attempt 1 loses the lease. - r.advanceOrigin(t, "docs/changes/active/0005-writer-change.md", - corpusChange(5, "writer-change", "proposed")) - } - } - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied", res.Disposition) - } - if res.Attempts != 2 { - t.Errorf("attempts = %d, want 2 (one lease loss then apply)", res.Attempts) - } - if op.calls != 2 { - t.Errorf("operation re-planned %d times, want 2", op.calls) - } - // Final origin carries both the writer's change and the engine's. - names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) - for _, want := range []string{thirdChangePath, "docs/changes/active/0005-writer-change.md"} { - if !strings.Contains(names, want) { - t.Errorf("final origin tree missing %q:\n%s", want, names) - } - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after a retried apply") - } -} - // TestEngineConcurrentExecuteIsRaceFree runs two Execute calls on one shared Engine // against two independent repositories, coordinated by a barrier, to catch shared // state races under -race. diff --git a/internal/repository/transaction/harness_integration_test.go b/internal/repository/transaction/harness_integration_test.go new file mode 100644 index 000000000..815a6ef72 --- /dev/null +++ b/internal/repository/transaction/harness_integration_test.go @@ -0,0 +1,46 @@ +//go:build integration + +package transaction + +import ( + "strings" + "testing" +) + +// TestIntegrationTxnApplyHarnessBuildersProduceExpectedTopology proves each builder yields the +// topology the engine tests depend on: a bare origin, a clean invocation checkout, +// the corpus on the target branch, and (docket mode) a linked docket worktree. +func TestIntegrationTxnApplyHarnessBuildersProduceExpectedTopology(t *testing.T) { + requireGit(t) + + t.Run("main", func(t *testing.T) { + r := newMainModeRepos(t) + if got := hgitOut(t, r.Origin, "rev-parse", "--is-bare-repository"); got != "true" { + t.Errorf("origin is-bare = %q, want true", got) + } + if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != "" { + t.Errorf("invocation status not clean:\n%s", got) + } + names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", "main") + for _, want := range []string{"docs/adrs/0001-first-decision.md", "docs/changes/active/0001-first-change.md"} { + if !strings.Contains(names, want) { + t.Errorf("main tree missing corpus record %q", want) + } + } + }) + + t.Run("docket", func(t *testing.T) { + r := newDocketModeRepos(t) + if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != "" { + t.Errorf("docket-mode invocation status not clean:\n%s", got) + } + docketNames := hgitOut(t, r.Invocation, "ls-tree", "-r", "--name-only", "origin/docket") + if !strings.Contains(docketNames, "docs/changes/active/0001-first-change.md") { + t.Errorf("docket branch missing corpus:\n%s", docketNames) + } + mainNames := hgitOut(t, r.Invocation, "ls-tree", "-r", "--name-only", "main") + if strings.Contains(mainNames, "0001-first-change.md") { + t.Errorf("main tree unexpectedly carries docket corpus:\n%s", mainNames) + } + }) +} diff --git a/internal/repository/transaction/harness_test.go b/internal/repository/transaction/harness_test.go index c2ff337d1..bcfe4aeb6 100644 --- a/internal/repository/transaction/harness_test.go +++ b/internal/repository/transaction/harness_test.go @@ -378,41 +378,3 @@ func transactionsEmpty(t *testing.T, repo gitcli.Repository) bool { } return true } - -// TestHarnessBuildersProduceExpectedTopology proves each builder yields the -// topology the engine tests depend on: a bare origin, a clean invocation checkout, -// the corpus on the target branch, and (docket mode) a linked docket worktree. -func TestHarnessBuildersProduceExpectedTopology(t *testing.T) { - requireGit(t) - - t.Run("main", func(t *testing.T) { - r := newMainModeRepos(t) - if got := hgitOut(t, r.Origin, "rev-parse", "--is-bare-repository"); got != "true" { - t.Errorf("origin is-bare = %q, want true", got) - } - if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != "" { - t.Errorf("invocation status not clean:\n%s", got) - } - names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", "main") - for _, want := range []string{"docs/adrs/0001-first-decision.md", "docs/changes/active/0001-first-change.md"} { - if !strings.Contains(names, want) { - t.Errorf("main tree missing corpus record %q", want) - } - } - }) - - t.Run("docket", func(t *testing.T) { - r := newDocketModeRepos(t) - if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != "" { - t.Errorf("docket-mode invocation status not clean:\n%s", got) - } - docketNames := hgitOut(t, r.Invocation, "ls-tree", "-r", "--name-only", "origin/docket") - if !strings.Contains(docketNames, "docs/changes/active/0001-first-change.md") { - t.Errorf("docket branch missing corpus:\n%s", docketNames) - } - mainNames := hgitOut(t, r.Invocation, "ls-tree", "-r", "--name-only", "main") - if strings.Contains(mainNames, "0001-first-change.md") { - t.Errorf("main tree unexpectedly carries docket corpus:\n%s", mainNames) - } - }) -} diff --git a/internal/repository/transaction/loader_integration_test.go b/internal/repository/transaction/loader_integration_test.go new file mode 100644 index 000000000..694546cce --- /dev/null +++ b/internal/repository/transaction/loader_integration_test.go @@ -0,0 +1,57 @@ +//go:build integration + +package transaction + +import ( + "context" + "sort" + "strings" + "testing" +) + +// TestIntegrationTxnApplyLoaderBuildsCleanStateFromCorpus proves the loader turns the harness corpus +// into a complete, error-free LoadedState reading through a real base tree. +func TestIntegrationTxnApplyLoaderBuildsCleanStateFromCorpus(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + ctx := context.Background() + + rev, err := client.FetchBranch(ctx, repo, "origin", r.Target) + if err != nil { + t.Fatalf("FetchBranch: %v", err) + } + src, err := client.OpenObjectSource(ctx, repo, rev) + if err != nil { + t.Fatalf("OpenObjectSource: %v", err) + } + + st, err := testLoader{}.Load(ctx, newBaseTree(src)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if st.Report.HasErrors() { + t.Fatalf("corpus produced error findings: %v", st.Report.Findings()) + } + if n := len(st.Snapshot.Changes()); n != 2 { + t.Errorf("changes = %d, want 2", n) + } + if n := len(st.Snapshot.ADRs()); n != 1 { + t.Errorf("adrs = %d, want 1", n) + } + + var gotPaths []string + for p := range st.Sources { + gotPaths = append(gotPaths, p) + } + sort.Strings(gotPaths) + want := []string{ + "docs/adrs/0001-first-decision.md", + "docs/changes/active/0001-first-change.md", + "docs/changes/active/0002-second-change.md", + } + sort.Strings(want) + if strings.Join(gotPaths, "|") != strings.Join(want, "|") { + t.Errorf("loaded sources = %v, want %v", gotPaths, want) + } +} diff --git a/internal/repository/transaction/loader_test.go b/internal/repository/transaction/loader_test.go index bf03e854b..c28889f43 100644 --- a/internal/repository/transaction/loader_test.go +++ b/internal/repository/transaction/loader_test.go @@ -3,7 +3,6 @@ package transaction import ( "context" "fmt" - "sort" "strings" "testing" @@ -122,53 +121,6 @@ func loaderConfig() config.Effective { // compile-time interface satisfaction. var _ StateLoader = testLoader{} -// TestLoaderBuildsCleanStateFromCorpus proves the loader turns the harness corpus -// into a complete, error-free LoadedState reading through a real base tree. -func TestLoaderBuildsCleanStateFromCorpus(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - ctx := context.Background() - - rev, err := client.FetchBranch(ctx, repo, "origin", r.Target) - if err != nil { - t.Fatalf("FetchBranch: %v", err) - } - src, err := client.OpenObjectSource(ctx, repo, rev) - if err != nil { - t.Fatalf("OpenObjectSource: %v", err) - } - - st, err := testLoader{}.Load(ctx, newBaseTree(src)) - if err != nil { - t.Fatalf("Load: %v", err) - } - if st.Report.HasErrors() { - t.Fatalf("corpus produced error findings: %v", st.Report.Findings()) - } - if n := len(st.Snapshot.Changes()); n != 2 { - t.Errorf("changes = %d, want 2", n) - } - if n := len(st.Snapshot.ADRs()); n != 1 { - t.Errorf("adrs = %d, want 1", n) - } - - var gotPaths []string - for p := range st.Sources { - gotPaths = append(gotPaths, p) - } - sort.Strings(gotPaths) - want := []string{ - "docs/adrs/0001-first-decision.md", - "docs/changes/active/0001-first-change.md", - "docs/changes/active/0002-second-change.md", - } - sort.Strings(want) - if strings.Join(gotPaths, "|") != strings.Join(want, "|") { - t.Errorf("loaded sources = %v, want %v", gotPaths, want) - } -} - // TestLoaderErrorsOnUnparseableRecord proves a record whose bytes will not parse // surfaces as a Go error (a loader failure), not a domain finding. func TestLoaderErrorsOnUnparseableRecord(t *testing.T) { diff --git a/internal/repository/transaction/preserve_integration_test.go b/internal/repository/transaction/preserve_integration_test.go new file mode 100644 index 000000000..b35c5d99a --- /dev/null +++ b/internal/repository/transaction/preserve_integration_test.go @@ -0,0 +1,72 @@ +//go:build integration + +package transaction + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationTxnApplyTransactionPreservesDirtyCheckout proves the engine leaves a working tree +// carrying staged, unstaged, AND untracked local edits byte-identical across an +// applied transaction. The engine works only in its private detached worktree and +// pushes to origin, so the invocation clone — dirty user work and all — must not +// move. +func TestIntegrationTxnApplyTransactionPreservesDirtyCheckout(t *testing.T) { + requireGit(t) + for _, topo := range topologies() { + t.Run(topo.name, func(t *testing.T) { + r := topo.build(t) + client, err := gitcli.NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + eng := newEngine(t, client) + repo, dir := freshClone(t, client, r, "dirty") + + // Dirty the checkout three ways: a staged edit, an unstaged edit, and an + // untracked file. The three must all survive byte-for-byte. + staged := filepath.Join(dir, "staged-edit.md") + if err := os.WriteFile(staged, []byte("staged local work\n"), 0o644); err != nil { + t.Fatal(err) + } + hgitOut(t, dir, "add", "--", "staged-edit.md") + unstaged := filepath.Join(dir, "README-or-docket.md") + if err := os.WriteFile(unstaged, []byte("unstaged local work\n"), 0o644); err != nil { + t.Fatal(err) + } + hgitOut(t, dir, "add", "--", "README-or-docket.md") + if err := os.WriteFile(unstaged, []byte("unstaged local work\nMORE UNSTAGED\n"), 0o644); err != nil { + t.Fatal(err) + } + untracked := filepath.Join(dir, "untracked.local") + if err := os.WriteFile(untracked, []byte("untracked local work\n"), 0o644); err != nil { + t.Fatal(err) + } + + before := captureCheckouts(t, dir) + + op := createOp(thirdChangePath, thirdChange()) + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) + } + + // The transaction landed on origin, and the dirty checkout is untouched. + assertCheckoutsUnchanged(t, []string{dir}, before) + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after apply") + } + }) + } +} diff --git a/internal/repository/transaction/preserve_test.go b/internal/repository/transaction/preserve_test.go index e8c4b7b66..d660c635c 100644 --- a/internal/repository/transaction/preserve_test.go +++ b/internal/repository/transaction/preserve_test.go @@ -1,13 +1,8 @@ package transaction import ( - "context" - "os" - "path/filepath" "strings" "testing" - - "github.com/danielhanold/docket/internal/gitcli" ) // This file is the byte-identical preservation proof for the transaction engine. @@ -91,63 +86,3 @@ func assertCheckoutsUnchanged(t *testing.T, dirs []string, before []checkoutStat } } } - -// TestTransactionPreservesDirtyCheckout proves the engine leaves a working tree -// carrying staged, unstaged, AND untracked local edits byte-identical across an -// applied transaction. The engine works only in its private detached worktree and -// pushes to origin, so the invocation clone — dirty user work and all — must not -// move. -func TestTransactionPreservesDirtyCheckout(t *testing.T) { - requireGit(t) - for _, topo := range topologies() { - t.Run(topo.name, func(t *testing.T) { - r := topo.build(t) - client, err := gitcli.NewClient() - if err != nil { - t.Fatalf("NewClient: %v", err) - } - eng := newEngine(t, client) - repo, dir := freshClone(t, client, r, "dirty") - - // Dirty the checkout three ways: a staged edit, an unstaged edit, and an - // untracked file. The three must all survive byte-for-byte. - staged := filepath.Join(dir, "staged-edit.md") - if err := os.WriteFile(staged, []byte("staged local work\n"), 0o644); err != nil { - t.Fatal(err) - } - hgitOut(t, dir, "add", "--", "staged-edit.md") - unstaged := filepath.Join(dir, "README-or-docket.md") - if err := os.WriteFile(unstaged, []byte("unstaged local work\n"), 0o644); err != nil { - t.Fatal(err) - } - hgitOut(t, dir, "add", "--", "README-or-docket.md") - if err := os.WriteFile(unstaged, []byte("unstaged local work\nMORE UNSTAGED\n"), 0o644); err != nil { - t.Fatal(err) - } - untracked := filepath.Join(dir, "untracked.local") - if err := os.WriteFile(untracked, []byte("untracked local work\n"), 0o644); err != nil { - t.Fatal(err) - } - - before := captureCheckouts(t, dir) - - op := createOp(thirdChangePath, thirdChange()) - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) - } - - // The transaction landed on origin, and the dirty checkout is untouched. - assertCheckoutsUnchanged(t, []string{dir}, before) - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after apply") - } - }) - } -} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 77f2151b6..b51958abd 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -73,6 +73,7 @@ tests/test_go_integration_githubcli_harness.sh 10 parallel tests/test_go_integration_githubcli_merge.sh 10 parallel tests/test_go_integration_githubcli_probe.sh 10 parallel tests/test_go_integration_githubcli_prbatch.sh 10 parallel +tests/test_go_integration_transaction_apply.sh 25 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_transaction_apply.sh b/tests/test_go_integration_transaction_apply.sh new file mode 100755 index 000000000..a367a5f62 --- /dev/null +++ b/tests/test_go_integration_transaction_apply.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_apply.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's apply-path real-git tests (engine, scope +# gates, candidate allocation, loader, harness topology, dirty-checkout preservation) — moved +# out of the default internal/repository/transaction corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationTxnApply. Declarations only — execution and +# inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestIntegrationTxnApply" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From af29579da131f164a7a0237ab2b3ebee472d3c91 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:09:17 -0400 Subject: [PATCH 04/16] test(transaction): move replay and recovery real-git tests behind the integration tag (TestIntegrationTxnRecovery, change 0466) --- .../transaction/cleanup_integration_test.go | 127 ++++++ .../repository/transaction/cleanup_test.go | 122 ------ .../repository/transaction/harness_test.go | 2 +- .../idempotency_integration_test.go | 285 +++++++++++++ .../transaction/idempotency_test.go | 277 ------------ .../transaction/interrupt_integration_test.go | 185 +++++++++ .../repository/transaction/interrupt_test.go | 175 -------- .../materialize_integration_test.go | 296 +++++++++++++ .../transaction/materialize_test.go | 283 ------------- .../transaction/recovery_integration_test.go | 393 ++++++++++++++++++ .../repository/transaction/recovery_test.go | 381 ----------------- tests/runtime-budgets.tsv | 1 + ...est_go_integration_transaction_recovery.sh | 24 ++ 13 files changed, 1312 insertions(+), 1239 deletions(-) create mode 100644 internal/repository/transaction/cleanup_integration_test.go create mode 100644 internal/repository/transaction/idempotency_integration_test.go create mode 100644 internal/repository/transaction/interrupt_integration_test.go create mode 100644 internal/repository/transaction/materialize_integration_test.go create mode 100644 internal/repository/transaction/recovery_integration_test.go create mode 100755 tests/test_go_integration_transaction_recovery.sh diff --git a/internal/repository/transaction/cleanup_integration_test.go b/internal/repository/transaction/cleanup_integration_test.go new file mode 100644 index 000000000..3b857c76f --- /dev/null +++ b/internal/repository/transaction/cleanup_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package transaction + +import ( + "context" + "os" + "sort" + "testing" +) + +// TestIntegrationTxnRecoveryPruneReportEmptyOnCleanRoot proves a sweep of a repository with no candidates +// returns an empty, non-error report — the transactions root need not pre-exist. +func TestIntegrationTxnRecoveryPruneReportEmptyOnCleanRoot(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, _, repo := recoveryEngine(t, r) + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + if len(rep.Entries) != 0 { + t.Errorf("entries = %+v, want empty", rep.Entries) + } +} + +// TestIntegrationTxnRecoveryPruneReportDeterministicOrder proves the report lists every candidate exactly +// once in ascending ID order regardless of filesystem enumeration order. +func TestIntegrationTxnRecoveryPruneReportDeterministicOrder(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + + var ids []string + for i := 0; i < 3; i++ { + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + ids = append(ids, c.id) + } + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + if len(rep.Entries) != len(ids) { + t.Fatalf("entries = %+v, want %d", rep.Entries, len(ids)) + } + + var gotOrder []string + for _, e := range rep.Entries { + gotOrder = append(gotOrder, e.ID) + if e.Verdict != verdictPruned { + t.Errorf("candidate %s verdict = %q, want pruned", e.ID, e.Verdict) + } + } + wantOrder := append([]string(nil), gotOrder...) + sort.Strings(wantOrder) + for i := range wantOrder { + if gotOrder[i] != wantOrder[i] { + t.Errorf("report not sorted by ID: %v", gotOrder) + break + } + } +} + +// TestIntegrationTxnRecoveryPruneDocketModeIgnoresLinkedWorktree proves recovery prunes a candidate under +// the docket-mode topology and never mistakes the unrelated linked .docket worktree +// for a candidate registration. +func TestIntegrationTxnRecoveryPruneDocketModeIgnoresLinkedWorktree(t *testing.T) { + requireGit(t) + r := newDocketModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictPruned { + t.Fatalf("verdict = %q, want pruned (detail: %s)", e.Verdict, e.Detail) + } + if _, err := os.Stat(c.root); !os.IsNotExist(err) { + t.Errorf("candidate root still present: %v", err) + } + + // The linked .docket worktree is untouched and still registered. + infos, err := client.ListWorktrees(context.Background(), repo) + if err != nil { + t.Fatalf("ListWorktrees: %v", err) + } + if !hasWorktreeNamed(infos, ".docket") { + t.Errorf("linked .docket worktree missing after sweep: %+v", infos) + } +} + +// TestIntegrationTxnRecoveryCleanupRetainsRegisteredCandidateOnListError proves that when the worktree +// listing worktreeRegistered relies on fails during per-candidate cleanup, the +// candidate directory is RETAINED with a cleanup-pending warning rather than +// removed. A list error is indistinguishable from a genuine "not registered", so a +// direct removal would orphan the candidate's still-live worktree registration — +// administrative state PruneAbandoned can never reclaim once the directory is gone. +// This mirrors the retain-on-uncertainty posture of the RemoveWorktree-failed +// branch. (0309 review finding 1.) +func TestIntegrationTxnRecoveryCleanupRetainsRegisteredCandidateOnListError(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + + // A cancelled context forces every git invocation — including the worktree + // listing — to fail, simulating a transient ListWorktrees error for a + // candidate that IS registered. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + warnings := eng.cleanupCandidate(ctx, repo, c) + + if _, err := os.Stat(c.root); err != nil { + t.Fatalf("candidate root was removed on worktree-list error, want retained: %v", err) + } + if !hasCleanupPending(warnings, c.id) { + t.Errorf("warnings = %v, want a %q entry", warnings, "cleanup-pending: "+c.id) + } +} diff --git a/internal/repository/transaction/cleanup_test.go b/internal/repository/transaction/cleanup_test.go index 1bcd14d96..5e99b9fb2 100644 --- a/internal/repository/transaction/cleanup_test.go +++ b/internal/repository/transaction/cleanup_test.go @@ -1,11 +1,6 @@ package transaction import ( - "context" - "os" - "sort" - "testing" - "github.com/danielhanold/docket/internal/gitcli" ) @@ -14,123 +9,6 @@ import ( // worktree present) as under main mode. The full ownership-and-recovery matrix // lives in recovery_test.go. -// TestPruneReportEmptyOnCleanRoot proves a sweep of a repository with no candidates -// returns an empty, non-error report — the transactions root need not pre-exist. -func TestPruneReportEmptyOnCleanRoot(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, _, repo := recoveryEngine(t, r) - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - if len(rep.Entries) != 0 { - t.Errorf("entries = %+v, want empty", rep.Entries) - } -} - -// TestPruneReportDeterministicOrder proves the report lists every candidate exactly -// once in ascending ID order regardless of filesystem enumeration order. -func TestPruneReportDeterministicOrder(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - - var ids []string - for i := 0; i < 3; i++ { - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - ids = append(ids, c.id) - } - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - if len(rep.Entries) != len(ids) { - t.Fatalf("entries = %+v, want %d", rep.Entries, len(ids)) - } - - var gotOrder []string - for _, e := range rep.Entries { - gotOrder = append(gotOrder, e.ID) - if e.Verdict != verdictPruned { - t.Errorf("candidate %s verdict = %q, want pruned", e.ID, e.Verdict) - } - } - wantOrder := append([]string(nil), gotOrder...) - sort.Strings(wantOrder) - for i := range wantOrder { - if gotOrder[i] != wantOrder[i] { - t.Errorf("report not sorted by ID: %v", gotOrder) - break - } - } -} - -// TestPruneDocketModeIgnoresLinkedWorktree proves recovery prunes a candidate under -// the docket-mode topology and never mistakes the unrelated linked .docket worktree -// for a candidate registration. -func TestPruneDocketModeIgnoresLinkedWorktree(t *testing.T) { - requireGit(t) - r := newDocketModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictPruned { - t.Fatalf("verdict = %q, want pruned (detail: %s)", e.Verdict, e.Detail) - } - if _, err := os.Stat(c.root); !os.IsNotExist(err) { - t.Errorf("candidate root still present: %v", err) - } - - // The linked .docket worktree is untouched and still registered. - infos, err := client.ListWorktrees(context.Background(), repo) - if err != nil { - t.Fatalf("ListWorktrees: %v", err) - } - if !hasWorktreeNamed(infos, ".docket") { - t.Errorf("linked .docket worktree missing after sweep: %+v", infos) - } -} - -// TestCleanupRetainsRegisteredCandidateOnListError proves that when the worktree -// listing worktreeRegistered relies on fails during per-candidate cleanup, the -// candidate directory is RETAINED with a cleanup-pending warning rather than -// removed. A list error is indistinguishable from a genuine "not registered", so a -// direct removal would orphan the candidate's still-live worktree registration — -// administrative state PruneAbandoned can never reclaim once the directory is gone. -// This mirrors the retain-on-uncertainty posture of the RemoveWorktree-failed -// branch. (0309 review finding 1.) -func TestCleanupRetainsRegisteredCandidateOnListError(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - - // A cancelled context forces every git invocation — including the worktree - // listing — to fail, simulating a transient ListWorktrees error for a - // candidate that IS registered. - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - warnings := eng.cleanupCandidate(ctx, repo, c) - - if _, err := os.Stat(c.root); err != nil { - t.Fatalf("candidate root was removed on worktree-list error, want retained: %v", err) - } - if !hasCleanupPending(warnings, c.id) { - t.Errorf("warnings = %v, want a %q entry", warnings, "cleanup-pending: "+c.id) - } -} - // hasCleanupPending reports whether warnings names id's cleanup-pending marker. func hasCleanupPending(warnings []string, id string) bool { want := "cleanup-pending: " + id diff --git a/internal/repository/transaction/harness_test.go b/internal/repository/transaction/harness_test.go index bcfe4aeb6..f76c4cecc 100644 --- a/internal/repository/transaction/harness_test.go +++ b/internal/repository/transaction/harness_test.go @@ -50,7 +50,7 @@ func requireGit(t *testing.T) { // detached git housekeeping child spawned by a fixture commit can outlive the // test and keep writing into a testsupport.TempDir, racing RemoveAll teardown to // "directory not empty" under parallel load (change 0373, sighting 4: -// TestKeyedCommitCarriesFiveTrailers/keyed). Git spawned through the product +// TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers/keyed). Git spawned through the product // gitcli client scrubs GIT_CONFIG, so its housekeeping children are instead // absorbed by the fixture's drain-then-retry removal. Set process-wide via // t.Setenv because the low-level helpers take no *testing.T; safe because this diff --git a/internal/repository/transaction/idempotency_integration_test.go b/internal/repository/transaction/idempotency_integration_test.go new file mode 100644 index 000000000..af1d3de8b --- /dev/null +++ b/internal/repository/transaction/idempotency_integration_test.go @@ -0,0 +1,285 @@ +//go:build integration + +package transaction + +import ( + "context" + "sort" + "strings" + "testing" +) + +// TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers proves a keyed apply writes exactly the five +// engine trailers and an unkeyed apply writes exactly the three always-present +// ones — never one of the request pair alone. +func TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers(t *testing.T) { + t.Run("keyed", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) + } + got := trailerKeys(t, r.Origin, res.AppliedCommit) + want := []string{"Docket-Operation", "Docket-Request-Digest", "Docket-Request-ID", "Docket-Result", "Docket-Transaction-ID"} + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("keyed trailer keys = %v, want %v", got, want) + } + if res.RequestID != keyReq().RequestID { + t.Errorf("result request id = %q, want %q", res.RequestID, keyReq().RequestID) + } + }) + + t.Run("unkeyed", func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + got := trailerKeys(t, r.Origin, res.AppliedCommit) + want := []string{"Docket-Operation", "Docket-Result", "Docket-Transaction-ID"} + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("unkeyed trailer keys = %v, want %v", got, want) + } + for _, k := range got { + if k == "Docket-Request-ID" || k == "Docket-Request-Digest" { + t.Errorf("unkeyed commit carries request trailer %q", k) + } + } + }) +} + +// TestIntegrationTxnRecoveryKeyedReplayReturnsOriginalReceipt proves a repeated keyed request after a +// successful apply returns the ORIGINAL receipt bytes and the authoritative commit +// with no new commit, even after the corpus has moved on. +func TestIntegrationTxnRecoveryKeyedReplayReturnsOriginalReceipt(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + // A distinctive canonical receipt carrying the allocated id. + planted := []byte(`{"id":"0003"}`) + op := createOp(thirdChangePath, thirdChange()) + op.receipt = planted + + res1, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("first Execute: %v", err) + } + if res1.Disposition != DispositionApplied { + t.Fatalf("first disposition = %q, want applied", res1.Disposition) + } + if string(res1.Receipt) != string(planted) { + t.Fatalf("first receipt = %q, want %q", res1.Receipt, planted) + } + original := res1.AppliedCommit + + // Move the corpus on with an unrelated unkeyed apply on top of the keyed commit. + res2, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: createOp("docs/changes/active/0004-fourth-change.md", corpusChange(4, "fourth-change", "proposed")), + }) + if err != nil || res2.Disposition != DispositionApplied { + t.Fatalf("moving-on Execute: disposition %q err %v", res2.Disposition, err) + } + movedTip := r.originTip(t) + + // Replay the first key. It must return the ORIGINAL receipt and commit, add no + // commit, and never re-run the operation. + replayOp := createOp(thirdChangePath, thirdChange()) + replayOp.receipt = []byte(`{"id":"9999"}`) // a fresh reconstruction would surface this + res3, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: replayOp, + }) + if err != nil { + t.Fatalf("replay Execute: %v", err) + } + if res3.Disposition != DispositionAlreadyApplied { + t.Fatalf("replay disposition = %q, want already-applied", res3.Disposition) + } + if res3.AppliedCommit != original { + t.Errorf("replay applied commit = %q, want original %q", res3.AppliedCommit, original) + } + if string(res3.Receipt) != string(planted) { + t.Errorf("replay receipt = %q, want ORIGINAL %q", res3.Receipt, planted) + } + if replayOp.calls != 0 { + t.Errorf("replay consulted the operation %d times; a replay must not replan", replayOp.calls) + } + if r.originTip(t) != movedTip { + t.Error("replay added a commit to origin") + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after a replay") + } +} + +// TestIntegrationTxnRecoveryRequestIDReusedDifferentDigest proves the same request ID with a different +// digest is invalid-input "request-id-reused". +func TestIntegrationTxnRecoveryRequestIDReusedDifferentDigest(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + // A well-formed keyed commit for our request id but a DIFFERENT digest. + plantCommit(t, r, engineBlockMessage("prior request", "deadbeefcafe", "other.op", + keyReq().RequestID, string(otherDigest()), b64(validReceipt()))) + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err == nil { + t.Fatal("reused request id: want error") + } + assertFailureKind(t, err, KindInvalidInput) + f, _ := AsFailure(err) + if f.Detail != "request-id-reused" { + t.Errorf("detail = %q, want request-id-reused", f.Detail) + } + if res.Disposition != DispositionFailed { + t.Errorf("disposition = %q, want failed", res.Disposition) + } +} + +// TestIntegrationTxnRecoveryDuplicateRequestIDIsInvalidState proves two history commits carrying the same +// request id are invalid-state — the engine never picks a winner by commit order. +func TestIntegrationTxnRecoveryDuplicateRequestIDIsInvalidState(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + plantCommit(t, r, engineBlockMessage("first", "aaaa1111", "test.op", + keyReq().RequestID, string(validDigest()), b64(validReceipt()))) + plantCommit(t, r, engineBlockMessage("second", "bbbb2222", "test.op", + keyReq().RequestID, string(validDigest()), b64(validReceipt()))) + + _, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err == nil { + t.Fatal("duplicate request id: want error") + } + assertFailureKind(t, err, KindInvalidState) +} + +// TestIntegrationTxnRecoveryMalformedResultIsInvalidState proves a malformed Docket-Result on a commit +// bearing our request id is invalid-state, for each malformation class. +func TestIntegrationTxnRecoveryMalformedResultIsInvalidState(t *testing.T) { + // >4096 decoded bytes, canonical JSON. + big := `{"x":"` + strings.Repeat("y", 5000) + `"}` + cases := []struct { + name string + result string // the raw Docket-Result trailer value + }{ + {"bad-base64", "!!!not-base64!!!"}, + {"non-canonical-json", b64([]byte(`{ "ok" : true }`))}, // insignificant whitespace + {"oversize", b64([]byte(big))}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + plantCommit(t, r, engineBlockMessage("bad receipt", "cccc3333", "test.op", + keyReq().RequestID, string(validDigest()), c.result)) + + _, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err == nil { + t.Fatalf("%s: want error", c.name) + } + assertFailureKind(t, err, KindInvalidState) + }) + } +} + +// TestIntegrationTxnRecoveryRequestIDInProseDoesNotMatch proves a Docket-Request-ID line living in body +// prose (outside the final trailer block) never matches: the engine proceeds and +// applies, it does not replay. +func TestIntegrationTxnRecoveryRequestIDInProseDoesNotMatch(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + // The request-id appears in an EARLIER paragraph with trailing prose, and the + // final paragraph is ordinary prose — so git parses no trailers at all. + message := "prose commit\n\n" + + "Docket-Request-ID: " + keyReq().RequestID + " is only mentioned in prose here.\n\n" + + "An ordinary closing paragraph with no trailers.\n" + planted := plantCommit(t, r, message) + if keys := trailerKeys(t, r.Origin, planted); len(keys) != 0 { + t.Fatalf("planted prose commit parsed trailers %v, want none", keys) + } + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (prose must not match)", res.Disposition) + } +} + +// TestIntegrationTxnRecoveryKeyedReplayFoundDeepInHistory proves the ancestry scan has no depth window: +// the key's commit buried under 30 later commits is still found. +func TestIntegrationTxnRecoveryKeyedReplayFoundDeepInHistory(t *testing.T) { + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + + planted := []byte(`{"id":"deep"}`) + keyedCommit := plantCommit(t, r, engineBlockMessage("buried apply", "dddd4444", "test.op", + keyReq().RequestID, string(validDigest()), b64(planted))) + + // Bury it under 30 later commits. + branch := r.short() + for i := 0; i < 30; i++ { + hgitOut(t, r.Writer, "commit", "-q", "--allow-empty", "-m", "later") + } + hgitOut(t, r.Writer, "push", "-q", "origin", branch) + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionAlreadyApplied { + t.Fatalf("disposition = %q, want already-applied", res.Disposition) + } + if res.AppliedCommit != keyedCommit { + t.Errorf("applied commit = %q, want buried %q", res.AppliedCommit, keyedCommit) + } + if string(res.Receipt) != string(planted) { + t.Errorf("receipt = %q, want %q", res.Receipt, planted) + } +} diff --git a/internal/repository/transaction/idempotency_test.go b/internal/repository/transaction/idempotency_test.go index 29aa9baea..9b193622f 100644 --- a/internal/repository/transaction/idempotency_test.go +++ b/internal/repository/transaction/idempotency_test.go @@ -1,9 +1,7 @@ package transaction import ( - "context" "encoding/base64" - "sort" "strings" "testing" @@ -78,278 +76,3 @@ func engineBlockMessage(subject, txnID, op, reqID, digest, resultB64 string) str b.WriteString("Docket-Result: " + resultB64 + "\n") return b.String() } - -// TestKeyedCommitCarriesFiveTrailers proves a keyed apply writes exactly the five -// engine trailers and an unkeyed apply writes exactly the three always-present -// ones — never one of the request pair alone. -func TestKeyedCommitCarriesFiveTrailers(t *testing.T) { - t.Run("keyed", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (findings %v)", res.Disposition, res.Findings) - } - got := trailerKeys(t, r.Origin, res.AppliedCommit) - want := []string{"Docket-Operation", "Docket-Request-Digest", "Docket-Request-ID", "Docket-Result", "Docket-Transaction-ID"} - sort.Strings(got) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("keyed trailer keys = %v, want %v", got, want) - } - if res.RequestID != keyReq().RequestID { - t.Errorf("result request id = %q, want %q", res.RequestID, keyReq().RequestID) - } - }) - - t.Run("unkeyed", func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - got := trailerKeys(t, r.Origin, res.AppliedCommit) - want := []string{"Docket-Operation", "Docket-Result", "Docket-Transaction-ID"} - sort.Strings(got) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("unkeyed trailer keys = %v, want %v", got, want) - } - for _, k := range got { - if k == "Docket-Request-ID" || k == "Docket-Request-Digest" { - t.Errorf("unkeyed commit carries request trailer %q", k) - } - } - }) -} - -// TestKeyedReplayReturnsOriginalReceipt proves a repeated keyed request after a -// successful apply returns the ORIGINAL receipt bytes and the authoritative commit -// with no new commit, even after the corpus has moved on. -func TestKeyedReplayReturnsOriginalReceipt(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - // A distinctive canonical receipt carrying the allocated id. - planted := []byte(`{"id":"0003"}`) - op := createOp(thirdChangePath, thirdChange()) - op.receipt = planted - - res1, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("first Execute: %v", err) - } - if res1.Disposition != DispositionApplied { - t.Fatalf("first disposition = %q, want applied", res1.Disposition) - } - if string(res1.Receipt) != string(planted) { - t.Fatalf("first receipt = %q, want %q", res1.Receipt, planted) - } - original := res1.AppliedCommit - - // Move the corpus on with an unrelated unkeyed apply on top of the keyed commit. - res2, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: createOp("docs/changes/active/0004-fourth-change.md", corpusChange(4, "fourth-change", "proposed")), - }) - if err != nil || res2.Disposition != DispositionApplied { - t.Fatalf("moving-on Execute: disposition %q err %v", res2.Disposition, err) - } - movedTip := r.originTip(t) - - // Replay the first key. It must return the ORIGINAL receipt and commit, add no - // commit, and never re-run the operation. - replayOp := createOp(thirdChangePath, thirdChange()) - replayOp.receipt = []byte(`{"id":"9999"}`) // a fresh reconstruction would surface this - res3, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: replayOp, - }) - if err != nil { - t.Fatalf("replay Execute: %v", err) - } - if res3.Disposition != DispositionAlreadyApplied { - t.Fatalf("replay disposition = %q, want already-applied", res3.Disposition) - } - if res3.AppliedCommit != original { - t.Errorf("replay applied commit = %q, want original %q", res3.AppliedCommit, original) - } - if string(res3.Receipt) != string(planted) { - t.Errorf("replay receipt = %q, want ORIGINAL %q", res3.Receipt, planted) - } - if replayOp.calls != 0 { - t.Errorf("replay consulted the operation %d times; a replay must not replan", replayOp.calls) - } - if r.originTip(t) != movedTip { - t.Error("replay added a commit to origin") - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after a replay") - } -} - -// TestRequestIDReusedDifferentDigest proves the same request ID with a different -// digest is invalid-input "request-id-reused". -func TestRequestIDReusedDifferentDigest(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - // A well-formed keyed commit for our request id but a DIFFERENT digest. - plantCommit(t, r, engineBlockMessage("prior request", "deadbeefcafe", "other.op", - keyReq().RequestID, string(otherDigest()), b64(validReceipt()))) - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err == nil { - t.Fatal("reused request id: want error") - } - assertFailureKind(t, err, KindInvalidInput) - f, _ := AsFailure(err) - if f.Detail != "request-id-reused" { - t.Errorf("detail = %q, want request-id-reused", f.Detail) - } - if res.Disposition != DispositionFailed { - t.Errorf("disposition = %q, want failed", res.Disposition) - } -} - -// TestDuplicateRequestIDIsInvalidState proves two history commits carrying the same -// request id are invalid-state — the engine never picks a winner by commit order. -func TestDuplicateRequestIDIsInvalidState(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - plantCommit(t, r, engineBlockMessage("first", "aaaa1111", "test.op", - keyReq().RequestID, string(validDigest()), b64(validReceipt()))) - plantCommit(t, r, engineBlockMessage("second", "bbbb2222", "test.op", - keyReq().RequestID, string(validDigest()), b64(validReceipt()))) - - _, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err == nil { - t.Fatal("duplicate request id: want error") - } - assertFailureKind(t, err, KindInvalidState) -} - -// TestMalformedResultIsInvalidState proves a malformed Docket-Result on a commit -// bearing our request id is invalid-state, for each malformation class. -func TestMalformedResultIsInvalidState(t *testing.T) { - // >4096 decoded bytes, canonical JSON. - big := `{"x":"` + strings.Repeat("y", 5000) + `"}` - cases := []struct { - name string - result string // the raw Docket-Result trailer value - }{ - {"bad-base64", "!!!not-base64!!!"}, - {"non-canonical-json", b64([]byte(`{ "ok" : true }`))}, // insignificant whitespace - {"oversize", b64([]byte(big))}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - plantCommit(t, r, engineBlockMessage("bad receipt", "cccc3333", "test.op", - keyReq().RequestID, string(validDigest()), c.result)) - - _, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err == nil { - t.Fatalf("%s: want error", c.name) - } - assertFailureKind(t, err, KindInvalidState) - }) - } -} - -// TestRequestIDInProseDoesNotMatch proves a Docket-Request-ID line living in body -// prose (outside the final trailer block) never matches: the engine proceeds and -// applies, it does not replay. -func TestRequestIDInProseDoesNotMatch(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - // The request-id appears in an EARLIER paragraph with trailing prose, and the - // final paragraph is ordinary prose — so git parses no trailers at all. - message := "prose commit\n\n" + - "Docket-Request-ID: " + keyReq().RequestID + " is only mentioned in prose here.\n\n" + - "An ordinary closing paragraph with no trailers.\n" - planted := plantCommit(t, r, message) - if keys := trailerKeys(t, r.Origin, planted); len(keys) != 0 { - t.Fatalf("planted prose commit parsed trailers %v, want none", keys) - } - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (prose must not match)", res.Disposition) - } -} - -// TestKeyedReplayFoundDeepInHistory proves the ancestry scan has no depth window: -// the key's commit buried under 30 later commits is still found. -func TestKeyedReplayFoundDeepInHistory(t *testing.T) { - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - - planted := []byte(`{"id":"deep"}`) - keyedCommit := plantCommit(t, r, engineBlockMessage("buried apply", "dddd4444", "test.op", - keyReq().RequestID, string(validDigest()), b64(planted))) - - // Bury it under 30 later commits. - branch := r.short() - for i := 0; i < 30; i++ { - hgitOut(t, r.Writer, "commit", "-q", "--allow-empty", "-m", "later") - } - hgitOut(t, r.Writer, "push", "-q", "origin", branch) - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionAlreadyApplied { - t.Fatalf("disposition = %q, want already-applied", res.Disposition) - } - if res.AppliedCommit != keyedCommit { - t.Errorf("applied commit = %q, want buried %q", res.AppliedCommit, keyedCommit) - } - if string(res.Receipt) != string(planted) { - t.Errorf("receipt = %q, want %q", res.Receipt, planted) - } -} diff --git a/internal/repository/transaction/interrupt_integration_test.go b/internal/repository/transaction/interrupt_integration_test.go new file mode 100644 index 000000000..e2eeb362e --- /dev/null +++ b/internal/repository/transaction/interrupt_integration_test.go @@ -0,0 +1,185 @@ +//go:build integration + +package transaction + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestIntegrationTxnRecoveryInterruptLostResponseReplaysOriginalOnce proves a lost response is safe: a +// keyed allocating operation applies once; re-running the SAME request against a +// FRESH engine returns already-applied with the original receipt and commit, adds +// no commit, and leaves exactly one matching receipt in origin history. +func TestIntegrationTxnRecoveryInterruptLostResponseReplaysOriginalOnce(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + ctx := context.Background() + + planted := []byte(`{"id":"0003"}`) + op := createOp(thirdChangePath, thirdChange()) + op.receipt = planted + + res1, err := newEngine(t, client).Execute(ctx, Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("first Execute: %v", err) + } + if res1.Disposition != DispositionApplied { + t.Fatalf("first disposition = %q, want applied", res1.Disposition) + } + original := res1.AppliedCommit + tipAfterApply := r.originTip(t) + + // The client observed no response; the caller retries the exact request against a + // brand-new engine, as a resumed process would. + replayOp := createOp(thirdChangePath, thirdChange()) + replayOp.receipt = []byte(`{"id":"9999"}`) // a re-run from scratch would surface this + res2, err := newEngine(t, client).Execute(ctx, Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Idempotency: keyReq(), Loader: testLoader{}, Operation: replayOp, + }) + if err != nil { + t.Fatalf("replay Execute: %v", err) + } + if res2.Disposition != DispositionAlreadyApplied { + t.Fatalf("replay disposition = %q, want already-applied", res2.Disposition) + } + if res2.AppliedCommit != original { + t.Errorf("replay commit = %q, want original %q", res2.AppliedCommit, original) + } + if string(res2.Receipt) != string(planted) { + t.Errorf("replay receipt = %q, want original %q", res2.Receipt, planted) + } + if replayOp.calls != 0 { + t.Errorf("replay planned %d times; a replay must not replan", replayOp.calls) + } + if r.originTip(t) != tipAfterApply { + t.Error("replay added a commit to origin") + } + + // Exactly one commit in origin history carries this request id. + cts, err := client.ScanCommitTrailers(ctx, repo, r.originTip(t), []string{trailerRequestID}) + if err != nil { + t.Fatalf("ScanCommitTrailers: %v", err) + } + count := 0 + for _, ct := range cts { + for _, tr := range ct.Trailers { + if tr.Key == trailerRequestID && tr.Value == keyReq().RequestID { + count++ + } + } + } + if count != 1 { + t.Errorf("matching receipt commits = %d, want exactly 1", count) + } +} + +// TestIntegrationTxnRecoveryInterruptPreCancelledContext proves a context already cancelled before the +// first fetch yields an interrupted result with no allocation and no remote +// change. +func TestIntegrationTxnRecoveryInterruptPreCancelledContext(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := eng.Execute(ctx, Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + if res.Disposition != DispositionInterrupted { + t.Fatalf("disposition = %q, want interrupted", res.Disposition) + } + assertFailureKind(t, err, KindCancelled) + if r.originTip(t) != base { + t.Error("origin advanced on a pre-cancelled context") + } + if !transactionsEmpty(t, repo) { + t.Error("a candidate was allocated despite a pre-cancelled context") + } +} + +// TestIntegrationTxnRecoveryInterruptDeltaMismatchDoesNotPush proves an engine-level plan whose declared +// bytes equal the base — so Git sees no delta — fails at the delta guard and never +// pushes; origin is untouched. +func TestIntegrationTxnRecoveryInterruptDeltaMismatchDoesNotPush(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + // Replace record 1 with its EXACT current bytes: a non-empty plan producing no + // actual Git delta — the spec's "plan did not describe reality". + same := corpusChange(1, "first-change", "proposed") + op := &scriptedOp{files: []FileMutation{ + {Path: "docs/changes/active/0001-first-change.md", Kind: MutationReplace, Bytes: []byte(same)}, + }} + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err == nil { + t.Fatal("delta-mismatch plan: want a Go *Failure") + } + f := assertMaterializeFailure(t, err, StageVerifyDelta) + if f.Kind != KindInvalidState { + t.Errorf("failure kind = %q, want invalid-state", f.Kind) + } + if res.Disposition != DispositionFailed { + t.Errorf("disposition = %q, want failed", res.Disposition) + } + if r.originTip(t) != base { + t.Error("origin advanced on a delta-mismatch failure") + } +} + +// TestIntegrationTxnRecoveryInterruptContainmentFailureDoesNotPush proves an engine-level plan whose +// declared file has a non-directory parent component is refused at materialize and +// never pushes, leaving origin and the offending base file untouched. +func TestIntegrationTxnRecoveryInterruptContainmentFailureDoesNotPush(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + // README.md is a regular file in the base tree; a create beneath it has a + // non-directory parent component, which materialize must refuse. It lives + // outside docs/, so the loader never parses it and the failure is containment, + // not validation. + op := &scriptedOp{files: []FileMutation{ + {Path: "README.md/child.md", Kind: MutationCreate, Bytes: []byte("planted\n")}, + }} + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err == nil { + t.Fatal("containment-violating plan: want a Go *Failure") + } + assertMaterializeFailure(t, err, StageMaterialize) + if res.Disposition != DispositionFailed { + t.Errorf("disposition = %q, want failed", res.Disposition) + } + if r.originTip(t) != base { + t.Error("origin advanced on a containment failure") + } + // README.md is still a regular file with its original bytes in the checkout. + readme := filepath.Join(repo.PrimaryWorktree, "README.md") + fi, lerr := os.Lstat(readme) + if lerr != nil || !fi.Mode().IsRegular() { + t.Errorf("README.md no longer a regular file: mode=%v err=%v", fi.Mode(), lerr) + } +} diff --git a/internal/repository/transaction/interrupt_test.go b/internal/repository/transaction/interrupt_test.go index dba54344d..699cbac23 100644 --- a/internal/repository/transaction/interrupt_test.go +++ b/internal/repository/transaction/interrupt_test.go @@ -61,78 +61,6 @@ func (c *barrierClock) Now() time.Time { return c.base } -// TestInterruptLostResponseReplaysOriginalOnce proves a lost response is safe: a -// keyed allocating operation applies once; re-running the SAME request against a -// FRESH engine returns already-applied with the original receipt and commit, adds -// no commit, and leaves exactly one matching receipt in origin history. -func TestInterruptLostResponseReplaysOriginalOnce(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - ctx := context.Background() - - planted := []byte(`{"id":"0003"}`) - op := createOp(thirdChangePath, thirdChange()) - op.receipt = planted - - res1, err := newEngine(t, client).Execute(ctx, Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("first Execute: %v", err) - } - if res1.Disposition != DispositionApplied { - t.Fatalf("first disposition = %q, want applied", res1.Disposition) - } - original := res1.AppliedCommit - tipAfterApply := r.originTip(t) - - // The client observed no response; the caller retries the exact request against a - // brand-new engine, as a resumed process would. - replayOp := createOp(thirdChangePath, thirdChange()) - replayOp.receipt = []byte(`{"id":"9999"}`) // a re-run from scratch would surface this - res2, err := newEngine(t, client).Execute(ctx, Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Idempotency: keyReq(), Loader: testLoader{}, Operation: replayOp, - }) - if err != nil { - t.Fatalf("replay Execute: %v", err) - } - if res2.Disposition != DispositionAlreadyApplied { - t.Fatalf("replay disposition = %q, want already-applied", res2.Disposition) - } - if res2.AppliedCommit != original { - t.Errorf("replay commit = %q, want original %q", res2.AppliedCommit, original) - } - if string(res2.Receipt) != string(planted) { - t.Errorf("replay receipt = %q, want original %q", res2.Receipt, planted) - } - if replayOp.calls != 0 { - t.Errorf("replay planned %d times; a replay must not replan", replayOp.calls) - } - if r.originTip(t) != tipAfterApply { - t.Error("replay added a commit to origin") - } - - // Exactly one commit in origin history carries this request id. - cts, err := client.ScanCommitTrailers(ctx, repo, r.originTip(t), []string{trailerRequestID}) - if err != nil { - t.Fatalf("ScanCommitTrailers: %v", err) - } - count := 0 - for _, ct := range cts { - for _, tr := range ct.Trailers { - if tr.Key == trailerRequestID && tr.Value == keyReq().RequestID { - count++ - } - } - } - if count != 1 { - t.Errorf("matching receipt commits = %d, want exactly 1", count) - } -} - // blockingPlanOp blocks inside Plan until the context is cancelled, then returns // ctx.Err() — the operation-observable barrier for the "cancel inside Plan" case. type blockingPlanOp struct{ entered chan struct{} } @@ -227,109 +155,6 @@ func TestInterruptCancelBetweenCommitAndPush(t *testing.T) { } } -// TestInterruptPreCancelledContext proves a context already cancelled before the -// first fetch yields an interrupted result with no allocation and no remote -// change. -func TestInterruptPreCancelledContext(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - res, err := eng.Execute(ctx, Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - if res.Disposition != DispositionInterrupted { - t.Fatalf("disposition = %q, want interrupted", res.Disposition) - } - assertFailureKind(t, err, KindCancelled) - if r.originTip(t) != base { - t.Error("origin advanced on a pre-cancelled context") - } - if !transactionsEmpty(t, repo) { - t.Error("a candidate was allocated despite a pre-cancelled context") - } -} - -// TestInterruptDeltaMismatchDoesNotPush proves an engine-level plan whose declared -// bytes equal the base — so Git sees no delta — fails at the delta guard and never -// pushes; origin is untouched. -func TestInterruptDeltaMismatchDoesNotPush(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - // Replace record 1 with its EXACT current bytes: a non-empty plan producing no - // actual Git delta — the spec's "plan did not describe reality". - same := corpusChange(1, "first-change", "proposed") - op := &scriptedOp{files: []FileMutation{ - {Path: "docs/changes/active/0001-first-change.md", Kind: MutationReplace, Bytes: []byte(same)}, - }} - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err == nil { - t.Fatal("delta-mismatch plan: want a Go *Failure") - } - f := assertMaterializeFailure(t, err, StageVerifyDelta) - if f.Kind != KindInvalidState { - t.Errorf("failure kind = %q, want invalid-state", f.Kind) - } - if res.Disposition != DispositionFailed { - t.Errorf("disposition = %q, want failed", res.Disposition) - } - if r.originTip(t) != base { - t.Error("origin advanced on a delta-mismatch failure") - } -} - -// TestInterruptContainmentFailureDoesNotPush proves an engine-level plan whose -// declared file has a non-directory parent component is refused at materialize and -// never pushes, leaving origin and the offending base file untouched. -func TestInterruptContainmentFailureDoesNotPush(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - // README.md is a regular file in the base tree; a create beneath it has a - // non-directory parent component, which materialize must refuse. It lives - // outside docs/, so the loader never parses it and the failure is containment, - // not validation. - op := &scriptedOp{files: []FileMutation{ - {Path: "README.md/child.md", Kind: MutationCreate, Bytes: []byte("planted\n")}, - }} - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err == nil { - t.Fatal("containment-violating plan: want a Go *Failure") - } - assertMaterializeFailure(t, err, StageMaterialize) - if res.Disposition != DispositionFailed { - t.Errorf("disposition = %q, want failed", res.Disposition) - } - if r.originTip(t) != base { - t.Error("origin advanced on a containment failure") - } - // README.md is still a regular file with its original bytes in the checkout. - readme := filepath.Join(repo.PrimaryWorktree, "README.md") - fi, lerr := os.Lstat(readme) - if lerr != nil || !fi.Mode().IsRegular() { - t.Errorf("README.md no longer a regular file: mode=%v err=%v", fi.Mode(), lerr) - } -} - // soleCandidateWorktree returns the detached worktree path of the single candidate // currently allocated under repo's transactions root — used while an attempt is // parked on the barrier clock, when exactly one candidate exists. diff --git a/internal/repository/transaction/materialize_integration_test.go b/internal/repository/transaction/materialize_integration_test.go new file mode 100644 index 000000000..97db344f2 --- /dev/null +++ b/internal/repository/transaction/materialize_integration_test.go @@ -0,0 +1,296 @@ +//go:build integration + +package transaction + +import ( + "context" + "github.com/danielhanold/docket/internal/testsupport" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationTxnRecoveryMaterializeCreateReplaceDeleteByteExact proves create/replace/delete land +// byte-exact (including a create under a brand-new nested directory), that +// verifyMaterialized accepts them, and that every unrelated file is untouched. +func TestIntegrationTxnRecoveryMaterializeCreateReplaceDeleteByteExact(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + declared := map[string]bool{ + "created.md": true, + "fresh/deep/new.md": true, + "replace-me.md": true, + "delete-me.md": true, + } + before := hashTreeExcept(t, wt, declared) + + plan := planWith( + FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("created bytes\n")}, + FileMutation{Path: "fresh/deep/new.md", Kind: MutationCreate, Bytes: []byte("nested new\n")}, + FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("replaced bytes\n")}, + FileMutation{Path: "delete-me.md", Kind: MutationDelete}, + ) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := verifyMaterialized(wt, plan); err != nil { + t.Fatalf("verifyMaterialized: %v", err) + } + + assertFile(t, wt, "created.md", "created bytes\n") + assertFile(t, wt, "fresh/deep/new.md", "nested new\n") + assertFile(t, wt, "replace-me.md", "replaced bytes\n") + if _, err := os.Lstat(filepath.Join(wt, "delete-me.md")); !os.IsNotExist(err) { + t.Errorf("delete-me.md still present: err=%v", err) + } + + if after := hashTreeExcept(t, wt, declared); before != after { + t.Errorf("unrelated files changed: %s != %s", before, after) + } +} + +// TestIntegrationTxnRecoveryMaterializeCreateEmptyFile proves an intentionally empty create lands as a +// zero-byte regular file that verifyMaterialized accepts. +func TestIntegrationTxnRecoveryMaterializeCreateEmptyFile(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + plan := planWith(FileMutation{Path: "empty.md", Kind: MutationCreate, Bytes: nil}) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := verifyMaterialized(wt, plan); err != nil { + t.Fatalf("verifyMaterialized: %v", err) + } + got, err := os.ReadFile(filepath.Join(wt, "empty.md")) + if err != nil || len(got) != 0 { + t.Errorf("empty.md not a zero-byte file: content=%q err=%v", got, err) + } +} + +// TestIntegrationTxnRecoveryMaterializeReplacePreservesExecutableMode proves the sibling-temp+rename +// path copies the base file's mode onto the replacement: an executable file +// keeps its 100755 mode through a replace. The assertion compares against the +// file's own pre-replace mode, so it is umask-independent. +func TestIntegrationTxnRecoveryMaterializeReplacePreservesExecutableMode(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + execPath := filepath.Join(wt, "exec.sh") + orig, err := os.Lstat(execPath) + if err != nil { + t.Fatal(err) + } + origMode := orig.Mode().Perm() + if origMode&0o100 == 0 { + t.Fatalf("fixture exec.sh is not executable: %04o", origMode) + } + + plan := planWith(FileMutation{Path: "exec.sh", Kind: MutationReplace, Bytes: []byte("#!/bin/sh\necho replaced\n")}) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + + after, err := os.Lstat(execPath) + if err != nil { + t.Fatal(err) + } + if got := after.Mode().Perm(); got != origMode { + t.Errorf("exec.sh mode = %04o, want %04o (mode not preserved through replace)", got, origMode) + } + assertFile(t, wt, "exec.sh", "#!/bin/sh\necho replaced\n") +} + +// TestIntegrationTxnRecoveryMaterializeRefusesReplaceTargetSymlink proves a replace whose target is a +// symlink is refused (never writing through the link), and the link and its +// referent are left untouched. +func TestIntegrationTxnRecoveryMaterializeRefusesReplaceTargetSymlink(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + linkPath := filepath.Join(wt, "linktarget.md") + if err := os.Symlink("keep.md", linkPath); err != nil { + t.Fatal(err) + } + + plan := planWith(FileMutation{Path: "linktarget.md", Kind: MutationReplace, Bytes: []byte("through the link\n")}) + err := materializePlan(wt, plan) + assertMaterializeFailure(t, err, StageMaterialize) + + fi, lerr := os.Lstat(linkPath) + if lerr != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Errorf("linktarget.md is no longer a symlink: mode=%v err=%v", fi.Mode(), lerr) + } + assertFile(t, wt, "keep.md", "base keep\n") +} + +// TestIntegrationTxnRecoveryMaterializeRefusesSymlinkParentComponent proves a create whose parent +// component is a symlink is refused. Two cases matter for different reasons: +// +// - "outside": the symlink points to a directory OUTSIDE the worktree. os.Root +// alone would refuse the escape, but this also asserts the stronger property +// that nothing at all is written into that outside directory. +// - "inside": the symlink points to a real directory INSIDE the worktree. This +// is the case os.Root does NOT catch — it would happily FOLLOW the link and +// write the file through it into the linked directory, silently escaping the +// declared path. Only the explicit parent-component Lstat walk refuses it. +// This is the load-bearing containment guard. +func TestIntegrationTxnRecoveryMaterializeRefusesSymlinkParentComponent(t *testing.T) { + t.Run("outside", func(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + + outside := testsupport.TempDir(t) + secret := filepath.Join(outside, "secret.md") + if err := os.WriteFile(secret, []byte("do not touch\n"), 0o644); err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(secret) + if err != nil { + t.Fatal(err) + } + beforeList, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + + if err := os.Symlink(outside, filepath.Join(wt, "evil")); err != nil { + t.Fatal(err) + } + + plan := planWith(FileMutation{Path: "evil/planted.md", Kind: MutationCreate, Bytes: []byte("planted\n")}) + assertMaterializeFailure(t, materializePlan(wt, plan), StageMaterialize) + + if _, err := os.Stat(filepath.Join(outside, "planted.md")); !os.IsNotExist(err) { + t.Errorf("a file escaped the root into the outside dir: err=%v", err) + } + afterList, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + if len(afterList) != len(beforeList) { + t.Errorf("outside dir entry count changed: %d -> %d", len(beforeList), len(afterList)) + } + afterInfo, err := os.Stat(secret) + if err != nil { + t.Fatal(err) + } + if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) { + t.Errorf("outside secret mtime changed: %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime()) + } + if got, _ := os.ReadFile(secret); string(got) != "do not touch\n" { + t.Errorf("outside secret content changed: %q", got) + } + }) + + t.Run("inside", func(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + + // A symlink to a real directory INSIDE the worktree. os.Root would follow + // it; the explicit guard must refuse it. "docs/sub" exists in the fixture. + if err := os.Symlink("docs/sub", filepath.Join(wt, "inside-link")); err != nil { + t.Fatal(err) + } + + plan := planWith(FileMutation{Path: "inside-link/planted.md", Kind: MutationCreate, Bytes: []byte("planted\n")}) + assertMaterializeFailure(t, materializePlan(wt, plan), StageMaterialize) + + // The write must NOT have gone through the symlink into docs/sub. + if _, err := os.Lstat(filepath.Join(wt, "docs", "sub", "planted.md")); !os.IsNotExist(err) { + t.Errorf("a file was written THROUGH the inside symlink into docs/sub: err=%v", err) + } + // And the link's own name resolved to nothing on disk beneath it. + if _, err := os.Lstat(filepath.Join(wt, "inside-link", "planted.md")); err == nil { + t.Errorf("planted.md exists under the symlinked parent") + } + }) +} + +// TestIntegrationTxnRecoveryVerifyMaterializedDetectsCorruption proves the readback guard reddens when +// the on-disk bytes no longer match the plan (post-materialization tampering). +func TestIntegrationTxnRecoveryVerifyMaterializedDetectsCorruption(t *testing.T) { + _, _, wt := newMaterializeWorktree(t) + plan := planWith(FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("good bytes\n")}) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := os.WriteFile(filepath.Join(wt, "created.md"), []byte("tampered\n"), 0o644); err != nil { + t.Fatal(err) + } + err := verifyMaterialized(wt, plan) + assertMaterializeFailure(t, err, StageMaterialize) +} + +// TestIntegrationTxnRecoveryVerifyActualDeltaExactMatch proves the two-way delta guard accepts a +// worktree whose actual changed-path set equals the plan's declared set exactly. +func TestIntegrationTxnRecoveryVerifyActualDeltaExactMatch(t *testing.T) { + client, repo, wt := newMaterializeWorktree(t) + plan := planWith( + FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("created\n")}, + FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("changed\n")}, + FileMutation{Path: "delete-me.md", Kind: MutationDelete}, + ) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := verifyActualDelta(context.Background(), client, repo, wt, plan); err != nil { + t.Fatalf("verifyActualDelta: %v", err) + } +} + +// TestIntegrationTxnRecoveryVerifyActualDeltaRejectsUndeclaredChange proves an undeclared changed path +// in the worktree fails the guard (undeclared direction). +func TestIntegrationTxnRecoveryVerifyActualDeltaRejectsUndeclaredChange(t *testing.T) { + client, repo, wt := newMaterializeWorktree(t) + plan := planWith(FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("changed\n")}) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := os.WriteFile(filepath.Join(wt, "stray.md"), []byte("stray\n"), 0o644); err != nil { + t.Fatal(err) + } + err := verifyActualDelta(context.Background(), client, repo, wt, plan) + assertMaterializeFailure(t, err, StageVerifyDelta) + assertFailureKind(t, err, KindInvalidState) +} + +// TestIntegrationTxnRecoveryVerifyActualDeltaRejectsDeclaredUnchanged proves a declared path whose +// bytes equal base — so it produces no Git delta — fails the guard +// (declared-but-unchanged direction), even though verifyMaterialized passes. +func TestIntegrationTxnRecoveryVerifyActualDeltaRejectsDeclaredUnchanged(t *testing.T) { + client, repo, wt := newMaterializeWorktree(t) + // Replace with bytes identical to the base content: no actual change. + plan := planWith(FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("base replace\n")}) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + // The readback guard is content-vs-plan and must PASS here — the delta guard + // is the one that catches "the plan did not describe reality". + if err := verifyMaterialized(wt, plan); err != nil { + t.Fatalf("verifyMaterialized should pass when bytes match the plan: %v", err) + } + err := verifyActualDelta(context.Background(), client, repo, wt, plan) + assertMaterializeFailure(t, err, StageVerifyDelta) + assertFailureKind(t, err, KindInvalidState) +} + +// TestIntegrationTxnRecoveryMaterializeHostilePathsByteExact drives create/replace/delete on +// space/tab/newline-laden paths through the whole pipeline: materialize, +// readback, and the Git-derived delta guard all accept them byte-exact. +func TestIntegrationTxnRecoveryMaterializeHostilePathsByteExact(t *testing.T) { + client, repo, wt := newMaterializeWorktree(t) + plan := planWith( + FileMutation{Path: gitcli.RepoPath(matHostileTab), Kind: MutationReplace, Bytes: []byte("hostile tab replaced\n")}, + FileMutation{Path: gitcli.RepoPath(matHostileNewline), Kind: MutationDelete}, + FileMutation{Path: gitcli.RepoPath(matHostileCreate), Kind: MutationCreate, Bytes: []byte("hostile create\n")}, + ) + if err := materializePlan(wt, plan); err != nil { + t.Fatalf("materializePlan: %v", err) + } + if err := verifyMaterialized(wt, plan); err != nil { + t.Fatalf("verifyMaterialized: %v", err) + } + if err := verifyActualDelta(context.Background(), client, repo, wt, plan); err != nil { + t.Fatalf("verifyActualDelta: %v", err) + } + + assertFile(t, wt, matHostileTab, "hostile tab replaced\n") + assertFile(t, wt, matHostileCreate, "hostile create\n") + if _, err := os.Lstat(filepath.Join(wt, matHostileNewline)); !os.IsNotExist(err) { + t.Errorf("hostile newline path still present: err=%v", err) + } +} diff --git a/internal/repository/transaction/materialize_test.go b/internal/repository/transaction/materialize_test.go index e06670ce2..a952c2bc6 100644 --- a/internal/repository/transaction/materialize_test.go +++ b/internal/repository/transaction/materialize_test.go @@ -190,286 +190,3 @@ func hashTreeExcept(t *testing.T, root string, exclude map[string]bool) string { func planWith(files ...FileMutation) MutationPlan { return MutationPlan{Files: files, CommitSubject: "materialize test", Receipt: []byte(`{}`)} } - -// TestMaterializeCreateReplaceDeleteByteExact proves create/replace/delete land -// byte-exact (including a create under a brand-new nested directory), that -// verifyMaterialized accepts them, and that every unrelated file is untouched. -func TestMaterializeCreateReplaceDeleteByteExact(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - declared := map[string]bool{ - "created.md": true, - "fresh/deep/new.md": true, - "replace-me.md": true, - "delete-me.md": true, - } - before := hashTreeExcept(t, wt, declared) - - plan := planWith( - FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("created bytes\n")}, - FileMutation{Path: "fresh/deep/new.md", Kind: MutationCreate, Bytes: []byte("nested new\n")}, - FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("replaced bytes\n")}, - FileMutation{Path: "delete-me.md", Kind: MutationDelete}, - ) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := verifyMaterialized(wt, plan); err != nil { - t.Fatalf("verifyMaterialized: %v", err) - } - - assertFile(t, wt, "created.md", "created bytes\n") - assertFile(t, wt, "fresh/deep/new.md", "nested new\n") - assertFile(t, wt, "replace-me.md", "replaced bytes\n") - if _, err := os.Lstat(filepath.Join(wt, "delete-me.md")); !os.IsNotExist(err) { - t.Errorf("delete-me.md still present: err=%v", err) - } - - if after := hashTreeExcept(t, wt, declared); before != after { - t.Errorf("unrelated files changed: %s != %s", before, after) - } -} - -// TestMaterializeCreateEmptyFile proves an intentionally empty create lands as a -// zero-byte regular file that verifyMaterialized accepts. -func TestMaterializeCreateEmptyFile(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - plan := planWith(FileMutation{Path: "empty.md", Kind: MutationCreate, Bytes: nil}) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := verifyMaterialized(wt, plan); err != nil { - t.Fatalf("verifyMaterialized: %v", err) - } - got, err := os.ReadFile(filepath.Join(wt, "empty.md")) - if err != nil || len(got) != 0 { - t.Errorf("empty.md not a zero-byte file: content=%q err=%v", got, err) - } -} - -// TestMaterializeReplacePreservesExecutableMode proves the sibling-temp+rename -// path copies the base file's mode onto the replacement: an executable file -// keeps its 100755 mode through a replace. The assertion compares against the -// file's own pre-replace mode, so it is umask-independent. -func TestMaterializeReplacePreservesExecutableMode(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - execPath := filepath.Join(wt, "exec.sh") - orig, err := os.Lstat(execPath) - if err != nil { - t.Fatal(err) - } - origMode := orig.Mode().Perm() - if origMode&0o100 == 0 { - t.Fatalf("fixture exec.sh is not executable: %04o", origMode) - } - - plan := planWith(FileMutation{Path: "exec.sh", Kind: MutationReplace, Bytes: []byte("#!/bin/sh\necho replaced\n")}) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - - after, err := os.Lstat(execPath) - if err != nil { - t.Fatal(err) - } - if got := after.Mode().Perm(); got != origMode { - t.Errorf("exec.sh mode = %04o, want %04o (mode not preserved through replace)", got, origMode) - } - assertFile(t, wt, "exec.sh", "#!/bin/sh\necho replaced\n") -} - -// TestMaterializeRefusesReplaceTargetSymlink proves a replace whose target is a -// symlink is refused (never writing through the link), and the link and its -// referent are left untouched. -func TestMaterializeRefusesReplaceTargetSymlink(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - linkPath := filepath.Join(wt, "linktarget.md") - if err := os.Symlink("keep.md", linkPath); err != nil { - t.Fatal(err) - } - - plan := planWith(FileMutation{Path: "linktarget.md", Kind: MutationReplace, Bytes: []byte("through the link\n")}) - err := materializePlan(wt, plan) - assertMaterializeFailure(t, err, StageMaterialize) - - fi, lerr := os.Lstat(linkPath) - if lerr != nil || fi.Mode()&os.ModeSymlink == 0 { - t.Errorf("linktarget.md is no longer a symlink: mode=%v err=%v", fi.Mode(), lerr) - } - assertFile(t, wt, "keep.md", "base keep\n") -} - -// TestMaterializeRefusesSymlinkParentComponent proves a create whose parent -// component is a symlink is refused. Two cases matter for different reasons: -// -// - "outside": the symlink points to a directory OUTSIDE the worktree. os.Root -// alone would refuse the escape, but this also asserts the stronger property -// that nothing at all is written into that outside directory. -// - "inside": the symlink points to a real directory INSIDE the worktree. This -// is the case os.Root does NOT catch — it would happily FOLLOW the link and -// write the file through it into the linked directory, silently escaping the -// declared path. Only the explicit parent-component Lstat walk refuses it. -// This is the load-bearing containment guard. -func TestMaterializeRefusesSymlinkParentComponent(t *testing.T) { - t.Run("outside", func(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - - outside := testsupport.TempDir(t) - secret := filepath.Join(outside, "secret.md") - if err := os.WriteFile(secret, []byte("do not touch\n"), 0o644); err != nil { - t.Fatal(err) - } - beforeInfo, err := os.Stat(secret) - if err != nil { - t.Fatal(err) - } - beforeList, err := os.ReadDir(outside) - if err != nil { - t.Fatal(err) - } - - if err := os.Symlink(outside, filepath.Join(wt, "evil")); err != nil { - t.Fatal(err) - } - - plan := planWith(FileMutation{Path: "evil/planted.md", Kind: MutationCreate, Bytes: []byte("planted\n")}) - assertMaterializeFailure(t, materializePlan(wt, plan), StageMaterialize) - - if _, err := os.Stat(filepath.Join(outside, "planted.md")); !os.IsNotExist(err) { - t.Errorf("a file escaped the root into the outside dir: err=%v", err) - } - afterList, err := os.ReadDir(outside) - if err != nil { - t.Fatal(err) - } - if len(afterList) != len(beforeList) { - t.Errorf("outside dir entry count changed: %d -> %d", len(beforeList), len(afterList)) - } - afterInfo, err := os.Stat(secret) - if err != nil { - t.Fatal(err) - } - if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) { - t.Errorf("outside secret mtime changed: %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime()) - } - if got, _ := os.ReadFile(secret); string(got) != "do not touch\n" { - t.Errorf("outside secret content changed: %q", got) - } - }) - - t.Run("inside", func(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - - // A symlink to a real directory INSIDE the worktree. os.Root would follow - // it; the explicit guard must refuse it. "docs/sub" exists in the fixture. - if err := os.Symlink("docs/sub", filepath.Join(wt, "inside-link")); err != nil { - t.Fatal(err) - } - - plan := planWith(FileMutation{Path: "inside-link/planted.md", Kind: MutationCreate, Bytes: []byte("planted\n")}) - assertMaterializeFailure(t, materializePlan(wt, plan), StageMaterialize) - - // The write must NOT have gone through the symlink into docs/sub. - if _, err := os.Lstat(filepath.Join(wt, "docs", "sub", "planted.md")); !os.IsNotExist(err) { - t.Errorf("a file was written THROUGH the inside symlink into docs/sub: err=%v", err) - } - // And the link's own name resolved to nothing on disk beneath it. - if _, err := os.Lstat(filepath.Join(wt, "inside-link", "planted.md")); err == nil { - t.Errorf("planted.md exists under the symlinked parent") - } - }) -} - -// TestVerifyMaterializedDetectsCorruption proves the readback guard reddens when -// the on-disk bytes no longer match the plan (post-materialization tampering). -func TestVerifyMaterializedDetectsCorruption(t *testing.T) { - _, _, wt := newMaterializeWorktree(t) - plan := planWith(FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("good bytes\n")}) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := os.WriteFile(filepath.Join(wt, "created.md"), []byte("tampered\n"), 0o644); err != nil { - t.Fatal(err) - } - err := verifyMaterialized(wt, plan) - assertMaterializeFailure(t, err, StageMaterialize) -} - -// TestVerifyActualDeltaExactMatch proves the two-way delta guard accepts a -// worktree whose actual changed-path set equals the plan's declared set exactly. -func TestVerifyActualDeltaExactMatch(t *testing.T) { - client, repo, wt := newMaterializeWorktree(t) - plan := planWith( - FileMutation{Path: "created.md", Kind: MutationCreate, Bytes: []byte("created\n")}, - FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("changed\n")}, - FileMutation{Path: "delete-me.md", Kind: MutationDelete}, - ) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := verifyActualDelta(context.Background(), client, repo, wt, plan); err != nil { - t.Fatalf("verifyActualDelta: %v", err) - } -} - -// TestVerifyActualDeltaRejectsUndeclaredChange proves an undeclared changed path -// in the worktree fails the guard (undeclared direction). -func TestVerifyActualDeltaRejectsUndeclaredChange(t *testing.T) { - client, repo, wt := newMaterializeWorktree(t) - plan := planWith(FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("changed\n")}) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := os.WriteFile(filepath.Join(wt, "stray.md"), []byte("stray\n"), 0o644); err != nil { - t.Fatal(err) - } - err := verifyActualDelta(context.Background(), client, repo, wt, plan) - assertMaterializeFailure(t, err, StageVerifyDelta) - assertFailureKind(t, err, KindInvalidState) -} - -// TestVerifyActualDeltaRejectsDeclaredUnchanged proves a declared path whose -// bytes equal base — so it produces no Git delta — fails the guard -// (declared-but-unchanged direction), even though verifyMaterialized passes. -func TestVerifyActualDeltaRejectsDeclaredUnchanged(t *testing.T) { - client, repo, wt := newMaterializeWorktree(t) - // Replace with bytes identical to the base content: no actual change. - plan := planWith(FileMutation{Path: "replace-me.md", Kind: MutationReplace, Bytes: []byte("base replace\n")}) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - // The readback guard is content-vs-plan and must PASS here — the delta guard - // is the one that catches "the plan did not describe reality". - if err := verifyMaterialized(wt, plan); err != nil { - t.Fatalf("verifyMaterialized should pass when bytes match the plan: %v", err) - } - err := verifyActualDelta(context.Background(), client, repo, wt, plan) - assertMaterializeFailure(t, err, StageVerifyDelta) - assertFailureKind(t, err, KindInvalidState) -} - -// TestMaterializeHostilePathsByteExact drives create/replace/delete on -// space/tab/newline-laden paths through the whole pipeline: materialize, -// readback, and the Git-derived delta guard all accept them byte-exact. -func TestMaterializeHostilePathsByteExact(t *testing.T) { - client, repo, wt := newMaterializeWorktree(t) - plan := planWith( - FileMutation{Path: gitcli.RepoPath(matHostileTab), Kind: MutationReplace, Bytes: []byte("hostile tab replaced\n")}, - FileMutation{Path: gitcli.RepoPath(matHostileNewline), Kind: MutationDelete}, - FileMutation{Path: gitcli.RepoPath(matHostileCreate), Kind: MutationCreate, Bytes: []byte("hostile create\n")}, - ) - if err := materializePlan(wt, plan); err != nil { - t.Fatalf("materializePlan: %v", err) - } - if err := verifyMaterialized(wt, plan); err != nil { - t.Fatalf("verifyMaterialized: %v", err) - } - if err := verifyActualDelta(context.Background(), client, repo, wt, plan); err != nil { - t.Fatalf("verifyActualDelta: %v", err) - } - - assertFile(t, wt, matHostileTab, "hostile tab replaced\n") - assertFile(t, wt, matHostileCreate, "hostile create\n") - if _, err := os.Lstat(filepath.Join(wt, matHostileNewline)); !os.IsNotExist(err) { - t.Errorf("hostile newline path still present: err=%v", err) - } -} diff --git a/internal/repository/transaction/recovery_integration_test.go b/internal/repository/transaction/recovery_integration_test.go new file mode 100644 index 000000000..f7967e808 --- /dev/null +++ b/internal/repository/transaction/recovery_integration_test.go @@ -0,0 +1,393 @@ +//go:build integration + +package transaction + +import ( + "context" + "github.com/danielhanold/docket/internal/testsupport" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationTxnRecoveryPruneReportsLiveCandidatesUntouched proves that two concurrently active +// candidates in one clone — both holding their live locks — are both reported live +// and both survive. A held lock is the sole liveness signal. +func TestIntegrationTxnRecoveryPruneReportsLiveCandidatesUntouched(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, _, repo := recoveryEngine(t, r) + + c1, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) + if err != nil { + t.Fatalf("allocate c1: %v", err) + } + defer func() { _ = c1.live.release() }() + c2, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) + if err != nil { + t.Fatalf("allocate c2: %v", err) + } + defer func() { _ = c2.live.release() }() + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + for _, c := range []*candidate{c1, c2} { + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictLive { + t.Errorf("candidate %s verdict = %q, want live", c.id, e.Verdict) + } + if _, err := os.Stat(c.root); err != nil { + t.Errorf("live candidate %s was removed: %v", c.id, err) + } + } +} + +// TestIntegrationTxnRecoveryPrunePrunesAbandonedCandidate proves a normally abandoned candidate is +// pruned — worktree deregistered, directory gone — and that Pushed is reported +// correctly for both an already-reachable (pushed) and a never-pushed commit. +func TestIntegrationTxnRecoveryPrunePrunesAbandonedCandidate(t *testing.T) { + requireGit(t) + + t.Run("pushed_commit_reachable", func(t *testing.T) { + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + // Worktree parked at the target tip: its commit is reachable from the target. + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictPruned { + t.Fatalf("verdict = %q, want pruned", e.Verdict) + } + if !e.Pushed { + t.Errorf("Pushed = false, want true for a tip-reachable commit") + } + assertGoneAndDeregistered(t, client, repo, c) + }) + + t.Run("unpushed_commit_unreachable", func(t *testing.T) { + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + // Advance the worktree's detached HEAD to a local-only commit: unreachable + // from the target, so the residue was never pushed. + hgitOut(t, c.worktree, "commit", "--allow-empty", "-q", "--no-gpg-sign", "-m", "local only") + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictPruned { + t.Fatalf("verdict = %q, want pruned", e.Verdict) + } + if e.Pushed { + t.Errorf("Pushed = true, want false for a local-only commit") + } + assertGoneAndDeregistered(t, client, repo, c) + }) +} + +// TestIntegrationTxnRecoveryPruneHeldLockBeatsAncientPIDAndTimestamp proves a held live lock defeats +// pruning even when the manifest advertises an ancient creation time and a dead +// PID: no age threshold overrides a held lock and PID liveness is never consulted. +func TestIntegrationTxnRecoveryPruneHeldLockBeatsAncientPIDAndTimestamp(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, _, repo := recoveryEngine(t, r) + + c, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) + if err != nil { + t.Fatalf("allocateCandidate: %v", err) + } + defer func() { _ = c.live.release() }() + + // Rewrite the manifest with an ancient timestamp and an implausible PID while the + // live lock is STILL held. Every other field stays valid, so only the held lock + // can be what saves the candidate. + m, err := c.readManifest() + if err != nil { + t.Fatalf("read manifest: %v", err) + } + m.CreatedUTC = "2000-01-01T00:00:00Z" + m.UpdatedUTC = "2000-01-01T00:00:00Z" + m.PID = 2147480000 + if err := writeManifestAtomic(c.root, m); err != nil { + t.Fatalf("rewrite manifest: %v", err) + } + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictLive { + t.Errorf("verdict = %q, want live (held lock must beat age/PID)", e.Verdict) + } + if _, err := os.Stat(c.root); err != nil { + t.Errorf("held-lock candidate removed: %v", err) + } +} + +// TestIntegrationTxnRecoveryPruneLeavesMalformedAndForeignByteUntouched drives every survival variant +// from the spec: each is reported with a verdict and left byte-for-byte identical. +func TestIntegrationTxnRecoveryPruneLeavesMalformedAndForeignByteUntouched(t *testing.T) { + requireGit(t) + + type variant struct { + name string + id string + verdict string + // build lays the on-disk state down under repo and returns the path whose + // bytes must be identical afterward (candidate root or the entry itself). + build func(t *testing.T, eng *Engine, client *gitcli.Client, repo gitcli.Repository) (id, hashPath string) + } + + variants := []variant{ + { + name: "missing_manifest", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa1") + p := mkOwnedDir(t, repo, id) + return id, p + }, + verdict: verdictMalformed, + }, + { + name: "truncated_json", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa2") + p := mkOwnedDir(t, repo, id) + if err := os.WriteFile(filepath.Join(p, manifestFileName), []byte("{ \"schema\": 1"), txnFileMode); err != nil { + t.Fatal(err) + } + return id, p + }, + verdict: verdictMalformed, + }, + { + name: "unsupported_schema", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa3") + p := mkOwnedDir(t, repo, id) + m := baseValidManifest(id, repo.CommonDir) + m.Schema = 99 + if err := writeManifestAtomic(p, m); err != nil { + t.Fatal(err) + } + return id, p + }, + verdict: verdictMalformed, + }, + { + name: "wrong_repository", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa4") + p := mkOwnedDir(t, repo, id) + // A canonical common dir belonging to a different repository. + other := canonicalPath(testsupport.TempDir(t)) + m := baseValidManifest(id, other) + if err := writeManifestAtomic(p, m); err != nil { + t.Fatal(err) + } + return id, p + }, + verdict: verdictForeign, + }, + { + name: "worktree_rel_escapes", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa5") + p := mkOwnedDir(t, repo, id) + m := baseValidManifest(id, repo.CommonDir) + m.WorktreeRel = "../../x" + if err := writeManifestAtomic(p, m); err != nil { + t.Fatal(err) + } + return id, p + }, + verdict: verdictMalformed, + }, + { + name: "symlinked_root_to_foreign", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + id := hexID("aaaa6") + root := transactionsRoot(repo) + if err := ensureTransactionsRoot(root); err != nil { + t.Fatal(err) + } + foreign := testsupport.TempDir(t) + if err := os.WriteFile(filepath.Join(foreign, "secret"), []byte("do not touch\n"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, id) + if err := os.Symlink(foreign, link); err != nil { + t.Fatal(err) + } + return id, link + }, + verdict: verdictForeign, + }, + { + name: "foreign_named_directory", + build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { + root := transactionsRoot(repo) + if err := ensureTransactionsRoot(root); err != nil { + t.Fatal(err) + } + name := "not-32-hex" + p := filepath.Join(root, name) + if err := mkdirMode(p, txnDirMode); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, "keep"), []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + return name, p + }, + verdict: verdictForeign, + }, + { + name: "ambiguous_registration", + build: func(t *testing.T, _ *Engine, client *gitcli.Client, repo gitcli.Repository) (string, string) { + // A valid, abandoned candidate — but Git has a registration inside its + // tree at an unexpected path, so ownership is ambiguous. + c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + if err != nil { + t.Fatalf("allocate: %v", err) + } + rogue := filepath.Join(c.root, "rogue") + if err := client.AddDetachedWorktree(context.Background(), repo, rogue, headCommit(t, repo)); err != nil { + t.Fatalf("add rogue worktree: %v", err) + } + if err := c.live.release(); err != nil { + t.Fatal(err) + } + return c.id, c.root + }, + verdict: verdictForeign, + }, + } + + for _, v := range variants { + t.Run(v.name, func(t *testing.T) { + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + id, hp := v.build(t, eng, client, repo) + + before := hashTree(t, hp) + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, id) + if e.Verdict != v.verdict { + t.Errorf("verdict = %q, want %q (detail: %s)", e.Verdict, v.verdict, e.Detail) + } + if _, err := os.Lstat(hp); err != nil { + t.Fatalf("survival entry vanished: %v", err) + } + if after := hashTree(t, hp); after != before { + t.Errorf("survival entry was mutated: before %s, after %s", before, after) + } + }) + } +} + +// TestIntegrationTxnRecoveryPruneReportsCleanupFailedOnForcedRemovalFailure proves that when Git cannot +// remove the registered worktree, the candidate is retained with a cleanup-failed +// verdict and a diagnostic rather than being force-deleted by pathname. +func TestIntegrationTxnRecoveryPruneReportsCleanupFailedOnForcedRemovalFailure(t *testing.T) { + requireGit(t) + if os.Geteuid() == 0 { + t.Skip("running as root: 000 permissions do not block removal") + } + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + + // Make the worktree directory unremovable, then restore it so t.TempDir cleanup + // can proceed regardless of the test outcome. + if err := os.Chmod(c.worktree, 0o000); err != nil { + t.Fatalf("chmod worktree 000: %v", err) + } + defer func() { _ = os.Chmod(c.worktree, 0o700) }() + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + e := pruneEntryFor(t, rep, c.id) + if e.Verdict != verdictCleanupFailed { + t.Fatalf("verdict = %q, want cleanup-failed (detail: %s)", e.Verdict, e.Detail) + } + if _, err := os.Stat(c.root); err != nil { + t.Errorf("cleanup-failed candidate was removed: %v", err) + } +} + +// TestIntegrationTxnRecoveryPruneNeverGlobalPrunesOrTouchesUserCheckout proves that a full prune sweep +// leaves the invocation checkout byte-identical and never runs a global worktree +// prune: a second, unrelated but perfectly valid worktree registration planted in +// the repo still exists afterward. +func TestIntegrationTxnRecoveryPruneNeverGlobalPrunesOrTouchesUserCheckout(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, client, repo := recoveryEngine(t, r) + + // A legitimate, unrelated linked worktree the user keeps around. + userWorktree := filepath.Join(testsupport.TempDir(t), "user-wt") + if err := client.AddDetachedWorktree(context.Background(), repo, userWorktree, targetTip(t, r)); err != nil { + t.Fatalf("add user worktree: %v", err) + } + + // A prunable candidate to make the sweep actually do destructive work. + c := abandonRegistered(t, client, repo, r, targetTip(t, r)) + + // Snapshot the invocation checkout AFTER setup, BEFORE the sweep. + beforeHead := hgitOut(t, r.Invocation, "rev-parse", "HEAD") + beforeIndex := hgitOutRaw(t, r.Invocation, "ls-files", "--stage", "-z") + beforeStatus := hgitOut(t, r.Invocation, "status", "--porcelain") + + rep, err := eng.PruneAbandoned(context.Background(), repo) + if err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + if e := pruneEntryFor(t, rep, c.id); e.Verdict != verdictPruned { + t.Fatalf("candidate verdict = %q, want pruned", e.Verdict) + } + + // The user's unrelated worktree is still registered — no global prune ran. + infos, err := client.ListWorktrees(context.Background(), repo) + if err != nil { + t.Fatalf("ListWorktrees: %v", err) + } + want := canonicalPath(userWorktree) + found := false + for _, info := range infos { + if canonicalPath(info.Path) == want { + found = true + } + } + if !found { + t.Errorf("unrelated user worktree was deregistered by the sweep") + } + + // The invocation checkout is byte-identical. + if got := hgitOut(t, r.Invocation, "rev-parse", "HEAD"); got != beforeHead { + t.Errorf("HEAD moved: %s -> %s", beforeHead, got) + } + if got := hgitOutRaw(t, r.Invocation, "ls-files", "--stage", "-z"); got != beforeIndex { + t.Errorf("index changed") + } + if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != beforeStatus { + t.Errorf("working tree status changed:\n%s", got) + } +} diff --git a/internal/repository/transaction/recovery_test.go b/internal/repository/transaction/recovery_test.go index 95d8bdc81..49cdd012c 100644 --- a/internal/repository/transaction/recovery_test.go +++ b/internal/repository/transaction/recovery_test.go @@ -5,7 +5,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "github.com/danielhanold/docket/internal/testsupport" "io/fs" "os" "path/filepath" @@ -160,89 +159,6 @@ func pruneEntryFor(t *testing.T, rep PruneReport, id string) PruneEntry { return PruneEntry{} } -// TestPruneReportsLiveCandidatesUntouched proves that two concurrently active -// candidates in one clone — both holding their live locks — are both reported live -// and both survive. A held lock is the sole liveness signal. -func TestPruneReportsLiveCandidatesUntouched(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, _, repo := recoveryEngine(t, r) - - c1, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) - if err != nil { - t.Fatalf("allocate c1: %v", err) - } - defer func() { _ = c1.live.release() }() - c2, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) - if err != nil { - t.Fatalf("allocate c2: %v", err) - } - defer func() { _ = c2.live.release() }() - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - for _, c := range []*candidate{c1, c2} { - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictLive { - t.Errorf("candidate %s verdict = %q, want live", c.id, e.Verdict) - } - if _, err := os.Stat(c.root); err != nil { - t.Errorf("live candidate %s was removed: %v", c.id, err) - } - } -} - -// TestPrunePrunesAbandonedCandidate proves a normally abandoned candidate is -// pruned — worktree deregistered, directory gone — and that Pushed is reported -// correctly for both an already-reachable (pushed) and a never-pushed commit. -func TestPrunePrunesAbandonedCandidate(t *testing.T) { - requireGit(t) - - t.Run("pushed_commit_reachable", func(t *testing.T) { - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - // Worktree parked at the target tip: its commit is reachable from the target. - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictPruned { - t.Fatalf("verdict = %q, want pruned", e.Verdict) - } - if !e.Pushed { - t.Errorf("Pushed = false, want true for a tip-reachable commit") - } - assertGoneAndDeregistered(t, client, repo, c) - }) - - t.Run("unpushed_commit_unreachable", func(t *testing.T) { - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - // Advance the worktree's detached HEAD to a local-only commit: unreachable - // from the target, so the residue was never pushed. - hgitOut(t, c.worktree, "commit", "--allow-empty", "-q", "--no-gpg-sign", "-m", "local only") - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictPruned { - t.Fatalf("verdict = %q, want pruned", e.Verdict) - } - if e.Pushed { - t.Errorf("Pushed = true, want false for a local-only commit") - } - assertGoneAndDeregistered(t, client, repo, c) - }) -} - // assertGoneAndDeregistered proves the candidate directory is removed and its // worktree is no longer registered with Git. func assertGoneAndDeregistered(t *testing.T, client *gitcli.Client, repo gitcli.Repository, c *candidate) { @@ -262,212 +178,6 @@ func assertGoneAndDeregistered(t *testing.T, client *gitcli.Client, repo gitcli. } } -// TestPruneHeldLockBeatsAncientPIDAndTimestamp proves a held live lock defeats -// pruning even when the manifest advertises an ancient creation time and a dead -// PID: no age threshold overrides a held lock and PID liveness is never consulted. -func TestPruneHeldLockBeatsAncientPIDAndTimestamp(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, _, repo := recoveryEngine(t, r) - - c, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) - if err != nil { - t.Fatalf("allocateCandidate: %v", err) - } - defer func() { _ = c.live.release() }() - - // Rewrite the manifest with an ancient timestamp and an implausible PID while the - // live lock is STILL held. Every other field stays valid, so only the held lock - // can be what saves the candidate. - m, err := c.readManifest() - if err != nil { - t.Fatalf("read manifest: %v", err) - } - m.CreatedUTC = "2000-01-01T00:00:00Z" - m.UpdatedUTC = "2000-01-01T00:00:00Z" - m.PID = 2147480000 - if err := writeManifestAtomic(c.root, m); err != nil { - t.Fatalf("rewrite manifest: %v", err) - } - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictLive { - t.Errorf("verdict = %q, want live (held lock must beat age/PID)", e.Verdict) - } - if _, err := os.Stat(c.root); err != nil { - t.Errorf("held-lock candidate removed: %v", err) - } -} - -// TestPruneLeavesMalformedAndForeignByteUntouched drives every survival variant -// from the spec: each is reported with a verdict and left byte-for-byte identical. -func TestPruneLeavesMalformedAndForeignByteUntouched(t *testing.T) { - requireGit(t) - - type variant struct { - name string - id string - verdict string - // build lays the on-disk state down under repo and returns the path whose - // bytes must be identical afterward (candidate root or the entry itself). - build func(t *testing.T, eng *Engine, client *gitcli.Client, repo gitcli.Repository) (id, hashPath string) - } - - variants := []variant{ - { - name: "missing_manifest", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa1") - p := mkOwnedDir(t, repo, id) - return id, p - }, - verdict: verdictMalformed, - }, - { - name: "truncated_json", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa2") - p := mkOwnedDir(t, repo, id) - if err := os.WriteFile(filepath.Join(p, manifestFileName), []byte("{ \"schema\": 1"), txnFileMode); err != nil { - t.Fatal(err) - } - return id, p - }, - verdict: verdictMalformed, - }, - { - name: "unsupported_schema", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa3") - p := mkOwnedDir(t, repo, id) - m := baseValidManifest(id, repo.CommonDir) - m.Schema = 99 - if err := writeManifestAtomic(p, m); err != nil { - t.Fatal(err) - } - return id, p - }, - verdict: verdictMalformed, - }, - { - name: "wrong_repository", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa4") - p := mkOwnedDir(t, repo, id) - // A canonical common dir belonging to a different repository. - other := canonicalPath(testsupport.TempDir(t)) - m := baseValidManifest(id, other) - if err := writeManifestAtomic(p, m); err != nil { - t.Fatal(err) - } - return id, p - }, - verdict: verdictForeign, - }, - { - name: "worktree_rel_escapes", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa5") - p := mkOwnedDir(t, repo, id) - m := baseValidManifest(id, repo.CommonDir) - m.WorktreeRel = "../../x" - if err := writeManifestAtomic(p, m); err != nil { - t.Fatal(err) - } - return id, p - }, - verdict: verdictMalformed, - }, - { - name: "symlinked_root_to_foreign", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - id := hexID("aaaa6") - root := transactionsRoot(repo) - if err := ensureTransactionsRoot(root); err != nil { - t.Fatal(err) - } - foreign := testsupport.TempDir(t) - if err := os.WriteFile(filepath.Join(foreign, "secret"), []byte("do not touch\n"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, id) - if err := os.Symlink(foreign, link); err != nil { - t.Fatal(err) - } - return id, link - }, - verdict: verdictForeign, - }, - { - name: "foreign_named_directory", - build: func(t *testing.T, _ *Engine, _ *gitcli.Client, repo gitcli.Repository) (string, string) { - root := transactionsRoot(repo) - if err := ensureTransactionsRoot(root); err != nil { - t.Fatal(err) - } - name := "not-32-hex" - p := filepath.Join(root, name) - if err := mkdirMode(p, txnDirMode); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(p, "keep"), []byte("keep\n"), 0o600); err != nil { - t.Fatal(err) - } - return name, p - }, - verdict: verdictForeign, - }, - { - name: "ambiguous_registration", - build: func(t *testing.T, _ *Engine, client *gitcli.Client, repo gitcli.Repository) (string, string) { - // A valid, abandoned candidate — but Git has a registration inside its - // tree at an unexpected path, so ownership is ambiguous. - c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - if err != nil { - t.Fatalf("allocate: %v", err) - } - rogue := filepath.Join(c.root, "rogue") - if err := client.AddDetachedWorktree(context.Background(), repo, rogue, headCommit(t, repo)); err != nil { - t.Fatalf("add rogue worktree: %v", err) - } - if err := c.live.release(); err != nil { - t.Fatal(err) - } - return c.id, c.root - }, - verdict: verdictForeign, - }, - } - - for _, v := range variants { - t.Run(v.name, func(t *testing.T) { - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - id, hp := v.build(t, eng, client, repo) - - before := hashTree(t, hp) - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, id) - if e.Verdict != v.verdict { - t.Errorf("verdict = %q, want %q (detail: %s)", e.Verdict, v.verdict, e.Detail) - } - if _, err := os.Lstat(hp); err != nil { - t.Fatalf("survival entry vanished: %v", err) - } - if after := hashTree(t, hp); after != before { - t.Errorf("survival entry was mutated: before %s, after %s", before, after) - } - }) - } -} - // headCommit resolves the invocation repo's current HEAD commit for a worktree add. func headCommit(t *testing.T, repo gitcli.Repository) gitcli.ObjectID { t.Helper() @@ -550,94 +260,3 @@ func TestPruneRegistryLockSerializesAllocation(t *testing.T) { t.Errorf("entry = %+v, want live entry for %s (a half-published dir would read malformed)", e, candID) } } - -// TestPruneReportsCleanupFailedOnForcedRemovalFailure proves that when Git cannot -// remove the registered worktree, the candidate is retained with a cleanup-failed -// verdict and a diagnostic rather than being force-deleted by pathname. -func TestPruneReportsCleanupFailedOnForcedRemovalFailure(t *testing.T) { - requireGit(t) - if os.Geteuid() == 0 { - t.Skip("running as root: 000 permissions do not block removal") - } - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - - // Make the worktree directory unremovable, then restore it so t.TempDir cleanup - // can proceed regardless of the test outcome. - if err := os.Chmod(c.worktree, 0o000); err != nil { - t.Fatalf("chmod worktree 000: %v", err) - } - defer func() { _ = os.Chmod(c.worktree, 0o700) }() - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - e := pruneEntryFor(t, rep, c.id) - if e.Verdict != verdictCleanupFailed { - t.Fatalf("verdict = %q, want cleanup-failed (detail: %s)", e.Verdict, e.Detail) - } - if _, err := os.Stat(c.root); err != nil { - t.Errorf("cleanup-failed candidate was removed: %v", err) - } -} - -// TestPruneNeverGlobalPrunesOrTouchesUserCheckout proves that a full prune sweep -// leaves the invocation checkout byte-identical and never runs a global worktree -// prune: a second, unrelated but perfectly valid worktree registration planted in -// the repo still exists afterward. -func TestPruneNeverGlobalPrunesOrTouchesUserCheckout(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, client, repo := recoveryEngine(t, r) - - // A legitimate, unrelated linked worktree the user keeps around. - userWorktree := filepath.Join(testsupport.TempDir(t), "user-wt") - if err := client.AddDetachedWorktree(context.Background(), repo, userWorktree, targetTip(t, r)); err != nil { - t.Fatalf("add user worktree: %v", err) - } - - // A prunable candidate to make the sweep actually do destructive work. - c := abandonRegistered(t, client, repo, r, targetTip(t, r)) - - // Snapshot the invocation checkout AFTER setup, BEFORE the sweep. - beforeHead := hgitOut(t, r.Invocation, "rev-parse", "HEAD") - beforeIndex := hgitOutRaw(t, r.Invocation, "ls-files", "--stage", "-z") - beforeStatus := hgitOut(t, r.Invocation, "status", "--porcelain") - - rep, err := eng.PruneAbandoned(context.Background(), repo) - if err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - if e := pruneEntryFor(t, rep, c.id); e.Verdict != verdictPruned { - t.Fatalf("candidate verdict = %q, want pruned", e.Verdict) - } - - // The user's unrelated worktree is still registered — no global prune ran. - infos, err := client.ListWorktrees(context.Background(), repo) - if err != nil { - t.Fatalf("ListWorktrees: %v", err) - } - want := canonicalPath(userWorktree) - found := false - for _, info := range infos { - if canonicalPath(info.Path) == want { - found = true - } - } - if !found { - t.Errorf("unrelated user worktree was deregistered by the sweep") - } - - // The invocation checkout is byte-identical. - if got := hgitOut(t, r.Invocation, "rev-parse", "HEAD"); got != beforeHead { - t.Errorf("HEAD moved: %s -> %s", beforeHead, got) - } - if got := hgitOutRaw(t, r.Invocation, "ls-files", "--stage", "-z"); got != beforeIndex { - t.Errorf("index changed") - } - if got := hgitOut(t, r.Invocation, "status", "--porcelain"); got != beforeStatus { - t.Errorf("working tree status changed:\n%s", got) - } -} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index b51958abd..1adf4ed2e 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -74,6 +74,7 @@ tests/test_go_integration_githubcli_merge.sh 10 parallel tests/test_go_integration_githubcli_probe.sh 10 parallel tests/test_go_integration_githubcli_prbatch.sh 10 parallel tests/test_go_integration_transaction_apply.sh 25 parallel +tests/test_go_integration_transaction_recovery.sh 20 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_transaction_recovery.sh b/tests/test_go_integration_transaction_recovery.sh new file mode 100755 index 000000000..96d978941 --- /dev/null +++ b/tests/test_go_integration_transaction_recovery.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_recovery.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's replay, materialization, interruption, +# cleanup, and abandoned-candidate recovery real-git tests — moved out of the default +# internal/repository/transaction corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationTxnRecovery. Declarations only — execution and +# inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestIntegrationTxnRecovery" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From a1e3ffd49f6dc8225135ddb7b0c2eb15c93f48b1 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:13:43 -0400 Subject: [PATCH 05/16] test(transaction): move concurrency real-git tests into a race shard (TestRaceIntegrationTxn, change 0466) --- .../candidate_race_integration_test.go | 109 +++++++++ .../repository/transaction/candidate_test.go | 97 -------- .../concurrency_race_integration_test.go | 218 ++++++++++++++++++ .../transaction/concurrency_test.go | 202 ---------------- .../engine_race_integration_test.go | 61 +++++ .../repository/transaction/engine_test.go | 50 ---- .../interrupt_race_integration_test.go | 214 +++++++++++++++++ .../repository/transaction/interrupt_test.go | 202 ---------------- .../recovery_race_integration_test.go | 87 +++++++ .../repository/transaction/recovery_test.go | 77 ------- tests/runtime-budgets.tsv | 1 + tests/test_go_integration_transaction_race.sh | 24 ++ 12 files changed, 714 insertions(+), 628 deletions(-) create mode 100644 internal/repository/transaction/candidate_race_integration_test.go create mode 100644 internal/repository/transaction/concurrency_race_integration_test.go create mode 100644 internal/repository/transaction/engine_race_integration_test.go create mode 100644 internal/repository/transaction/interrupt_race_integration_test.go create mode 100644 internal/repository/transaction/recovery_race_integration_test.go create mode 100755 tests/test_go_integration_transaction_race.sh diff --git a/internal/repository/transaction/candidate_race_integration_test.go b/internal/repository/transaction/candidate_race_integration_test.go new file mode 100644 index 000000000..3e501740f --- /dev/null +++ b/internal/repository/transaction/candidate_race_integration_test.go @@ -0,0 +1,109 @@ +//go:build integration + +package transaction + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +// TestRaceIntegrationTxnSetPhaseAtomicUnderConcurrentReads rewrites the phase many times while a +// reader goroutine parses the manifest in a tight loop. Because publication is a +// same-directory temp+rename, every read observes a complete document — a naive +// in-place rewrite would let the reader catch a truncated file. +// Race shard (change 0466): a reader goroutine polls the manifest while the phase is rewritten. +func TestRaceIntegrationTxnSetPhaseAtomicUnderConcurrentReads(t *testing.T) { + _, repo := newTxnRepo(t) + c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + if err != nil { + t.Fatalf("allocateCandidate: %v", err) + } + defer func() { _ = c.live.release() }() + + manifestPath := filepath.Join(c.root, "manifest.json") + stop := make(chan struct{}) + readerErr := make(chan error, 1) + go func() { + for { + select { + case <-stop: + readerErr <- nil + return + default: + } + data, err := os.ReadFile(manifestPath) + if err != nil { + readerErr <- fmt.Errorf("read: %w", err) + return + } + var m manifest + if err := json.Unmarshal(data, &m); err != nil { + readerErr <- fmt.Errorf("partial/parse: %w", err) + return + } + } + }() + + phases := []phase{phaseReady, phaseCommitted, phasePushed, phaseAllocating} + const iters = 400 + var last phase + for i := 0; i < iters; i++ { + last = phases[i%len(phases)] + if err := c.setPhase(txnTestClock, last); err != nil { + close(stop) + <-readerErr + t.Fatalf("setPhase: %v", err) + } + } + close(stop) + if err := <-readerErr; err != nil { + t.Fatalf("concurrent reader saw a bad manifest: %v", err) + } + + if got := readManifestFile(t, c.root).Phase; got != last { + t.Errorf("final phase = %q, want %q", got, last) + } +} + +// TestRaceIntegrationTxnRegistryLockAllocationExcludesConcurrentAllocation proves two allocations +// contending on the same transactions root both succeed and produce distinct +// candidate directories — the registry lock serializes them without deadlock. +// Race shard (change 0466): two goroutines allocate candidates under the registry lock. +func TestRaceIntegrationTxnRegistryLockAllocationExcludesConcurrentAllocation(t *testing.T) { + _, repo := newTxnRepo(t) + + start := make(chan struct{}) + type res struct { + c *candidate + err error + } + results := make(chan res, 2) + for i := 0; i < 2; i++ { + go func() { + <-start + c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) + results <- res{c, err} + }() + } + close(start) + + var got []*candidate + for i := 0; i < 2; i++ { + r := <-results + if r.err != nil { + t.Fatalf("concurrent allocateCandidate: %v", r.err) + } + got = append(got, r.c) + } + defer func() { + for _, c := range got { + _ = c.live.release() + } + }() + if got[0].id == got[1].id { + t.Errorf("concurrent allocations collided on id %q", got[0].id) + } +} diff --git a/internal/repository/transaction/candidate_test.go b/internal/repository/transaction/candidate_test.go index f7cc65e30..14b1ce02b 100644 --- a/internal/repository/transaction/candidate_test.go +++ b/internal/repository/transaction/candidate_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "github.com/danielhanold/docket/internal/testsupport" "os" "os/exec" @@ -91,63 +90,6 @@ func readManifestFile(t *testing.T, root string) manifest { return m } -// TestSetPhaseAtomicUnderConcurrentReads rewrites the phase many times while a -// reader goroutine parses the manifest in a tight loop. Because publication is a -// same-directory temp+rename, every read observes a complete document — a naive -// in-place rewrite would let the reader catch a truncated file. -func TestSetPhaseAtomicUnderConcurrentReads(t *testing.T) { - _, repo := newTxnRepo(t) - c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - if err != nil { - t.Fatalf("allocateCandidate: %v", err) - } - defer func() { _ = c.live.release() }() - - manifestPath := filepath.Join(c.root, "manifest.json") - stop := make(chan struct{}) - readerErr := make(chan error, 1) - go func() { - for { - select { - case <-stop: - readerErr <- nil - return - default: - } - data, err := os.ReadFile(manifestPath) - if err != nil { - readerErr <- fmt.Errorf("read: %w", err) - return - } - var m manifest - if err := json.Unmarshal(data, &m); err != nil { - readerErr <- fmt.Errorf("partial/parse: %w", err) - return - } - } - }() - - phases := []phase{phaseReady, phaseCommitted, phasePushed, phaseAllocating} - const iters = 400 - var last phase - for i := 0; i < iters; i++ { - last = phases[i%len(phases)] - if err := c.setPhase(txnTestClock, last); err != nil { - close(stop) - <-readerErr - t.Fatalf("setPhase: %v", err) - } - } - close(stop) - if err := <-readerErr; err != nil { - t.Fatalf("concurrent reader saw a bad manifest: %v", err) - } - - if got := readManifestFile(t, c.root).Phase; got != last { - t.Errorf("final phase = %q, want %q", got, last) - } -} - // TestLiveLockExcludesSecondNonBlocking proves a second non-blocking acquire of // a held lock reports the would-block sentinel rather than silently succeeding. func TestLiveLockExcludesSecondNonBlocking(t *testing.T) { @@ -210,42 +152,3 @@ func TestRegistryLockMutualExclusion(t *testing.T) { } _ = l.release() } - -// TestRegistryLockAllocationExcludesConcurrentAllocation proves two allocations -// contending on the same transactions root both succeed and produce distinct -// candidate directories — the registry lock serializes them without deadlock. -func TestRegistryLockAllocationExcludesConcurrentAllocation(t *testing.T) { - _, repo := newTxnRepo(t) - - start := make(chan struct{}) - type res struct { - c *candidate - err error - } - results := make(chan res, 2) - for i := 0; i < 2; i++ { - go func() { - <-start - c, err := allocateCandidate(txnTestClock, repo, "origin", "refs/heads/main", fixedBase) - results <- res{c, err} - }() - } - close(start) - - var got []*candidate - for i := 0; i < 2; i++ { - r := <-results - if r.err != nil { - t.Fatalf("concurrent allocateCandidate: %v", r.err) - } - got = append(got, r.c) - } - defer func() { - for _, c := range got { - _ = c.live.release() - } - }() - if got[0].id == got[1].id { - t.Errorf("concurrent allocations collided on id %q", got[0].id) - } -} diff --git a/internal/repository/transaction/concurrency_race_integration_test.go b/internal/repository/transaction/concurrency_race_integration_test.go new file mode 100644 index 000000000..11ba4f493 --- /dev/null +++ b/internal/repository/transaction/concurrency_race_integration_test.go @@ -0,0 +1,218 @@ +//go:build integration + +package transaction + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestRaceIntegrationTxnConcurrencyUnrelatedWritersConverge proves two writers touching different +// records converge: the loser loses its lease, refetches the winner's base, +// replans from fresh state, and applies — its FIRST plan bytes never appear in the +// winning commit, and origin ends with both records. +// Race shard (change 0466): concurrent writers contend on one target branch. +func TestRaceIntegrationTxnConcurrencyUnrelatedWritersConverge(t *testing.T) { + for _, topo := range concTopologies() { + t.Run(topo.name, func(t *testing.T) { + h := newConcHarness(t, topo.build) + before := captureCheckouts(t, h.dirA, h.dirB) + + op1 := &recordOp{id: 5, slug: "writer-x", path: "docs/changes/active/0005-writer-x.md", kind: MutationCreate} + op2 := &recordOp{id: 6, slug: "writer-y", path: "docs/changes/active/0006-writer-y.md", kind: MutationCreate} + res1, res2, err1, err2 := h.contendingWriters(t, nil, nil, op1, op2) + if err1 != nil || res1.Disposition != DispositionApplied { + t.Fatalf("winner: disposition %q err %v", res1.Disposition, err1) + } + if err2 != nil { + t.Fatalf("loser Execute: %v", err2) + } + if res2.Disposition != DispositionApplied { + t.Fatalf("loser disposition = %q, want applied", res2.Disposition) + } + if res2.Attempts != 2 { + t.Errorf("loser attempts = %d, want 2 (one lease loss then apply)", res2.Attempts) + } + if op2.calls != 2 { + t.Errorf("loser replanned %d times, want 2", op2.calls) + } + + // Both records converged onto origin. + names := hgitOut(t, h.r.Origin, "ls-tree", "-r", "--name-only", string(h.r.Target)) + for _, want := range []string{op1.path, op2.path} { + if !strings.Contains(names, want) { + t.Errorf("origin missing converged record %q:\n%s", want, names) + } + } + + // The loser's FIRST plan bytes (base A, "changes seen: 2") must not appear in + // the winning commit; the committed blob is the SECOND plan (base B, + // "changes seen: 3"). This is the proof the retry replanned from fresh state + // rather than reusing a stale patch. + committed := originShow(t, h.r, op2.path) + if committed == string(op2.planBytes[0]) { + t.Errorf("committed record equals the FIRST (stale) plan — retry reused a patch") + } + if committed != string(op2.planBytes[1]) { + t.Errorf("committed record != the fresh replan bytes:\ncommitted %q\nreplan %q", committed, op2.planBytes[1]) + } + if !strings.Contains(committed, "changes seen: 3") { + t.Errorf("committed record body = %q, want the fresh base's count (3)", committed) + } + + assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) + }) + } +} + +// TestRaceIntegrationTxnConcurrencySameEntityContends proves two writers expecting the same blob do +// NOT both win: the loser's retry sees the winner's new blob rather than the one +// it expected and returns contended with no commit of its own. +// Race shard (change 0466): concurrent writers contend on one target branch. +func TestRaceIntegrationTxnConcurrencySameEntityContends(t *testing.T) { + const rec = "docs/changes/active/0001-first-change.md" + for _, topo := range concTopologies() { + t.Run(topo.name, func(t *testing.T) { + h := newConcHarness(t, topo.build) + before := captureCheckouts(t, h.dirA, h.dirB) + x1 := h.r.blobID(t, rec) + + // Both writers REPLACE record 1; both expect it at blob X1. The winner sets + // X2; the loser's retry sees X2 != X1 and contends. + op1 := &recordOp{id: 1, slug: "first-change", path: rec, kind: MutationReplace} + op2 := &recordOp{id: 1, slug: "first-change", path: rec, kind: MutationReplace} + exp := []EntityExpectation{{Path: rec, Version: ExpectedVersion{Kind: VersionBlob, ObjectID: x1}}} + + res1, res2, err1, err2 := h.contendingWriters(t, exp, exp, op1, op2) + if err1 != nil || res1.Disposition != DispositionApplied { + t.Fatalf("winner: disposition %q err %v (findings %v)", res1.Disposition, err1, res1.Findings) + } + if err2 != nil { + t.Fatalf("loser Execute: %v", err2) + } + if res2.Disposition != DispositionContended { + t.Fatalf("loser disposition = %q, want contended", res2.Disposition) + } + if len(res2.ContendedPaths) != 1 || res2.ContendedPaths[0] != gitcli.RepoPath(rec) { + t.Errorf("loser contended paths = %v, want [%s]", res2.ContendedPaths, rec) + } + if res2.Attempts != 2 { + t.Errorf("loser attempts = %d, want 2 (one lease loss then a contended re-check)", res2.Attempts) + } + + // The loser committed nothing: origin's record 1 is the winner's body. + committed := originShow(t, h.r, rec) + if committed != string(op1.planBytes[len(op1.planBytes)-1]) { + t.Errorf("origin record 1 is not the winner's bytes:\n%s", committed) + } + // The winner made exactly one commit past the shared base; the loser added none. + assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) + }) + } +} + +// TestRaceIntegrationTxnConcurrencyDerivedOverlapReplansView proves two writers that both regenerate +// one derived index file converge WITHOUT a text merge: the loser replans the +// derived bytes from fresh state, so the final index reflects both primary +// changes, byte-for-byte the deterministic rendering. +// Race shard (change 0466): concurrent writers contend on one target branch. +func TestRaceIntegrationTxnConcurrencyDerivedOverlapReplansView(t *testing.T) { + const indexPath = "records-index.txt" + for _, topo := range concTopologies() { + t.Run(topo.name, func(t *testing.T) { + h := newConcHarness(t, topo.build) + before := captureCheckouts(t, h.dirA, h.dirB) + + op1 := &recordOp{id: 5, slug: "writer-x", path: "docs/changes/active/0005-writer-x.md", kind: MutationCreate, indexPath: indexPath} + op2 := &recordOp{id: 6, slug: "writer-y", path: "docs/changes/active/0006-writer-y.md", kind: MutationCreate, indexPath: indexPath} + res1, res2, err1, err2 := h.contendingWriters(t, nil, nil, op1, op2) + if err1 != nil || res1.Disposition != DispositionApplied { + t.Fatalf("winner: disposition %q err %v", res1.Disposition, err1) + } + if err2 != nil || res2.Disposition != DispositionApplied { + t.Fatalf("loser: disposition %q err %v", res2.Disposition, err2) + } + if res2.Attempts != 2 { + t.Errorf("loser attempts = %d, want 2", res2.Attempts) + } + + // The final derived view reflects BOTH primary changes, rendered fresh: the + // two base records (1,2) plus the winner's 5 and the loser's 6, sorted. A + // text merge would leave conflict markers or drop one side; the exact + // deterministic rendering proves neither happened. + want := renderIndex([]int{1, 2, 5, 6}) + got := originShow(t, h.r, indexPath) + if got != want { + t.Errorf("derived index = %q, want %q (both primary changes, no merge artifact)", got, want) + } + // The loser's FIRST derived bytes (base A: 1,2,6) never reached origin. + first := string(op2.idxBytes[0]) + if got == first { + t.Errorf("derived index equals the loser's stale first render %q", first) + } + + assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) + }) + } +} + +// TestRaceIntegrationTxnConcurrencyFourLeaseLossesContend proves the attempt cap: a writer whose +// origin is advanced before every one of its pushes makes exactly four attempts, +// returns contended, and leaves its transactions root empty — every candidate was +// cleaned. +// Race shard (change 0466): concurrent writers contend on one target branch. +func TestRaceIntegrationTxnConcurrencyFourLeaseLossesContend(t *testing.T) { + for _, topo := range concTopologies() { + t.Run(topo.name, func(t *testing.T) { + requireGit(t) + r := topo.build(t) + client, err := gitcli.NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + eng := newEngine(t, client) + repo, dir := freshClone(t, client, r, "loser") + before := captureCheckouts(t, dir) + + op := &recordOp{id: 7, slug: "loser", path: "docs/changes/active/0007-loser.md", kind: MutationCreate} + // On every attempt, advance origin (via the independent writer clone) AFTER + // the engine has fetched its base but BEFORE it pushes, so the lease always + // loses. Execute runs in THIS goroutine, so advanceOrigin's t.Fatalf is safe. + op.barrier = func(call int) { + adv := call + 10 + r.advanceOrigin(t, fmt.Sprintf("docs/changes/active/00%02d-adv%d.md", adv, call), + corpusChange(adv, fmt.Sprintf("adv%d", call), "proposed")) + } + + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Disposition != DispositionContended { + t.Fatalf("disposition = %q, want contended", res.Disposition) + } + if res.Attempts != maxAttempts { + t.Errorf("attempts = %d, want %d", res.Attempts, maxAttempts) + } + if op.calls != maxAttempts { + t.Errorf("operation planned %d times, want %d", op.calls, maxAttempts) + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after four cleaned lease losses") + } + // The loser's own record never reached origin. + names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) + if strings.Contains(names, op.path) { + t.Errorf("loser's record reached origin despite contention:\n%s", names) + } + assertCheckoutsUnchanged(t, []string{dir}, before) + }) + } +} diff --git a/internal/repository/transaction/concurrency_test.go b/internal/repository/transaction/concurrency_test.go index 3ab5b06e5..d0cfdb7b0 100644 --- a/internal/repository/transaction/concurrency_test.go +++ b/internal/repository/transaction/concurrency_test.go @@ -211,205 +211,3 @@ func (h *concHarness) contendingWriters(t *testing.T, exp1, exp2 []EntityExpecta // concTopologies names the two harness builders the matrix runs each scenario // against. func concTopologies() []topology { return topologies() } - -// TestConcurrencyUnrelatedWritersConverge proves two writers touching different -// records converge: the loser loses its lease, refetches the winner's base, -// replans from fresh state, and applies — its FIRST plan bytes never appear in the -// winning commit, and origin ends with both records. -func TestConcurrencyUnrelatedWritersConverge(t *testing.T) { - for _, topo := range concTopologies() { - t.Run(topo.name, func(t *testing.T) { - h := newConcHarness(t, topo.build) - before := captureCheckouts(t, h.dirA, h.dirB) - - op1 := &recordOp{id: 5, slug: "writer-x", path: "docs/changes/active/0005-writer-x.md", kind: MutationCreate} - op2 := &recordOp{id: 6, slug: "writer-y", path: "docs/changes/active/0006-writer-y.md", kind: MutationCreate} - res1, res2, err1, err2 := h.contendingWriters(t, nil, nil, op1, op2) - if err1 != nil || res1.Disposition != DispositionApplied { - t.Fatalf("winner: disposition %q err %v", res1.Disposition, err1) - } - if err2 != nil { - t.Fatalf("loser Execute: %v", err2) - } - if res2.Disposition != DispositionApplied { - t.Fatalf("loser disposition = %q, want applied", res2.Disposition) - } - if res2.Attempts != 2 { - t.Errorf("loser attempts = %d, want 2 (one lease loss then apply)", res2.Attempts) - } - if op2.calls != 2 { - t.Errorf("loser replanned %d times, want 2", op2.calls) - } - - // Both records converged onto origin. - names := hgitOut(t, h.r.Origin, "ls-tree", "-r", "--name-only", string(h.r.Target)) - for _, want := range []string{op1.path, op2.path} { - if !strings.Contains(names, want) { - t.Errorf("origin missing converged record %q:\n%s", want, names) - } - } - - // The loser's FIRST plan bytes (base A, "changes seen: 2") must not appear in - // the winning commit; the committed blob is the SECOND plan (base B, - // "changes seen: 3"). This is the proof the retry replanned from fresh state - // rather than reusing a stale patch. - committed := originShow(t, h.r, op2.path) - if committed == string(op2.planBytes[0]) { - t.Errorf("committed record equals the FIRST (stale) plan — retry reused a patch") - } - if committed != string(op2.planBytes[1]) { - t.Errorf("committed record != the fresh replan bytes:\ncommitted %q\nreplan %q", committed, op2.planBytes[1]) - } - if !strings.Contains(committed, "changes seen: 3") { - t.Errorf("committed record body = %q, want the fresh base's count (3)", committed) - } - - assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) - }) - } -} - -// TestConcurrencySameEntityContends proves two writers expecting the same blob do -// NOT both win: the loser's retry sees the winner's new blob rather than the one -// it expected and returns contended with no commit of its own. -func TestConcurrencySameEntityContends(t *testing.T) { - const rec = "docs/changes/active/0001-first-change.md" - for _, topo := range concTopologies() { - t.Run(topo.name, func(t *testing.T) { - h := newConcHarness(t, topo.build) - before := captureCheckouts(t, h.dirA, h.dirB) - x1 := h.r.blobID(t, rec) - - // Both writers REPLACE record 1; both expect it at blob X1. The winner sets - // X2; the loser's retry sees X2 != X1 and contends. - op1 := &recordOp{id: 1, slug: "first-change", path: rec, kind: MutationReplace} - op2 := &recordOp{id: 1, slug: "first-change", path: rec, kind: MutationReplace} - exp := []EntityExpectation{{Path: rec, Version: ExpectedVersion{Kind: VersionBlob, ObjectID: x1}}} - - res1, res2, err1, err2 := h.contendingWriters(t, exp, exp, op1, op2) - if err1 != nil || res1.Disposition != DispositionApplied { - t.Fatalf("winner: disposition %q err %v (findings %v)", res1.Disposition, err1, res1.Findings) - } - if err2 != nil { - t.Fatalf("loser Execute: %v", err2) - } - if res2.Disposition != DispositionContended { - t.Fatalf("loser disposition = %q, want contended", res2.Disposition) - } - if len(res2.ContendedPaths) != 1 || res2.ContendedPaths[0] != gitcli.RepoPath(rec) { - t.Errorf("loser contended paths = %v, want [%s]", res2.ContendedPaths, rec) - } - if res2.Attempts != 2 { - t.Errorf("loser attempts = %d, want 2 (one lease loss then a contended re-check)", res2.Attempts) - } - - // The loser committed nothing: origin's record 1 is the winner's body. - committed := originShow(t, h.r, rec) - if committed != string(op1.planBytes[len(op1.planBytes)-1]) { - t.Errorf("origin record 1 is not the winner's bytes:\n%s", committed) - } - // The winner made exactly one commit past the shared base; the loser added none. - assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) - }) - } -} - -// TestConcurrencyDerivedOverlapReplansView proves two writers that both regenerate -// one derived index file converge WITHOUT a text merge: the loser replans the -// derived bytes from fresh state, so the final index reflects both primary -// changes, byte-for-byte the deterministic rendering. -func TestConcurrencyDerivedOverlapReplansView(t *testing.T) { - const indexPath = "records-index.txt" - for _, topo := range concTopologies() { - t.Run(topo.name, func(t *testing.T) { - h := newConcHarness(t, topo.build) - before := captureCheckouts(t, h.dirA, h.dirB) - - op1 := &recordOp{id: 5, slug: "writer-x", path: "docs/changes/active/0005-writer-x.md", kind: MutationCreate, indexPath: indexPath} - op2 := &recordOp{id: 6, slug: "writer-y", path: "docs/changes/active/0006-writer-y.md", kind: MutationCreate, indexPath: indexPath} - res1, res2, err1, err2 := h.contendingWriters(t, nil, nil, op1, op2) - if err1 != nil || res1.Disposition != DispositionApplied { - t.Fatalf("winner: disposition %q err %v", res1.Disposition, err1) - } - if err2 != nil || res2.Disposition != DispositionApplied { - t.Fatalf("loser: disposition %q err %v", res2.Disposition, err2) - } - if res2.Attempts != 2 { - t.Errorf("loser attempts = %d, want 2", res2.Attempts) - } - - // The final derived view reflects BOTH primary changes, rendered fresh: the - // two base records (1,2) plus the winner's 5 and the loser's 6, sorted. A - // text merge would leave conflict markers or drop one side; the exact - // deterministic rendering proves neither happened. - want := renderIndex([]int{1, 2, 5, 6}) - got := originShow(t, h.r, indexPath) - if got != want { - t.Errorf("derived index = %q, want %q (both primary changes, no merge artifact)", got, want) - } - // The loser's FIRST derived bytes (base A: 1,2,6) never reached origin. - first := string(op2.idxBytes[0]) - if got == first { - t.Errorf("derived index equals the loser's stale first render %q", first) - } - - assertCheckoutsUnchanged(t, []string{h.dirA, h.dirB}, before) - }) - } -} - -// TestConcurrencyFourLeaseLossesContend proves the attempt cap: a writer whose -// origin is advanced before every one of its pushes makes exactly four attempts, -// returns contended, and leaves its transactions root empty — every candidate was -// cleaned. -func TestConcurrencyFourLeaseLossesContend(t *testing.T) { - for _, topo := range concTopologies() { - t.Run(topo.name, func(t *testing.T) { - requireGit(t) - r := topo.build(t) - client, err := gitcli.NewClient() - if err != nil { - t.Fatalf("NewClient: %v", err) - } - eng := newEngine(t, client) - repo, dir := freshClone(t, client, r, "loser") - before := captureCheckouts(t, dir) - - op := &recordOp{id: 7, slug: "loser", path: "docs/changes/active/0007-loser.md", kind: MutationCreate} - // On every attempt, advance origin (via the independent writer clone) AFTER - // the engine has fetched its base but BEFORE it pushes, so the lease always - // loses. Execute runs in THIS goroutine, so advanceOrigin's t.Fatalf is safe. - op.barrier = func(call int) { - adv := call + 10 - r.advanceOrigin(t, fmt.Sprintf("docs/changes/active/00%02d-adv%d.md", adv, call), - corpusChange(adv, fmt.Sprintf("adv%d", call), "proposed")) - } - - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if res.Disposition != DispositionContended { - t.Fatalf("disposition = %q, want contended", res.Disposition) - } - if res.Attempts != maxAttempts { - t.Errorf("attempts = %d, want %d", res.Attempts, maxAttempts) - } - if op.calls != maxAttempts { - t.Errorf("operation planned %d times, want %d", op.calls, maxAttempts) - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after four cleaned lease losses") - } - // The loser's own record never reached origin. - names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) - if strings.Contains(names, op.path) { - t.Errorf("loser's record reached origin despite contention:\n%s", names) - } - assertCheckoutsUnchanged(t, []string{dir}, before) - }) - } -} diff --git a/internal/repository/transaction/engine_race_integration_test.go b/internal/repository/transaction/engine_race_integration_test.go new file mode 100644 index 000000000..1a7a0be51 --- /dev/null +++ b/internal/repository/transaction/engine_race_integration_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package transaction + +import ( + "context" + "sync" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestRaceIntegrationTxnEngineConcurrentExecuteIsRaceFree runs two Execute calls on one shared Engine +// against two independent repositories, coordinated by a barrier, to catch shared +// state races under -race. +// Race shard (change 0466): concurrent Execute calls share one engine. +func TestRaceIntegrationTxnEngineConcurrentExecuteIsRaceFree(t *testing.T) { + requireGit(t) + client, err := gitcli.NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + eng := newEngine(t, client) + + r1 := newMainModeRepos(t) + r2 := newDocketModeRepos(t) + repo1, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r1.Invocation}) + if err != nil { + t.Fatalf("Discover r1: %v", err) + } + repo2, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r2.Invocation}) + if err != nil { + t.Fatalf("Discover r2: %v", err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + run := func(repo gitcli.Repository, ref gitcli.RefName, out *Result, rerr *error) { + defer wg.Done() + <-start + res, e := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: ref, + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + *out, *rerr = res, e + } + var res1, res2 Result + var err1, err2 error + wg.Add(2) + go run(repo1, r1.Target, &res1, &err1) + go run(repo2, r2.Target, &res2, &err2) + close(start) + wg.Wait() + + if err1 != nil || res1.Disposition != DispositionApplied { + t.Errorf("goroutine 1: disposition %q err %v", res1.Disposition, err1) + } + if err2 != nil || res2.Disposition != DispositionApplied { + t.Errorf("goroutine 2: disposition %q err %v", res2.Disposition, err2) + } +} diff --git a/internal/repository/transaction/engine_test.go b/internal/repository/transaction/engine_test.go index bce323ab0..e2715a4c0 100644 --- a/internal/repository/transaction/engine_test.go +++ b/internal/repository/transaction/engine_test.go @@ -2,7 +2,6 @@ package transaction import ( "context" - "sync" "testing" "time" @@ -88,55 +87,6 @@ func TestNewEngineRejectsNilDependencies(t *testing.T) { } } -// TestEngineConcurrentExecuteIsRaceFree runs two Execute calls on one shared Engine -// against two independent repositories, coordinated by a barrier, to catch shared -// state races under -race. -func TestEngineConcurrentExecuteIsRaceFree(t *testing.T) { - requireGit(t) - client, err := gitcli.NewClient() - if err != nil { - t.Fatalf("NewClient: %v", err) - } - eng := newEngine(t, client) - - r1 := newMainModeRepos(t) - r2 := newDocketModeRepos(t) - repo1, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r1.Invocation}) - if err != nil { - t.Fatalf("Discover r1: %v", err) - } - repo2, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r2.Invocation}) - if err != nil { - t.Fatalf("Discover r2: %v", err) - } - - start := make(chan struct{}) - var wg sync.WaitGroup - run := func(repo gitcli.Repository, ref gitcli.RefName, out *Result, rerr *error) { - defer wg.Done() - <-start - res, e := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: ref, - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - *out, *rerr = res, e - } - var res1, res2 Result - var err1, err2 error - wg.Add(2) - go run(repo1, r1.Target, &res1, &err1) - go run(repo2, r2.Target, &res2, &err2) - close(start) - wg.Wait() - - if err1 != nil || res1.Disposition != DispositionApplied { - t.Errorf("goroutine 1: disposition %q err %v", res1.Disposition, err1) - } - if err2 != nil || res2.Disposition != DispositionApplied { - t.Errorf("goroutine 2: disposition %q err %v", res2.Disposition, err2) - } -} - // topology names a harness builder so a test can run against both metadata shapes. type topology struct { name string diff --git a/internal/repository/transaction/interrupt_race_integration_test.go b/internal/repository/transaction/interrupt_race_integration_test.go new file mode 100644 index 000000000..05ca28376 --- /dev/null +++ b/internal/repository/transaction/interrupt_race_integration_test.go @@ -0,0 +1,214 @@ +//go:build integration + +package transaction + +import ( + "context" + "strings" + "testing" +) + +// TestRaceIntegrationTxnInterruptCancelInsidePlan proves cancelling the context while the operation +// is planning yields an interrupted disposition and leaves origin untouched — a +// pre-push cancellation never changes the remote. +// Race shard (change 0466): Execute runs in a goroutine while the test goroutine cancels or races it through shared hooks. +func TestRaceIntegrationTxnInterruptCancelInsidePlan(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + eng := newEngine(t, client) + base := r.originTip(t) + + ctx, cancel := context.WithCancel(context.Background()) + op := &blockingPlanOp{entered: make(chan struct{})} + + var res Result + var rerr error + done := make(chan struct{}) + go func() { + res, rerr = eng.Execute(ctx, Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + close(done) + }() + + <-op.entered + cancel() + <-done + + if res.Disposition != DispositionInterrupted { + t.Fatalf("disposition = %q, want interrupted", res.Disposition) + } + assertFailureKind(t, rerr, KindCancelled) + if r.originTip(t) != base { + t.Error("origin advanced on a cancellation inside Plan") + } +} + +// TestRaceIntegrationTxnInterruptCancelBetweenCommitAndPush proves that cancelling after the local +// commit exists but before the push leaves origin byte-identical: the push is +// launched with a dead context and never reaches the remote. The barrier clock +// parks the attempt on the setPhase(committed) stamp — after CommitPaths, before +// PushLease — which is the deterministic pre-push seam. +// Race shard (change 0466): Execute runs in a goroutine while the test goroutine cancels or races it through shared hooks. +func TestRaceIntegrationTxnInterruptCancelBetweenCommitAndPush(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + base := r.originTip(t) + + clk := newBarrierClock(3) // 3rd clock read == setPhase(committed), immediately pre-push + eng, err := NewEngine(client, clk) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + op := createOp(thirdChangePath, thirdChange()) + + var res Result + var rerr error + done := make(chan struct{}) + go func() { + res, rerr = eng.Execute(ctx, Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + close(done) + }() + + <-clk.reached // the local commit exists; the push has not been launched + cancel() + close(clk.release) + <-done + + if res.Disposition != DispositionInterrupted { + t.Fatalf("disposition = %q, want interrupted (a pre-push cancel must not apply)", res.Disposition) + } + assertFailureKind(t, rerr, KindCancelled) + if r.originTip(t) != base { + t.Error("origin advanced despite a cancellation before the push") + } +} + +// TestRaceIntegrationTxnInterruptLiteralLeaseRejectsFresherTrackingRef proves the push lease is +// pinned to the exact base the operation read, not to the clone's remote-tracking +// ref. The barrier clock parks the attempt after the local commit; the test then +// advances origin to a DIVERGENT commit AND updates the engine clone's tracking +// ref to match. A bare/implicit lease would read the fresh tracking ref, find it +// equal to the remote, and force-push — silently clobbering the concurrent +// writer. The literal lease, pinned to the stale base, correctly loses, retries on +// the new base, and both changes converge. +// Race shard (change 0466): Execute runs in a goroutine while the test goroutine cancels or races it through shared hooks. +func TestRaceIntegrationTxnInterruptLiteralLeaseRejectsFresherTrackingRef(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + + clk := newBarrierClock(3) + eng, err := NewEngine(client, clk) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + + op := createOp(thirdChangePath, thirdChange()) + var res Result + var rerr error + done := make(chan struct{}) + go func() { + res, rerr = eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: op, + }) + close(done) + }() + + <-clk.reached + // Origin advances to a divergent commit (a different record), and the engine + // clone's remote-tracking ref is fast-forwarded to it. A bare lease would now be + // satisfied and force-clobber; the literal lease pinned to the stale base must + // not be. + writerRec := "docs/changes/active/0008-writer-b.md" + r.advanceOrigin(t, writerRec, corpusChange(8, "writer-b", "proposed")) + hgitOut(t, repo.PrimaryWorktree, "fetch", "-q", "origin", r.short()) + close(clk.release) + <-done + + if rerr != nil { + t.Fatalf("Execute: %v", rerr) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (literal lease loses then reapplies)", res.Disposition) + } + if res.Attempts != 2 { + t.Errorf("attempts = %d, want 2 (one literal-lease loss then apply)", res.Attempts) + } + // Both changes converged: the concurrent writer's record was NOT clobbered. + names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) + for _, want := range []string{thirdChangePath, writerRec} { + if !strings.Contains(names, want) { + t.Errorf("origin missing %q — the concurrent writer's change was clobbered:\n%s", want, names) + } + } +} + +// TestRaceIntegrationTxnInterruptAmbiguousPushLandedIsApplied proves the post-push probe: when the +// lease push is rejected but the engine's own commit is nonetheless reachable from +// the advanced remote — the "ambiguous response where the write actually landed" — +// the engine classifies the transaction APPLIED, not failed. The barrier clock +// parks the attempt after the local commit; the test then publishes a DESCENDANT +// of that exact commit to origin (a fast-forward built with commit-tree, touching +// no checkout), so the engine's subsequent lease push loses the lease yet its +// commit is a proven ancestor of the new remote tip. +// Race shard (change 0466): Execute runs in a goroutine while the test goroutine cancels or races it through shared hooks. +func TestRaceIntegrationTxnInterruptAmbiguousPushLandedIsApplied(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + client, repo := r.discover(t) + + clk := newBarrierClock(3) // park on setPhase(committed): the commit exists, push has not run + eng, err := NewEngine(client, clk) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + + var res Result + var rerr error + done := make(chan struct{}) + go func() { + res, rerr = eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), + }) + close(done) + }() + + <-clk.reached + // The engine's commit exists in the candidate worktree (shared object store with + // the invocation clone). Publish a descendant of it to origin so the pending push + // loses its lease but the commit stays reachable from the new tip. + wt := soleCandidateWorktree(t, repo) + engineCommit := hgitOut(t, wt, "rev-parse", "HEAD") + tree := hgitOut(t, repo.PrimaryWorktree, "rev-parse", engineCommit+"^{tree}") + descendant := hgitOut(t, repo.PrimaryWorktree, "commit-tree", tree, "-p", engineCommit, "-m", "ambiguous descendant") + hgitOut(t, repo.PrimaryWorktree, "push", "origin", descendant+":"+string(r.Target)) + close(clk.release) + <-done + + if rerr != nil { + t.Fatalf("Execute returned a Go error: %v", rerr) + } + if res.Disposition != DispositionApplied { + t.Fatalf("disposition = %q, want applied (the commit landed and is reachable)", res.Disposition) + } + if string(res.AppliedCommit) != engineCommit { + t.Errorf("applied commit = %q, want the engine's commit %q", res.AppliedCommit, engineCommit) + } + if got := string(r.originTip(t)); got != descendant { + t.Errorf("origin tip = %q, want the published descendant %q", got, descendant) + } + if !transactionsEmpty(t, repo) { + t.Error("transactions root not empty after an applied ambiguous push") + } +} diff --git a/internal/repository/transaction/interrupt_test.go b/internal/repository/transaction/interrupt_test.go index 699cbac23..48268a9ef 100644 --- a/internal/repository/transaction/interrupt_test.go +++ b/internal/repository/transaction/interrupt_test.go @@ -4,7 +4,6 @@ import ( "context" "os" "path/filepath" - "strings" "sync" "testing" "time" @@ -73,88 +72,6 @@ func (o *blockingPlanOp) Plan(ctx context.Context, _ AttemptState) (MutationPlan return MutationPlan{}, OperationResult{}, ctx.Err() } -// TestInterruptCancelInsidePlan proves cancelling the context while the operation -// is planning yields an interrupted disposition and leaves origin untouched — a -// pre-push cancellation never changes the remote. -func TestInterruptCancelInsidePlan(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - eng := newEngine(t, client) - base := r.originTip(t) - - ctx, cancel := context.WithCancel(context.Background()) - op := &blockingPlanOp{entered: make(chan struct{})} - - var res Result - var rerr error - done := make(chan struct{}) - go func() { - res, rerr = eng.Execute(ctx, Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - close(done) - }() - - <-op.entered - cancel() - <-done - - if res.Disposition != DispositionInterrupted { - t.Fatalf("disposition = %q, want interrupted", res.Disposition) - } - assertFailureKind(t, rerr, KindCancelled) - if r.originTip(t) != base { - t.Error("origin advanced on a cancellation inside Plan") - } -} - -// TestInterruptCancelBetweenCommitAndPush proves that cancelling after the local -// commit exists but before the push leaves origin byte-identical: the push is -// launched with a dead context and never reaches the remote. The barrier clock -// parks the attempt on the setPhase(committed) stamp — after CommitPaths, before -// PushLease — which is the deterministic pre-push seam. -func TestInterruptCancelBetweenCommitAndPush(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - base := r.originTip(t) - - clk := newBarrierClock(3) // 3rd clock read == setPhase(committed), immediately pre-push - eng, err := NewEngine(client, clk) - if err != nil { - t.Fatalf("NewEngine: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - op := createOp(thirdChangePath, thirdChange()) - - var res Result - var rerr error - done := make(chan struct{}) - go func() { - res, rerr = eng.Execute(ctx, Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - close(done) - }() - - <-clk.reached // the local commit exists; the push has not been launched - cancel() - close(clk.release) - <-done - - if res.Disposition != DispositionInterrupted { - t.Fatalf("disposition = %q, want interrupted (a pre-push cancel must not apply)", res.Disposition) - } - assertFailureKind(t, rerr, KindCancelled) - if r.originTip(t) != base { - t.Error("origin advanced despite a cancellation before the push") - } -} - // soleCandidateWorktree returns the detached worktree path of the single candidate // currently allocated under repo's transactions root — used while an attempt is // parked on the barrier clock, when exactly one candidate exists. @@ -173,122 +90,3 @@ func soleCandidateWorktree(t *testing.T, repo gitcli.Repository) string { t.Fatal("no candidate worktree found under transactions root") return "" } - -// TestInterruptLiteralLeaseRejectsFresherTrackingRef proves the push lease is -// pinned to the exact base the operation read, not to the clone's remote-tracking -// ref. The barrier clock parks the attempt after the local commit; the test then -// advances origin to a DIVERGENT commit AND updates the engine clone's tracking -// ref to match. A bare/implicit lease would read the fresh tracking ref, find it -// equal to the remote, and force-push — silently clobbering the concurrent -// writer. The literal lease, pinned to the stale base, correctly loses, retries on -// the new base, and both changes converge. -func TestInterruptLiteralLeaseRejectsFresherTrackingRef(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - - clk := newBarrierClock(3) - eng, err := NewEngine(client, clk) - if err != nil { - t.Fatalf("NewEngine: %v", err) - } - - op := createOp(thirdChangePath, thirdChange()) - var res Result - var rerr error - done := make(chan struct{}) - go func() { - res, rerr = eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: op, - }) - close(done) - }() - - <-clk.reached - // Origin advances to a divergent commit (a different record), and the engine - // clone's remote-tracking ref is fast-forwarded to it. A bare lease would now be - // satisfied and force-clobber; the literal lease pinned to the stale base must - // not be. - writerRec := "docs/changes/active/0008-writer-b.md" - r.advanceOrigin(t, writerRec, corpusChange(8, "writer-b", "proposed")) - hgitOut(t, repo.PrimaryWorktree, "fetch", "-q", "origin", r.short()) - close(clk.release) - <-done - - if rerr != nil { - t.Fatalf("Execute: %v", rerr) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (literal lease loses then reapplies)", res.Disposition) - } - if res.Attempts != 2 { - t.Errorf("attempts = %d, want 2 (one literal-lease loss then apply)", res.Attempts) - } - // Both changes converged: the concurrent writer's record was NOT clobbered. - names := hgitOut(t, r.Origin, "ls-tree", "-r", "--name-only", string(r.Target)) - for _, want := range []string{thirdChangePath, writerRec} { - if !strings.Contains(names, want) { - t.Errorf("origin missing %q — the concurrent writer's change was clobbered:\n%s", want, names) - } - } -} - -// TestInterruptAmbiguousPushLandedIsApplied proves the post-push probe: when the -// lease push is rejected but the engine's own commit is nonetheless reachable from -// the advanced remote — the "ambiguous response where the write actually landed" — -// the engine classifies the transaction APPLIED, not failed. The barrier clock -// parks the attempt after the local commit; the test then publishes a DESCENDANT -// of that exact commit to origin (a fast-forward built with commit-tree, touching -// no checkout), so the engine's subsequent lease push loses the lease yet its -// commit is a proven ancestor of the new remote tip. -func TestInterruptAmbiguousPushLandedIsApplied(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - client, repo := r.discover(t) - - clk := newBarrierClock(3) // park on setPhase(committed): the commit exists, push has not run - eng, err := NewEngine(client, clk) - if err != nil { - t.Fatalf("NewEngine: %v", err) - } - - var res Result - var rerr error - done := make(chan struct{}) - go func() { - res, rerr = eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Loader: testLoader{}, Operation: createOp(thirdChangePath, thirdChange()), - }) - close(done) - }() - - <-clk.reached - // The engine's commit exists in the candidate worktree (shared object store with - // the invocation clone). Publish a descendant of it to origin so the pending push - // loses its lease but the commit stays reachable from the new tip. - wt := soleCandidateWorktree(t, repo) - engineCommit := hgitOut(t, wt, "rev-parse", "HEAD") - tree := hgitOut(t, repo.PrimaryWorktree, "rev-parse", engineCommit+"^{tree}") - descendant := hgitOut(t, repo.PrimaryWorktree, "commit-tree", tree, "-p", engineCommit, "-m", "ambiguous descendant") - hgitOut(t, repo.PrimaryWorktree, "push", "origin", descendant+":"+string(r.Target)) - close(clk.release) - <-done - - if rerr != nil { - t.Fatalf("Execute returned a Go error: %v", rerr) - } - if res.Disposition != DispositionApplied { - t.Fatalf("disposition = %q, want applied (the commit landed and is reachable)", res.Disposition) - } - if string(res.AppliedCommit) != engineCommit { - t.Errorf("applied commit = %q, want the engine's commit %q", res.AppliedCommit, engineCommit) - } - if got := string(r.originTip(t)); got != descendant { - t.Errorf("origin tip = %q, want the published descendant %q", got, descendant) - } - if !transactionsEmpty(t, repo) { - t.Error("transactions root not empty after an applied ambiguous push") - } -} diff --git a/internal/repository/transaction/recovery_race_integration_test.go b/internal/repository/transaction/recovery_race_integration_test.go new file mode 100644 index 000000000..26a1f0935 --- /dev/null +++ b/internal/repository/transaction/recovery_race_integration_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package transaction + +import ( + "context" + "path/filepath" + "testing" +) + +// TestRaceIntegrationTxnPruneRegistryLockSerializesAllocation proves the registry lock prevents +// PruneAbandoned from ever observing a half-published candidate directory. An +// allocator holds the registry lock across the whole mkdir→lock→manifest critical +// section; the prune, started while the lock is held, cannot list until the +// allocator releases — by which point the candidate is complete and live-locked, so +// it reports "live", never the "malformed" a half-published dir would yield. The +// ordering is enforced by channels and the lock, never a sleep. +// Race shard (change 0466): a goroutine holds the registry lock while PruneAbandoned contends for it. +func TestRaceIntegrationTxnPruneRegistryLockSerializesAllocation(t *testing.T) { + requireGit(t) + r := newMainModeRepos(t) + eng, _, repo := recoveryEngine(t, r) + root := transactionsRoot(repo) + + inside := make(chan struct{}) + proceed := make(chan struct{}) + allocDone := make(chan struct{}) + var liveLock *fileLock + var candID string + + go func() { + _ = withRegistryLock(root, func() error { + // Registry lock held. Announce, then wait until the prune is known to be + // contending before publishing anything. + close(inside) + <-proceed + id, err := newTransactionID() + if err != nil { + return err + } + candID = id + candRoot := filepath.Join(root, id) + if err := mkdirMode(candRoot, txnDirMode); err != nil { + return err + } + if err := mkdirMode(filepath.Join(candRoot, hooksDirName), txnDirMode); err != nil { + return err + } + lk, err := acquireLock(filepath.Join(candRoot, liveLockName), false) + if err != nil { + return err + } + liveLock = lk + return writeManifestAtomic(candRoot, baseValidManifest(id, repo.CommonDir)) + }) + close(allocDone) + }() + + <-inside + reportCh := make(chan PruneReport, 1) + errCh := make(chan error, 1) + go func() { + rep, err := eng.PruneAbandoned(context.Background(), repo) + errCh <- err + reportCh <- rep + }() + + // The prune goroutine is now blocked on the registry lock (it started after the + // allocator signalled `inside` and holds it). Release the allocator; only then + // can the prune list — and it must see a complete, live-locked candidate. + close(proceed) + <-allocDone + + if err := <-errCh; err != nil { + t.Fatalf("PruneAbandoned: %v", err) + } + rep := <-reportCh + defer func() { _ = liveLock.release() }() + + if len(rep.Entries) != 1 { + t.Fatalf("report entries = %+v, want exactly one", rep.Entries) + } + e := rep.Entries[0] + if e.ID != candID || e.Verdict != verdictLive { + t.Errorf("entry = %+v, want live entry for %s (a half-published dir would read malformed)", e, candID) + } +} diff --git a/internal/repository/transaction/recovery_test.go b/internal/repository/transaction/recovery_test.go index 49cdd012c..6888e1ccd 100644 --- a/internal/repository/transaction/recovery_test.go +++ b/internal/repository/transaction/recovery_test.go @@ -183,80 +183,3 @@ func headCommit(t *testing.T, repo gitcli.Repository) gitcli.ObjectID { t.Helper() return gitcli.ObjectID(hgitOut(t, repo.PrimaryWorktree, "rev-parse", "HEAD")) } - -// TestPruneRegistryLockSerializesAllocation proves the registry lock prevents -// PruneAbandoned from ever observing a half-published candidate directory. An -// allocator holds the registry lock across the whole mkdir→lock→manifest critical -// section; the prune, started while the lock is held, cannot list until the -// allocator releases — by which point the candidate is complete and live-locked, so -// it reports "live", never the "malformed" a half-published dir would yield. The -// ordering is enforced by channels and the lock, never a sleep. -func TestPruneRegistryLockSerializesAllocation(t *testing.T) { - requireGit(t) - r := newMainModeRepos(t) - eng, _, repo := recoveryEngine(t, r) - root := transactionsRoot(repo) - - inside := make(chan struct{}) - proceed := make(chan struct{}) - allocDone := make(chan struct{}) - var liveLock *fileLock - var candID string - - go func() { - _ = withRegistryLock(root, func() error { - // Registry lock held. Announce, then wait until the prune is known to be - // contending before publishing anything. - close(inside) - <-proceed - id, err := newTransactionID() - if err != nil { - return err - } - candID = id - candRoot := filepath.Join(root, id) - if err := mkdirMode(candRoot, txnDirMode); err != nil { - return err - } - if err := mkdirMode(filepath.Join(candRoot, hooksDirName), txnDirMode); err != nil { - return err - } - lk, err := acquireLock(filepath.Join(candRoot, liveLockName), false) - if err != nil { - return err - } - liveLock = lk - return writeManifestAtomic(candRoot, baseValidManifest(id, repo.CommonDir)) - }) - close(allocDone) - }() - - <-inside - reportCh := make(chan PruneReport, 1) - errCh := make(chan error, 1) - go func() { - rep, err := eng.PruneAbandoned(context.Background(), repo) - errCh <- err - reportCh <- rep - }() - - // The prune goroutine is now blocked on the registry lock (it started after the - // allocator signalled `inside` and holds it). Release the allocator; only then - // can the prune list — and it must see a complete, live-locked candidate. - close(proceed) - <-allocDone - - if err := <-errCh; err != nil { - t.Fatalf("PruneAbandoned: %v", err) - } - rep := <-reportCh - defer func() { _ = liveLock.release() }() - - if len(rep.Entries) != 1 { - t.Fatalf("report entries = %+v, want exactly one", rep.Entries) - } - e := rep.Entries[0] - if e.ID != candID || e.Verdict != verdictLive { - t.Errorf("entry = %+v, want live entry for %s (a half-published dir would read malformed)", e, candID) - } -} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 1adf4ed2e..8ee7d4e40 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -75,6 +75,7 @@ tests/test_go_integration_githubcli_probe.sh 10 parallel tests/test_go_integration_githubcli_prbatch.sh 10 parallel tests/test_go_integration_transaction_apply.sh 25 parallel tests/test_go_integration_transaction_recovery.sh 20 parallel +tests/test_go_integration_transaction_race.sh 25 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_transaction_race.sh b/tests/test_go_integration_transaction_race.sh new file mode 100755 index 000000000..5e56aaac9 --- /dev/null +++ b/tests/test_go_integration_transaction_race.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_transaction_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the transaction engine's real-concurrency real-git tests (writers +# contending on one target branch, concurrent Execute, interrupt/cancel races, registry-lock +# contention) — moved out of the default internal/repository/transaction corpus, which must +# never start real git (testsupport.InstallNoGitGuard, installed from the package's TestMain) — +# behind the `integration` build tag, prefix ^TestRaceIntegrationTxn, run in RACE mode. +# Declarations only — execution and inspection live in tests/lib/go-integration-shard.sh; the +# completeness contract is tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/repository/transaction" +SHARD_PREFIX="TestRaceIntegrationTxn" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From f696ce813e4acc1d8b55dc766d2e0cadcfa72bed Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:21:38 -0400 Subject: [PATCH 06/16] test(transaction): install the no-real-git guard in the default corpus (change 0466) --- .../transaction/candidate_integration_test.go | 77 ++++ .../repository/transaction/candidate_test.go | 77 ---- .../transaction/cleanup_integration_test.go | 39 ++ .../repository/transaction/cleanup_test.go | 42 -- ...est.go => concurrency_integration_test.go} | 2 + .../transaction/engine_integration_test.go | 89 +++++ .../engine_scope_integration_test.go | 165 ++++++++ .../transaction/engine_scope_test.go | 175 --------- .../repository/transaction/engine_test.go | 91 ----- .../transaction/harness_integration_test.go | 353 +++++++++++++++++ .../repository/transaction/harness_test.go | 363 +----------------- .../idempotency_integration_test.go | 72 ++++ .../transaction/idempotency_test.go | 78 ---- .../transaction/interrupt_integration_test.go | 84 ++++ .../repository/transaction/interrupt_test.go | 92 ----- internal/repository/transaction/main_test.go | 25 ++ .../materialize_integration_test.go | 183 ++++++++- .../transaction/materialize_test.go | 192 --------- .../transaction/nogit_guard_test.go | 31 ++ .../transaction/preserve_integration_test.go | 83 ++++ .../repository/transaction/preserve_test.go | 88 ----- .../transaction/recovery_integration_test.go | 177 ++++++++- .../repository/transaction/recovery_test.go | 185 --------- 23 files changed, 1381 insertions(+), 1382 deletions(-) delete mode 100644 internal/repository/transaction/cleanup_test.go rename internal/repository/transaction/{concurrency_test.go => concurrency_integration_test.go} (99%) delete mode 100644 internal/repository/transaction/engine_scope_test.go delete mode 100644 internal/repository/transaction/idempotency_test.go delete mode 100644 internal/repository/transaction/interrupt_test.go create mode 100644 internal/repository/transaction/main_test.go delete mode 100644 internal/repository/transaction/materialize_test.go create mode 100644 internal/repository/transaction/nogit_guard_test.go delete mode 100644 internal/repository/transaction/preserve_test.go delete mode 100644 internal/repository/transaction/recovery_test.go diff --git a/internal/repository/transaction/candidate_integration_test.go b/internal/repository/transaction/candidate_integration_test.go index 7e345ca99..090b4d1e4 100644 --- a/internal/repository/transaction/candidate_integration_test.go +++ b/internal/repository/transaction/candidate_integration_test.go @@ -4,13 +4,18 @@ package transaction import ( "context" + "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "regexp" "syscall" "testing" "time" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" ) // TestIntegrationTxnApplyAllocateCandidateStructureAndManifest proves allocation lays down the @@ -121,3 +126,75 @@ func TestIntegrationTxnApplyCandidateModesUnderUmask(t *testing.T) { }) } } + +// txnTestClock is the pinned instant every candidate test stamps manifests with. +var txnTestClock = fakeClock{t: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)} + +// newTxnRepo builds a real, non-bare Git repository under testsupport.TempDir(t), makes one +// commit so HEAD exists, and resolves its canonical identity through the same +// gitcli.Discover the engine uses. It returns the client (for ChangedPaths) and +// the repository whose CommonDir roots the transactions tree. The test is +// skipped when git is unavailable. +func newTxnRepo(t *testing.T) (*gitcli.Client, gitcli.Repository) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found on PATH") + } + useBackgroundOffGit(t) + dir := testsupport.TempDir(t) + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-b", "main") + run("config", "user.name", "t") + run("config", "user.email", "t@t") + run("config", "core.quotePath", "true") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", "README.md") + run("commit", "-q", "-m", "initial") + + client, err := gitcli.NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + repo, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: dir}) + if err != nil { + t.Fatalf("Discover: %v", err) + } + return client, repo +} + +// assertPerm fails unless path's permission bits equal want. Lstat so a symlink +// is never followed to something else's mode. +func assertPerm(t *testing.T, path string, want os.FileMode) { + t.Helper() + fi, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat %s: %v", path, err) + } + if got := fi.Mode().Perm(); got != want { + t.Errorf("%s mode = %04o, want %04o", path, got, want) + } +} + +func readManifestFile(t *testing.T, root string) manifest { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "manifest.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var m manifest + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + return m +} diff --git a/internal/repository/transaction/candidate_test.go b/internal/repository/transaction/candidate_test.go index 14b1ce02b..a3d4913c6 100644 --- a/internal/repository/transaction/candidate_test.go +++ b/internal/repository/transaction/candidate_test.go @@ -1,15 +1,10 @@ package transaction import ( - "context" - "encoding/json" "errors" "github.com/danielhanold/docket/internal/testsupport" - "os" - "os/exec" "path/filepath" "testing" - "time" "github.com/danielhanold/docket/internal/gitcli" ) @@ -18,78 +13,6 @@ import ( // candidate.go treats it as opaque, so the exact value only has to round-trip. const fixedBase gitcli.ObjectID = "0123456789abcdef0123456789abcdef01234567" -// txnTestClock is the pinned instant every candidate test stamps manifests with. -var txnTestClock = fakeClock{t: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)} - -// newTxnRepo builds a real, non-bare Git repository under testsupport.TempDir(t), makes one -// commit so HEAD exists, and resolves its canonical identity through the same -// gitcli.Discover the engine uses. It returns the client (for ChangedPaths) and -// the repository whose CommonDir roots the transactions tree. The test is -// skipped when git is unavailable. -func newTxnRepo(t *testing.T) (*gitcli.Client, gitcli.Repository) { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not found on PATH") - } - useBackgroundOffGit(t) - dir := testsupport.TempDir(t) - run := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - } - run("init", "-b", "main") - run("config", "user.name", "t") - run("config", "user.email", "t@t") - run("config", "core.quotePath", "true") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme\n"), 0o644); err != nil { - t.Fatal(err) - } - run("add", "README.md") - run("commit", "-q", "-m", "initial") - - client, err := gitcli.NewClient() - if err != nil { - t.Fatalf("NewClient: %v", err) - } - repo, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: dir}) - if err != nil { - t.Fatalf("Discover: %v", err) - } - return client, repo -} - -// assertPerm fails unless path's permission bits equal want. Lstat so a symlink -// is never followed to something else's mode. -func assertPerm(t *testing.T, path string, want os.FileMode) { - t.Helper() - fi, err := os.Lstat(path) - if err != nil { - t.Fatalf("lstat %s: %v", path, err) - } - if got := fi.Mode().Perm(); got != want { - t.Errorf("%s mode = %04o, want %04o", path, got, want) - } -} - -func readManifestFile(t *testing.T, root string) manifest { - t.Helper() - data, err := os.ReadFile(filepath.Join(root, "manifest.json")) - if err != nil { - t.Fatalf("read manifest: %v", err) - } - var m manifest - if err := json.Unmarshal(data, &m); err != nil { - t.Fatalf("unmarshal manifest: %v", err) - } - return m -} - // TestLiveLockExcludesSecondNonBlocking proves a second non-blocking acquire of // a held lock reports the would-block sentinel rather than silently succeeding. func TestLiveLockExcludesSecondNonBlocking(t *testing.T) { diff --git a/internal/repository/transaction/cleanup_integration_test.go b/internal/repository/transaction/cleanup_integration_test.go index 3b857c76f..73592b158 100644 --- a/internal/repository/transaction/cleanup_integration_test.go +++ b/internal/repository/transaction/cleanup_integration_test.go @@ -7,6 +7,8 @@ import ( "os" "sort" "testing" + + "github.com/danielhanold/docket/internal/gitcli" ) // TestIntegrationTxnRecoveryPruneReportEmptyOnCleanRoot proves a sweep of a repository with no candidates @@ -125,3 +127,40 @@ func TestIntegrationTxnRecoveryCleanupRetainsRegisteredCandidateOnListError(t *t t.Errorf("warnings = %v, want a %q entry", warnings, "cleanup-pending: "+c.id) } } + +// This file covers PruneReport itself: an empty sweep, deterministic ordering, and +// that recovery works identically under the docket-mode topology (a linked .docket +// worktree present) as under main mode. The full ownership-and-recovery matrix +// lives in recovery_test.go. + +// hasCleanupPending reports whether warnings names id's cleanup-pending marker. +func hasCleanupPending(warnings []string, id string) bool { + want := "cleanup-pending: " + id + for _, w := range warnings { + if w == want { + return true + } + } + return false +} + +// hasWorktreeNamed reports whether any registration's path basename equals name. +func hasWorktreeNamed(infos []gitcli.WorktreeInfo, name string) bool { + for _, info := range infos { + if base := baseName(info.Path); base == name { + return true + } + } + return false +} + +// baseName returns the final path element without importing path/filepath twice in +// assertions; it keeps this file's helper self-contained. +func baseName(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + return p[i+1:] + } + } + return p +} diff --git a/internal/repository/transaction/cleanup_test.go b/internal/repository/transaction/cleanup_test.go deleted file mode 100644 index 5e99b9fb2..000000000 --- a/internal/repository/transaction/cleanup_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package transaction - -import ( - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file covers PruneReport itself: an empty sweep, deterministic ordering, and -// that recovery works identically under the docket-mode topology (a linked .docket -// worktree present) as under main mode. The full ownership-and-recovery matrix -// lives in recovery_test.go. - -// hasCleanupPending reports whether warnings names id's cleanup-pending marker. -func hasCleanupPending(warnings []string, id string) bool { - want := "cleanup-pending: " + id - for _, w := range warnings { - if w == want { - return true - } - } - return false -} - -// hasWorktreeNamed reports whether any registration's path basename equals name. -func hasWorktreeNamed(infos []gitcli.WorktreeInfo, name string) bool { - for _, info := range infos { - if base := baseName(info.Path); base == name { - return true - } - } - return false -} - -// baseName returns the final path element without importing path/filepath twice in -// assertions; it keeps this file's helper self-contained. -func baseName(p string) string { - for i := len(p) - 1; i >= 0; i-- { - if p[i] == '/' { - return p[i+1:] - } - } - return p -} diff --git a/internal/repository/transaction/concurrency_test.go b/internal/repository/transaction/concurrency_integration_test.go similarity index 99% rename from internal/repository/transaction/concurrency_test.go rename to internal/repository/transaction/concurrency_integration_test.go index d0cfdb7b0..35ad45560 100644 --- a/internal/repository/transaction/concurrency_test.go +++ b/internal/repository/transaction/concurrency_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package transaction import ( diff --git a/internal/repository/transaction/engine_integration_test.go b/internal/repository/transaction/engine_integration_test.go index bc3437d4e..3b55661fa 100644 --- a/internal/repository/transaction/engine_integration_test.go +++ b/internal/repository/transaction/engine_integration_test.go @@ -382,3 +382,92 @@ func TestIntegrationTxnApplyEngineRetriesLeaseLoss(t *testing.T) { t.Error("transactions root not empty after a retried apply") } } + +// topology names a harness builder so a test can run against both metadata shapes. +type topology struct { + name string + build func(*testing.T) *testRepos +} + +func topologies() []topology { + return []topology{ + {"main", newMainModeRepos}, + {"docket", newDocketModeRepos}, + } +} + +// mustExecute runs a standard single-operation transaction and fails on a Go error. +func mustExecute(t *testing.T, eng *Engine, r *testRepos, repo gitcli.Repository, + exp []EntityExpectation, op SemanticOperation) Result { + t.Helper() + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Expected: exp, Loader: testLoader{}, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + return res +} + +// scriptedOp is a configurable SemanticOperation for the engine tests. Each field +// shapes one facet of an attempt's plan; beforePlan runs a side effect (advancing +// origin) before a chosen attempt's plan is returned. It is used single-threaded +// within one Execute, so the calls counter needs no synchronization. +type scriptedOp struct { + files []FileMutation + subject string + receipt []byte + refuse bool + findings []domain.Finding + planErr error + calls int + beforePlan func(call int) +} + +func (o *scriptedOp) Key() OperationKey { return "test.op" } + +func (o *scriptedOp) Plan(_ context.Context, _ AttemptState) (MutationPlan, OperationResult, error) { + o.calls++ + if o.beforePlan != nil { + o.beforePlan(o.calls) + } + if o.planErr != nil { + return MutationPlan{}, OperationResult{}, o.planErr + } + if o.refuse { + return MutationPlan{}, OperationResult{Refused: true, Findings: o.findings}, nil + } + subject := o.subject + if subject == "" { + subject = "test: apply" + } + receipt := o.receipt + if receipt == nil { + receipt = validReceipt() + } + return MutationPlan{Files: o.files, CommitSubject: subject, Receipt: receipt}, OperationResult{}, nil +} + +// createOp returns an operation that creates one record at path with content. +func createOp(path, content string) *scriptedOp { + return &scriptedOp{files: []FileMutation{ + {Path: gitcli.RepoPath(path), Kind: MutationCreate, Bytes: []byte(content)}, + }} +} + +// newEngine builds an Engine over a fresh client and the pinned test clock. +func newEngine(t *testing.T, client *gitcli.Client) *Engine { + t.Helper() + eng, err := NewEngine(client, engineClock) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return eng +} + +// thirdChangePath / thirdChange is the standard record the happy-path operations +// create: a valid change whose filename encodes its id and slug. +const thirdChangePath = "docs/changes/active/0003-third-change.md" + +func thirdChange() string { return corpusChange(3, "third-change", "proposed") } diff --git a/internal/repository/transaction/engine_scope_integration_test.go b/internal/repository/transaction/engine_scope_integration_test.go index e7cc921ed..d13be7dd3 100644 --- a/internal/repository/transaction/engine_scope_integration_test.go +++ b/internal/repository/transaction/engine_scope_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "strings" + "sync" "testing" "github.com/danielhanold/docket/internal/domain" @@ -429,3 +430,167 @@ func TestIntegrationTxnApplyEngineScopeKeyedReplay(t *testing.T) { t.Errorf("replay consulted resolver %d / operation %d times, want 0", resolverCalls-callsAfterFirst, replayOp.calls) } } + +// These tests drive the engine's scoped gate sequence (change 0449) end to end +// through real isolated Git repositories: the before-gate, the post-plan +// recheck, and the after-gate refuse only errors relevant to the resolved +// subject set, grandfather exact unchanged unrelated errors, and fall back to +// strict whole-corpus validation whenever the scope cannot be resolved. + +const ( + // scopeSubjectPath is the root record B every scoped operation acts on. + scopeSubjectPath = "docs/changes/active/0001-first-change.md" + // scopeDependencyPath is a second corpus record a resolver may name as B's + // required record (a dependency). + scopeDependencyPath = "docs/changes/active/0002-second-change.md" + // scopeUnrelatedPath is the unrelated invalid record A: its filename slug + // disagrees with its frontmatter slug, an error-severity finding on A alone. + scopeUnrelatedPath = "docs/changes/active/0007-mismatch.md" +) + +func scopeUnrelatedRecord() string { return corpusChange(7, "different-slug", "proposed") } + +// editedSubject is B with a body edit — a legal, validation-clean replacement. +func editedSubject() string { + return strings.Replace(corpusChange(1, "first-change", "proposed"), "Body.", "Body, edited.", 1) +} + +// editSubjectOp replaces B with editedSubject. +func editSubjectOp() *scriptedOp { + return &scriptedOp{files: []FileMutation{ + {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(editedSubject())}, + }} +} + +// scopeLoader wraps testLoader, filling Blobs from the tree listing exactly as +// the production loader does (an overlay-touched path carries ""), counting +// before/after loads, and optionally tampering with the after state so a test +// can present after-gate volatility the overlay cannot produce organically. +type scopeLoader struct { + mu sync.Mutex + befores, afters int + beforeErrors int // error findings in the most recent before state + tamperAfter func(*LoadedState) +} + +func (l *scopeLoader) Load(ctx context.Context, t Tree) (LoadedState, error) { + st, err := testLoader{}.Load(ctx, t) + if err != nil { + return st, err + } + entries, err := t.ListTree(ctx, []gitcli.RepoPath{docsPrefix}) + if err != nil { + return LoadedState{}, err + } + st.Blobs = make(map[string]gitcli.ObjectID, len(entries)) + for _, e := range entries { + st.Blobs[string(e.Path)] = e.ObjectID + } + l.mu.Lock() + defer l.mu.Unlock() + if _, overlay := t.(*overlayTree); overlay { + l.afters++ + if l.tamperAfter != nil { + l.tamperAfter(&st) + } + } else { + l.befores++ + l.beforeErrors = len(errorFindings(st.Report.Findings())) + } + return st, nil +} + +func (l *scopeLoader) ValidateEvolution(before, after LoadedState) []domain.Finding { + return testLoader{}.ValidateEvolution(before, after) +} + +var _ StateLoader = (*scopeLoader)(nil) + +// staticScope resolves the same fixed subject paths in every state and counts +// its invocations. +func staticScope(calls *int, paths ...string) *ValidationScope { + return &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { + if calls != nil { + *calls++ + } + out := make(map[gitcli.RepoPath]bool, len(paths)) + for _, p := range paths { + out[gitcli.RepoPath(p)] = true + } + return out, nil + }} +} + +// dependencyScope resolves B plus the path of every depends_on target B carries +// in the state it is handed — the shape of a production resolver, so a +// dependency the plan ADDS shows up only in the candidate state's resolution. +func dependencyScope() *ValidationScope { + return &ValidationScope{Subjects: func(st LoadedState) (map[gitcli.RepoPath]bool, error) { + out := map[gitcli.RepoPath]bool{scopeSubjectPath: true} + b, found := st.Snapshot.Change(1) + if found != domain.LookupFound { + return out, nil + } + for _, dep := range b.DependsOn() { + if c, ok := st.Snapshot.Change(dep); ok == domain.LookupFound { + out[gitcli.RepoPath(c.Path())] = true + } + } + return out, nil + }} +} + +// execScoped runs one scoped transaction and fails on a Go error. +func execScoped(t *testing.T, eng *Engine, r *testRepos, repo gitcli.Repository, loader StateLoader, + scope *ValidationScope, exp []EntityExpectation, op SemanticOperation) Result { + t.Helper() + res, err := eng.Execute(context.Background(), Request{ + Repository: repo, Remote: "origin", TargetRef: r.Target, + Expected: exp, Loader: loader, Scope: scope, Operation: op, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + return res +} + +// hasFindingAt reports whether findings carry one whose Entity path is p. +func hasFindingAt(findings []domain.Finding, p string) bool { + for _, f := range findings { + if f.Entity.Path == p { + return true + } + } + return false +} + +// assertScopedRefusal checks a refusal left origin untouched. +func assertScopedRefusal(t *testing.T, r *testRepos, res Result, base gitcli.ObjectID) { + t.Helper() + if res.Disposition != DispositionRefused { + t.Fatalf("disposition = %q, want refused (findings %v)", res.Disposition, res.Findings) + } + if len(res.Findings) == 0 { + t.Error("refusal carried no findings") + } + if r.originTip(t) != base { + t.Error("origin advanced on a refusal") + } +} + +// assertGrandfatheredSurfaced proves an applied or no-op scoped result carries +// the unrelated record A's grandfathered error finding — and only it, at its +// error severity (spec §1 step 5: unrelated health findings travel through the +// result's findings without changing the disposition). Mutation check: drop the +// kept findings from the applied/no-op result in runCandidate and this reddens. +func assertGrandfatheredSurfaced(t *testing.T, findings []domain.Finding) { + t.Helper() + if len(findings) == 0 { + t.Fatal("result dropped the grandfathered unrelated finding; want it surfaced") + } + for _, f := range findings { + if f.Entity.Path != scopeUnrelatedPath || f.Severity != domain.SeverityError { + t.Errorf("surfaced finding %+v; want only A's error findings at %s", f, scopeUnrelatedPath) + } + } +} diff --git a/internal/repository/transaction/engine_scope_test.go b/internal/repository/transaction/engine_scope_test.go deleted file mode 100644 index 8b8cecde5..000000000 --- a/internal/repository/transaction/engine_scope_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package transaction - -import ( - "context" - "strings" - "sync" - "testing" - - "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/gitcli" -) - -// These tests drive the engine's scoped gate sequence (change 0449) end to end -// through real isolated Git repositories: the before-gate, the post-plan -// recheck, and the after-gate refuse only errors relevant to the resolved -// subject set, grandfather exact unchanged unrelated errors, and fall back to -// strict whole-corpus validation whenever the scope cannot be resolved. - -const ( - // scopeSubjectPath is the root record B every scoped operation acts on. - scopeSubjectPath = "docs/changes/active/0001-first-change.md" - // scopeDependencyPath is a second corpus record a resolver may name as B's - // required record (a dependency). - scopeDependencyPath = "docs/changes/active/0002-second-change.md" - // scopeUnrelatedPath is the unrelated invalid record A: its filename slug - // disagrees with its frontmatter slug, an error-severity finding on A alone. - scopeUnrelatedPath = "docs/changes/active/0007-mismatch.md" -) - -func scopeUnrelatedRecord() string { return corpusChange(7, "different-slug", "proposed") } - -// editedSubject is B with a body edit — a legal, validation-clean replacement. -func editedSubject() string { - return strings.Replace(corpusChange(1, "first-change", "proposed"), "Body.", "Body, edited.", 1) -} - -// editSubjectOp replaces B with editedSubject. -func editSubjectOp() *scriptedOp { - return &scriptedOp{files: []FileMutation{ - {Path: scopeSubjectPath, Kind: MutationReplace, Bytes: []byte(editedSubject())}, - }} -} - -// scopeLoader wraps testLoader, filling Blobs from the tree listing exactly as -// the production loader does (an overlay-touched path carries ""), counting -// before/after loads, and optionally tampering with the after state so a test -// can present after-gate volatility the overlay cannot produce organically. -type scopeLoader struct { - mu sync.Mutex - befores, afters int - beforeErrors int // error findings in the most recent before state - tamperAfter func(*LoadedState) -} - -func (l *scopeLoader) Load(ctx context.Context, t Tree) (LoadedState, error) { - st, err := testLoader{}.Load(ctx, t) - if err != nil { - return st, err - } - entries, err := t.ListTree(ctx, []gitcli.RepoPath{docsPrefix}) - if err != nil { - return LoadedState{}, err - } - st.Blobs = make(map[string]gitcli.ObjectID, len(entries)) - for _, e := range entries { - st.Blobs[string(e.Path)] = e.ObjectID - } - l.mu.Lock() - defer l.mu.Unlock() - if _, overlay := t.(*overlayTree); overlay { - l.afters++ - if l.tamperAfter != nil { - l.tamperAfter(&st) - } - } else { - l.befores++ - l.beforeErrors = len(errorFindings(st.Report.Findings())) - } - return st, nil -} - -func (l *scopeLoader) ValidateEvolution(before, after LoadedState) []domain.Finding { - return testLoader{}.ValidateEvolution(before, after) -} - -var _ StateLoader = (*scopeLoader)(nil) - -// staticScope resolves the same fixed subject paths in every state and counts -// its invocations. -func staticScope(calls *int, paths ...string) *ValidationScope { - return &ValidationScope{Subjects: func(LoadedState) (map[gitcli.RepoPath]bool, error) { - if calls != nil { - *calls++ - } - out := make(map[gitcli.RepoPath]bool, len(paths)) - for _, p := range paths { - out[gitcli.RepoPath(p)] = true - } - return out, nil - }} -} - -// dependencyScope resolves B plus the path of every depends_on target B carries -// in the state it is handed — the shape of a production resolver, so a -// dependency the plan ADDS shows up only in the candidate state's resolution. -func dependencyScope() *ValidationScope { - return &ValidationScope{Subjects: func(st LoadedState) (map[gitcli.RepoPath]bool, error) { - out := map[gitcli.RepoPath]bool{scopeSubjectPath: true} - b, found := st.Snapshot.Change(1) - if found != domain.LookupFound { - return out, nil - } - for _, dep := range b.DependsOn() { - if c, ok := st.Snapshot.Change(dep); ok == domain.LookupFound { - out[gitcli.RepoPath(c.Path())] = true - } - } - return out, nil - }} -} - -// execScoped runs one scoped transaction and fails on a Go error. -func execScoped(t *testing.T, eng *Engine, r *testRepos, repo gitcli.Repository, loader StateLoader, - scope *ValidationScope, exp []EntityExpectation, op SemanticOperation) Result { - t.Helper() - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Expected: exp, Loader: loader, Scope: scope, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - return res -} - -// hasFindingAt reports whether findings carry one whose Entity path is p. -func hasFindingAt(findings []domain.Finding, p string) bool { - for _, f := range findings { - if f.Entity.Path == p { - return true - } - } - return false -} - -// assertScopedRefusal checks a refusal left origin untouched. -func assertScopedRefusal(t *testing.T, r *testRepos, res Result, base gitcli.ObjectID) { - t.Helper() - if res.Disposition != DispositionRefused { - t.Fatalf("disposition = %q, want refused (findings %v)", res.Disposition, res.Findings) - } - if len(res.Findings) == 0 { - t.Error("refusal carried no findings") - } - if r.originTip(t) != base { - t.Error("origin advanced on a refusal") - } -} - -// assertGrandfatheredSurfaced proves an applied or no-op scoped result carries -// the unrelated record A's grandfathered error finding — and only it, at its -// error severity (spec §1 step 5: unrelated health findings travel through the -// result's findings without changing the disposition). Mutation check: drop the -// kept findings from the applied/no-op result in runCandidate and this reddens. -func assertGrandfatheredSurfaced(t *testing.T, findings []domain.Finding) { - t.Helper() - if len(findings) == 0 { - t.Fatal("result dropped the grandfathered unrelated finding; want it surfaced") - } - for _, f := range findings { - if f.Entity.Path != scopeUnrelatedPath || f.Severity != domain.SeverityError { - t.Errorf("surfaced finding %+v; want only A's error findings at %s", f, scopeUnrelatedPath) - } - } -} diff --git a/internal/repository/transaction/engine_test.go b/internal/repository/transaction/engine_test.go index e2715a4c0..38c2ef693 100644 --- a/internal/repository/transaction/engine_test.go +++ b/internal/repository/transaction/engine_test.go @@ -1,79 +1,15 @@ package transaction import ( - "context" "testing" "time" - "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" ) // engineClock is the pinned instant every engine test commits and stamps with. var engineClock = fakeClock{t: time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC)} -// scriptedOp is a configurable SemanticOperation for the engine tests. Each field -// shapes one facet of an attempt's plan; beforePlan runs a side effect (advancing -// origin) before a chosen attempt's plan is returned. It is used single-threaded -// within one Execute, so the calls counter needs no synchronization. -type scriptedOp struct { - files []FileMutation - subject string - receipt []byte - refuse bool - findings []domain.Finding - planErr error - calls int - beforePlan func(call int) -} - -func (o *scriptedOp) Key() OperationKey { return "test.op" } - -func (o *scriptedOp) Plan(_ context.Context, _ AttemptState) (MutationPlan, OperationResult, error) { - o.calls++ - if o.beforePlan != nil { - o.beforePlan(o.calls) - } - if o.planErr != nil { - return MutationPlan{}, OperationResult{}, o.planErr - } - if o.refuse { - return MutationPlan{}, OperationResult{Refused: true, Findings: o.findings}, nil - } - subject := o.subject - if subject == "" { - subject = "test: apply" - } - receipt := o.receipt - if receipt == nil { - receipt = validReceipt() - } - return MutationPlan{Files: o.files, CommitSubject: subject, Receipt: receipt}, OperationResult{}, nil -} - -// createOp returns an operation that creates one record at path with content. -func createOp(path, content string) *scriptedOp { - return &scriptedOp{files: []FileMutation{ - {Path: gitcli.RepoPath(path), Kind: MutationCreate, Bytes: []byte(content)}, - }} -} - -// newEngine builds an Engine over a fresh client and the pinned test clock. -func newEngine(t *testing.T, client *gitcli.Client) *Engine { - t.Helper() - eng, err := NewEngine(client, engineClock) - if err != nil { - t.Fatalf("NewEngine: %v", err) - } - return eng -} - -// thirdChangePath / thirdChange is the standard record the happy-path operations -// create: a valid change whose filename encodes its id and slug. -const thirdChangePath = "docs/changes/active/0003-third-change.md" - -func thirdChange() string { return corpusChange(3, "third-change", "proposed") } - func TestNewEngineRejectsNilDependencies(t *testing.T) { if _, err := NewEngine(nil, engineClock); err == nil { t.Error("NewEngine(nil client): want error") @@ -86,30 +22,3 @@ func TestNewEngineRejectsNilDependencies(t *testing.T) { t.Error("NewEngine(nil clock): want error") } } - -// topology names a harness builder so a test can run against both metadata shapes. -type topology struct { - name string - build func(*testing.T) *testRepos -} - -func topologies() []topology { - return []topology{ - {"main", newMainModeRepos}, - {"docket", newDocketModeRepos}, - } -} - -// mustExecute runs a standard single-operation transaction and fails on a Go error. -func mustExecute(t *testing.T, eng *Engine, r *testRepos, repo gitcli.Repository, - exp []EntityExpectation, op SemanticOperation) Result { - t.Helper() - res, err := eng.Execute(context.Background(), Request{ - Repository: repo, Remote: "origin", TargetRef: r.Target, - Expected: exp, Loader: testLoader{}, Operation: op, - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - return res -} diff --git a/internal/repository/transaction/harness_integration_test.go b/internal/repository/transaction/harness_integration_test.go index 815a6ef72..d5157f182 100644 --- a/internal/repository/transaction/harness_integration_test.go +++ b/internal/repository/transaction/harness_integration_test.go @@ -3,8 +3,15 @@ package transaction import ( + "context" + "os" + "os/exec" + "path/filepath" "strings" "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" ) // TestIntegrationTxnApplyHarnessBuildersProduceExpectedTopology proves each builder yields the @@ -44,3 +51,349 @@ func TestIntegrationTxnApplyHarnessBuildersProduceExpectedTopology(t *testing.T) } }) } + +// newMainModeRepos builds a bare origin whose branch main holds the corpus plus a +// .docket.yml and README.md, a writer clone that advances origin, and an +// invocation clone checked out on main with core.quotePath=true. Its Target is +// refs/heads/main. +func newMainModeRepos(t *testing.T) *testRepos { + t.Helper() + requireGit(t) + root := testsupport.TempDir(t) + r := &testRepos{ + Origin: filepath.Join(root, "origin.git"), + Writer: filepath.Join(root, "writer"), + Invocation: filepath.Join(root, "invocation"), + Target: "refs/heads/main", + } + + hgitOut(t, root, "init", "--bare", "-b", "main", r.Origin) + + hgitOut(t, root, "init", "-b", "main", r.Writer) + hconfigIdentity(t, r.Writer) + hgitOut(t, r.Writer, "config", "core.quotePath", "true") + + hwriteFile(t, r.Writer, "README.md", "readme\n") + hwriteFile(t, r.Writer, ".docket.yml", "version: 1\n") + for rel, content := range corpusFiles() { + hwriteFile(t, r.Writer, rel, content) + } + hgitOut(t, r.Writer, "add", "-A") + hgitOut(t, r.Writer, "commit", "-q", "-m", "main content") + hgitOut(t, r.Writer, "remote", "add", "origin", r.Origin) + hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") + + hgitOut(t, root, "clone", "-q", r.Origin, r.Invocation) + hgitOut(t, r.Invocation, "config", "core.quotePath", "true") + hconfigIdentity(t, r.Invocation) + return r +} + +// newDocketModeRepos builds a bare origin with branch main (.docket.yml, code, and +// a .gitignore excluding .docket/ and .worktrees/) plus an orphan "docket" branch +// holding the corpus. The invocation clone adds a linked ".docket" worktree parked +// on docket. Its Target is refs/heads/docket. +func newDocketModeRepos(t *testing.T) *testRepos { + t.Helper() + requireGit(t) + root := testsupport.TempDir(t) + r := &testRepos{ + Origin: filepath.Join(root, "origin.git"), + Writer: filepath.Join(root, "writer"), + Invocation: filepath.Join(root, "invocation"), + Target: "refs/heads/docket", + } + + hgitOut(t, root, "init", "--bare", "-b", "main", r.Origin) + + hgitOut(t, root, "init", "-b", "main", r.Writer) + hconfigIdentity(t, r.Writer) + hgitOut(t, r.Writer, "config", "core.quotePath", "true") + + hwriteFile(t, r.Writer, ".docket.yml", "version: 1\n") + hwriteFile(t, r.Writer, "main.go", "package main\n") + hwriteFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") + hgitOut(t, r.Writer, "add", "-A") + hgitOut(t, r.Writer, "commit", "-q", "-m", "main content") + hgitOut(t, r.Writer, "remote", "add", "origin", r.Origin) + hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") + + // Orphan docket branch: unrelated history, the corpus only. + hgitOut(t, r.Writer, "checkout", "-q", "--orphan", "docket") + hgitOut(t, r.Writer, "rm", "-rfq", "--cached", ".") + for _, name := range []string{".docket.yml", "main.go", ".gitignore"} { + if err := os.Remove(filepath.Join(r.Writer, name)); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + for rel, content := range corpusFiles() { + hwriteFile(t, r.Writer, rel, content) + } + hgitOut(t, r.Writer, "add", "-A") + hgitOut(t, r.Writer, "commit", "-q", "-m", "docket corpus") + hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "docket") + hgitOut(t, r.Writer, "checkout", "-q", "main") + + hgitOut(t, root, "clone", "-q", r.Origin, r.Invocation) + hconfigIdentity(t, r.Invocation) + hgitOut(t, r.Invocation, "config", "core.quotePath", "true") + hgitOut(t, r.Invocation, "worktree", "add", "-q", "-B", "docket", ".docket", "origin/docket") + return r +} + +// short returns the short branch name of the target ref (the part after +// refs/heads/), for oracle git commands that take a branch name. +func (r *testRepos) short() string { + return strings.TrimPrefix(string(r.Target), "refs/heads/") +} + +// discover builds a gitcli.Client and discovers the invocation repository the +// engine operates, exactly as the engine's caller would. +func (r *testRepos) discover(t *testing.T) (*gitcli.Client, gitcli.Repository) { + t.Helper() + client, err := gitcli.NewClient() + if err != nil { + t.Fatalf("NewClient: %v", err) + } + repo, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Invocation}) + if err != nil { + t.Fatalf("Discover: %v", err) + } + return client, repo +} + +// originTip returns the origin's current commit for the target ref, read from the +// bare origin directly — an oracle independent of the adapter under test. +func (r *testRepos) originTip(t *testing.T) gitcli.ObjectID { + t.Helper() + return gitcli.ObjectID(hgitOut(t, r.Origin, "rev-parse", string(r.Target))) +} + +// blobID returns the object id of a path at the target ref's tip on origin. +func (r *testRepos) blobID(t *testing.T, path string) gitcli.ObjectID { + t.Helper() + out, err := hgitTry(r.Origin, "rev-parse", string(r.Target)+":"+path) + if err != nil { + t.Fatalf("blobID %q: %v", path, err) + } + return gitcli.ObjectID(strings.TrimSpace(out)) +} + +// advanceOrigin makes the writer clone push one new change on the target branch, +// advancing origin so a subsequent lease loses. It returns the writer's new tip. +func (r *testRepos) advanceOrigin(t *testing.T, rel, content string) gitcli.ObjectID { + t.Helper() + branch := r.short() + if branchExistsWriter(r.Writer, branch) { + hgitOut(t, r.Writer, "checkout", "-q", branch) + } else { + hgitOut(t, r.Writer, "checkout", "-q", "-b", branch) + } + hwriteFile(t, r.Writer, rel, content) + hgitOut(t, r.Writer, "add", "--", rel) + hgitOut(t, r.Writer, "commit", "-q", "-m", "writer advance") + hgitOut(t, r.Writer, "push", "-q", "origin", branch) + return gitcli.ObjectID(hgitOut(t, r.Writer, "rev-parse", "HEAD")) +} + +// branchExistsWriter reports whether refs/heads/ exists in the writer. +func branchExistsWriter(dir, branch string) bool { + _, err := hgitTry(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +// diffTreePaths returns the exact changed-path set of commit against its first +// parent, rename detection off and NUL-delimited so hostile paths survive. +func diffTreePaths(t *testing.T, originDir string, commit gitcli.ObjectID) []string { + t.Helper() + out := hgitOutRaw(t, originDir, "diff-tree", "--no-renames", "--no-commit-id", + "--name-only", "-z", "-r", string(commit)) + var paths []string + for _, p := range strings.Split(out, "\x00") { + if p != "" { + paths = append(paths, p) + } + } + return paths +} + +// hgitOutRaw runs git -C and returns UNtrimmed stdout (NUL-delimited output +// must not be trimmed), failing the test on a non-zero exit. +func hgitOutRaw(t *testing.T, dir string, args ...string) string { + t.Helper() + out, err := hgitTry(dir, args...) + if err != nil { + t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) + } + return out +} + +// transactionsEmpty reports whether the repository's transactions root has no +// candidate directories left (every candidate cleaned). A missing root counts as +// empty. +func transactionsEmpty(t *testing.T, repo gitcli.Repository) bool { + t.Helper() + entries, err := os.ReadDir(transactionsRoot(repo)) + if err != nil { + if os.IsNotExist(err) { + return true + } + t.Fatalf("read transactions root: %v", err) + } + for _, e := range entries { + // registry.lock is a mutex file, not a candidate; ignore it. + if e.Name() == registryLockName { + continue + } + return false + } + return true +} + +// This file builds real temporary Git repositories for the engine tests, ported +// from internal/gitcli/harness_test.go and extended with a small complete Docket +// corpus committed on the target branch. Two topologies are produced: a plain +// "main mode" repo whose target branch is refs/heads/main, and a docket-style +// repo whose target branch is an orphan refs/heads/docket with a linked .docket +// worktree in the invocation clone. Each is backed by a bare file origin, an +// independent writer clone that advances the origin, and the invocation clone the +// engine discovers and operates. All paths live under testsupport.TempDir(t); builders +// return the raw testsupport.TempDir(t) spelling so the symlinked /tmp -> /private/tmp case +// on macOS is exercised. core.quotePath=true is pinned so a developer's global +// "false" cannot disarm a hostile-path proof. Everything here is _test.go only. + +// testRepos is a bare origin plus two clones: a writer that advances the origin, +// and the invocation clone the engine discovers. Target names the fully qualified +// branch the corpus lives on and the engine writes to. +type testRepos struct { + Origin string + Writer string + Invocation string + Target gitcli.RefName +} + +// requireGit skips the test when no real git is on PATH. +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found on PATH") + } + useBackgroundOffGit(t) +} + +// useBackgroundOffGit points the git children these tests spawn at a per-fixture +// GIT_CONFIG_GLOBAL (testsupport.GitEnv) that disables auto-gc, auto-maintenance, +// and fsmonitor. The direct oracle helpers (hgitOut/hgitTry, matGit, newTxnRepo's +// run) inherit the test-process environment, so this reaches them; without it a +// detached git housekeeping child spawned by a fixture commit can outlive the +// test and keep writing into a testsupport.TempDir, racing RemoveAll teardown to +// "directory not empty" under parallel load (change 0373, sighting 4: +// TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers/keyed). Git spawned through the product +// gitcli client scrubs GIT_CONFIG, so its housekeeping children are instead +// absorbed by the fixture's drain-then-retry removal. Set process-wide via +// t.Setenv because the low-level helpers take no *testing.T; safe because this +// package runs no test in parallel. +func useBackgroundOffGit(t *testing.T) { + t.Helper() + for _, kv := range testsupport.GitEnv(t) { + if v, ok := strings.CutPrefix(kv, "GIT_CONFIG_GLOBAL="); ok { + t.Setenv("GIT_CONFIG_GLOBAL", v) + } + } +} + +// hgitOut runs real git with -C , returns trimmed stdout, and fails on a +// non-zero exit. It is the plumbing oracle fixture assertions compare against. +func hgitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + out, err := hgitTry(dir, args...) + if err != nil { + t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) + } + return strings.TrimSpace(out) +} + +// hgitTry runs git -C and returns raw stdout plus an error carrying stderr; +// it never touches testing.T so callers can probe for an expected failure. +func hgitTry(dir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), &harnessGitError{err: err, stderr: stderr.String()} + } + return stdout.String(), nil +} + +type harnessGitError struct { + err error + stderr string +} + +func (e *harnessGitError) Error() string { + return e.err.Error() + ": " + strings.TrimSpace(e.stderr) +} + +// hconfigIdentity pins a deterministic committer identity and disables signing. +func hconfigIdentity(t *testing.T, dir string) { + t.Helper() + hgitOut(t, dir, "config", "user.name", "t") + hgitOut(t, dir, "config", "user.email", "t@t") + hgitOut(t, dir, "config", "commit.gpgsign", "false") +} + +// hwriteFile writes content (creating parent directories) at a repo-relative +// path, tolerating hostile bytes in the name. +func hwriteFile(t *testing.T, root, rel, content string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// corpusFiles is the small complete Docket corpus committed on the target branch: +// two active changes and one Accepted ADR. Every filename encodes the record's id +// and slug exactly as docket's writers do, so a snapshot over it carries no error +// findings and a before/after gate passes. +func corpusFiles() map[string]string { + return map[string]string{ + "docs/changes/active/0001-first-change.md": corpusChange(1, "first-change", "proposed"), + "docs/changes/active/0002-second-change.md": corpusChange(2, "second-change", "proposed"), + "docs/adrs/0001-first-decision.md": corpusADR(1, "first-decision"), + } +} + +// corpusChange renders a well-formed change record with no cross-references, so a +// snapshot over it carries no error findings. +func corpusChange(id int, slug, status string) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("id: " + itoa(id) + "\n") + b.WriteString("slug: " + slug + "\n") + b.WriteString("title: 'A change'\n") + b.WriteString("status: " + status + "\n") + b.WriteString("priority: medium\n") + b.WriteString("type: feat\n") + b.WriteString("created: 2026-08-01\n") + b.WriteString("updated: 2026-08-02\n") + b.WriteString("---\n\n## Why\n\nBody.\n") + return b.String() +} + +// corpusADR renders a well-formed Accepted ADR record. +func corpusADR(id int, slug string) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("id: " + itoa(id) + "\n") + b.WriteString("slug: " + slug + "\n") + b.WriteString("title: 'A decision'\n") + b.WriteString("status: Accepted\n") + b.WriteString("date: 2026-08-01\n") + b.WriteString("---\n\n## Decision\n\nBody.\n") + return b.String() +} diff --git a/internal/repository/transaction/harness_test.go b/internal/repository/transaction/harness_test.go index f76c4cecc..995fc2d57 100644 --- a/internal/repository/transaction/harness_test.go +++ b/internal/repository/transaction/harness_test.go @@ -1,167 +1,8 @@ package transaction -import ( - "context" - "github.com/danielhanold/docket/internal/testsupport" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file builds real temporary Git repositories for the engine tests, ported -// from internal/gitcli/harness_test.go and extended with a small complete Docket -// corpus committed on the target branch. Two topologies are produced: a plain -// "main mode" repo whose target branch is refs/heads/main, and a docket-style -// repo whose target branch is an orphan refs/heads/docket with a linked .docket -// worktree in the invocation clone. Each is backed by a bare file origin, an -// independent writer clone that advances the origin, and the invocation clone the -// engine discovers and operates. All paths live under testsupport.TempDir(t); builders -// return the raw testsupport.TempDir(t) spelling so the symlinked /tmp -> /private/tmp case -// on macOS is exercised. core.quotePath=true is pinned so a developer's global -// "false" cannot disarm a hostile-path proof. Everything here is _test.go only. - -// testRepos is a bare origin plus two clones: a writer that advances the origin, -// and the invocation clone the engine discovers. Target names the fully qualified -// branch the corpus lives on and the engine writes to. -type testRepos struct { - Origin string - Writer string - Invocation string - Target gitcli.RefName -} - -// requireGit skips the test when no real git is on PATH. -func requireGit(t *testing.T) { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not found on PATH") - } - useBackgroundOffGit(t) -} - -// useBackgroundOffGit points the git children these tests spawn at a per-fixture -// GIT_CONFIG_GLOBAL (testsupport.GitEnv) that disables auto-gc, auto-maintenance, -// and fsmonitor. The direct oracle helpers (hgitOut/hgitTry, matGit, newTxnRepo's -// run) inherit the test-process environment, so this reaches them; without it a -// detached git housekeeping child spawned by a fixture commit can outlive the -// test and keep writing into a testsupport.TempDir, racing RemoveAll teardown to -// "directory not empty" under parallel load (change 0373, sighting 4: -// TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers/keyed). Git spawned through the product -// gitcli client scrubs GIT_CONFIG, so its housekeeping children are instead -// absorbed by the fixture's drain-then-retry removal. Set process-wide via -// t.Setenv because the low-level helpers take no *testing.T; safe because this -// package runs no test in parallel. -func useBackgroundOffGit(t *testing.T) { - t.Helper() - for _, kv := range testsupport.GitEnv(t) { - if v, ok := strings.CutPrefix(kv, "GIT_CONFIG_GLOBAL="); ok { - t.Setenv("GIT_CONFIG_GLOBAL", v) - } - } -} - -// hgitOut runs real git with -C , returns trimmed stdout, and fails on a -// non-zero exit. It is the plumbing oracle fixture assertions compare against. -func hgitOut(t *testing.T, dir string, args ...string) string { - t.Helper() - out, err := hgitTry(dir, args...) - if err != nil { - t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) - } - return strings.TrimSpace(out) -} - -// hgitTry runs git -C and returns raw stdout plus an error carrying stderr; -// it never touches testing.T so callers can probe for an expected failure. -func hgitTry(dir string, args ...string) (string, error) { - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return stdout.String(), &harnessGitError{err: err, stderr: stderr.String()} - } - return stdout.String(), nil -} - -type harnessGitError struct { - err error - stderr string -} - -func (e *harnessGitError) Error() string { - return e.err.Error() + ": " + strings.TrimSpace(e.stderr) -} - -// hconfigIdentity pins a deterministic committer identity and disables signing. -func hconfigIdentity(t *testing.T, dir string) { - t.Helper() - hgitOut(t, dir, "config", "user.name", "t") - hgitOut(t, dir, "config", "user.email", "t@t") - hgitOut(t, dir, "config", "commit.gpgsign", "false") -} - -// hwriteFile writes content (creating parent directories) at a repo-relative -// path, tolerating hostile bytes in the name. -func hwriteFile(t *testing.T, root, rel, content string) { - t.Helper() - p := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -// corpusFiles is the small complete Docket corpus committed on the target branch: -// two active changes and one Accepted ADR. Every filename encodes the record's id -// and slug exactly as docket's writers do, so a snapshot over it carries no error -// findings and a before/after gate passes. -func corpusFiles() map[string]string { - return map[string]string{ - "docs/changes/active/0001-first-change.md": corpusChange(1, "first-change", "proposed"), - "docs/changes/active/0002-second-change.md": corpusChange(2, "second-change", "proposed"), - "docs/adrs/0001-first-decision.md": corpusADR(1, "first-decision"), - } -} - -// corpusChange renders a well-formed change record with no cross-references, so a -// snapshot over it carries no error findings. -func corpusChange(id int, slug, status string) string { - var b strings.Builder - b.WriteString("---\n") - b.WriteString("id: " + itoa(id) + "\n") - b.WriteString("slug: " + slug + "\n") - b.WriteString("title: 'A change'\n") - b.WriteString("status: " + status + "\n") - b.WriteString("priority: medium\n") - b.WriteString("type: feat\n") - b.WriteString("created: 2026-08-01\n") - b.WriteString("updated: 2026-08-02\n") - b.WriteString("---\n\n## Why\n\nBody.\n") - return b.String() -} - -// corpusADR renders a well-formed Accepted ADR record. -func corpusADR(id int, slug string) string { - var b strings.Builder - b.WriteString("---\n") - b.WriteString("id: " + itoa(id) + "\n") - b.WriteString("slug: " + slug + "\n") - b.WriteString("title: 'A decision'\n") - b.WriteString("status: Accepted\n") - b.WriteString("date: 2026-08-01\n") - b.WriteString("---\n\n## Decision\n\nBody.\n") - return b.String() -} - -// itoa is a tiny local integer formatter that keeps the corpus builders free of -// an fmt import for one field. +// itoa is a tiny local integer formatter that keeps the corpus builders (now in +// harness_integration_test.go, behind the integration tag) and scope_test.go free +// of an fmt import for one field. It stays untagged because both builds use it. func itoa(n int) string { if n == 0 { return "0" @@ -180,201 +21,3 @@ func itoa(n int) string { } return string(digits) } - -// newMainModeRepos builds a bare origin whose branch main holds the corpus plus a -// .docket.yml and README.md, a writer clone that advances origin, and an -// invocation clone checked out on main with core.quotePath=true. Its Target is -// refs/heads/main. -func newMainModeRepos(t *testing.T) *testRepos { - t.Helper() - requireGit(t) - root := testsupport.TempDir(t) - r := &testRepos{ - Origin: filepath.Join(root, "origin.git"), - Writer: filepath.Join(root, "writer"), - Invocation: filepath.Join(root, "invocation"), - Target: "refs/heads/main", - } - - hgitOut(t, root, "init", "--bare", "-b", "main", r.Origin) - - hgitOut(t, root, "init", "-b", "main", r.Writer) - hconfigIdentity(t, r.Writer) - hgitOut(t, r.Writer, "config", "core.quotePath", "true") - - hwriteFile(t, r.Writer, "README.md", "readme\n") - hwriteFile(t, r.Writer, ".docket.yml", "version: 1\n") - for rel, content := range corpusFiles() { - hwriteFile(t, r.Writer, rel, content) - } - hgitOut(t, r.Writer, "add", "-A") - hgitOut(t, r.Writer, "commit", "-q", "-m", "main content") - hgitOut(t, r.Writer, "remote", "add", "origin", r.Origin) - hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") - - hgitOut(t, root, "clone", "-q", r.Origin, r.Invocation) - hgitOut(t, r.Invocation, "config", "core.quotePath", "true") - hconfigIdentity(t, r.Invocation) - return r -} - -// newDocketModeRepos builds a bare origin with branch main (.docket.yml, code, and -// a .gitignore excluding .docket/ and .worktrees/) plus an orphan "docket" branch -// holding the corpus. The invocation clone adds a linked ".docket" worktree parked -// on docket. Its Target is refs/heads/docket. -func newDocketModeRepos(t *testing.T) *testRepos { - t.Helper() - requireGit(t) - root := testsupport.TempDir(t) - r := &testRepos{ - Origin: filepath.Join(root, "origin.git"), - Writer: filepath.Join(root, "writer"), - Invocation: filepath.Join(root, "invocation"), - Target: "refs/heads/docket", - } - - hgitOut(t, root, "init", "--bare", "-b", "main", r.Origin) - - hgitOut(t, root, "init", "-b", "main", r.Writer) - hconfigIdentity(t, r.Writer) - hgitOut(t, r.Writer, "config", "core.quotePath", "true") - - hwriteFile(t, r.Writer, ".docket.yml", "version: 1\n") - hwriteFile(t, r.Writer, "main.go", "package main\n") - hwriteFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") - hgitOut(t, r.Writer, "add", "-A") - hgitOut(t, r.Writer, "commit", "-q", "-m", "main content") - hgitOut(t, r.Writer, "remote", "add", "origin", r.Origin) - hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") - - // Orphan docket branch: unrelated history, the corpus only. - hgitOut(t, r.Writer, "checkout", "-q", "--orphan", "docket") - hgitOut(t, r.Writer, "rm", "-rfq", "--cached", ".") - for _, name := range []string{".docket.yml", "main.go", ".gitignore"} { - if err := os.Remove(filepath.Join(r.Writer, name)); err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - } - for rel, content := range corpusFiles() { - hwriteFile(t, r.Writer, rel, content) - } - hgitOut(t, r.Writer, "add", "-A") - hgitOut(t, r.Writer, "commit", "-q", "-m", "docket corpus") - hgitOut(t, r.Writer, "push", "-q", "-u", "origin", "docket") - hgitOut(t, r.Writer, "checkout", "-q", "main") - - hgitOut(t, root, "clone", "-q", r.Origin, r.Invocation) - hconfigIdentity(t, r.Invocation) - hgitOut(t, r.Invocation, "config", "core.quotePath", "true") - hgitOut(t, r.Invocation, "worktree", "add", "-q", "-B", "docket", ".docket", "origin/docket") - return r -} - -// short returns the short branch name of the target ref (the part after -// refs/heads/), for oracle git commands that take a branch name. -func (r *testRepos) short() string { - return strings.TrimPrefix(string(r.Target), "refs/heads/") -} - -// discover builds a gitcli.Client and discovers the invocation repository the -// engine operates, exactly as the engine's caller would. -func (r *testRepos) discover(t *testing.T) (*gitcli.Client, gitcli.Repository) { - t.Helper() - client, err := gitcli.NewClient() - if err != nil { - t.Fatalf("NewClient: %v", err) - } - repo, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Invocation}) - if err != nil { - t.Fatalf("Discover: %v", err) - } - return client, repo -} - -// originTip returns the origin's current commit for the target ref, read from the -// bare origin directly — an oracle independent of the adapter under test. -func (r *testRepos) originTip(t *testing.T) gitcli.ObjectID { - t.Helper() - return gitcli.ObjectID(hgitOut(t, r.Origin, "rev-parse", string(r.Target))) -} - -// blobID returns the object id of a path at the target ref's tip on origin. -func (r *testRepos) blobID(t *testing.T, path string) gitcli.ObjectID { - t.Helper() - out, err := hgitTry(r.Origin, "rev-parse", string(r.Target)+":"+path) - if err != nil { - t.Fatalf("blobID %q: %v", path, err) - } - return gitcli.ObjectID(strings.TrimSpace(out)) -} - -// advanceOrigin makes the writer clone push one new change on the target branch, -// advancing origin so a subsequent lease loses. It returns the writer's new tip. -func (r *testRepos) advanceOrigin(t *testing.T, rel, content string) gitcli.ObjectID { - t.Helper() - branch := r.short() - if branchExistsWriter(r.Writer, branch) { - hgitOut(t, r.Writer, "checkout", "-q", branch) - } else { - hgitOut(t, r.Writer, "checkout", "-q", "-b", branch) - } - hwriteFile(t, r.Writer, rel, content) - hgitOut(t, r.Writer, "add", "--", rel) - hgitOut(t, r.Writer, "commit", "-q", "-m", "writer advance") - hgitOut(t, r.Writer, "push", "-q", "origin", branch) - return gitcli.ObjectID(hgitOut(t, r.Writer, "rev-parse", "HEAD")) -} - -// branchExistsWriter reports whether refs/heads/ exists in the writer. -func branchExistsWriter(dir, branch string) bool { - _, err := hgitTry(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) - return err == nil -} - -// diffTreePaths returns the exact changed-path set of commit against its first -// parent, rename detection off and NUL-delimited so hostile paths survive. -func diffTreePaths(t *testing.T, originDir string, commit gitcli.ObjectID) []string { - t.Helper() - out := hgitOutRaw(t, originDir, "diff-tree", "--no-renames", "--no-commit-id", - "--name-only", "-z", "-r", string(commit)) - var paths []string - for _, p := range strings.Split(out, "\x00") { - if p != "" { - paths = append(paths, p) - } - } - return paths -} - -// hgitOutRaw runs git -C and returns UNtrimmed stdout (NUL-delimited output -// must not be trimmed), failing the test on a non-zero exit. -func hgitOutRaw(t *testing.T, dir string, args ...string) string { - t.Helper() - out, err := hgitTry(dir, args...) - if err != nil { - t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) - } - return out -} - -// transactionsEmpty reports whether the repository's transactions root has no -// candidate directories left (every candidate cleaned). A missing root counts as -// empty. -func transactionsEmpty(t *testing.T, repo gitcli.Repository) bool { - t.Helper() - entries, err := os.ReadDir(transactionsRoot(repo)) - if err != nil { - if os.IsNotExist(err) { - return true - } - t.Fatalf("read transactions root: %v", err) - } - for _, e := range entries { - // registry.lock is a mutex file, not a candidate; ignore it. - if e.Name() == registryLockName { - continue - } - return false - } - return true -} diff --git a/internal/repository/transaction/idempotency_integration_test.go b/internal/repository/transaction/idempotency_integration_test.go index af1d3de8b..59cee5cca 100644 --- a/internal/repository/transaction/idempotency_integration_test.go +++ b/internal/repository/transaction/idempotency_integration_test.go @@ -4,9 +4,12 @@ package transaction import ( "context" + "encoding/base64" "sort" "strings" "testing" + + "github.com/danielhanold/docket/internal/gitcli" ) // TestIntegrationTxnRecoveryKeyedCommitCarriesFiveTrailers proves a keyed apply writes exactly the five @@ -283,3 +286,72 @@ func TestIntegrationTxnRecoveryKeyedReplayFoundDeepInHistory(t *testing.T) { t.Errorf("receipt = %q, want %q", res.Receipt, planted) } } + +// This file proves the request-ID idempotency contract end to end through +// Execute against real Git topologies: the engine's five-trailer block on keyed +// commits (three on unkeyed), lost-response replay returning the ORIGINAL receipt +// with no new commit, request-id reuse detection by digest, and the invalid-state +// verdicts for duplicate/malformed/contradictory history. Hand-crafted history is +// built through the writer clone with git's own commit machinery, so the scan is +// exercised against genuine trailer blocks, not fixtures the engine authored. + +// keyReq is the standard idempotency key the keyed tests reuse. +func keyReq() *IdempotencyKey { + return &IdempotencyKey{RequestID: "req-abc-00000001", Digest: validDigest()} +} + +// otherDigest is a well-formed sha256 digest distinct from validDigest, for the +// request-id-reuse case (same ID, different digest). +func otherDigest() RequestDigest { + return RequestDigest("sha256:" + strings.Repeat("b", 64)) +} + +// b64 is the unpadded base64url encoding the Docket-Result trailer uses. +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// trailerKeys returns the ordered keys of the parsed trailer block of a commit, +// read with git's own trailer interpretation (the same parser +// `git interpret-trailers --parse` uses), so a body-prose line never appears. +func trailerKeys(t *testing.T, dir string, commit gitcli.ObjectID) []string { + t.Helper() + block := hgitOut(t, dir, "log", "-1", "--format=%(trailers:only,unfold)", string(commit)) + var keys []string + for _, line := range strings.Split(block, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if idx := strings.Index(line, ":"); idx >= 0 { + keys = append(keys, strings.TrimSpace(line[:idx])) + } + } + return keys +} + +// plantCommit makes one empty commit on the target branch in the writer clone with +// exactly message as its full commit message (subject plus a hand-authored trailer +// block), pushes it to origin, and returns the new commit id. The writer must be +// current with origin on the target branch (true in a freshly built main-mode repo +// before the engine has applied anything). +func plantCommit(t *testing.T, r *testRepos, message string) gitcli.ObjectID { + t.Helper() + branch := r.short() + hgitOut(t, r.Writer, "checkout", "-q", branch) + hgitOut(t, r.Writer, "commit", "-q", "--allow-empty", "-m", message) + hgitOut(t, r.Writer, "push", "-q", "origin", branch) + return gitcli.ObjectID(hgitOut(t, r.Writer, "rev-parse", "HEAD")) +} + +// engineBlockMessage renders a full commit message with a subject and the engine's +// five-trailer block as its final paragraph. +func engineBlockMessage(subject, txnID, op, reqID, digest, resultB64 string) string { + var b strings.Builder + b.WriteString(subject) + b.WriteString("\n\n") + b.WriteString("Docket-Transaction-ID: " + txnID + "\n") + b.WriteString("Docket-Operation: " + op + "\n") + b.WriteString("Docket-Request-ID: " + reqID + "\n") + b.WriteString("Docket-Request-Digest: " + digest + "\n") + b.WriteString("Docket-Result: " + resultB64 + "\n") + return b.String() +} diff --git a/internal/repository/transaction/idempotency_test.go b/internal/repository/transaction/idempotency_test.go deleted file mode 100644 index 9b193622f..000000000 --- a/internal/repository/transaction/idempotency_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package transaction - -import ( - "encoding/base64" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file proves the request-ID idempotency contract end to end through -// Execute against real Git topologies: the engine's five-trailer block on keyed -// commits (three on unkeyed), lost-response replay returning the ORIGINAL receipt -// with no new commit, request-id reuse detection by digest, and the invalid-state -// verdicts for duplicate/malformed/contradictory history. Hand-crafted history is -// built through the writer clone with git's own commit machinery, so the scan is -// exercised against genuine trailer blocks, not fixtures the engine authored. - -// keyReq is the standard idempotency key the keyed tests reuse. -func keyReq() *IdempotencyKey { - return &IdempotencyKey{RequestID: "req-abc-00000001", Digest: validDigest()} -} - -// otherDigest is a well-formed sha256 digest distinct from validDigest, for the -// request-id-reuse case (same ID, different digest). -func otherDigest() RequestDigest { - return RequestDigest("sha256:" + strings.Repeat("b", 64)) -} - -// b64 is the unpadded base64url encoding the Docket-Result trailer uses. -func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } - -// trailerKeys returns the ordered keys of the parsed trailer block of a commit, -// read with git's own trailer interpretation (the same parser -// `git interpret-trailers --parse` uses), so a body-prose line never appears. -func trailerKeys(t *testing.T, dir string, commit gitcli.ObjectID) []string { - t.Helper() - block := hgitOut(t, dir, "log", "-1", "--format=%(trailers:only,unfold)", string(commit)) - var keys []string - for _, line := range strings.Split(block, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if idx := strings.Index(line, ":"); idx >= 0 { - keys = append(keys, strings.TrimSpace(line[:idx])) - } - } - return keys -} - -// plantCommit makes one empty commit on the target branch in the writer clone with -// exactly message as its full commit message (subject plus a hand-authored trailer -// block), pushes it to origin, and returns the new commit id. The writer must be -// current with origin on the target branch (true in a freshly built main-mode repo -// before the engine has applied anything). -func plantCommit(t *testing.T, r *testRepos, message string) gitcli.ObjectID { - t.Helper() - branch := r.short() - hgitOut(t, r.Writer, "checkout", "-q", branch) - hgitOut(t, r.Writer, "commit", "-q", "--allow-empty", "-m", message) - hgitOut(t, r.Writer, "push", "-q", "origin", branch) - return gitcli.ObjectID(hgitOut(t, r.Writer, "rev-parse", "HEAD")) -} - -// engineBlockMessage renders a full commit message with a subject and the engine's -// five-trailer block as its final paragraph. -func engineBlockMessage(subject, txnID, op, reqID, digest, resultB64 string) string { - var b strings.Builder - b.WriteString(subject) - b.WriteString("\n\n") - b.WriteString("Docket-Transaction-ID: " + txnID + "\n") - b.WriteString("Docket-Operation: " + op + "\n") - b.WriteString("Docket-Request-ID: " + reqID + "\n") - b.WriteString("Docket-Request-Digest: " + digest + "\n") - b.WriteString("Docket-Result: " + resultB64 + "\n") - return b.String() -} diff --git a/internal/repository/transaction/interrupt_integration_test.go b/internal/repository/transaction/interrupt_integration_test.go index e2eeb362e..d422424f6 100644 --- a/internal/repository/transaction/interrupt_integration_test.go +++ b/internal/repository/transaction/interrupt_integration_test.go @@ -6,7 +6,11 @@ import ( "context" "os" "path/filepath" + "sync" "testing" + "time" + + "github.com/danielhanold/docket/internal/gitcli" ) // TestIntegrationTxnRecoveryInterruptLostResponseReplaysOriginalOnce proves a lost response is safe: a @@ -183,3 +187,83 @@ func TestIntegrationTxnRecoveryInterruptContainmentFailureDoesNotPush(t *testing t.Errorf("README.md no longer a regular file: mode=%v err=%v", fi.Mode(), lerr) } } + +// This file is the interruption matrix from the spec's acceptance boundary: +// lost-response replay, cancellation at three points (inside Plan, between the +// local commit and the push, and before the first fetch), and engine-level +// materialization/verification failures. The single invariant every pre-push +// case proves: origin's ref is byte-identical before and after, and no candidate +// bytes ever reach the remote. Coordination is by channels and a barrier clock, +// never sleeps. + +// barrierClock is the pinned instant every attempt stamps with, plus a +// deterministic seam: on its Nth Now() call it blocks until the test releases it. +// The engine reads the clock in a fixed order within one attempt — +// allocate(1), commit author date(2), setPhase(committed)(3), setPhase(pushed)(4) +// — so blocking on call 3 parks the attempt exactly AFTER the local commit exists +// and BEFORE PushLease is invoked. That makes "cancel between commit and push" +// deterministic: the test cancels the context while the engine is parked, so the +// push is launched with an already-dead context and never touches origin. A +// mis-count cannot yield a false green — blocking earlier is still a pre-push +// cancel (remote unchanged), and blocking on call 4 (post-push) would surface as +// an APPLIED disposition the test asserts against. +type barrierClock struct { + base time.Time + target int + mu sync.Mutex + n int + reached chan struct{} + release chan struct{} +} + +func newBarrierClock(target int) *barrierClock { + return &barrierClock{ + base: time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC), + target: target, + reached: make(chan struct{}), + release: make(chan struct{}), + } +} + +func (c *barrierClock) Now() time.Time { + c.mu.Lock() + c.n++ + n := c.n + c.mu.Unlock() + if n == c.target { + close(c.reached) + <-c.release + } + return c.base +} + +// blockingPlanOp blocks inside Plan until the context is cancelled, then returns +// ctx.Err() — the operation-observable barrier for the "cancel inside Plan" case. +type blockingPlanOp struct{ entered chan struct{} } + +func (o *blockingPlanOp) Key() OperationKey { return "test.op" } + +func (o *blockingPlanOp) Plan(ctx context.Context, _ AttemptState) (MutationPlan, OperationResult, error) { + close(o.entered) + <-ctx.Done() + return MutationPlan{}, OperationResult{}, ctx.Err() +} + +// soleCandidateWorktree returns the detached worktree path of the single candidate +// currently allocated under repo's transactions root — used while an attempt is +// parked on the barrier clock, when exactly one candidate exists. +func soleCandidateWorktree(t *testing.T, repo gitcli.Repository) string { + t.Helper() + root := transactionsRoot(repo) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read transactions root: %v", err) + } + for _, e := range entries { + if e.IsDir() && isOwnedTransactionID(e.Name()) { + return filepath.Join(root, e.Name(), worktreeDirName) + } + } + t.Fatal("no candidate worktree found under transactions root") + return "" +} diff --git a/internal/repository/transaction/interrupt_test.go b/internal/repository/transaction/interrupt_test.go deleted file mode 100644 index 48268a9ef..000000000 --- a/internal/repository/transaction/interrupt_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package transaction - -import ( - "context" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file is the interruption matrix from the spec's acceptance boundary: -// lost-response replay, cancellation at three points (inside Plan, between the -// local commit and the push, and before the first fetch), and engine-level -// materialization/verification failures. The single invariant every pre-push -// case proves: origin's ref is byte-identical before and after, and no candidate -// bytes ever reach the remote. Coordination is by channels and a barrier clock, -// never sleeps. - -// barrierClock is the pinned instant every attempt stamps with, plus a -// deterministic seam: on its Nth Now() call it blocks until the test releases it. -// The engine reads the clock in a fixed order within one attempt — -// allocate(1), commit author date(2), setPhase(committed)(3), setPhase(pushed)(4) -// — so blocking on call 3 parks the attempt exactly AFTER the local commit exists -// and BEFORE PushLease is invoked. That makes "cancel between commit and push" -// deterministic: the test cancels the context while the engine is parked, so the -// push is launched with an already-dead context and never touches origin. A -// mis-count cannot yield a false green — blocking earlier is still a pre-push -// cancel (remote unchanged), and blocking on call 4 (post-push) would surface as -// an APPLIED disposition the test asserts against. -type barrierClock struct { - base time.Time - target int - mu sync.Mutex - n int - reached chan struct{} - release chan struct{} -} - -func newBarrierClock(target int) *barrierClock { - return &barrierClock{ - base: time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC), - target: target, - reached: make(chan struct{}), - release: make(chan struct{}), - } -} - -func (c *barrierClock) Now() time.Time { - c.mu.Lock() - c.n++ - n := c.n - c.mu.Unlock() - if n == c.target { - close(c.reached) - <-c.release - } - return c.base -} - -// blockingPlanOp blocks inside Plan until the context is cancelled, then returns -// ctx.Err() — the operation-observable barrier for the "cancel inside Plan" case. -type blockingPlanOp struct{ entered chan struct{} } - -func (o *blockingPlanOp) Key() OperationKey { return "test.op" } - -func (o *blockingPlanOp) Plan(ctx context.Context, _ AttemptState) (MutationPlan, OperationResult, error) { - close(o.entered) - <-ctx.Done() - return MutationPlan{}, OperationResult{}, ctx.Err() -} - -// soleCandidateWorktree returns the detached worktree path of the single candidate -// currently allocated under repo's transactions root — used while an attempt is -// parked on the barrier clock, when exactly one candidate exists. -func soleCandidateWorktree(t *testing.T, repo gitcli.Repository) string { - t.Helper() - root := transactionsRoot(repo) - entries, err := os.ReadDir(root) - if err != nil { - t.Fatalf("read transactions root: %v", err) - } - for _, e := range entries { - if e.IsDir() && isOwnedTransactionID(e.Name()) { - return filepath.Join(root, e.Name(), worktreeDirName) - } - } - t.Fatal("no candidate worktree found under transactions root") - return "" -} diff --git a/internal/repository/transaction/main_test.go b/internal/repository/transaction/main_test.go new file mode 100644 index 000000000..039262599 --- /dev/null +++ b/internal/repository/transaction/main_test.go @@ -0,0 +1,25 @@ +package transaction + +import ( + "os" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (change 0466): the default-tag internal/repository/transaction test corpus never +// starts a real git; real-git tests live behind //go:build integration in the +// tests/test_go_integration_transaction_*.sh shards. +const ( + nogitPkg = "internal/repository/transaction" + nogitShardGlob = "tests/test_go_integration_transaction_*.sh" +) + +// TestMain installs the no-real-git guard (testsupport.InstallNoGitGuard) around +// m.Run in the default build; the integration build gets testsupport's identity +// finisher, so the tagged shards run real git as before. +func TestMain(m *testing.M) { + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + os.Exit(finish(m.Run())) +} diff --git a/internal/repository/transaction/materialize_integration_test.go b/internal/repository/transaction/materialize_integration_test.go index 97db344f2..77ea26bf5 100644 --- a/internal/repository/transaction/materialize_integration_test.go +++ b/internal/repository/transaction/materialize_integration_test.go @@ -3,13 +3,21 @@ package transaction import ( + "bytes" "context" - "github.com/danielhanold/docket/internal/testsupport" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" "os" + "os/exec" "path/filepath" + "sort" + "strings" "testing" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" ) // TestIntegrationTxnRecoveryMaterializeCreateReplaceDeleteByteExact proves create/replace/delete land @@ -294,3 +302,176 @@ func TestIntegrationTxnRecoveryMaterializeHostilePathsByteExact(t *testing.T) { t.Errorf("hostile newline path still present: err=%v", err) } } + +// Hostile fixture path names carrying bytes that line-oriented or quoting +// parsers mishandle: a space, a literal tab, and (for a create/delete target) an +// embedded newline. These exercise the NUL-delimited status parse end to end. +const ( + matHostileTab = "spa ce/na\tme.md" // parent dir carries a space; leaf carries a tab + matHostileNewline = "ho\nstile.md" // embedded newline + matHostileCreate = "cr ea\tted.md" // hostile create target (no parent) +) + +// newMaterializeWorktree builds a real Git repository with a fixture base commit +// and returns a client, its canonical repository, and the absolute path of a +// fresh detached worktree checked out at that commit. The worktree is where the +// materializer writes; verifyActualDelta reads its Git status. Skipped when git +// is unavailable (newTxnRepo handles the skip). +func newMaterializeWorktree(t *testing.T) (*gitcli.Client, gitcli.Repository, string) { + t.Helper() + client, repo := newTxnRepo(t) + dir := repo.PrimaryWorktree + + writeFixture(t, dir, "keep.md", "base keep\n", 0o644) + writeFixture(t, dir, "keep2.md", "second keep\n", 0o644) + writeFixture(t, dir, "replace-me.md", "base replace\n", 0o644) + writeFixture(t, dir, "delete-me.md", "base delete\n", 0o644) + writeFixture(t, dir, "exec.sh", "#!/bin/sh\necho base\n", 0o755) + writeFixture(t, dir, "docs/sub/nested.md", "nested base\n", 0o644) + writeFixture(t, dir, matHostileTab, "hostile tab base\n", 0o644) + writeFixture(t, dir, matHostileNewline, "hostile newline base\n", 0o644) + + matGit(t, dir, "add", "-A") + matGit(t, dir, "commit", "-q", "-m", "materialize fixtures") + + head := gitcli.ObjectID(matGit(t, dir, "rev-parse", "HEAD")) + + wt := filepath.Join(testsupport.TempDir(t), "wt") + if err := client.AddDetachedWorktree(context.Background(), repo, wt, head); err != nil { + t.Fatalf("AddDetachedWorktree: %v", err) + } + return client, repo, wt +} + +// writeFixture writes content (creating parent directories) at a repo-relative +// path and forces mode with an explicit Chmod so the executable bit survives any +// ambient umask. +func writeFixture(t *testing.T, root, rel, content string, mode os.FileMode) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } +} + +// matGit runs real git with -C , returns trimmed stdout, and fails the test +// on a non-zero exit — an oracle independent of the adapter under test. +func matGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, stderr.String()) + } + return strings.TrimSpace(stdout.String()) +} + +// assertMaterializeFailure requires err to be a *Failure at the wanted stage. +func assertMaterializeFailure(t *testing.T, err error, wantStage Stage) *Failure { + t.Helper() + if err == nil { + t.Fatalf("expected a failure at stage %q, got nil", wantStage) + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error is not *Failure: %v", err) + } + if f.Stage != wantStage { + t.Errorf("failure stage = %q, want %q (detail %q)", f.Stage, wantStage, f.Detail) + } + return f +} + +// assertFailureKind requires err to be a *Failure of the wanted kind. +func assertFailureKind(t *testing.T, err error, want Kind) { + t.Helper() + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error is not *Failure: %v", err) + } + if f.Kind != want { + t.Errorf("failure kind = %q, want %q", f.Kind, want) + } +} + +// assertFile requires the file at wt/rel to hold exactly want. +func assertFile(t *testing.T, wt, rel, want string) { + t.Helper() + got, err := os.ReadFile(filepath.Join(wt, rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + if string(got) != want { + t.Errorf("%s = %q, want %q", rel, got, want) + } +} + +// hashTreeExcept hashes every regular file under root (skipping the worktree's +// .git pointer and every excluded repo-relative path) into one order-independent +// digest of (path, mode, content). Two digests being equal proves the whole tree +// minus the excluded set is byte-identical. +func hashTreeExcept(t *testing.T, root string, exclude map[string]bool) string { + t.Helper() + type ent struct{ rel, mode, sum string } + var ents []ent + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + if rel == ".git" || strings.HasPrefix(rel, ".git/") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() || exclude[rel] { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + var content []byte + if info.Mode().IsRegular() { + if content, err = os.ReadFile(p); err != nil { + return err + } + } + sum := sha256.Sum256(content) + ents = append(ents, ent{rel: rel, mode: info.Mode().String(), sum: hex.EncodeToString(sum[:])}) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + sort.Slice(ents, func(i, j int) bool { return ents[i].rel < ents[j].rel }) + h := sha256.New() + for _, e := range ents { + fmt.Fprintf(h, "%s\x00%s\x00%s\n", e.rel, e.mode, e.sum) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// planWith wraps a file set into a valid MutationPlan (subject + receipt fixed). +func planWith(files ...FileMutation) MutationPlan { + return MutationPlan{Files: files, CommitSubject: "materialize test", Receipt: []byte(`{}`)} +} diff --git a/internal/repository/transaction/materialize_test.go b/internal/repository/transaction/materialize_test.go deleted file mode 100644 index a952c2bc6..000000000 --- a/internal/repository/transaction/materialize_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package transaction - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "github.com/danielhanold/docket/internal/testsupport" - "io/fs" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// Hostile fixture path names carrying bytes that line-oriented or quoting -// parsers mishandle: a space, a literal tab, and (for a create/delete target) an -// embedded newline. These exercise the NUL-delimited status parse end to end. -const ( - matHostileTab = "spa ce/na\tme.md" // parent dir carries a space; leaf carries a tab - matHostileNewline = "ho\nstile.md" // embedded newline - matHostileCreate = "cr ea\tted.md" // hostile create target (no parent) -) - -// newMaterializeWorktree builds a real Git repository with a fixture base commit -// and returns a client, its canonical repository, and the absolute path of a -// fresh detached worktree checked out at that commit. The worktree is where the -// materializer writes; verifyActualDelta reads its Git status. Skipped when git -// is unavailable (newTxnRepo handles the skip). -func newMaterializeWorktree(t *testing.T) (*gitcli.Client, gitcli.Repository, string) { - t.Helper() - client, repo := newTxnRepo(t) - dir := repo.PrimaryWorktree - - writeFixture(t, dir, "keep.md", "base keep\n", 0o644) - writeFixture(t, dir, "keep2.md", "second keep\n", 0o644) - writeFixture(t, dir, "replace-me.md", "base replace\n", 0o644) - writeFixture(t, dir, "delete-me.md", "base delete\n", 0o644) - writeFixture(t, dir, "exec.sh", "#!/bin/sh\necho base\n", 0o755) - writeFixture(t, dir, "docs/sub/nested.md", "nested base\n", 0o644) - writeFixture(t, dir, matHostileTab, "hostile tab base\n", 0o644) - writeFixture(t, dir, matHostileNewline, "hostile newline base\n", 0o644) - - matGit(t, dir, "add", "-A") - matGit(t, dir, "commit", "-q", "-m", "materialize fixtures") - - head := gitcli.ObjectID(matGit(t, dir, "rev-parse", "HEAD")) - - wt := filepath.Join(testsupport.TempDir(t), "wt") - if err := client.AddDetachedWorktree(context.Background(), repo, wt, head); err != nil { - t.Fatalf("AddDetachedWorktree: %v", err) - } - return client, repo, wt -} - -// writeFixture writes content (creating parent directories) at a repo-relative -// path and forces mode with an explicit Chmod so the executable bit survives any -// ambient umask. -func writeFixture(t *testing.T, root, rel, content string, mode os.FileMode) { - t.Helper() - p := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(content), mode); err != nil { - t.Fatal(err) - } - if err := os.Chmod(p, mode); err != nil { - t.Fatal(err) - } -} - -// matGit runs real git with -C , returns trimmed stdout, and fails the test -// on a non-zero exit — an oracle independent of the adapter under test. -func matGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("git %v: %v\n%s", args, err, stderr.String()) - } - return strings.TrimSpace(stdout.String()) -} - -// assertMaterializeFailure requires err to be a *Failure at the wanted stage. -func assertMaterializeFailure(t *testing.T, err error, wantStage Stage) *Failure { - t.Helper() - if err == nil { - t.Fatalf("expected a failure at stage %q, got nil", wantStage) - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error is not *Failure: %v", err) - } - if f.Stage != wantStage { - t.Errorf("failure stage = %q, want %q (detail %q)", f.Stage, wantStage, f.Detail) - } - return f -} - -// assertFailureKind requires err to be a *Failure of the wanted kind. -func assertFailureKind(t *testing.T, err error, want Kind) { - t.Helper() - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error is not *Failure: %v", err) - } - if f.Kind != want { - t.Errorf("failure kind = %q, want %q", f.Kind, want) - } -} - -// assertFile requires the file at wt/rel to hold exactly want. -func assertFile(t *testing.T, wt, rel, want string) { - t.Helper() - got, err := os.ReadFile(filepath.Join(wt, rel)) - if err != nil { - t.Fatalf("read %s: %v", rel, err) - } - if string(got) != want { - t.Errorf("%s = %q, want %q", rel, got, want) - } -} - -// hashTreeExcept hashes every regular file under root (skipping the worktree's -// .git pointer and every excluded repo-relative path) into one order-independent -// digest of (path, mode, content). Two digests being equal proves the whole tree -// minus the excluded set is byte-identical. -func hashTreeExcept(t *testing.T, root string, exclude map[string]bool) string { - t.Helper() - type ent struct{ rel, mode, sum string } - var ents []ent - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(root, p) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - if rel == "." { - return nil - } - if rel == ".git" || strings.HasPrefix(rel, ".git/") { - if d.IsDir() { - return fs.SkipDir - } - return nil - } - if d.IsDir() || exclude[rel] { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - var content []byte - if info.Mode().IsRegular() { - if content, err = os.ReadFile(p); err != nil { - return err - } - } - sum := sha256.Sum256(content) - ents = append(ents, ent{rel: rel, mode: info.Mode().String(), sum: hex.EncodeToString(sum[:])}) - return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", root, err) - } - sort.Slice(ents, func(i, j int) bool { return ents[i].rel < ents[j].rel }) - h := sha256.New() - for _, e := range ents { - fmt.Fprintf(h, "%s\x00%s\x00%s\n", e.rel, e.mode, e.sum) - } - return hex.EncodeToString(h.Sum(nil)) -} - -// planWith wraps a file set into a valid MutationPlan (subject + receipt fixed). -func planWith(files ...FileMutation) MutationPlan { - return MutationPlan{Files: files, CommitSubject: "materialize test", Receipt: []byte(`{}`)} -} diff --git a/internal/repository/transaction/nogit_guard_test.go b/internal/repository/transaction/nogit_guard_test.go new file mode 100644 index 000000000..b697d84cd --- /dev/null +++ b/internal/repository/transaction/nogit_guard_test.go @@ -0,0 +1,31 @@ +//go:build !integration && !e2e + +package transaction + +// The no-real-git guard's proving tests for internal/repository/transaction (change +// 0466). The guard lives in internal/testsupport (InstallNoGitGuard) and is +// installed from TestMain in main_test.go. These tests prove it is installed in +// THIS package's binary and fails the package on any real-git exec, even one a test +// tolerates. Real-git tests live behind //go:build integration in the +// tests/test_go_integration_transaction_*.sh shards. + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + testsupport.AssertNoGitGuardShadowsGit(t) +} + +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. +func TestNoGitGuardRefusesBareExec(t *testing.T) { + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) +} + +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") +} diff --git a/internal/repository/transaction/preserve_integration_test.go b/internal/repository/transaction/preserve_integration_test.go index b35c5d99a..c94d38be9 100644 --- a/internal/repository/transaction/preserve_integration_test.go +++ b/internal/repository/transaction/preserve_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/danielhanold/docket/internal/gitcli" @@ -70,3 +71,85 @@ func TestIntegrationTxnApplyTransactionPreservesDirtyCheckout(t *testing.T) { }) } } + +// This file is the byte-identical preservation proof for the transaction engine. +// The spec's acceptance boundary requires that a transaction move ONLY the remote +// refs/objects and the private /docket/transactions state — every user +// checkout and index must be byte-for-byte untouched. The two helpers here +// (captureCheckouts / assertCheckoutsUnchanged) snapshot every preserved property +// of a working tree using read-only Git plumbing, and are shared by the +// concurrency and interruption matrices so every scenario also carries a +// preservation assertion. A dedicated dirty-checkout case proves the guarantee +// holds even when the invocation clone carries staged, unstaged, AND untracked +// local work across an applied transaction. + +// checkoutState records every preserved property of one working tree, captured +// with read-only plumbing only (rev-parse, symbolic-ref -q, ls-files -s -z, +// status --porcelain=v2 -z, and a whole-tree content hash) so snapshotting can +// never itself perturb what it measures. The working-tree hash (hashTreeExcept, +// which skips .git) catches any file-content change the porcelain status would +// summarize but not fingerprint. +type checkoutState struct { + symbolic string // symbolic-ref -q HEAD (empty on a detached HEAD) + head string // rev-parse HEAD + index string // ls-files --stage -z (the full index) + status string // status --porcelain=v2 -z --untracked-files=all + workHash string // content hash of the whole working tree minus .git +} + +// captureCheckout snapshots one working tree. symbolic-ref -q returns non-zero on +// a detached HEAD, recorded as the empty string. +func captureCheckout(t *testing.T, dir string) checkoutState { + t.Helper() + sym := "" + if out, err := hgitTry(dir, "symbolic-ref", "-q", "HEAD"); err == nil { + sym = strings.TrimSpace(out) + } + return checkoutState{ + symbolic: sym, + head: hgitOut(t, dir, "rev-parse", "HEAD"), + index: hgitOutRaw(t, dir, "ls-files", "--stage", "-z"), + status: hgitOutRaw(t, dir, "status", "--porcelain=v2", "-z", "--untracked-files=all"), + workHash: hashTreeExcept(t, dir, nil), + } +} + +// captureCheckouts snapshots several working trees in order, returning one state +// per directory. +func captureCheckouts(t *testing.T, dirs ...string) []checkoutState { + t.Helper() + snaps := make([]checkoutState, len(dirs)) + for i, d := range dirs { + snaps[i] = captureCheckout(t, d) + } + return snaps +} + +// assertCheckoutsUnchanged re-snapshots each directory and fails, field by field, +// on any drift from its captured state — the proof that a transaction left every +// user checkout byte-identical. +func assertCheckoutsUnchanged(t *testing.T, dirs []string, before []checkoutState) { + t.Helper() + if len(dirs) != len(before) { + t.Fatalf("assertCheckoutsUnchanged: %d dirs vs %d snapshots", len(dirs), len(before)) + } + for i, dir := range dirs { + after := captureCheckout(t, dir) + b := before[i] + if b.symbolic != after.symbolic { + t.Errorf("%s: HEAD symbolic ref changed: %q -> %q", dir, b.symbolic, after.symbolic) + } + if b.head != after.head { + t.Errorf("%s: HEAD commit changed: %q -> %q", dir, b.head, after.head) + } + if b.index != after.index { + t.Errorf("%s: index changed", dir) + } + if b.status != after.status { + t.Errorf("%s: working-tree status changed:\nbefore %q\nafter %q", dir, b.status, after.status) + } + if b.workHash != after.workHash { + t.Errorf("%s: working-tree content changed: %s -> %s", dir, b.workHash, after.workHash) + } + } +} diff --git a/internal/repository/transaction/preserve_test.go b/internal/repository/transaction/preserve_test.go deleted file mode 100644 index d660c635c..000000000 --- a/internal/repository/transaction/preserve_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package transaction - -import ( - "strings" - "testing" -) - -// This file is the byte-identical preservation proof for the transaction engine. -// The spec's acceptance boundary requires that a transaction move ONLY the remote -// refs/objects and the private /docket/transactions state — every user -// checkout and index must be byte-for-byte untouched. The two helpers here -// (captureCheckouts / assertCheckoutsUnchanged) snapshot every preserved property -// of a working tree using read-only Git plumbing, and are shared by the -// concurrency and interruption matrices so every scenario also carries a -// preservation assertion. A dedicated dirty-checkout case proves the guarantee -// holds even when the invocation clone carries staged, unstaged, AND untracked -// local work across an applied transaction. - -// checkoutState records every preserved property of one working tree, captured -// with read-only plumbing only (rev-parse, symbolic-ref -q, ls-files -s -z, -// status --porcelain=v2 -z, and a whole-tree content hash) so snapshotting can -// never itself perturb what it measures. The working-tree hash (hashTreeExcept, -// which skips .git) catches any file-content change the porcelain status would -// summarize but not fingerprint. -type checkoutState struct { - symbolic string // symbolic-ref -q HEAD (empty on a detached HEAD) - head string // rev-parse HEAD - index string // ls-files --stage -z (the full index) - status string // status --porcelain=v2 -z --untracked-files=all - workHash string // content hash of the whole working tree minus .git -} - -// captureCheckout snapshots one working tree. symbolic-ref -q returns non-zero on -// a detached HEAD, recorded as the empty string. -func captureCheckout(t *testing.T, dir string) checkoutState { - t.Helper() - sym := "" - if out, err := hgitTry(dir, "symbolic-ref", "-q", "HEAD"); err == nil { - sym = strings.TrimSpace(out) - } - return checkoutState{ - symbolic: sym, - head: hgitOut(t, dir, "rev-parse", "HEAD"), - index: hgitOutRaw(t, dir, "ls-files", "--stage", "-z"), - status: hgitOutRaw(t, dir, "status", "--porcelain=v2", "-z", "--untracked-files=all"), - workHash: hashTreeExcept(t, dir, nil), - } -} - -// captureCheckouts snapshots several working trees in order, returning one state -// per directory. -func captureCheckouts(t *testing.T, dirs ...string) []checkoutState { - t.Helper() - snaps := make([]checkoutState, len(dirs)) - for i, d := range dirs { - snaps[i] = captureCheckout(t, d) - } - return snaps -} - -// assertCheckoutsUnchanged re-snapshots each directory and fails, field by field, -// on any drift from its captured state — the proof that a transaction left every -// user checkout byte-identical. -func assertCheckoutsUnchanged(t *testing.T, dirs []string, before []checkoutState) { - t.Helper() - if len(dirs) != len(before) { - t.Fatalf("assertCheckoutsUnchanged: %d dirs vs %d snapshots", len(dirs), len(before)) - } - for i, dir := range dirs { - after := captureCheckout(t, dir) - b := before[i] - if b.symbolic != after.symbolic { - t.Errorf("%s: HEAD symbolic ref changed: %q -> %q", dir, b.symbolic, after.symbolic) - } - if b.head != after.head { - t.Errorf("%s: HEAD commit changed: %q -> %q", dir, b.head, after.head) - } - if b.index != after.index { - t.Errorf("%s: index changed", dir) - } - if b.status != after.status { - t.Errorf("%s: working-tree status changed:\nbefore %q\nafter %q", dir, b.status, after.status) - } - if b.workHash != after.workHash { - t.Errorf("%s: working-tree content changed: %s -> %s", dir, b.workHash, after.workHash) - } - } -} diff --git a/internal/repository/transaction/recovery_integration_test.go b/internal/repository/transaction/recovery_integration_test.go index f7967e808..444708e77 100644 --- a/internal/repository/transaction/recovery_integration_test.go +++ b/internal/repository/transaction/recovery_integration_test.go @@ -4,12 +4,17 @@ package transaction import ( "context" - "github.com/danielhanold/docket/internal/testsupport" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" "os" "path/filepath" "testing" + "time" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" ) // TestIntegrationTxnRecoveryPruneReportsLiveCandidatesUntouched proves that two concurrently active @@ -391,3 +396,173 @@ func TestIntegrationTxnRecoveryPruneNeverGlobalPrunesOrTouchesUserCheckout(t *te t.Errorf("working tree status changed:\n%s", got) } } + +// This file is the ownership-and-recovery matrix for PruneAbandoned. Every +// scenario runs against a real Git topology so the six-point proof is exercised +// against actual worktree registrations, real HEADs, and real flocks — never a +// fake. The single invariant under test: PruneAbandoned removes ONLY candidates +// that pass ALL SIX ownership checks and leaves everything else byte-untouched +// with a verdict, never resetting a branch, deleting a ref, or globally pruning. + +// recoveryEngine discovers the invocation repository and builds an Engine over the +// real git client and the pinned engine clock. +func recoveryEngine(t *testing.T, r *testRepos) (*Engine, *gitcli.Client, gitcli.Repository) { + t.Helper() + client, repo := r.discover(t) + eng, err := NewEngine(client, engineClock) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return eng, client, repo +} + +// baseValidManifest returns a fully valid manifest for id whose repository identity +// is commonDir and whose target is refs/heads/main. Individual tests corrupt one +// field to exercise a single failing check while every other field stays canonical. +func baseValidManifest(id, commonDir string) manifest { + stamp := txnTestClock.Now().UTC().Format(time.RFC3339) + return manifest{ + Schema: manifestSchemaVersion, + TransactionID: id, + CommonDir: commonDir, + Remote: "origin", + TargetRef: "refs/heads/main", + BaseCommit: fixedBase, + WorktreeRel: worktreeDirName, + Phase: phaseAllocating, + CreatedUTC: stamp, + UpdatedUTC: stamp, + PID: os.Getpid(), + } +} + +// mkOwnedDir creates an owned-shape candidate directory (0700) under repo's +// transactions root and returns its path. It ensures the root exists first. +func mkOwnedDir(t *testing.T, repo gitcli.Repository, id string) string { + t.Helper() + root := transactionsRoot(repo) + if err := ensureTransactionsRoot(root); err != nil { + t.Fatalf("ensure transactions root: %v", err) + } + candRoot := filepath.Join(root, id) + if err := mkdirMode(candRoot, txnDirMode); err != nil { + t.Fatalf("mkdir candidate: %v", err) + } + return candRoot +} + +// hexID returns a valid 32-hex transaction id built from a distinguishing prefix +// so table rows sort deterministically and read clearly in failures. +func hexID(prefix string) string { + id := prefix + for len(id) < 32 { + id += "0" + } + return id[:32] +} + +// hashTree returns a content hash of the entire tree rooted at path: every entry's +// relative name, type, and permission bits, plus regular-file contents and symlink +// targets. filepath.WalkDir never follows symlinks, so a symlinked root hashes as +// the link itself. A byte-untouched survival must produce an identical hash. +func hashTree(t *testing.T, path string) string { + t.Helper() + h := sha256.New() + err := filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, rerr := filepath.Rel(path, p) + if rerr != nil { + return rerr + } + info, ierr := os.Lstat(p) + if ierr != nil { + return ierr + } + fmt.Fprintf(h, "path=%s type=%v perm=%o\n", rel, info.Mode()&os.ModeType, info.Mode().Perm()) + switch { + case info.Mode()&os.ModeSymlink != 0: + target, lerr := os.Readlink(p) + if lerr != nil { + return lerr + } + fmt.Fprintf(h, "link=%s\n", target) + case info.Mode().IsRegular(): + data, derr := os.ReadFile(p) + if derr != nil { + return derr + } + h.Write(data) + } + return nil + }) + if err != nil { + t.Fatalf("hash tree %s: %v", path, err) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// abandonRegistered allocates a candidate, registers its detached worktree at +// commit, then releases the live lock — the exact shape of a normally abandoned +// candidate a crashed run left behind: valid manifest, released lock, registered +// worktree. It returns the candidate. +func abandonRegistered(t *testing.T, client *gitcli.Client, repo gitcli.Repository, r *testRepos, commit gitcli.ObjectID) *candidate { + t.Helper() + c, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) + if err != nil { + t.Fatalf("allocateCandidate: %v", err) + } + if err := client.AddDetachedWorktree(context.Background(), repo, c.worktree, commit); err != nil { + _ = c.live.release() + t.Fatalf("AddDetachedWorktree: %v", err) + } + if err := c.live.release(); err != nil { + t.Fatalf("release live lock: %v", err) + } + return c +} + +// targetTip resolves the invocation's current target-ref commit — an ancestor of +// which is "already pushed" from recovery's point of view. +func targetTip(t *testing.T, r *testRepos) gitcli.ObjectID { + t.Helper() + return gitcli.ObjectID(hgitOut(t, r.Invocation, "rev-parse", string(r.Target))) +} + +// pruneEntryFor returns the report entry for id, failing if absent. +func pruneEntryFor(t *testing.T, rep PruneReport, id string) PruneEntry { + t.Helper() + for _, e := range rep.Entries { + if e.ID == id { + return e + } + } + t.Fatalf("no report entry for %q in %+v", id, rep.Entries) + return PruneEntry{} +} + +// assertGoneAndDeregistered proves the candidate directory is removed and its +// worktree is no longer registered with Git. +func assertGoneAndDeregistered(t *testing.T, client *gitcli.Client, repo gitcli.Repository, c *candidate) { + t.Helper() + if _, err := os.Stat(c.root); !os.IsNotExist(err) { + t.Errorf("candidate root still present: %v", err) + } + infos, err := client.ListWorktrees(context.Background(), repo) + if err != nil { + t.Fatalf("ListWorktrees: %v", err) + } + want := canonicalPath(c.worktree) + for _, info := range infos { + if canonicalPath(info.Path) == want { + t.Errorf("worktree still registered after prune: %s", info.Path) + } + } +} + +// headCommit resolves the invocation repo's current HEAD commit for a worktree add. +func headCommit(t *testing.T, repo gitcli.Repository) gitcli.ObjectID { + t.Helper() + return gitcli.ObjectID(hgitOut(t, repo.PrimaryWorktree, "rev-parse", "HEAD")) +} diff --git a/internal/repository/transaction/recovery_test.go b/internal/repository/transaction/recovery_test.go deleted file mode 100644 index 6888e1ccd..000000000 --- a/internal/repository/transaction/recovery_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package transaction - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io/fs" - "os" - "path/filepath" - "testing" - "time" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file is the ownership-and-recovery matrix for PruneAbandoned. Every -// scenario runs against a real Git topology so the six-point proof is exercised -// against actual worktree registrations, real HEADs, and real flocks — never a -// fake. The single invariant under test: PruneAbandoned removes ONLY candidates -// that pass ALL SIX ownership checks and leaves everything else byte-untouched -// with a verdict, never resetting a branch, deleting a ref, or globally pruning. - -// recoveryEngine discovers the invocation repository and builds an Engine over the -// real git client and the pinned engine clock. -func recoveryEngine(t *testing.T, r *testRepos) (*Engine, *gitcli.Client, gitcli.Repository) { - t.Helper() - client, repo := r.discover(t) - eng, err := NewEngine(client, engineClock) - if err != nil { - t.Fatalf("NewEngine: %v", err) - } - return eng, client, repo -} - -// baseValidManifest returns a fully valid manifest for id whose repository identity -// is commonDir and whose target is refs/heads/main. Individual tests corrupt one -// field to exercise a single failing check while every other field stays canonical. -func baseValidManifest(id, commonDir string) manifest { - stamp := txnTestClock.Now().UTC().Format(time.RFC3339) - return manifest{ - Schema: manifestSchemaVersion, - TransactionID: id, - CommonDir: commonDir, - Remote: "origin", - TargetRef: "refs/heads/main", - BaseCommit: fixedBase, - WorktreeRel: worktreeDirName, - Phase: phaseAllocating, - CreatedUTC: stamp, - UpdatedUTC: stamp, - PID: os.Getpid(), - } -} - -// mkOwnedDir creates an owned-shape candidate directory (0700) under repo's -// transactions root and returns its path. It ensures the root exists first. -func mkOwnedDir(t *testing.T, repo gitcli.Repository, id string) string { - t.Helper() - root := transactionsRoot(repo) - if err := ensureTransactionsRoot(root); err != nil { - t.Fatalf("ensure transactions root: %v", err) - } - candRoot := filepath.Join(root, id) - if err := mkdirMode(candRoot, txnDirMode); err != nil { - t.Fatalf("mkdir candidate: %v", err) - } - return candRoot -} - -// hexID returns a valid 32-hex transaction id built from a distinguishing prefix -// so table rows sort deterministically and read clearly in failures. -func hexID(prefix string) string { - id := prefix - for len(id) < 32 { - id += "0" - } - return id[:32] -} - -// hashTree returns a content hash of the entire tree rooted at path: every entry's -// relative name, type, and permission bits, plus regular-file contents and symlink -// targets. filepath.WalkDir never follows symlinks, so a symlinked root hashes as -// the link itself. A byte-untouched survival must produce an identical hash. -func hashTree(t *testing.T, path string) string { - t.Helper() - h := sha256.New() - err := filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, rerr := filepath.Rel(path, p) - if rerr != nil { - return rerr - } - info, ierr := os.Lstat(p) - if ierr != nil { - return ierr - } - fmt.Fprintf(h, "path=%s type=%v perm=%o\n", rel, info.Mode()&os.ModeType, info.Mode().Perm()) - switch { - case info.Mode()&os.ModeSymlink != 0: - target, lerr := os.Readlink(p) - if lerr != nil { - return lerr - } - fmt.Fprintf(h, "link=%s\n", target) - case info.Mode().IsRegular(): - data, derr := os.ReadFile(p) - if derr != nil { - return derr - } - h.Write(data) - } - return nil - }) - if err != nil { - t.Fatalf("hash tree %s: %v", path, err) - } - return hex.EncodeToString(h.Sum(nil)) -} - -// abandonRegistered allocates a candidate, registers its detached worktree at -// commit, then releases the live lock — the exact shape of a normally abandoned -// candidate a crashed run left behind: valid manifest, released lock, registered -// worktree. It returns the candidate. -func abandonRegistered(t *testing.T, client *gitcli.Client, repo gitcli.Repository, r *testRepos, commit gitcli.ObjectID) *candidate { - t.Helper() - c, err := allocateCandidate(txnTestClock, repo, "origin", r.Target, fixedBase) - if err != nil { - t.Fatalf("allocateCandidate: %v", err) - } - if err := client.AddDetachedWorktree(context.Background(), repo, c.worktree, commit); err != nil { - _ = c.live.release() - t.Fatalf("AddDetachedWorktree: %v", err) - } - if err := c.live.release(); err != nil { - t.Fatalf("release live lock: %v", err) - } - return c -} - -// targetTip resolves the invocation's current target-ref commit — an ancestor of -// which is "already pushed" from recovery's point of view. -func targetTip(t *testing.T, r *testRepos) gitcli.ObjectID { - t.Helper() - return gitcli.ObjectID(hgitOut(t, r.Invocation, "rev-parse", string(r.Target))) -} - -// pruneEntryFor returns the report entry for id, failing if absent. -func pruneEntryFor(t *testing.T, rep PruneReport, id string) PruneEntry { - t.Helper() - for _, e := range rep.Entries { - if e.ID == id { - return e - } - } - t.Fatalf("no report entry for %q in %+v", id, rep.Entries) - return PruneEntry{} -} - -// assertGoneAndDeregistered proves the candidate directory is removed and its -// worktree is no longer registered with Git. -func assertGoneAndDeregistered(t *testing.T, client *gitcli.Client, repo gitcli.Repository, c *candidate) { - t.Helper() - if _, err := os.Stat(c.root); !os.IsNotExist(err) { - t.Errorf("candidate root still present: %v", err) - } - infos, err := client.ListWorktrees(context.Background(), repo) - if err != nil { - t.Fatalf("ListWorktrees: %v", err) - } - want := canonicalPath(c.worktree) - for _, info := range infos { - if canonicalPath(info.Path) == want { - t.Errorf("worktree still registered after prune: %s", info.Path) - } - } -} - -// headCommit resolves the invocation repo's current HEAD commit for a worktree add. -func headCommit(t *testing.T, repo gitcli.Repository) gitcli.ObjectID { - t.Helper() - return gitcli.ObjectID(hgitOut(t, repo.PrimaryWorktree, "rev-parse", "HEAD")) -} From dbe332cbd7a9aeb5ee890282f1f6f3fb2c39d6ac Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:27:41 -0400 Subject: [PATCH 07/16] test(workspace): move Prepare real-git tests behind the integration tag (TestIntegrationWorkspaceSetup, TestRaceIntegrationWorkspace, change 0466) --- .../workspace/harness_integration_test.go | 46 + internal/workspace/harness_test.go | 38 - .../workspace/prepare_integration_test.go | 736 ++++++++++++++++ .../prepare_race_integration_test.go | 107 +++ internal/workspace/prepare_test.go | 816 ------------------ tests/runtime-budgets.tsv | 2 + tests/test_go_integration_workspace_race.sh | 24 + tests/test_go_integration_workspace_setup.sh | 24 + 8 files changed, 939 insertions(+), 854 deletions(-) create mode 100644 internal/workspace/harness_integration_test.go create mode 100644 internal/workspace/prepare_integration_test.go create mode 100644 internal/workspace/prepare_race_integration_test.go create mode 100755 tests/test_go_integration_workspace_race.sh create mode 100755 tests/test_go_integration_workspace_setup.sh diff --git a/internal/workspace/harness_integration_test.go b/internal/workspace/harness_integration_test.go new file mode 100644 index 000000000..61f1087e3 --- /dev/null +++ b/internal/workspace/harness_integration_test.go @@ -0,0 +1,46 @@ +//go:build integration + +package workspace + +import ( + "strings" + "testing" +) + +// TestIntegrationWorkspaceSetupHarnessBuildersProduceExpectedTopology is the harness self-test: it proves +// each builder produces the topology the Prepare tests depend on. +func TestIntegrationWorkspaceSetupHarnessBuildersProduceExpectedTopology(t *testing.T) { + requireGit(t) + + t.Run("main", func(t *testing.T) { + r := mainModeRepo(t) + if got := gitOut(t, r.Origin, "rev-parse", "--is-bare-repository"); got != "true" { + t.Errorf("origin is-bare-repository = %q, want true", got) + } + if got := gitOut(t, r.Primary, "status", "--porcelain"); got != "" { + t.Errorf("primary status not clean:\n%s", got) + } + if n := countWorktrees(gitOut(t, r.Primary, "worktree", "list", "--porcelain")); n != 1 { + t.Errorf("registered worktrees = %d, want 1", n) + } + }) + + t.Run("docket", func(t *testing.T) { + r := docketModeRepo(t) + if got := gitOut(t, r.Primary, "status", "--porcelain"); got != "" { + t.Errorf("docket primary status not clean:\n%s", got) + } + wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if n := countWorktrees(wl); n != 4 { + t.Errorf("registered worktrees = %d, want 4 (primary + .docket + txn + sibling):\n%s", n, wl) + } + for _, want := range []string{"refs/heads/docket", "refs/heads/feat/other"} { + if !strings.Contains(wl, want) { + t.Errorf("worktree list missing %q:\n%s", want, wl) + } + } + if len(r.Preserve) != 4 { + t.Errorf("Preserve = %d worktrees, want 4", len(r.Preserve)) + } + }) +} diff --git a/internal/workspace/harness_test.go b/internal/workspace/harness_test.go index 2513fb933..a1c8849d8 100644 --- a/internal/workspace/harness_test.go +++ b/internal/workspace/harness_test.go @@ -377,44 +377,6 @@ func eachTopology(t *testing.T, fn func(t *testing.T, r *wsRepos)) { t.Run("docket", func(t *testing.T) { fn(t, docketModeRepo(t)) }) } -// TestHarnessBuildersProduceExpectedTopology is the harness self-test: it proves -// each builder produces the topology the Prepare tests depend on. -func TestHarnessBuildersProduceExpectedTopology(t *testing.T) { - requireGit(t) - - t.Run("main", func(t *testing.T) { - r := mainModeRepo(t) - if got := gitOut(t, r.Origin, "rev-parse", "--is-bare-repository"); got != "true" { - t.Errorf("origin is-bare-repository = %q, want true", got) - } - if got := gitOut(t, r.Primary, "status", "--porcelain"); got != "" { - t.Errorf("primary status not clean:\n%s", got) - } - if n := countWorktrees(gitOut(t, r.Primary, "worktree", "list", "--porcelain")); n != 1 { - t.Errorf("registered worktrees = %d, want 1", n) - } - }) - - t.Run("docket", func(t *testing.T) { - r := docketModeRepo(t) - if got := gitOut(t, r.Primary, "status", "--porcelain"); got != "" { - t.Errorf("docket primary status not clean:\n%s", got) - } - wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if n := countWorktrees(wl); n != 4 { - t.Errorf("registered worktrees = %d, want 4 (primary + .docket + txn + sibling):\n%s", n, wl) - } - for _, want := range []string{"refs/heads/docket", "refs/heads/feat/other"} { - if !strings.Contains(wl, want) { - t.Errorf("worktree list missing %q:\n%s", want, wl) - } - } - if len(r.Preserve) != 4 { - t.Errorf("Preserve = %d worktrees, want 4", len(r.Preserve)) - } - }) -} - // countWorktrees counts "worktree " stanza headers in porcelain output. func countWorktrees(porcelain string) int { n := 0 diff --git a/internal/workspace/prepare_integration_test.go b/internal/workspace/prepare_integration_test.go new file mode 100644 index 000000000..8dad1e14f --- /dev/null +++ b/internal/workspace/prepare_integration_test.go @@ -0,0 +1,736 @@ +//go:build integration + +package workspace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestIntegrationWorkspaceSetupPrepareFreshUnstacked prepares an unstacked target whose base is the +// fetched integration branch. Origin main is advanced after the clone, leaving +// the primary's origin/main tracking ref stale; the prepared base equals +// origin's CURRENT commit, proving Prepare fetched rather than trusting the +// cached ref. Preservation of every uninvolved worktree is asserted. +func TestIntegrationWorkspaceSetupPrepareFreshUnstacked(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + + stale := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "origin/main")) + current := r.advanceMain(t) + if stale == current { + t.Fatalf("advanceMain did not move origin main (fixture bug)") + } + // The tracking ref is still stale until Prepare fetches. + if got := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "origin/main")); got != stale { + t.Fatalf("origin/main tracking ref = %q; want stale %q before Prepare", got, stale) + } + + base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + + before := r.snapshotAll(t) + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + assertFreshCreated(t, r, repo, ws, current) + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareFreshLiveParentStack prepares a target stacked on a live parent +// whose remote branch is the resolved base; the workspace starts at that +// parent branch's commit, not integration. +func TestIntegrationWorkspaceSetupPrepareFreshLiveParentStack(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + parentTip := r.pushBranch(t, "feat/five", "main") + svc, repo := r.newService(t) + + base := resolveBase(t, []domain.ChangeSpec{ + {ID: 5, Status: domain.StatusInProgress, Branch: present("feat/five")}, + {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, + }, []string{"feat/five"}, 7) + if base.Branch != "feat/five" { + t.Fatalf("resolved base branch = %q; want feat/five", base.Branch) + } + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + + before := r.snapshotAll(t) + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if ws.BaseRef != gitcli.RefName("refs/heads/feat/five") { + t.Errorf("BaseRef = %q; want refs/heads/feat/five", ws.BaseRef) + } + assertFreshCreated(t, r, repo, ws, parentTip) + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareFreshDoneParent prepares a target stacked on a DONE parent, which +// resolves terminally to the integration branch: the workspace starts at +// origin main, not at the parent branch. +func TestIntegrationWorkspaceSetupPrepareFreshDoneParent(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + r.pushBranch(t, "feat/five", "main") // exists remotely but is bypassed by rule 3 + svc, repo := r.newService(t) + mainTip := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "main")) + + base := resolveBase(t, []domain.ChangeSpec{ + {ID: 5, Status: domain.StatusDone, Branch: present("feat/five")}, + {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, + }, []string{"feat/five"}, 7) + if base.Branch != "main" { + t.Fatalf("resolved base branch = %q; want main (done parent -> integration)", base.Branch) + } + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + + before := r.snapshotAll(t) + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + assertFreshCreated(t, r, repo, ws, mainTip) + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareFreshStackedMergedRecurse prepares a target whose immediate parent +// is a branchless stacked-merged change; resolution recurses through it to the +// grandparent's branch, and the workspace starts there. +func TestIntegrationWorkspaceSetupPrepareFreshStackedMergedRecurse(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + grandTip := r.pushBranch(t, "feat/four", "main") + svc, repo := r.newService(t) + + base := resolveBase(t, []domain.ChangeSpec{ + {ID: 4, Status: domain.StatusInProgress, Branch: present("feat/four")}, + {ID: 5, Status: domain.StatusStackedMerged, StackedOn: parentOf(4)}, + {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, + }, []string{"feat/four"}, 7) + if base.Branch != "feat/four" { + t.Fatalf("resolved base branch = %q; want feat/four", base.Branch) + } + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + + before := r.snapshotAll(t) + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + assertFreshCreated(t, r, repo, ws, grandTip) + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareReturnsReinspectedFacts proves the returned HeadCommit is a value +// read back from Git after creation (the branch tip via ResolveRef), not an +// echo of the requested base — they are equal here, and the assertion pins that +// the reported head is the branch's actual current commit. +func TestIntegrationWorkspaceSetupPrepareReturnsReinspectedFacts(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + + base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + wantHead := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))) + if ws.HeadCommit != wantHead { + t.Errorf("HeadCommit = %q; want branch tip %q read back via git", ws.HeadCommit, wantHead) + } +} + +// TestIntegrationWorkspaceSetupPrepareRejectsMismatchedRepository proves an inconsistent Repository (a +// CommonDir belonging to a different repository) is rejected as invalid-input at +// the validate stage, before any directory or branch is created. +func TestIntegrationWorkspaceSetupPrepareRejectsMismatchedRepository(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + + other := mainModeRepo(t) + _, otherRepo := other.newService(t) + + bad := repo + bad.CommonDir = otherRepo.CommonDir + + base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + _, err = svc.Prepare(context.Background(), PrepareRequest{Repository: bad, Remote: "origin", Target: tgt}) + if err == nil { + t.Fatalf("Prepare with mismatched repository = nil error; want rejection") + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindInvalidInput { + t.Errorf("Kind = %q; want %q", f.Kind, KindInvalidInput) + } + if f.Stage != "validate" { + t.Errorf("Stage = %q; want validate", f.Stage) + } + assertNothingCreated(t, r, repo.CommonDir) +} + +// TestIntegrationWorkspaceSetupPrepareFetchFailureCreatesNothing proves a base branch that is absent on +// the remote fails the fetch with an external failure and leaves no checkout, +// branch, or manifest behind (fetch precedes any manifest publication). +func TestIntegrationWorkspaceSetupPrepareFetchFailureCreatesNothing(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + + // A resolved base whose branch never exists on the remote. + base := domain.EffectiveBase{Kind: domain.BaseResolved, Branch: "ghostbase"} + tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + + before := r.snapshotAll(t) + _, err = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err == nil { + t.Fatalf("Prepare against absent remote base = nil error; want failure") + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindExternal { + t.Errorf("Kind = %q; want %q", f.Kind, KindExternal) + } + if f.Stage != "fetch" { + t.Errorf("Stage = %q; want fetch", f.Stage) + } + assertNothingCreated(t, r, repo.CommonDir) + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareInvocationMatrix is the CWD/symlink invocation matrix (spec +// §"Real-Git workspace matrix" first bullet). It seeds gitcli.Discover from five +// spellings of the SAME repository — the primary checkout, inside `.docket/`, +// inside another feature worktree, a nested subdirectory, and a symlinked +// spelling of the primary path — and requires every one to resolve ONE canonical +// repository identity and therefore ONE canonical workspace location: the same +// hashed metadata directory and the same checkout path, with the first Prepare +// creating it and every later invocation adopting it as existing. The workspace +// location is derived from Repository.PrimaryWorktree, never from CWD, so a +// caller's directory can never fork it into a second checkout. +func TestIntegrationWorkspaceSetupPrepareInvocationMatrix(t *testing.T) { + requireGit(t) + r := docketModeRepo(t) // the topology carrying `.docket/` and a sibling feature worktree + ctx := context.Background() + + c, err := gitcli.NewClient() + if err != nil { + t.Fatalf("gitcli.NewClient: %v", err) + } + svc, err := NewService(c) + if err != nil { + t.Fatalf("NewService: %v", err) + } + tgt := freshTarget(t, 7) + + // A real nested subdirectory beneath the primary checkout (an empty directory + // is invisible to git status, so it does not perturb any preservation proof). + nested := filepath.Join(r.Primary, "sub", "deep") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + // A symlinked spelling of the primary path, in a separate temp root, so the + // invocation path differs from the canonical one by a real symlink hop (on top + // of the macOS /tmp -> /private/tmp hop testsupport.TempDir(t) already provides). + linkParent := testsupport.TempDir(t) + link := filepath.Join(linkParent, "primary-link") + if err := os.Symlink(r.Primary, link); err != nil { + t.Fatal(err) + } + + invocations := []struct{ name, path string }{ + {"primary", r.Primary}, + {"dot-docket", filepath.Join(r.Primary, ".docket")}, + {"sibling-feature-worktree", filepath.Join(r.Primary, ".worktrees", "other")}, + {"nested-subdir", nested}, + {"symlinked-primary", link}, + } + + var canonical gitcli.Repository + var wantPath, wantDir string + for i, inv := range invocations { + repo, err := c.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: inv.path}) + if err != nil { + t.Fatalf("Discover from %s (%s): %v", inv.name, inv.path, err) + } + if i == 0 { + canonical = repo + wantPath = filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) + wantDir = workspaceDir(repo.CommonDir, tgt.FeatureRef) + } else if repo != canonical { + t.Errorf("Discover from %s resolved %+v; want the canonical identity %+v", inv.name, repo, canonical) + } + + ws, err := svc.Prepare(ctx, PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare from %s: %v", inv.name, err) + } + wantDisp := PrepareExisting + if i == 0 { + wantDisp = PrepareCreated + } + if ws.Disposition != wantDisp { + t.Errorf("Prepare from %s: Disposition = %q; want %q", inv.name, ws.Disposition, wantDisp) + } + if ws.Path != wantPath { + t.Errorf("Prepare from %s: Path = %q; want the one canonical location %q", inv.name, ws.Path, wantPath) + } + if got := workspaceDir(repo.CommonDir, tgt.FeatureRef); got != wantDir { + t.Errorf("Prepare from %s: manifest dir = %q; want %q", inv.name, got, wantDir) + } + } + + // Exactly one feature worktree was registered across all five invocations, and + // exactly one ready manifest exists at the single canonical location. + wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if n := countWorktreePathOccurrences(wl, wantPath); n != 1 { + t.Errorf("feature worktree registered %d times; want exactly one canonical registration:\n%s", n, wl) + } + m, present, err := loadManifest(wantDir) + if err != nil || !present { + t.Fatalf("loadManifest(%s): present=%v err=%v; want one present manifest", wantDir, present, err) + } + if m.Phase != PhaseReady { + t.Errorf("manifest phase = %q; want ready", m.Phase) + } +} + +// TestIntegrationWorkspaceSetupPrepareExistingIdempotent proves a second Prepare on a ready workspace +// returns `existing` and mutates nothing: commits, staged bytes, dirty tracked +// bytes, and untracked files created between the two calls all survive +// byte-identically, and Dirty is reported true, never repaired. +func TestIntegrationWorkspaceSetupPrepareExistingIdempotent(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + first := prepareOK(t, svc, repo, tgt) + if first.Disposition != PrepareCreated { + t.Fatalf("first Prepare disposition = %q; want created", first.Disposition) + } + ws := wsPathOf(repo) + + // Mutate the workspace between calls: a commit, a staged file, a dirty + // tracked file, and an untracked file. + writeWorktreeFile(t, ws, "feature.go", "package feature\n") + gitOut(t, ws, "add", "feature.go") + gitOut(t, ws, "commit", "-q", "-m", "feature work") + committedTip := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + + writeWorktreeFile(t, ws, "staged.txt", "staged bytes\n") + gitOut(t, ws, "add", "staged.txt") + writeWorktreeFile(t, ws, "main.go", "package main // dirtied\n") + writeWorktreeFile(t, ws, "untracked.txt", "untracked bytes\n") + + beforeWs := snapshotTree(t, ws) + beforePreserve := r.snapshotAll(t) + + second, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("second Prepare: %v", err) + } + if second.Disposition != PrepareExisting { + t.Errorf("second Prepare disposition = %q; want existing", second.Disposition) + } + if !second.Dirty { + t.Errorf("second Prepare Dirty = false; want true (dirty reported)") + } + if second.HeadCommit != committedTip { + t.Errorf("HeadCommit = %q; want committed tip %q", second.HeadCommit, committedTip) + } + if second.BaseCommit != first.BaseCommit { + t.Errorf("BaseCommit = %q; want recorded %q", second.BaseCommit, first.BaseCommit) + } + + // Nothing repaired: the workspace is byte-identical, and every uninvolved + // worktree is unchanged. Manifest is still ready (not rewritten to nonsense). + assertUnchanged(t, beforeWs, ws) + r.assertAllUnchanged(t, beforePreserve) + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } + }) +} + +// TestIntegrationWorkspaceSetupPrepareResumeCreateBoth is interrupted-allocation resume arm (i): an +// allocating manifest with no branch and no worktree. Resume creates both at the +// recorded base and advances to ready as `resumed`. +func TestIntegrationWorkspaceSetupPrepareResumeCreateBoth(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + + writeStateManifest(t, repo, tgt, base, PhaseAllocating) + if branchExists(r.Primary, "feat/"+prepSlug) { + t.Fatalf("fixture: branch already exists") + } + + before := r.snapshotAll(t) + ws := prepareOK(t, svc, repo, tgt) + if ws.Disposition != PrepareResumed { + t.Errorf("disposition = %q; want resumed", ws.Disposition) + } + if ws.BaseCommit != base { + t.Errorf("BaseCommit = %q; want %q", ws.BaseCommit, base) + } + if got := localBranchTip(t, r); got != base { + t.Errorf("branch tip = %q; want base %q", got, base) + } + if got := symbolicHead(t, wsPathOf(repo)); got != string(prepFeatureRef()) { + t.Errorf("workspace symbolic HEAD = %q; want %q", got, prepFeatureRef()) + } + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareResumeAttach is resume arm (ii): an allocating manifest and a +// branch already at the recorded base, but no worktree. Resume attaches the +// existing branch and NEVER moves its tip, even though origin advanced meanwhile. +func TestIntegrationWorkspaceSetupPrepareResumeAttach(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + + writeStateManifest(t, repo, tgt, base, PhaseAllocating) + gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(base)) + + // Origin moves forward after the branch was created; resume must not follow. + moved := r.advanceMain(t) + if moved == base { + t.Fatalf("advanceMain did not move origin (fixture bug)") + } + + before := r.snapshotAll(t) + ws := prepareOK(t, svc, repo, tgt) + if ws.Disposition != PrepareResumed { + t.Errorf("disposition = %q; want resumed", ws.Disposition) + } + if got := localBranchTip(t, r); got != base { + t.Errorf("branch tip = %q; want unchanged base %q (attach must not move it)", got, base) + } + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), wsPathOf(repo)) { + t.Errorf("worktree not registered after attach resume") + } + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceSetupPrepareResumeVerifyOnly is resume arm (iii): an allocating manifest with +// the branch AND a registered worktree already present, carrying a post-creation +// commit and dirty bytes. Resume verifies and advances to ready only; the commit +// and dirty bytes survive. +func TestIntegrationWorkspaceSetupPrepareResumeVerifyOnly(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + ws := wsPathOf(repo) + + writeStateManifest(t, repo, tgt, base, PhaseAllocating) + gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(base)) + gitOut(t, r.Primary, "worktree", "add", "--", ws, "feat/"+prepSlug) + + // Post-creation commit and dirty bytes. + writeWorktreeFile(t, ws, "resumed.go", "package resumed\n") + gitOut(t, ws, "add", "resumed.go") + gitOut(t, ws, "commit", "-q", "-m", "post-creation commit") + postTip := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + writeWorktreeFile(t, ws, "dirty.txt", "dirty\n") + + beforeWs := snapshotTree(t, ws) + + out := prepareOK(t, svc, repo, tgt) + if out.Disposition != PrepareResumed { + t.Errorf("disposition = %q; want resumed", out.Disposition) + } + if out.HeadCommit != postTip { + t.Errorf("HeadCommit = %q; want post-creation tip %q", out.HeadCommit, postTip) + } + if !out.Dirty { + t.Errorf("Dirty = false; want true (post-creation dirty reported)") + } + if got := localBranchTip(t, r); got != postTip { + t.Errorf("branch tip = %q; want post-creation %q (commit preserved)", got, postTip) + } + assertUnchanged(t, beforeWs, ws) + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } + }) +} + +// TestIntegrationWorkspaceSetupPrepareResumeBranchOffBaseBlocked is blocked case (c): a branch created by +// this manifest that no longer contains the recorded base commit (the base is a +// commit the branch does not reach). Prepare is blocked and byte-untouched: the +// branch is never reset and no worktree is created. +func TestIntegrationWorkspaceSetupPrepareResumeBranchOffBaseBlocked(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + c0 := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + // A base commit that the branch (at c0) does NOT contain: advance origin and + // fetch the new commit into the primary's object store, then record it as base. + c1 := r.advanceMain(t) + gitOut(t, r.Primary, "fetch", "-q", "origin", "main") + if c1 == c0 { + t.Fatalf("advanceMain did not move origin (fixture bug)") + } + + writeStateManifest(t, repo, tgt, c1, PhaseAllocating) + gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(c0)) // branch at c0, base is c1 + + before := r.snapshotAll(t) + beforeManifest := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) + + out := prepareOK(t, svc, repo, tgt) + if out.Disposition != PrepareBlocked { + t.Errorf("disposition = %q; want blocked", out.Disposition) + } + if got := localBranchTip(t, r); got != c0 { + t.Errorf("branch tip = %q; want unchanged %q (never reset)", got, c0) + } + if _, err := os.Lstat(wsPathOf(repo)); !os.IsNotExist(err) { + t.Errorf("workspace path exists (%v); want none created", err) + } + if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeManifest { + t.Errorf("manifest bytes changed on blocked resume") + } + r.assertAllUnchanged(t, before) +} + +// TestIntegrationWorkspaceSetupPrepareBlockedMatrix walks the fresh-path blocked matrix: each colliding +// artifact with no matching manifest yields PrepareBlocked and is left +// byte-untouched (pre-Go in-flight work is never adopted). +func TestIntegrationWorkspaceSetupPrepareBlockedMatrix(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + t.Run("target-dir-no-manifest", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + writeWorktreeFile(t, wsPathOf(repo), "leftover.txt", "prior bytes\n") + collidePath := filepath.Join(wsPathOf(repo), "leftover.txt") + before := readFileBytes(t, collidePath) + + assertBlocked(t, svc, repo, tgt) + if after := readFileBytes(t, collidePath); after != before { + t.Errorf("colliding directory bytes changed") + } + assertNoManifest(t, repo, tgt) + }) + + t.Run("foreign-registration", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + // A foreign detached worktree squatting the target path. + gitOut(t, r.Primary, "worktree", "add", "--detach", "--", wsPathOf(repo), "main") + collidePath := filepath.Join(wsPathOf(repo), "main.go") + before := readFileBytes(t, collidePath) + + assertBlocked(t, svc, repo, tgt) + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), wsPathOf(repo)) { + t.Errorf("foreign registration removed; must be preserved (never force-removed)") + } + if after := readFileBytes(t, collidePath); after != before { + t.Errorf("foreign worktree bytes changed") + } + assertNoManifest(t, repo, tgt) + }) + + t.Run("local-branch-no-manifest", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + gitOut(t, r.Primary, "branch", "feat/"+prepSlug, "main") + before := localBranchTip(t, r) + + assertBlocked(t, svc, repo, tgt) + if got := localBranchTip(t, r); got != before { + t.Errorf("local branch tip changed %q -> %q", before, got) + } + assertNoManifest(t, repo, tgt) + }) + + t.Run("remote-branch-no-manifest", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + r.pushBranch(t, "feat/"+prepSlug, "main") + before := gitcli.ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/feat/"+prepSlug)) + + assertBlocked(t, svc, repo, tgt) + if got := gitcli.ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/feat/"+prepSlug)); got != before { + t.Errorf("remote branch tip changed %q -> %q", before, got) + } + if branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("remote branch was adopted locally; must not be") + } + assertNoManifest(t, repo, tgt) + }) + + t.Run("malformed-manifest", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + if err := os.MkdirAll(metaDirOf(repo, tgt), 0o700); err != nil { + t.Fatal(err) + } + mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) + if err := os.WriteFile(mpath, []byte("{ this is not json"), 0o600); err != nil { + t.Fatal(err) + } + before := readFileBytes(t, mpath) + + assertBlocked(t, svc, repo, tgt) + if after := readFileBytes(t, mpath); after != before { + t.Errorf("malformed manifest bytes changed") + } + }) + + t.Run("foreign-commondir-manifest", func(t *testing.T) { + r := freshTopology(t, r) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + other := mainModeRepo(t) + _, otherRepo := other.newService(t) + // A structurally valid manifest owned by a DIFFERENT repository. + foreign := Manifest{ + Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, + ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, + BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), + Path: wsPathOf(repo), Phase: PhaseReady, + CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), + } + if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { + t.Fatalf("writeManifest(foreign): %v", err) + } + mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) + before := readFileBytes(t, mpath) + + assertBlocked(t, svc, repo, tgt) + if after := readFileBytes(t, mpath); after != before { + t.Errorf("foreign-commondir manifest bytes changed") + } + }) + }) +} + +// TestIntegrationWorkspaceSetupPrepareFreshBlockedByStaleRegistration pins change 0368's fresh-allocation +// tightening: a worktree registration at the intended target path whose directory +// has been removed — a STALE registration — blocks a fresh allocation instead of +// being skip-matched into a silent create. Pre-change, inventoryForFresh proved +// registration absence via registeredAt, which SKIPS an uncanonicalizable path +// (the stale registration's directory is gone); classifyRegistrationAbsence now +// recognizes it fail-closed (regPresent lexically, or regUnresolved), so Prepare +// returns PrepareBlocked, force-removes nothing, and publishes no manifest. Every +// earlier fresh-path leg (local feature ref, remote ref, target path) is left +// clean, so the block is attributable to the registration alone. +func TestIntegrationWorkspaceSetupPrepareFreshBlockedByStaleRegistration(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + // Register a worktree at the intended path on a THROWAWAY branch (never the + // feature ref), then remove its directory: the registration survives in the + // git metadata while its path can no longer be canonicalized. + gitOut(t, r.Primary, "worktree", "add", "-b", "throwaway/stale-reg", wsPathOf(repo), "main") + if err := os.RemoveAll(wsPathOf(repo)); err != nil { + t.Fatal(err) + } + // The registration must still be recorded (RemoveAll does not prune it), or + // the fixture cannot exercise the stale arm. Match the raw recorded path — the + // containsWorktreePath helper canonicalizes, which fails on the removed dir. + beforeList := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if !strings.Contains(beforeList, "worktree "+wsPathOf(repo)+"\n") { + t.Fatalf("stale registration not recorded after RemoveAll; cannot exercise the stale arm:\n%s", beforeList) + } + + assertBlocked(t, svc, repo, tgt) + + // The stale registration is preserved (never force-removed) and no manifest + // was published — the fresh allocation is refused, byte-untouched. + afterList := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if !strings.Contains(afterList, "worktree "+wsPathOf(repo)+"\n") { + t.Errorf("stale registration removed; must be preserved (never force-removed):\n%s", afterList) + } + assertNoManifest(t, repo, tgt) +} + +// TestIntegrationWorkspaceSetupPrepareProbeFailureCreatesNothing injects a probe failure at the remote +// feature-ref inventory step: a git wrapper that fails `ls-remote` (the only +// inventory probe used by no earlier Prepare step, so identity discovery, base +// fetch, and the local-ref probe all still succeed and the failure lands exactly +// at ProbeRemoteBranch). An errored probe is an external failure, NEVER clean +// absence, so nothing is created (learnings: probe-error-is-not-clean-absence). +func TestIntegrationWorkspaceSetupPrepareProbeFailureCreatesNothing(t *testing.T) { + r := mainModeRepo(t) + fakeGit := writeFailingGit(t, "ls-remote") + svc, repo := r.newServiceWithGit(t, fakeGit) + tgt := freshTarget(t, 7) + + before := r.snapshotAll(t) + _, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err == nil { + t.Fatalf("Prepare with failing ls-remote = nil error; want external failure") + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindExternal { + t.Errorf("Kind = %q; want external", f.Kind) + } + if f.Stage != "inventory" { + t.Errorf("Stage = %q; want inventory", f.Stage) + } + assertNothingCreated(t, r, repo.CommonDir) + r.assertAllUnchanged(t, before) +} diff --git a/internal/workspace/prepare_race_integration_test.go b/internal/workspace/prepare_race_integration_test.go new file mode 100644 index 000000000..c1f310237 --- /dev/null +++ b/internal/workspace/prepare_race_integration_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package workspace + +import ( + "context" + "sync" + "testing" + + "github.com/danielhanold/docket/internal/domain" +) + +// TestRaceIntegrationWorkspacePrepareConcurrentSameTarget proves two Prepares of the SAME target +// serialize on the operation lock and yield exactly one created plus one +// existing, with exactly one branch and one registration. +// Race shard (change 0466): concurrent Prepare calls race for one target. +func TestRaceIntegrationWorkspacePrepareConcurrentSameTarget(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + var wg sync.WaitGroup + results := make([]Workspace, 2) + errs := make([]error, 2) + start := make(chan struct{}) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + results[i], errs[i] = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + }(i) + } + close(start) + wg.Wait() + + created, existing := 0, 0 + for i := 0; i < 2; i++ { + if errs[i] != nil { + t.Fatalf("goroutine %d Prepare: %v", i, errs[i]) + } + switch results[i].Disposition { + case PrepareCreated: + created++ + case PrepareExisting, PrepareResumed: + existing++ + default: + t.Errorf("goroutine %d disposition = %q; want created/existing/resumed", i, results[i].Disposition) + } + } + if created != 1 || existing != 1 { + t.Errorf("dispositions: created=%d existing/resumed=%d; want 1 and 1", created, existing) + } + + // Exactly one branch and one registration for the target. + wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if n := countPathOccurrences(wl, wsPathOf(repo)); n != 1 { + t.Errorf("registrations at target path = %d; want 1", n) + } + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch missing after concurrent prepare") + } +} + +// TestRaceIntegrationWorkspacePrepareConcurrentDistinctTargets proves two Prepares of DIFFERENT targets +// proceed concurrently (distinct operation locks) and both create successfully. +// Race shard (change 0466): concurrent Prepare calls for distinct targets share one primary clone. +func TestRaceIntegrationWorkspacePrepareConcurrentDistinctTargets(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + + baseA := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) + tgtA, err := NewTarget(7, "alpha-slug", baseA, "feat/alpha-slug") + if err != nil { + t.Fatalf("NewTarget A: %v", err) + } + baseB := resolveBase(t, []domain.ChangeSpec{{ID: 8, Status: domain.StatusProposed}}, nil, 8) + tgtB, err := NewTarget(8, "beta-slug", baseB, "feat/beta-slug") + if err != nil { + t.Fatalf("NewTarget B: %v", err) + } + + var wg sync.WaitGroup + var outA, outB Workspace + var errA, errB error + start := make(chan struct{}) + wg.Add(2) + go func() { + defer wg.Done() + <-start + outA, errA = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgtA}) + }() + go func() { + defer wg.Done() + <-start + outB, errB = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgtB}) + }() + close(start) + wg.Wait() + + if errA != nil || errB != nil { + t.Fatalf("distinct-target Prepare errors: A=%v B=%v", errA, errB) + } + if outA.Disposition != PrepareCreated || outB.Disposition != PrepareCreated { + t.Errorf("dispositions A=%q B=%q; want both created", outA.Disposition, outB.Disposition) + } +} diff --git a/internal/workspace/prepare_test.go b/internal/workspace/prepare_test.go index 5231e1c28..884422899 100644 --- a/internal/workspace/prepare_test.go +++ b/internal/workspace/prepare_test.go @@ -4,8 +4,6 @@ import ( "context" "os" "path/filepath" - "strings" - "sync" "testing" "time" @@ -167,228 +165,6 @@ func cutPrefix(s, prefix string) (string, bool) { return "", false } -// TestPrepareFreshUnstacked prepares an unstacked target whose base is the -// fetched integration branch. Origin main is advanced after the clone, leaving -// the primary's origin/main tracking ref stale; the prepared base equals -// origin's CURRENT commit, proving Prepare fetched rather than trusting the -// cached ref. Preservation of every uninvolved worktree is asserted. -func TestPrepareFreshUnstacked(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - - stale := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "origin/main")) - current := r.advanceMain(t) - if stale == current { - t.Fatalf("advanceMain did not move origin main (fixture bug)") - } - // The tracking ref is still stale until Prepare fetches. - if got := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "origin/main")); got != stale { - t.Fatalf("origin/main tracking ref = %q; want stale %q before Prepare", got, stale) - } - - base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - - before := r.snapshotAll(t) - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - assertFreshCreated(t, r, repo, ws, current) - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareFreshLiveParentStack prepares a target stacked on a live parent -// whose remote branch is the resolved base; the workspace starts at that -// parent branch's commit, not integration. -func TestPrepareFreshLiveParentStack(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - parentTip := r.pushBranch(t, "feat/five", "main") - svc, repo := r.newService(t) - - base := resolveBase(t, []domain.ChangeSpec{ - {ID: 5, Status: domain.StatusInProgress, Branch: present("feat/five")}, - {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, - }, []string{"feat/five"}, 7) - if base.Branch != "feat/five" { - t.Fatalf("resolved base branch = %q; want feat/five", base.Branch) - } - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - - before := r.snapshotAll(t) - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - if ws.BaseRef != gitcli.RefName("refs/heads/feat/five") { - t.Errorf("BaseRef = %q; want refs/heads/feat/five", ws.BaseRef) - } - assertFreshCreated(t, r, repo, ws, parentTip) - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareFreshDoneParent prepares a target stacked on a DONE parent, which -// resolves terminally to the integration branch: the workspace starts at -// origin main, not at the parent branch. -func TestPrepareFreshDoneParent(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - r.pushBranch(t, "feat/five", "main") // exists remotely but is bypassed by rule 3 - svc, repo := r.newService(t) - mainTip := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "main")) - - base := resolveBase(t, []domain.ChangeSpec{ - {ID: 5, Status: domain.StatusDone, Branch: present("feat/five")}, - {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, - }, []string{"feat/five"}, 7) - if base.Branch != "main" { - t.Fatalf("resolved base branch = %q; want main (done parent -> integration)", base.Branch) - } - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - - before := r.snapshotAll(t) - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - assertFreshCreated(t, r, repo, ws, mainTip) - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareFreshStackedMergedRecurse prepares a target whose immediate parent -// is a branchless stacked-merged change; resolution recurses through it to the -// grandparent's branch, and the workspace starts there. -func TestPrepareFreshStackedMergedRecurse(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - grandTip := r.pushBranch(t, "feat/four", "main") - svc, repo := r.newService(t) - - base := resolveBase(t, []domain.ChangeSpec{ - {ID: 4, Status: domain.StatusInProgress, Branch: present("feat/four")}, - {ID: 5, Status: domain.StatusStackedMerged, StackedOn: parentOf(4)}, - {ID: 7, Status: domain.StatusProposed, StackedOn: parentOf(5)}, - }, []string{"feat/four"}, 7) - if base.Branch != "feat/four" { - t.Fatalf("resolved base branch = %q; want feat/four", base.Branch) - } - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - - before := r.snapshotAll(t) - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - assertFreshCreated(t, r, repo, ws, grandTip) - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareReturnsReinspectedFacts proves the returned HeadCommit is a value -// read back from Git after creation (the branch tip via ResolveRef), not an -// echo of the requested base — they are equal here, and the assertion pins that -// the reported head is the branch's actual current commit. -func TestPrepareReturnsReinspectedFacts(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - - base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - wantHead := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))) - if ws.HeadCommit != wantHead { - t.Errorf("HeadCommit = %q; want branch tip %q read back via git", ws.HeadCommit, wantHead) - } -} - -// TestPrepareRejectsMismatchedRepository proves an inconsistent Repository (a -// CommonDir belonging to a different repository) is rejected as invalid-input at -// the validate stage, before any directory or branch is created. -func TestPrepareRejectsMismatchedRepository(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - - other := mainModeRepo(t) - _, otherRepo := other.newService(t) - - bad := repo - bad.CommonDir = otherRepo.CommonDir - - base := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - _, err = svc.Prepare(context.Background(), PrepareRequest{Repository: bad, Remote: "origin", Target: tgt}) - if err == nil { - t.Fatalf("Prepare with mismatched repository = nil error; want rejection") - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindInvalidInput { - t.Errorf("Kind = %q; want %q", f.Kind, KindInvalidInput) - } - if f.Stage != "validate" { - t.Errorf("Stage = %q; want validate", f.Stage) - } - assertNothingCreated(t, r, repo.CommonDir) -} - -// TestPrepareFetchFailureCreatesNothing proves a base branch that is absent on -// the remote fails the fetch with an external failure and leaves no checkout, -// branch, or manifest behind (fetch precedes any manifest publication). -func TestPrepareFetchFailureCreatesNothing(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - - // A resolved base whose branch never exists on the remote. - base := domain.EffectiveBase{Kind: domain.BaseResolved, Branch: "ghostbase"} - tgt, err := NewTarget(7, prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - - before := r.snapshotAll(t) - _, err = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err == nil { - t.Fatalf("Prepare against absent remote base = nil error; want failure") - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindExternal { - t.Errorf("Kind = %q; want %q", f.Kind, KindExternal) - } - if f.Stage != "fetch" { - t.Errorf("Stage = %q; want fetch", f.Stage) - } - assertNothingCreated(t, r, repo.CommonDir) - r.assertAllUnchanged(t, before) - }) -} - // --------------------------------------------------------------------------- // Task 6: existing / resume / blocked matrix. // @@ -399,103 +175,6 @@ func TestPrepareFetchFailureCreatesNothing(t *testing.T) { // production writeManifest so a constructed state is one loadManifest accepts. // --------------------------------------------------------------------------- -// TestPrepareInvocationMatrix is the CWD/symlink invocation matrix (spec -// §"Real-Git workspace matrix" first bullet). It seeds gitcli.Discover from five -// spellings of the SAME repository — the primary checkout, inside `.docket/`, -// inside another feature worktree, a nested subdirectory, and a symlinked -// spelling of the primary path — and requires every one to resolve ONE canonical -// repository identity and therefore ONE canonical workspace location: the same -// hashed metadata directory and the same checkout path, with the first Prepare -// creating it and every later invocation adopting it as existing. The workspace -// location is derived from Repository.PrimaryWorktree, never from CWD, so a -// caller's directory can never fork it into a second checkout. -func TestPrepareInvocationMatrix(t *testing.T) { - requireGit(t) - r := docketModeRepo(t) // the topology carrying `.docket/` and a sibling feature worktree - ctx := context.Background() - - c, err := gitcli.NewClient() - if err != nil { - t.Fatalf("gitcli.NewClient: %v", err) - } - svc, err := NewService(c) - if err != nil { - t.Fatalf("NewService: %v", err) - } - tgt := freshTarget(t, 7) - - // A real nested subdirectory beneath the primary checkout (an empty directory - // is invisible to git status, so it does not perturb any preservation proof). - nested := filepath.Join(r.Primary, "sub", "deep") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatal(err) - } - // A symlinked spelling of the primary path, in a separate temp root, so the - // invocation path differs from the canonical one by a real symlink hop (on top - // of the macOS /tmp -> /private/tmp hop testsupport.TempDir(t) already provides). - linkParent := testsupport.TempDir(t) - link := filepath.Join(linkParent, "primary-link") - if err := os.Symlink(r.Primary, link); err != nil { - t.Fatal(err) - } - - invocations := []struct{ name, path string }{ - {"primary", r.Primary}, - {"dot-docket", filepath.Join(r.Primary, ".docket")}, - {"sibling-feature-worktree", filepath.Join(r.Primary, ".worktrees", "other")}, - {"nested-subdir", nested}, - {"symlinked-primary", link}, - } - - var canonical gitcli.Repository - var wantPath, wantDir string - for i, inv := range invocations { - repo, err := c.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: inv.path}) - if err != nil { - t.Fatalf("Discover from %s (%s): %v", inv.name, inv.path, err) - } - if i == 0 { - canonical = repo - wantPath = filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) - wantDir = workspaceDir(repo.CommonDir, tgt.FeatureRef) - } else if repo != canonical { - t.Errorf("Discover from %s resolved %+v; want the canonical identity %+v", inv.name, repo, canonical) - } - - ws, err := svc.Prepare(ctx, PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare from %s: %v", inv.name, err) - } - wantDisp := PrepareExisting - if i == 0 { - wantDisp = PrepareCreated - } - if ws.Disposition != wantDisp { - t.Errorf("Prepare from %s: Disposition = %q; want %q", inv.name, ws.Disposition, wantDisp) - } - if ws.Path != wantPath { - t.Errorf("Prepare from %s: Path = %q; want the one canonical location %q", inv.name, ws.Path, wantPath) - } - if got := workspaceDir(repo.CommonDir, tgt.FeatureRef); got != wantDir { - t.Errorf("Prepare from %s: manifest dir = %q; want %q", inv.name, got, wantDir) - } - } - - // Exactly one feature worktree was registered across all five invocations, and - // exactly one ready manifest exists at the single canonical location. - wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if n := countWorktreePathOccurrences(wl, wantPath); n != 1 { - t.Errorf("feature worktree registered %d times; want exactly one canonical registration:\n%s", n, wl) - } - m, present, err := loadManifest(wantDir) - if err != nil || !present { - t.Fatalf("loadManifest(%s): present=%v err=%v; want one present manifest", wantDir, present, err) - } - if m.Phase != PhaseReady { - t.Errorf("manifest phase = %q; want ready", m.Phase) - } -} - // countWorktreePathOccurrences counts how many registered worktrees resolve to // want, canonicalizing each porcelain path through every symlink hop. func countWorktreePathOccurrences(porcelain, want string) int { @@ -573,218 +252,6 @@ func localBranchTip(t *testing.T, r *wsRepos) gitcli.ObjectID { return gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))) } -// TestPrepareExistingIdempotent proves a second Prepare on a ready workspace -// returns `existing` and mutates nothing: commits, staged bytes, dirty tracked -// bytes, and untracked files created between the two calls all survive -// byte-identically, and Dirty is reported true, never repaired. -func TestPrepareExistingIdempotent(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - first := prepareOK(t, svc, repo, tgt) - if first.Disposition != PrepareCreated { - t.Fatalf("first Prepare disposition = %q; want created", first.Disposition) - } - ws := wsPathOf(repo) - - // Mutate the workspace between calls: a commit, a staged file, a dirty - // tracked file, and an untracked file. - writeWorktreeFile(t, ws, "feature.go", "package feature\n") - gitOut(t, ws, "add", "feature.go") - gitOut(t, ws, "commit", "-q", "-m", "feature work") - committedTip := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - - writeWorktreeFile(t, ws, "staged.txt", "staged bytes\n") - gitOut(t, ws, "add", "staged.txt") - writeWorktreeFile(t, ws, "main.go", "package main // dirtied\n") - writeWorktreeFile(t, ws, "untracked.txt", "untracked bytes\n") - - beforeWs := snapshotTree(t, ws) - beforePreserve := r.snapshotAll(t) - - second, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("second Prepare: %v", err) - } - if second.Disposition != PrepareExisting { - t.Errorf("second Prepare disposition = %q; want existing", second.Disposition) - } - if !second.Dirty { - t.Errorf("second Prepare Dirty = false; want true (dirty reported)") - } - if second.HeadCommit != committedTip { - t.Errorf("HeadCommit = %q; want committed tip %q", second.HeadCommit, committedTip) - } - if second.BaseCommit != first.BaseCommit { - t.Errorf("BaseCommit = %q; want recorded %q", second.BaseCommit, first.BaseCommit) - } - - // Nothing repaired: the workspace is byte-identical, and every uninvolved - // worktree is unchanged. Manifest is still ready (not rewritten to nonsense). - assertUnchanged(t, beforeWs, ws) - r.assertAllUnchanged(t, beforePreserve) - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } - }) -} - -// TestPrepareResumeCreateBoth is interrupted-allocation resume arm (i): an -// allocating manifest with no branch and no worktree. Resume creates both at the -// recorded base and advances to ready as `resumed`. -func TestPrepareResumeCreateBoth(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - - writeStateManifest(t, repo, tgt, base, PhaseAllocating) - if branchExists(r.Primary, "feat/"+prepSlug) { - t.Fatalf("fixture: branch already exists") - } - - before := r.snapshotAll(t) - ws := prepareOK(t, svc, repo, tgt) - if ws.Disposition != PrepareResumed { - t.Errorf("disposition = %q; want resumed", ws.Disposition) - } - if ws.BaseCommit != base { - t.Errorf("BaseCommit = %q; want %q", ws.BaseCommit, base) - } - if got := localBranchTip(t, r); got != base { - t.Errorf("branch tip = %q; want base %q", got, base) - } - if got := symbolicHead(t, wsPathOf(repo)); got != string(prepFeatureRef()) { - t.Errorf("workspace symbolic HEAD = %q; want %q", got, prepFeatureRef()) - } - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareResumeAttach is resume arm (ii): an allocating manifest and a -// branch already at the recorded base, but no worktree. Resume attaches the -// existing branch and NEVER moves its tip, even though origin advanced meanwhile. -func TestPrepareResumeAttach(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - - writeStateManifest(t, repo, tgt, base, PhaseAllocating) - gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(base)) - - // Origin moves forward after the branch was created; resume must not follow. - moved := r.advanceMain(t) - if moved == base { - t.Fatalf("advanceMain did not move origin (fixture bug)") - } - - before := r.snapshotAll(t) - ws := prepareOK(t, svc, repo, tgt) - if ws.Disposition != PrepareResumed { - t.Errorf("disposition = %q; want resumed", ws.Disposition) - } - if got := localBranchTip(t, r); got != base { - t.Errorf("branch tip = %q; want unchanged base %q (attach must not move it)", got, base) - } - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), wsPathOf(repo)) { - t.Errorf("worktree not registered after attach resume") - } - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } - r.assertAllUnchanged(t, before) - }) -} - -// TestPrepareResumeVerifyOnly is resume arm (iii): an allocating manifest with -// the branch AND a registered worktree already present, carrying a post-creation -// commit and dirty bytes. Resume verifies and advances to ready only; the commit -// and dirty bytes survive. -func TestPrepareResumeVerifyOnly(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - ws := wsPathOf(repo) - - writeStateManifest(t, repo, tgt, base, PhaseAllocating) - gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(base)) - gitOut(t, r.Primary, "worktree", "add", "--", ws, "feat/"+prepSlug) - - // Post-creation commit and dirty bytes. - writeWorktreeFile(t, ws, "resumed.go", "package resumed\n") - gitOut(t, ws, "add", "resumed.go") - gitOut(t, ws, "commit", "-q", "-m", "post-creation commit") - postTip := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - writeWorktreeFile(t, ws, "dirty.txt", "dirty\n") - - beforeWs := snapshotTree(t, ws) - - out := prepareOK(t, svc, repo, tgt) - if out.Disposition != PrepareResumed { - t.Errorf("disposition = %q; want resumed", out.Disposition) - } - if out.HeadCommit != postTip { - t.Errorf("HeadCommit = %q; want post-creation tip %q", out.HeadCommit, postTip) - } - if !out.Dirty { - t.Errorf("Dirty = false; want true (post-creation dirty reported)") - } - if got := localBranchTip(t, r); got != postTip { - t.Errorf("branch tip = %q; want post-creation %q (commit preserved)", got, postTip) - } - assertUnchanged(t, beforeWs, ws) - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } - }) -} - -// TestPrepareResumeBranchOffBaseBlocked is blocked case (c): a branch created by -// this manifest that no longer contains the recorded base commit (the base is a -// commit the branch does not reach). Prepare is blocked and byte-untouched: the -// branch is never reset and no worktree is created. -func TestPrepareResumeBranchOffBaseBlocked(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - c0 := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - // A base commit that the branch (at c0) does NOT contain: advance origin and - // fetch the new commit into the primary's object store, then record it as base. - c1 := r.advanceMain(t) - gitOut(t, r.Primary, "fetch", "-q", "origin", "main") - if c1 == c0 { - t.Fatalf("advanceMain did not move origin (fixture bug)") - } - - writeStateManifest(t, repo, tgt, c1, PhaseAllocating) - gitOut(t, r.Primary, "branch", "feat/"+prepSlug, string(c0)) // branch at c0, base is c1 - - before := r.snapshotAll(t) - beforeManifest := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) - - out := prepareOK(t, svc, repo, tgt) - if out.Disposition != PrepareBlocked { - t.Errorf("disposition = %q; want blocked", out.Disposition) - } - if got := localBranchTip(t, r); got != c0 { - t.Errorf("branch tip = %q; want unchanged %q (never reset)", got, c0) - } - if _, err := os.Lstat(wsPathOf(repo)); !os.IsNotExist(err) { - t.Errorf("workspace path exists (%v); want none created", err) - } - if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeManifest { - t.Errorf("manifest bytes changed on blocked resume") - } - r.assertAllUnchanged(t, before) -} - // readFileBytes reads a file as a string, failing the test on error. func readFileBytes(t *testing.T, path string) string { t.Helper() @@ -795,123 +262,6 @@ func readFileBytes(t *testing.T, path string) string { return string(b) } -// TestPrepareBlockedMatrix walks the fresh-path blocked matrix: each colliding -// artifact with no matching manifest yields PrepareBlocked and is left -// byte-untouched (pre-Go in-flight work is never adopted). -func TestPrepareBlockedMatrix(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - t.Run("target-dir-no-manifest", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - writeWorktreeFile(t, wsPathOf(repo), "leftover.txt", "prior bytes\n") - collidePath := filepath.Join(wsPathOf(repo), "leftover.txt") - before := readFileBytes(t, collidePath) - - assertBlocked(t, svc, repo, tgt) - if after := readFileBytes(t, collidePath); after != before { - t.Errorf("colliding directory bytes changed") - } - assertNoManifest(t, repo, tgt) - }) - - t.Run("foreign-registration", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - // A foreign detached worktree squatting the target path. - gitOut(t, r.Primary, "worktree", "add", "--detach", "--", wsPathOf(repo), "main") - collidePath := filepath.Join(wsPathOf(repo), "main.go") - before := readFileBytes(t, collidePath) - - assertBlocked(t, svc, repo, tgt) - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), wsPathOf(repo)) { - t.Errorf("foreign registration removed; must be preserved (never force-removed)") - } - if after := readFileBytes(t, collidePath); after != before { - t.Errorf("foreign worktree bytes changed") - } - assertNoManifest(t, repo, tgt) - }) - - t.Run("local-branch-no-manifest", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - gitOut(t, r.Primary, "branch", "feat/"+prepSlug, "main") - before := localBranchTip(t, r) - - assertBlocked(t, svc, repo, tgt) - if got := localBranchTip(t, r); got != before { - t.Errorf("local branch tip changed %q -> %q", before, got) - } - assertNoManifest(t, repo, tgt) - }) - - t.Run("remote-branch-no-manifest", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - r.pushBranch(t, "feat/"+prepSlug, "main") - before := gitcli.ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/feat/"+prepSlug)) - - assertBlocked(t, svc, repo, tgt) - if got := gitcli.ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/feat/"+prepSlug)); got != before { - t.Errorf("remote branch tip changed %q -> %q", before, got) - } - if branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("remote branch was adopted locally; must not be") - } - assertNoManifest(t, repo, tgt) - }) - - t.Run("malformed-manifest", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - if err := os.MkdirAll(metaDirOf(repo, tgt), 0o700); err != nil { - t.Fatal(err) - } - mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) - if err := os.WriteFile(mpath, []byte("{ this is not json"), 0o600); err != nil { - t.Fatal(err) - } - before := readFileBytes(t, mpath) - - assertBlocked(t, svc, repo, tgt) - if after := readFileBytes(t, mpath); after != before { - t.Errorf("malformed manifest bytes changed") - } - }) - - t.Run("foreign-commondir-manifest", func(t *testing.T) { - r := freshTopology(t, r) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - other := mainModeRepo(t) - _, otherRepo := other.newService(t) - // A structurally valid manifest owned by a DIFFERENT repository. - foreign := Manifest{ - Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, - ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, - BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), - Path: wsPathOf(repo), Phase: PhaseReady, - CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), - } - if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { - t.Fatalf("writeManifest(foreign): %v", err) - } - mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) - before := readFileBytes(t, mpath) - - assertBlocked(t, svc, repo, tgt) - if after := readFileBytes(t, mpath); after != before { - t.Errorf("foreign-commondir manifest bytes changed") - } - }) - }) -} - // freshTopology rebuilds the same fixture kind as r for an isolated subtest, so // each blocked-matrix case runs against its own repository. func freshTopology(t *testing.T, r *wsRepos) *wsRepos { @@ -942,98 +292,6 @@ func assertNoManifest(t *testing.T, repo gitcli.Repository, tgt Target) { } } -// TestPrepareFreshBlockedByStaleRegistration pins change 0368's fresh-allocation -// tightening: a worktree registration at the intended target path whose directory -// has been removed — a STALE registration — blocks a fresh allocation instead of -// being skip-matched into a silent create. Pre-change, inventoryForFresh proved -// registration absence via registeredAt, which SKIPS an uncanonicalizable path -// (the stale registration's directory is gone); classifyRegistrationAbsence now -// recognizes it fail-closed (regPresent lexically, or regUnresolved), so Prepare -// returns PrepareBlocked, force-removes nothing, and publishes no manifest. Every -// earlier fresh-path leg (local feature ref, remote ref, target path) is left -// clean, so the block is attributable to the registration alone. -func TestPrepareFreshBlockedByStaleRegistration(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - // Register a worktree at the intended path on a THROWAWAY branch (never the - // feature ref), then remove its directory: the registration survives in the - // git metadata while its path can no longer be canonicalized. - gitOut(t, r.Primary, "worktree", "add", "-b", "throwaway/stale-reg", wsPathOf(repo), "main") - if err := os.RemoveAll(wsPathOf(repo)); err != nil { - t.Fatal(err) - } - // The registration must still be recorded (RemoveAll does not prune it), or - // the fixture cannot exercise the stale arm. Match the raw recorded path — the - // containsWorktreePath helper canonicalizes, which fails on the removed dir. - beforeList := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if !strings.Contains(beforeList, "worktree "+wsPathOf(repo)+"\n") { - t.Fatalf("stale registration not recorded after RemoveAll; cannot exercise the stale arm:\n%s", beforeList) - } - - assertBlocked(t, svc, repo, tgt) - - // The stale registration is preserved (never force-removed) and no manifest - // was published — the fresh allocation is refused, byte-untouched. - afterList := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if !strings.Contains(afterList, "worktree "+wsPathOf(repo)+"\n") { - t.Errorf("stale registration removed; must be preserved (never force-removed):\n%s", afterList) - } - assertNoManifest(t, repo, tgt) -} - -// TestPrepareConcurrentSameTarget proves two Prepares of the SAME target -// serialize on the operation lock and yield exactly one created plus one -// existing, with exactly one branch and one registration. -func TestPrepareConcurrentSameTarget(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - var wg sync.WaitGroup - results := make([]Workspace, 2) - errs := make([]error, 2) - start := make(chan struct{}) - for i := 0; i < 2; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - <-start - results[i], errs[i] = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - }(i) - } - close(start) - wg.Wait() - - created, existing := 0, 0 - for i := 0; i < 2; i++ { - if errs[i] != nil { - t.Fatalf("goroutine %d Prepare: %v", i, errs[i]) - } - switch results[i].Disposition { - case PrepareCreated: - created++ - case PrepareExisting, PrepareResumed: - existing++ - default: - t.Errorf("goroutine %d disposition = %q; want created/existing/resumed", i, results[i].Disposition) - } - } - if created != 1 || existing != 1 { - t.Errorf("dispositions: created=%d existing/resumed=%d; want 1 and 1", created, existing) - } - - // Exactly one branch and one registration for the target. - wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if n := countPathOccurrences(wl, wsPathOf(repo)); n != 1 { - t.Errorf("registrations at target path = %d; want 1", n) - } - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch missing after concurrent prepare") - } -} - // countPathOccurrences counts porcelain "worktree " lines whose canonical // path equals want. func countPathOccurrences(porcelain, want string) int { @@ -1048,80 +306,6 @@ func countPathOccurrences(porcelain, want string) int { return n } -// TestPrepareConcurrentDistinctTargets proves two Prepares of DIFFERENT targets -// proceed concurrently (distinct operation locks) and both create successfully. -func TestPrepareConcurrentDistinctTargets(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - - baseA := resolveBase(t, []domain.ChangeSpec{{ID: 7, Status: domain.StatusProposed}}, nil, 7) - tgtA, err := NewTarget(7, "alpha-slug", baseA, "feat/alpha-slug") - if err != nil { - t.Fatalf("NewTarget A: %v", err) - } - baseB := resolveBase(t, []domain.ChangeSpec{{ID: 8, Status: domain.StatusProposed}}, nil, 8) - tgtB, err := NewTarget(8, "beta-slug", baseB, "feat/beta-slug") - if err != nil { - t.Fatalf("NewTarget B: %v", err) - } - - var wg sync.WaitGroup - var outA, outB Workspace - var errA, errB error - start := make(chan struct{}) - wg.Add(2) - go func() { - defer wg.Done() - <-start - outA, errA = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgtA}) - }() - go func() { - defer wg.Done() - <-start - outB, errB = svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgtB}) - }() - close(start) - wg.Wait() - - if errA != nil || errB != nil { - t.Fatalf("distinct-target Prepare errors: A=%v B=%v", errA, errB) - } - if outA.Disposition != PrepareCreated || outB.Disposition != PrepareCreated { - t.Errorf("dispositions A=%q B=%q; want both created", outA.Disposition, outB.Disposition) - } -} - -// TestPrepareProbeFailureCreatesNothing injects a probe failure at the remote -// feature-ref inventory step: a git wrapper that fails `ls-remote` (the only -// inventory probe used by no earlier Prepare step, so identity discovery, base -// fetch, and the local-ref probe all still succeed and the failure lands exactly -// at ProbeRemoteBranch). An errored probe is an external failure, NEVER clean -// absence, so nothing is created (learnings: probe-error-is-not-clean-absence). -func TestPrepareProbeFailureCreatesNothing(t *testing.T) { - r := mainModeRepo(t) - fakeGit := writeFailingGit(t, "ls-remote") - svc, repo := r.newServiceWithGit(t, fakeGit) - tgt := freshTarget(t, 7) - - before := r.snapshotAll(t) - _, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err == nil { - t.Fatalf("Prepare with failing ls-remote = nil error; want external failure") - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindExternal { - t.Errorf("Kind = %q; want external", f.Kind) - } - if f.Stage != "inventory" { - t.Errorf("Stage = %q; want inventory", f.Stage) - } - assertNothingCreated(t, r, repo.CommonDir) - r.assertAllUnchanged(t, before) -} - // writeFailingGit writes an executable git wrapper that forwards to the real git // on PATH except for the named subcommand, which it fails with exit 1. The // wrapper is invoked by absolute path, so PATH still resolves the real git. diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 8ee7d4e40..a34a64cd6 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -76,6 +76,8 @@ tests/test_go_integration_githubcli_prbatch.sh 10 parallel tests/test_go_integration_transaction_apply.sh 25 parallel tests/test_go_integration_transaction_recovery.sh 20 parallel tests/test_go_integration_transaction_race.sh 25 parallel +tests/test_go_integration_workspace_setup.sh 25 parallel +tests/test_go_integration_workspace_race.sh 10 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_workspace_race.sh b/tests/test_go_integration_workspace_race.sh new file mode 100755 index 000000000..3ff4bc186 --- /dev/null +++ b/tests/test_go_integration_workspace_race.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace real-concurrency real-git tests (concurrent Prepare +# for one target and for distinct targets over one primary clone) — moved out of the default +# internal/workspace corpus, which must never start real git (testsupport.InstallNoGitGuard, +# installed from the package's TestMain) — behind the `integration` build tag, prefix +# ^TestRaceIntegrationWorkspace, run in RACE mode. Declarations only — execution and inspection +# live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestRaceIntegrationWorkspace" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_workspace_setup.sh b/tests/test_go_integration_workspace_setup.sh new file mode 100755 index 000000000..335441fa4 --- /dev/null +++ b/tests/test_go_integration_workspace_setup.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_setup.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace Prepare real-git tests (fresh, stacked, resume, +# blocked, and probe-failure preparation, plus the fixture-topology proof) — moved out of the +# default internal/workspace corpus, which must never start real git +# (testsupport.InstallNoGitGuard, installed from the package's TestMain) — behind the +# `integration` build tag, prefix ^TestIntegrationWorkspaceSetup. Declarations only — execution +# and inspection live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestIntegrationWorkspaceSetup" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From 303b090e993272ee63b09a718915f46301a4d885 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:32:50 -0400 Subject: [PATCH 08/16] test(workspace): move inspect/publish/rewrite/cleanup real-git tests behind the integration tag (TestIntegrationWorkspaceLifecycle, change 0466) --- .../workspace/cleanup_integration_test.go | 411 ++++++++++++++++ internal/workspace/cleanup_test.go | 401 ---------------- .../workspace/inspect_integration_test.go | 387 +++++++++++++++ internal/workspace/inspect_test.go | 376 --------------- .../workspace/publish_integration_test.go | 452 ++++++++++++++++++ internal/workspace/publish_test.go | 441 ----------------- .../workspace/rewrite_integration_test.go | 347 ++++++++++++++ internal/workspace/rewrite_test.go | 338 ------------- tests/runtime-budgets.tsv | 1 + ...test_go_integration_workspace_lifecycle.sh | 24 + 10 files changed, 1622 insertions(+), 1556 deletions(-) create mode 100644 internal/workspace/cleanup_integration_test.go create mode 100644 internal/workspace/inspect_integration_test.go create mode 100644 internal/workspace/publish_integration_test.go create mode 100644 internal/workspace/rewrite_integration_test.go create mode 100755 tests/test_go_integration_workspace_lifecycle.sh diff --git a/internal/workspace/cleanup_integration_test.go b/internal/workspace/cleanup_integration_test.go new file mode 100644 index 000000000..74f4a485f --- /dev/null +++ b/internal/workspace/cleanup_integration_test.go @@ -0,0 +1,411 @@ +//go:build integration + +package workspace + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/danielhanold/docket/internal/gitcli" +) + +// TestIntegrationWorkspaceLifecycleCleanupReadyClean proves a ready + clean workspace is removed: the +// registration and checkout directory are gone, the manifest is retained as a +// cleaned tombstone that still loads, and the LOCAL BRANCH survives at its tip. +// Every uninvolved worktree is preserved. +func TestIntegrationWorkspaceLifecycleCleanupReadyClean(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + created := prepareOK(t, svc, repo, tgt) + if created.Disposition != PrepareCreated { + t.Fatalf("prepare disposition = %q; want created", created.Disposition) + } + ws := wsPathOf(repo) + branchTip := localBranchTip(t, r) + + before := r.snapshotAll(t) + res := cleanupOK(t, svc, repo, tgt) + + if res.Disposition != CleanupCleaned { + t.Errorf("Disposition = %q; want cleaned", res.Disposition) + } + if res.Path != ws { + t.Errorf("Path = %q; want %q", res.Path, ws) + } + // The checkout directory is gone. + if _, err := os.Lstat(ws); !os.IsNotExist(err) { + t.Errorf("workspace dir Lstat err = %v; want not-exist (removed)", err) + } + // The registration is gone. + if containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { + t.Errorf("worktree still registered at %q after cleanup", ws) + } + // The manifest is a retained cleaned tombstone that still loads. + m, present, err := loadManifest(metaDirOf(repo, tgt)) + if err != nil || !present { + t.Fatalf("tombstone loadManifest present=%v err=%v; want present", present, err) + } + if m.Phase != PhaseCleaned { + t.Errorf("tombstone phase = %q; want cleaned", m.Phase) + } + // The local branch STILL EXISTS at its unchanged tip: cleanup never deletes it. + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch deleted; cleanup must never delete a branch") + } + if got := localBranchTip(t, r); got != branchTip { + t.Errorf("branch tip = %q; want unchanged %q", got, branchTip) + } + r.assertAllUnchanged(t, before) + }) +} + +// TestIntegrationWorkspaceLifecycleCleanupRetryAlreadyClean proves a second Cleanup after a successful one is +// idempotent: the cleaned tombstone plus the absent registration yield +// already-clean, and nothing further changes. +func TestIntegrationWorkspaceLifecycleCleanupRetryAlreadyClean(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupCleaned { + t.Fatalf("first Cleanup = %q; want cleaned", res.Disposition) + } + beforeTombstone := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) + branchTip := localBranchTip(t, r) + + res := cleanupOK(t, svc, repo, tgt) + if res.Disposition != CleanupAlreadyClean { + t.Errorf("retry Disposition = %q; want already-clean", res.Disposition) + } + if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeTombstone { + t.Errorf("tombstone bytes changed on already-clean retry") + } + if got := localBranchTip(t, r); got != branchTip { + t.Errorf("branch tip = %q; want unchanged %q", got, branchTip) + } + }) +} + +// TestIntegrationWorkspaceLifecycleCleanupDirtyNeverRemoved is the observable data-loss contract: a dirty +// workspace is NEVER removed and its bytes survive. It encodes the reason Cleanup +// removes via the non-forcing RemoveWorktreeClean (git rechecks cleanliness at the +// destructive boundary) rather than a preflight check plus a forced removal. +func TestIntegrationWorkspaceLifecycleCleanupDirtyNeverRemoved(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + + // A dirty tracked file plus an untracked file: the checkout carries unsaved work. + writeWorktreeFile(t, ws, "main.go", "package main // unsaved edit\n") + writeWorktreeFile(t, ws, "scratch.txt", "unsaved untracked bytes\n") + beforeWs := snapshotTree(t, ws) + + res := cleanupOK(t, svc, repo, tgt) + if res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked", res.Disposition) + } + // NEVER removed: directory, registration, and dirty bytes all survive. + if _, err := os.Stat(ws); err != nil { + t.Errorf("workspace dir stat err = %v; want present (never removed)", err) + } + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { + t.Errorf("registration removed for a dirty workspace; must be preserved") + } + assertUnchanged(t, beforeWs, ws) + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready (not advanced)", present, m.Phase, err) + } +} + +// TestIntegrationWorkspaceLifecycleCleanupBlockedMatrix walks every unproven/dirty condition. Each leaves the +// colliding artifact byte-untouched, keeps the local branch, and never advances +// the manifest to a tombstone. +func TestIntegrationWorkspaceLifecycleCleanupBlockedMatrix(t *testing.T) { + t.Run("staged-file", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + writeWorktreeFile(t, ws, "staged.txt", "staged bytes\n") + gitOut(t, ws, "add", "staged.txt") + before := snapshotTree(t, ws) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked", res.Disposition) + } + assertUnchanged(t, before, ws) + assertReadyManifestKept(t, r, repo, tgt) + }) + + t.Run("untracked-file", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + writeWorktreeFile(t, ws, "untracked.txt", "untracked bytes\n") + before := snapshotTree(t, ws) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked", res.Disposition) + } + assertUnchanged(t, before, ws) + assertReadyManifestKept(t, r, repo, tgt) + }) + + t.Run("unresolved-conflict", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := prepareOK(t, svc, repo, tgt).BaseCommit + ws := wsPathOf(repo) + + // Build a real merge conflict inside the workspace: a divergent branch and + // the feature branch each touch main.go differently, then merge fails. + gitOut(t, ws, "checkout", "-q", "-b", "conflictor", string(base)) + writeWorktreeFile(t, ws, "main.go", "package main // conflictor side\n") + gitOut(t, ws, "add", "main.go") + gitOut(t, ws, "commit", "-q", "-m", "conflictor change") + gitOut(t, ws, "checkout", "-q", string(prepFeatureRef())) + writeWorktreeFile(t, ws, "main.go", "package main // feature side\n") + gitOut(t, ws, "add", "main.go") + gitOut(t, ws, "commit", "-q", "-m", "feature change") + if _, err := gitTry(ws, "merge", "conflictor"); err == nil { + t.Fatalf("fixture: merge did not conflict") + } + before := snapshotTree(t, ws) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for an unresolved conflict", res.Disposition) + } + assertUnchanged(t, before, ws) + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { + t.Errorf("registration removed for a conflicted workspace; must be preserved") + } + }) + + t.Run("detached-head", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + // Detach HEAD out-of-band; the tree stays clean but is no longer on the ref. + gitOut(t, ws, "checkout", "-q", "--detach", "HEAD") + before := snapshotTree(t, ws) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for a detached HEAD", res.Disposition) + } + assertUnchanged(t, before, ws) + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch deleted; must be preserved") + } + }) + + t.Run("moved-head-base-unreachable-removed", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + + // Rewrite the recorded base to a commit the feature head does not reach — + // after change 0429 a manual parent rebase is a legitimate ready state, so + // an otherwise-eligible cleanup proceeds: removed, tombstoned, feature + // branch preserved. + c1 := r.advanceMain(t) + gitOut(t, r.Primary, "fetch", "-q", "origin", "main") + m, present, err := loadManifest(metaDirOf(repo, tgt)) + if err != nil || !present { + t.Fatalf("loadManifest present=%v err=%v", present, err) + } + m.BaseCommit = c1 + if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { + t.Fatalf("writeManifest(rewritten base): %v", err) + } + ws := wsPathOf(repo) + + res := cleanupOK(t, svc, repo, tgt) + if res.Disposition != CleanupCleaned { + t.Errorf("Disposition = %q; want cleaned after a parent rebase", res.Disposition) + } + if containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { + t.Errorf("registration still present; want removed") + } + if tomb, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || tomb.Phase != PhaseCleaned { + t.Errorf("tombstone present=%v phase=%v err=%v; want present cleaned", present, tomb.Phase, err) + } + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch deleted; cleanup must preserve the branch") + } + }) + + t.Run("registration-path-mismatch", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + + // Deregister the checkout and re-attach the same branch elsewhere: the ready + // manifest's recorded path is no longer registered. + gitOut(t, r.Primary, "worktree", "remove", "--force", "--", ws) + elsewhere := filepath.Join(repo.PrimaryWorktree, ".worktrees", "moved") + gitOut(t, r.Primary, "worktree", "add", "--", elsewhere, "feat/"+prepSlug) + beforeManifest := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for a registration/path mismatch", res.Disposition) + } + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), elsewhere) { + t.Errorf("relocated registration removed; cleanup must only touch the recorded path") + } + if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeManifest { + t.Errorf("manifest bytes changed on a blocked mismatch") + } + }) + + t.Run("missing-manifest", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + res := cleanupOK(t, svc, repo, tgt) + if res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for a missing manifest", res.Disposition) + } + if _, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || present { + t.Errorf("manifest present=%v err=%v; want none (cleanup wrote nothing)", present, err) + } + }) + + t.Run("foreign-commondir-manifest", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + other := mainModeRepo(t) + _, otherRepo := other.newService(t) + foreign := Manifest{ + Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, + ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, + BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), + Path: wsPathOf(repo), Phase: PhaseReady, + CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), + } + if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { + t.Fatalf("writeManifest(foreign): %v", err) + } + mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) + before := readFileBytes(t, mpath) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for a foreign-CommonDir manifest", res.Disposition) + } + if after := readFileBytes(t, mpath); after != before { + t.Errorf("foreign manifest bytes changed") + } + }) + + t.Run("allocating-partial", func(t *testing.T) { + // An allocating manifest is an unproven partial: the monotonic phase chain + // refuses allocating->cleaned, so cleanup blocks rather than tombstoning it. + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + writeStateManifest(t, repo, tgt, base, PhaseAllocating) + mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) + before := readFileBytes(t, mpath) + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { + t.Errorf("Disposition = %q; want blocked for an allocating partial", res.Disposition) + } + if after := readFileBytes(t, mpath); after != before { + t.Errorf("allocating manifest bytes changed") + } + }) +} + +// TestIntegrationWorkspaceLifecycleCleanupProbeFailureIsFailedNotClean injects a git that fails `worktree` +// during cleanup, so ListWorktrees errors. An errored registration probe is a +// `failed` error, NEVER a false already-clean, and the workspace is fully intact +// (learnings: probe-error-is-not-clean-absence — the 0309 review's defect class). +func TestIntegrationWorkspaceLifecycleCleanupProbeFailureIsFailedNotClean(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + before := snapshotTree(t, ws) + + // Build a second service whose git fails `worktree list`. The repo is reused + // from the real discovery above (discovery itself uses `git worktree list`, so + // it must not run through the failing binary). + failGit := writeFailingGit(t, "worktree") + fc, err := gitcli.NewClient(gitcli.WithExecutable(failGit)) + if err != nil { + t.Fatalf("NewClient(failing): %v", err) + } + failSvc, err := NewService(fc) + if err != nil { + t.Fatalf("NewService(failing): %v", err) + } + + res, err := failSvc.Cleanup(context.Background(), CleanupRequest{Repository: repo, Target: tgt}) + if err == nil { + t.Fatalf("Cleanup with failing worktree probe = nil error; want failed") + } + if res.Disposition == CleanupAlreadyClean || res.Disposition == CleanupCleaned { + t.Errorf("Disposition = %q; a probe error must never read as clean/removed", res.Disposition) + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindExternal { + t.Errorf("Kind = %q; want external", f.Kind) + } + // Fully intact: directory, registration, and manifest all survive unchanged. + assertUnchanged(t, before, ws) + if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { + t.Errorf("registration removed after a probe failure; must be intact") + } + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } +} + +// TestIntegrationWorkspaceLifecycleCleanupNeverPrunes proves cleanup never runs a global `git worktree prune`: +// a second, stale-looking-but-registered worktree (its directory deleted) still +// appears in the worktree list after a cleanup of the target. +func TestIntegrationWorkspaceLifecycleCleanupNeverPrunes(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + + // A second valid registration whose directory is then removed on disk, so a + // `git worktree prune` would deregister it. Cleanup must not. + prunable := filepath.Join(repo.PrimaryWorktree, ".worktrees", "prunable") + gitOut(t, r.Primary, "worktree", "add", "-b", "feat/prunable", prunable, "main") + if err := os.RemoveAll(prunable); err != nil { + t.Fatal(err) + } + + if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupCleaned { + t.Fatalf("Cleanup = %q; want cleaned", res.Disposition) + } + // The prunable registration still appears: no global prune ran. The directory + // is deleted, so this checks the raw registration line (a canonicalizing check + // cannot resolve a path whose directory no longer exists). + if !registeredLine(gitOut(t, r.Primary, "worktree", "list", "--porcelain"), prunable) { + t.Errorf("prunable registration disappeared; cleanup must never `git worktree prune`") + } +} diff --git a/internal/workspace/cleanup_test.go b/internal/workspace/cleanup_test.go index 846f35159..56ee2f917 100644 --- a/internal/workspace/cleanup_test.go +++ b/internal/workspace/cleanup_test.go @@ -2,10 +2,7 @@ package workspace import ( "context" - "os" - "path/filepath" "testing" - "time" "github.com/danielhanold/docket/internal/gitcli" ) @@ -28,404 +25,6 @@ func cleanupOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) C return res } -// TestCleanupReadyClean proves a ready + clean workspace is removed: the -// registration and checkout directory are gone, the manifest is retained as a -// cleaned tombstone that still loads, and the LOCAL BRANCH survives at its tip. -// Every uninvolved worktree is preserved. -func TestCleanupReadyClean(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - created := prepareOK(t, svc, repo, tgt) - if created.Disposition != PrepareCreated { - t.Fatalf("prepare disposition = %q; want created", created.Disposition) - } - ws := wsPathOf(repo) - branchTip := localBranchTip(t, r) - - before := r.snapshotAll(t) - res := cleanupOK(t, svc, repo, tgt) - - if res.Disposition != CleanupCleaned { - t.Errorf("Disposition = %q; want cleaned", res.Disposition) - } - if res.Path != ws { - t.Errorf("Path = %q; want %q", res.Path, ws) - } - // The checkout directory is gone. - if _, err := os.Lstat(ws); !os.IsNotExist(err) { - t.Errorf("workspace dir Lstat err = %v; want not-exist (removed)", err) - } - // The registration is gone. - if containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { - t.Errorf("worktree still registered at %q after cleanup", ws) - } - // The manifest is a retained cleaned tombstone that still loads. - m, present, err := loadManifest(metaDirOf(repo, tgt)) - if err != nil || !present { - t.Fatalf("tombstone loadManifest present=%v err=%v; want present", present, err) - } - if m.Phase != PhaseCleaned { - t.Errorf("tombstone phase = %q; want cleaned", m.Phase) - } - // The local branch STILL EXISTS at its unchanged tip: cleanup never deletes it. - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch deleted; cleanup must never delete a branch") - } - if got := localBranchTip(t, r); got != branchTip { - t.Errorf("branch tip = %q; want unchanged %q", got, branchTip) - } - r.assertAllUnchanged(t, before) - }) -} - -// TestCleanupRetryAlreadyClean proves a second Cleanup after a successful one is -// idempotent: the cleaned tombstone plus the absent registration yield -// already-clean, and nothing further changes. -func TestCleanupRetryAlreadyClean(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupCleaned { - t.Fatalf("first Cleanup = %q; want cleaned", res.Disposition) - } - beforeTombstone := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) - branchTip := localBranchTip(t, r) - - res := cleanupOK(t, svc, repo, tgt) - if res.Disposition != CleanupAlreadyClean { - t.Errorf("retry Disposition = %q; want already-clean", res.Disposition) - } - if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeTombstone { - t.Errorf("tombstone bytes changed on already-clean retry") - } - if got := localBranchTip(t, r); got != branchTip { - t.Errorf("branch tip = %q; want unchanged %q", got, branchTip) - } - }) -} - -// TestCleanupDirtyNeverRemoved is the observable data-loss contract: a dirty -// workspace is NEVER removed and its bytes survive. It encodes the reason Cleanup -// removes via the non-forcing RemoveWorktreeClean (git rechecks cleanliness at the -// destructive boundary) rather than a preflight check plus a forced removal. -func TestCleanupDirtyNeverRemoved(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - - // A dirty tracked file plus an untracked file: the checkout carries unsaved work. - writeWorktreeFile(t, ws, "main.go", "package main // unsaved edit\n") - writeWorktreeFile(t, ws, "scratch.txt", "unsaved untracked bytes\n") - beforeWs := snapshotTree(t, ws) - - res := cleanupOK(t, svc, repo, tgt) - if res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked", res.Disposition) - } - // NEVER removed: directory, registration, and dirty bytes all survive. - if _, err := os.Stat(ws); err != nil { - t.Errorf("workspace dir stat err = %v; want present (never removed)", err) - } - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { - t.Errorf("registration removed for a dirty workspace; must be preserved") - } - assertUnchanged(t, beforeWs, ws) - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready (not advanced)", present, m.Phase, err) - } -} - -// TestCleanupBlockedMatrix walks every unproven/dirty condition. Each leaves the -// colliding artifact byte-untouched, keeps the local branch, and never advances -// the manifest to a tombstone. -func TestCleanupBlockedMatrix(t *testing.T) { - t.Run("staged-file", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - writeWorktreeFile(t, ws, "staged.txt", "staged bytes\n") - gitOut(t, ws, "add", "staged.txt") - before := snapshotTree(t, ws) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked", res.Disposition) - } - assertUnchanged(t, before, ws) - assertReadyManifestKept(t, r, repo, tgt) - }) - - t.Run("untracked-file", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - writeWorktreeFile(t, ws, "untracked.txt", "untracked bytes\n") - before := snapshotTree(t, ws) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked", res.Disposition) - } - assertUnchanged(t, before, ws) - assertReadyManifestKept(t, r, repo, tgt) - }) - - t.Run("unresolved-conflict", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := prepareOK(t, svc, repo, tgt).BaseCommit - ws := wsPathOf(repo) - - // Build a real merge conflict inside the workspace: a divergent branch and - // the feature branch each touch main.go differently, then merge fails. - gitOut(t, ws, "checkout", "-q", "-b", "conflictor", string(base)) - writeWorktreeFile(t, ws, "main.go", "package main // conflictor side\n") - gitOut(t, ws, "add", "main.go") - gitOut(t, ws, "commit", "-q", "-m", "conflictor change") - gitOut(t, ws, "checkout", "-q", string(prepFeatureRef())) - writeWorktreeFile(t, ws, "main.go", "package main // feature side\n") - gitOut(t, ws, "add", "main.go") - gitOut(t, ws, "commit", "-q", "-m", "feature change") - if _, err := gitTry(ws, "merge", "conflictor"); err == nil { - t.Fatalf("fixture: merge did not conflict") - } - before := snapshotTree(t, ws) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for an unresolved conflict", res.Disposition) - } - assertUnchanged(t, before, ws) - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { - t.Errorf("registration removed for a conflicted workspace; must be preserved") - } - }) - - t.Run("detached-head", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - // Detach HEAD out-of-band; the tree stays clean but is no longer on the ref. - gitOut(t, ws, "checkout", "-q", "--detach", "HEAD") - before := snapshotTree(t, ws) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for a detached HEAD", res.Disposition) - } - assertUnchanged(t, before, ws) - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch deleted; must be preserved") - } - }) - - t.Run("moved-head-base-unreachable-removed", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - - // Rewrite the recorded base to a commit the feature head does not reach — - // after change 0429 a manual parent rebase is a legitimate ready state, so - // an otherwise-eligible cleanup proceeds: removed, tombstoned, feature - // branch preserved. - c1 := r.advanceMain(t) - gitOut(t, r.Primary, "fetch", "-q", "origin", "main") - m, present, err := loadManifest(metaDirOf(repo, tgt)) - if err != nil || !present { - t.Fatalf("loadManifest present=%v err=%v", present, err) - } - m.BaseCommit = c1 - if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { - t.Fatalf("writeManifest(rewritten base): %v", err) - } - ws := wsPathOf(repo) - - res := cleanupOK(t, svc, repo, tgt) - if res.Disposition != CleanupCleaned { - t.Errorf("Disposition = %q; want cleaned after a parent rebase", res.Disposition) - } - if containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { - t.Errorf("registration still present; want removed") - } - if tomb, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || tomb.Phase != PhaseCleaned { - t.Errorf("tombstone present=%v phase=%v err=%v; want present cleaned", present, tomb.Phase, err) - } - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch deleted; cleanup must preserve the branch") - } - }) - - t.Run("registration-path-mismatch", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - - // Deregister the checkout and re-attach the same branch elsewhere: the ready - // manifest's recorded path is no longer registered. - gitOut(t, r.Primary, "worktree", "remove", "--force", "--", ws) - elsewhere := filepath.Join(repo.PrimaryWorktree, ".worktrees", "moved") - gitOut(t, r.Primary, "worktree", "add", "--", elsewhere, "feat/"+prepSlug) - beforeManifest := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for a registration/path mismatch", res.Disposition) - } - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), elsewhere) { - t.Errorf("relocated registration removed; cleanup must only touch the recorded path") - } - if after := readFileBytes(t, filepath.Join(metaDirOf(repo, tgt), manifestFileName)); after != beforeManifest { - t.Errorf("manifest bytes changed on a blocked mismatch") - } - }) - - t.Run("missing-manifest", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - res := cleanupOK(t, svc, repo, tgt) - if res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for a missing manifest", res.Disposition) - } - if _, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || present { - t.Errorf("manifest present=%v err=%v; want none (cleanup wrote nothing)", present, err) - } - }) - - t.Run("foreign-commondir-manifest", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - other := mainModeRepo(t) - _, otherRepo := other.newService(t) - foreign := Manifest{ - Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, - ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, - BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), - Path: wsPathOf(repo), Phase: PhaseReady, - CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), - } - if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { - t.Fatalf("writeManifest(foreign): %v", err) - } - mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) - before := readFileBytes(t, mpath) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for a foreign-CommonDir manifest", res.Disposition) - } - if after := readFileBytes(t, mpath); after != before { - t.Errorf("foreign manifest bytes changed") - } - }) - - t.Run("allocating-partial", func(t *testing.T) { - // An allocating manifest is an unproven partial: the monotonic phase chain - // refuses allocating->cleaned, so cleanup blocks rather than tombstoning it. - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - writeStateManifest(t, repo, tgt, base, PhaseAllocating) - mpath := filepath.Join(metaDirOf(repo, tgt), manifestFileName) - before := readFileBytes(t, mpath) - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupBlocked { - t.Errorf("Disposition = %q; want blocked for an allocating partial", res.Disposition) - } - if after := readFileBytes(t, mpath); after != before { - t.Errorf("allocating manifest bytes changed") - } - }) -} - -// TestCleanupProbeFailureIsFailedNotClean injects a git that fails `worktree` -// during cleanup, so ListWorktrees errors. An errored registration probe is a -// `failed` error, NEVER a false already-clean, and the workspace is fully intact -// (learnings: probe-error-is-not-clean-absence — the 0309 review's defect class). -func TestCleanupProbeFailureIsFailedNotClean(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - before := snapshotTree(t, ws) - - // Build a second service whose git fails `worktree list`. The repo is reused - // from the real discovery above (discovery itself uses `git worktree list`, so - // it must not run through the failing binary). - failGit := writeFailingGit(t, "worktree") - fc, err := gitcli.NewClient(gitcli.WithExecutable(failGit)) - if err != nil { - t.Fatalf("NewClient(failing): %v", err) - } - failSvc, err := NewService(fc) - if err != nil { - t.Fatalf("NewService(failing): %v", err) - } - - res, err := failSvc.Cleanup(context.Background(), CleanupRequest{Repository: repo, Target: tgt}) - if err == nil { - t.Fatalf("Cleanup with failing worktree probe = nil error; want failed") - } - if res.Disposition == CleanupAlreadyClean || res.Disposition == CleanupCleaned { - t.Errorf("Disposition = %q; a probe error must never read as clean/removed", res.Disposition) - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindExternal { - t.Errorf("Kind = %q; want external", f.Kind) - } - // Fully intact: directory, registration, and manifest all survive unchanged. - assertUnchanged(t, before, ws) - if !containsWorktreePath(t, gitOut(t, r.Primary, "worktree", "list", "--porcelain"), ws) { - t.Errorf("registration removed after a probe failure; must be intact") - } - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } -} - -// TestCleanupNeverPrunes proves cleanup never runs a global `git worktree prune`: -// a second, stale-looking-but-registered worktree (its directory deleted) still -// appears in the worktree list after a cleanup of the target. -func TestCleanupNeverPrunes(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - - // A second valid registration whose directory is then removed on disk, so a - // `git worktree prune` would deregister it. Cleanup must not. - prunable := filepath.Join(repo.PrimaryWorktree, ".worktrees", "prunable") - gitOut(t, r.Primary, "worktree", "add", "-b", "feat/prunable", prunable, "main") - if err := os.RemoveAll(prunable); err != nil { - t.Fatal(err) - } - - if res := cleanupOK(t, svc, repo, tgt); res.Disposition != CleanupCleaned { - t.Fatalf("Cleanup = %q; want cleaned", res.Disposition) - } - // The prunable registration still appears: no global prune ran. The directory - // is deleted, so this checks the raw registration line (a canonicalizing check - // cannot resolve a path whose directory no longer exists). - if !registeredLine(gitOut(t, r.Primary, "worktree", "list", "--porcelain"), prunable) { - t.Errorf("prunable registration disappeared; cleanup must never `git worktree prune`") - } -} - // registeredLine reports whether the porcelain worktree list contains a // `worktree ` stanza header for exactly path. Unlike containsWorktreePath // it does not canonicalize, so it can assert on a registration whose on-disk diff --git a/internal/workspace/inspect_integration_test.go b/internal/workspace/inspect_integration_test.go new file mode 100644 index 000000000..46dcadaaa --- /dev/null +++ b/internal/workspace/inspect_integration_test.go @@ -0,0 +1,387 @@ +//go:build integration + +package workspace + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +func TestIntegrationWorkspaceLifecycleInspectReady(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + first := prepareOK(t, svc, repo, tgt) + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateReady { + t.Errorf("Kind = %q; want ready", insp.Kind) + } + if !insp.Registered { + t.Errorf("Registered = false; want true") + } + if insp.Branch != tgt.FeatureRef { + t.Errorf("Branch = %q; want %q", insp.Branch, tgt.FeatureRef) + } + if insp.HeadCommit != first.HeadCommit || insp.BranchHead != first.HeadCommit { + t.Errorf("HeadCommit=%q BranchHead=%q; want both %q", insp.HeadCommit, insp.BranchHead, first.HeadCommit) + } + if !insp.BaseReached { + t.Errorf("BaseReached = false; want true") + } + if len(insp.DirtyPaths) != 0 { + t.Errorf("DirtyPaths = %v; want empty", insp.DirtyPaths) + } + if insp.Phase != PhaseReady { + t.Errorf("Phase = %q; want ready", insp.Phase) + } +} + +// TestIntegrationWorkspaceLifecycleInspectReadyAfterParentRebase proves a READY workspace whose branch was +// legitimately rebased — head no longer reaches the recorded creation base — +// still classifies ready: the recorded base is a fact (BaseReached=false), not +// an identity requirement. The allocating-phase ancestry protection and every +// registration/ref/head/clean check are unchanged (change 0429). +func TestIntegrationWorkspaceLifecycleInspectReadyAfterParentRebase(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + + // Rewrite the recorded base to a commit the feature head does not reach (a + // later origin-main commit, fetched into the object store) — the same + // construction cleanup_test's moved-head row uses, now a legitimate state. + c1 := r.advanceMain(t) + gitOut(t, r.Primary, "fetch", "-q", "origin", "main") + m, present, err := loadManifest(metaDirOf(repo, tgt)) + if err != nil || !present { + t.Fatalf("loadManifest present=%v err=%v", present, err) + } + m.BaseCommit = c1 + if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { + t.Fatalf("writeManifest(rewritten base): %v", err) + } + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateReady { + t.Errorf("Kind = %q; want ready after a parent rebase", insp.Kind) + } + if insp.BaseReached { + t.Errorf("BaseReached = true; want false (fact recorded, not gated)") + } +} + +func TestIntegrationWorkspaceLifecycleInspectDirty(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + + // Staged new file, dirtied tracked file, untracked file. + writeWorktreeFile(t, ws, "staged.txt", "staged\n") + gitOut(t, ws, "add", "staged.txt") + writeWorktreeFile(t, ws, "main.go", "package main // dirty\n") + writeWorktreeFile(t, ws, "untracked.txt", "untracked\n") + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateDirty { + t.Errorf("Kind = %q; want dirty-owned", insp.Kind) + } + want := []string{"main.go", "staged.txt", "untracked.txt"} + if !reflect.DeepEqual(insp.DirtyPaths, want) { + t.Errorf("DirtyPaths = %v; want %v", insp.DirtyPaths, want) + } +} + +func TestIntegrationWorkspaceLifecycleInspectResumable(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + writeStateManifest(t, repo, tgt, base, PhaseAllocating) + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateResumable { + t.Errorf("Kind = %q; want allocating (resumable)", insp.Kind) + } + if insp.Registered { + t.Errorf("Registered = true; want false on a bare allocating partial") + } + if insp.BaseCommit != base { + t.Errorf("BaseCommit = %q; want %q", insp.BaseCommit, base) + } +} + +func TestIntegrationWorkspaceLifecycleInspectCleaned(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + + // A cleaned tombstone: the checkout removed (branch kept), manifest advanced + // to cleaned. This mimics the state Task 7 Cleanup leaves. + gitOut(t, r.Primary, "worktree", "remove", "--force", "--", wsPathOf(repo)) + m, present, err := loadManifest(metaDirOf(repo, tgt)) + if err != nil || !present { + t.Fatalf("loadManifest: present=%v err=%v", present, err) + } + m.Phase = PhaseCleaned + m.UpdatedUTC = time.Now().UTC().Format(time.RFC3339) + if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { + t.Fatalf("writeManifest(cleaned): %v", err) + } + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateCleaned { + t.Errorf("Kind = %q; want cleaned", insp.Kind) + } + if insp.Registered { + t.Errorf("Registered = true; want false for a tombstone") + } + // The local branch survives cleanup. + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch missing; cleanup keeps the branch") + } +} + +func TestIntegrationWorkspaceLifecycleInspectBranchGone(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + + // A ready manifest whose recorded feature branch was never created / is gone. + writeStateManifest(t, repo, tgt, base, PhaseReady) + + insp := assertInspectReadOnly(t, svc, r, repo, tgt) + if insp.Kind != StateBranchGone { + t.Errorf("Kind = %q; want branch-missing", insp.Kind) + } +} + +func TestIntegrationWorkspaceLifecycleInspectMismatch(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + + // Ready manifest, branch still present, but the registered checkout is gone: + // path/registration/manifest disagree. + gitOut(t, r.Primary, "worktree", "remove", "--force", "--", wsPathOf(repo)) + + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateMismatch { + t.Errorf("Kind = %q; want mismatch", insp.Kind) + } + if insp.Registered { + t.Errorf("Registered = true; want false") + } +} + +func TestIntegrationWorkspaceLifecycleInspectForeignMalformed(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + if err := os.MkdirAll(metaDirOf(repo, tgt), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(metaDirOf(repo, tgt), manifestFileName), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign", insp.Kind) + } + if insp.Detail == "" { + t.Errorf("Detail empty; want the parse detail carried as data") + } +} + +// TestIntegrationWorkspaceLifecycleInspectAbsent is the regression for change 0368: with no manifest, no +// local feature branch, nothing at the intended path, and no registration, +// Inspect reports the proven-absent state instead of foreign. This test MUST +// fail against the pre-0368 conflation (which returned StateForeign here). +func TestIntegrationWorkspaceLifecycleInspectAbsent(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateAbsent { + t.Errorf("Kind = %q; want absent for an all-clean missing workspace", insp.Kind) + } +} + +// TestIntegrationWorkspaceLifecycleInspectAbsentBlockedByLeftovers pins each leftover that keeps a +// manifest-absent slot classified StateForeign rather than StateAbsent: a +// surviving local feature branch, anything at the intended path (including a +// dangling symlink), and a worktree registration on the feature ref or at the +// target path (change 0368). +func TestIntegrationWorkspaceLifecycleInspectAbsentBlockedByLeftovers(t *testing.T) { + t.Run("local-branch", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + gitOut(t, r.Primary, "branch", strings.TrimPrefix(string(tgt.FeatureRef), "refs/heads/"), "main") + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign when the local feature branch exists", insp.Kind) + } + }) + t.Run("occupied-path-dir", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + if err := os.MkdirAll(wsPathOf(repo), 0o755); err != nil { + t.Fatal(err) + } + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign when the target path is occupied", insp.Kind) + } + }) + t.Run("dangling-symlink", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + if err := os.MkdirAll(filepath.Dir(wsPathOf(repo)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(testsupport.TempDir(t), "gone"), wsPathOf(repo)); err != nil { + t.Fatal(err) + } + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign for a dangling symlink at the target path", insp.Kind) + } + }) + t.Run("registration-on-feature-ref-elsewhere", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + elsewhere := filepath.Join(r.Primary, "..", "elsewhere-ws") + gitOut(t, r.Primary, "worktree", "add", "-b", + strings.TrimPrefix(string(tgt.FeatureRef), "refs/heads/"), elsewhere, "main") + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign when a registration references the feature ref", insp.Kind) + } + }) + t.Run("stale-registration-at-path", func(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + gitOut(t, r.Primary, "worktree", "add", "-b", "throwaway/stale-reg", wsPathOf(repo), "main") + if err := os.RemoveAll(wsPathOf(repo)); err != nil { // directory gone, registration remains + t.Fatal(err) + } + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign for a stale registration at the target path", insp.Kind) + } + }) +} + +// TestIntegrationWorkspaceLifecycleInspectForeignUnownedCommonDir isolates the manifest OWNERSHIP gate +// (ownsManifest's CommonDir identity conjunct). It writes a structurally valid +// manifest whose ONLY defect is a foreign CommonDir — every other field matches +// this repository and target — and requires StateForeign with the identity +// detail. Unlike Prepare and Cleanup (which re-verify live registration and so +// backstop the ownership check), Inspect returns StateForeign directly on an +// unowned manifest with no other gate behind it, so this is the case that +// reddens when the CommonDir conjunct is dropped. +func TestIntegrationWorkspaceLifecycleInspectForeignUnownedCommonDir(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + other := mainModeRepo(t) + _, otherRepo := other.newService(t) + foreign := Manifest{ + Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, + ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, + BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), + Path: wsPathOf(repo), Phase: PhaseReady, + CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), + } + if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { + t.Fatalf("writeManifest(foreign): %v", err) + } + + insp := inspectOK(t, svc, repo, tgt) + if insp.Kind != StateForeign { + t.Errorf("Kind = %q; want foreign for an unowned (foreign-CommonDir) manifest", insp.Kind) + } + if insp.Detail == "" { + t.Errorf("Detail empty; want the identity-mismatch reason carried as data") + } +} + +// TestIntegrationWorkspaceLifecycleInspectAbsentSlotStatErrorIsError pins the STAT probe-error arm of +// classifyAbsentSlot (change 0368: "a genuine probe error is an error, never +// read as absence in either direction"). With the manifest slot and the local +// feature ref both cleanly absent, a stat error on the intended workspace path +// — here the `.worktrees` parent is a regular FILE, so Lstat of the leaf fails +// with ENOTDIR, which is NOT os.IsNotExist — must surface a typed Failure, never +// fall through to StateAbsent. It reddens if pathPresent's non-IsNotExist error +// is mutated into a clean-absence answer. +func TestIntegrationWorkspaceLifecycleInspectAbsentSlotStatErrorIsError(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + + // Occupy the `.worktrees` parent with a regular file so Lstat of the intended + // workspace path (its child) fails with a non-IsNotExist error. + wtParent := filepath.Dir(wsPathOf(repo)) + if err := os.WriteFile(wtParent, []byte("not a directory\n"), 0o644); err != nil { + t.Fatal(err) + } + + insp, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) + if err == nil { + t.Fatalf("Inspect with a stat-erroring target path = nil error, Kind=%q; want a typed Failure, never clean absence", insp.Kind) + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindExternal { + t.Errorf("Kind = %q; want external", f.Kind) + } +} + +func TestIntegrationWorkspaceLifecycleInspectUnreadableIsError(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) + writeStateManifest(t, repo, tgt, base, PhaseReady) + + dir := metaDirOf(repo, tgt) + if err := os.Chmod(dir, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + _, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) + if err == nil { + t.Fatalf("Inspect on unreadable manifest dir = nil error; want failure") + } + f, ok := AsFailure(err) + if !ok { + t.Fatalf("error %v is not a *Failure", err) + } + if f.Kind != KindExternal { + t.Errorf("Kind = %q; want external", f.Kind) + } +} diff --git a/internal/workspace/inspect_test.go b/internal/workspace/inspect_test.go index 369e473e1..2b2bc6d2f 100644 --- a/internal/workspace/inspect_test.go +++ b/internal/workspace/inspect_test.go @@ -3,14 +3,9 @@ package workspace import ( "context" "os" - "path/filepath" - "reflect" - "strings" "testing" - "time" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/testsupport" ) // The Inspect tests build each StateKind with the real-Git harness primitives @@ -46,374 +41,3 @@ func assertInspectReadOnly(t *testing.T, svc *Service, r *wsRepos, repo gitcli.R r.assertAllUnchanged(t, beforePreserve) return insp } - -func TestInspectReady(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - first := prepareOK(t, svc, repo, tgt) - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateReady { - t.Errorf("Kind = %q; want ready", insp.Kind) - } - if !insp.Registered { - t.Errorf("Registered = false; want true") - } - if insp.Branch != tgt.FeatureRef { - t.Errorf("Branch = %q; want %q", insp.Branch, tgt.FeatureRef) - } - if insp.HeadCommit != first.HeadCommit || insp.BranchHead != first.HeadCommit { - t.Errorf("HeadCommit=%q BranchHead=%q; want both %q", insp.HeadCommit, insp.BranchHead, first.HeadCommit) - } - if !insp.BaseReached { - t.Errorf("BaseReached = false; want true") - } - if len(insp.DirtyPaths) != 0 { - t.Errorf("DirtyPaths = %v; want empty", insp.DirtyPaths) - } - if insp.Phase != PhaseReady { - t.Errorf("Phase = %q; want ready", insp.Phase) - } -} - -// TestInspectReadyAfterParentRebase proves a READY workspace whose branch was -// legitimately rebased — head no longer reaches the recorded creation base — -// still classifies ready: the recorded base is a fact (BaseReached=false), not -// an identity requirement. The allocating-phase ancestry protection and every -// registration/ref/head/clean check are unchanged (change 0429). -func TestInspectReadyAfterParentRebase(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - - // Rewrite the recorded base to a commit the feature head does not reach (a - // later origin-main commit, fetched into the object store) — the same - // construction cleanup_test's moved-head row uses, now a legitimate state. - c1 := r.advanceMain(t) - gitOut(t, r.Primary, "fetch", "-q", "origin", "main") - m, present, err := loadManifest(metaDirOf(repo, tgt)) - if err != nil || !present { - t.Fatalf("loadManifest present=%v err=%v", present, err) - } - m.BaseCommit = c1 - if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { - t.Fatalf("writeManifest(rewritten base): %v", err) - } - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateReady { - t.Errorf("Kind = %q; want ready after a parent rebase", insp.Kind) - } - if insp.BaseReached { - t.Errorf("BaseReached = true; want false (fact recorded, not gated)") - } -} - -func TestInspectDirty(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - - // Staged new file, dirtied tracked file, untracked file. - writeWorktreeFile(t, ws, "staged.txt", "staged\n") - gitOut(t, ws, "add", "staged.txt") - writeWorktreeFile(t, ws, "main.go", "package main // dirty\n") - writeWorktreeFile(t, ws, "untracked.txt", "untracked\n") - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateDirty { - t.Errorf("Kind = %q; want dirty-owned", insp.Kind) - } - want := []string{"main.go", "staged.txt", "untracked.txt"} - if !reflect.DeepEqual(insp.DirtyPaths, want) { - t.Errorf("DirtyPaths = %v; want %v", insp.DirtyPaths, want) - } -} - -func TestInspectResumable(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - writeStateManifest(t, repo, tgt, base, PhaseAllocating) - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateResumable { - t.Errorf("Kind = %q; want allocating (resumable)", insp.Kind) - } - if insp.Registered { - t.Errorf("Registered = true; want false on a bare allocating partial") - } - if insp.BaseCommit != base { - t.Errorf("BaseCommit = %q; want %q", insp.BaseCommit, base) - } -} - -func TestInspectCleaned(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - - // A cleaned tombstone: the checkout removed (branch kept), manifest advanced - // to cleaned. This mimics the state Task 7 Cleanup leaves. - gitOut(t, r.Primary, "worktree", "remove", "--force", "--", wsPathOf(repo)) - m, present, err := loadManifest(metaDirOf(repo, tgt)) - if err != nil || !present { - t.Fatalf("loadManifest: present=%v err=%v", present, err) - } - m.Phase = PhaseCleaned - m.UpdatedUTC = time.Now().UTC().Format(time.RFC3339) - if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { - t.Fatalf("writeManifest(cleaned): %v", err) - } - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateCleaned { - t.Errorf("Kind = %q; want cleaned", insp.Kind) - } - if insp.Registered { - t.Errorf("Registered = true; want false for a tombstone") - } - // The local branch survives cleanup. - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch missing; cleanup keeps the branch") - } -} - -func TestInspectBranchGone(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - - // A ready manifest whose recorded feature branch was never created / is gone. - writeStateManifest(t, repo, tgt, base, PhaseReady) - - insp := assertInspectReadOnly(t, svc, r, repo, tgt) - if insp.Kind != StateBranchGone { - t.Errorf("Kind = %q; want branch-missing", insp.Kind) - } -} - -func TestInspectMismatch(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - - // Ready manifest, branch still present, but the registered checkout is gone: - // path/registration/manifest disagree. - gitOut(t, r.Primary, "worktree", "remove", "--force", "--", wsPathOf(repo)) - - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateMismatch { - t.Errorf("Kind = %q; want mismatch", insp.Kind) - } - if insp.Registered { - t.Errorf("Registered = true; want false") - } -} - -func TestInspectForeignMalformed(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - if err := os.MkdirAll(metaDirOf(repo, tgt), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(metaDirOf(repo, tgt), manifestFileName), []byte("{not json"), 0o600); err != nil { - t.Fatal(err) - } - - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign", insp.Kind) - } - if insp.Detail == "" { - t.Errorf("Detail empty; want the parse detail carried as data") - } -} - -// TestInspectAbsent is the regression for change 0368: with no manifest, no -// local feature branch, nothing at the intended path, and no registration, -// Inspect reports the proven-absent state instead of foreign. This test MUST -// fail against the pre-0368 conflation (which returned StateForeign here). -func TestInspectAbsent(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateAbsent { - t.Errorf("Kind = %q; want absent for an all-clean missing workspace", insp.Kind) - } -} - -// TestInspectAbsentBlockedByLeftovers pins each leftover that keeps a -// manifest-absent slot classified StateForeign rather than StateAbsent: a -// surviving local feature branch, anything at the intended path (including a -// dangling symlink), and a worktree registration on the feature ref or at the -// target path (change 0368). -func TestInspectAbsentBlockedByLeftovers(t *testing.T) { - t.Run("local-branch", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - gitOut(t, r.Primary, "branch", strings.TrimPrefix(string(tgt.FeatureRef), "refs/heads/"), "main") - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign when the local feature branch exists", insp.Kind) - } - }) - t.Run("occupied-path-dir", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - if err := os.MkdirAll(wsPathOf(repo), 0o755); err != nil { - t.Fatal(err) - } - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign when the target path is occupied", insp.Kind) - } - }) - t.Run("dangling-symlink", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - if err := os.MkdirAll(filepath.Dir(wsPathOf(repo)), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(filepath.Join(testsupport.TempDir(t), "gone"), wsPathOf(repo)); err != nil { - t.Fatal(err) - } - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign for a dangling symlink at the target path", insp.Kind) - } - }) - t.Run("registration-on-feature-ref-elsewhere", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - elsewhere := filepath.Join(r.Primary, "..", "elsewhere-ws") - gitOut(t, r.Primary, "worktree", "add", "-b", - strings.TrimPrefix(string(tgt.FeatureRef), "refs/heads/"), elsewhere, "main") - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign when a registration references the feature ref", insp.Kind) - } - }) - t.Run("stale-registration-at-path", func(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - gitOut(t, r.Primary, "worktree", "add", "-b", "throwaway/stale-reg", wsPathOf(repo), "main") - if err := os.RemoveAll(wsPathOf(repo)); err != nil { // directory gone, registration remains - t.Fatal(err) - } - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign for a stale registration at the target path", insp.Kind) - } - }) -} - -// TestInspectForeignUnownedCommonDir isolates the manifest OWNERSHIP gate -// (ownsManifest's CommonDir identity conjunct). It writes a structurally valid -// manifest whose ONLY defect is a foreign CommonDir — every other field matches -// this repository and target — and requires StateForeign with the identity -// detail. Unlike Prepare and Cleanup (which re-verify live registration and so -// backstop the ownership check), Inspect returns StateForeign directly on an -// unowned manifest with no other gate behind it, so this is the case that -// reddens when the CommonDir conjunct is dropped. -func TestInspectForeignUnownedCommonDir(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - other := mainModeRepo(t) - _, otherRepo := other.newService(t) - foreign := Manifest{ - Schema: manifestSchemaVersion, ID: workspaceID(tgt.FeatureRef), CommonDir: otherRepo.CommonDir, - ChangeID: tgt.ChangeID, Slug: tgt.Slug, FeatureRef: tgt.FeatureRef, BaseRef: tgt.BaseRef, - BaseCommit: gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")), - Path: wsPathOf(repo), Phase: PhaseReady, - CreatedUTC: time.Now().UTC().Format(time.RFC3339), UpdatedUTC: time.Now().UTC().Format(time.RFC3339), - } - if err := writeManifest(metaDirOf(repo, tgt), foreign); err != nil { - t.Fatalf("writeManifest(foreign): %v", err) - } - - insp := inspectOK(t, svc, repo, tgt) - if insp.Kind != StateForeign { - t.Errorf("Kind = %q; want foreign for an unowned (foreign-CommonDir) manifest", insp.Kind) - } - if insp.Detail == "" { - t.Errorf("Detail empty; want the identity-mismatch reason carried as data") - } -} - -// TestInspectAbsentSlotStatErrorIsError pins the STAT probe-error arm of -// classifyAbsentSlot (change 0368: "a genuine probe error is an error, never -// read as absence in either direction"). With the manifest slot and the local -// feature ref both cleanly absent, a stat error on the intended workspace path -// — here the `.worktrees` parent is a regular FILE, so Lstat of the leaf fails -// with ENOTDIR, which is NOT os.IsNotExist — must surface a typed Failure, never -// fall through to StateAbsent. It reddens if pathPresent's non-IsNotExist error -// is mutated into a clean-absence answer. -func TestInspectAbsentSlotStatErrorIsError(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - - // Occupy the `.worktrees` parent with a regular file so Lstat of the intended - // workspace path (its child) fails with a non-IsNotExist error. - wtParent := filepath.Dir(wsPathOf(repo)) - if err := os.WriteFile(wtParent, []byte("not a directory\n"), 0o644); err != nil { - t.Fatal(err) - } - - insp, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) - if err == nil { - t.Fatalf("Inspect with a stat-erroring target path = nil error, Kind=%q; want a typed Failure, never clean absence", insp.Kind) - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindExternal { - t.Errorf("Kind = %q; want external", f.Kind) - } -} - -func TestInspectUnreadableIsError(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - base := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", "main")) - writeStateManifest(t, repo, tgt, base, PhaseReady) - - dir := metaDirOf(repo, tgt) - if err := os.Chmod(dir, 0o000); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) - - _, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) - if err == nil { - t.Fatalf("Inspect on unreadable manifest dir = nil error; want failure") - } - f, ok := AsFailure(err) - if !ok { - t.Fatalf("error %v is not a *Failure", err) - } - if f.Kind != KindExternal { - t.Errorf("Kind = %q; want external", f.Kind) - } -} diff --git a/internal/workspace/publish_integration_test.go b/internal/workspace/publish_integration_test.go new file mode 100644 index 000000000..ac90c8a9a --- /dev/null +++ b/internal/workspace/publish_integration_test.go @@ -0,0 +1,452 @@ +//go:build integration + +package workspace + +import ( + "context" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestIntegrationWorkspaceLifecyclePublishAbsentRefCreates proves an absent remote feature ref is created +// under the absent-ref lease: the disposition is published and the origin ref +// equals the exact local HEAD. Proven on both topologies. +func TestIntegrationWorkspaceLifecyclePublishAbsentRefCreates(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + if _, ok := originFeatCommit(t, r); ok { + t.Fatalf("fixture: origin feat ref already exists before publish") + } + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishPublished { + t.Errorf("Disposition = %q; want published", res.Disposition) + } + if res.Head != head { + t.Errorf("Head = %q; want local head %q", res.Head, head) + } + remote, ok := originFeatCommit(t, r) + if !ok { + t.Fatalf("origin feat ref absent after publish") + } + if remote != head { + t.Errorf("origin feat ref = %q; want local head %q", remote, head) + } + if res.Remote != head { + t.Errorf("result Remote = %q; want %q", res.Remote, head) + } + }) +} + +// TestIntegrationWorkspaceLifecyclePublishReadyAfterParentRebase proves a clean, registered, on-ref ready +// workspace publishes even when its head no longer reaches the recorded +// creation base (a manual parent rebase). The stale-head, detached, dirty, and +// identity refusals are unchanged (change 0429). +func TestIntegrationWorkspaceLifecyclePublishReadyAfterParentRebase(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Rewrite the recorded base to a commit the feature head does not reach (a + // later origin-main commit, fetched into the object store) — the same + // construction inspect_test's parent-rebase row uses, now a legitimate state. + c1 := r.advanceMain(t) + gitOut(t, r.Primary, "fetch", "-q", "origin", "main") + m, present, err := loadManifest(metaDirOf(repo, tgt)) + if err != nil || !present { + t.Fatalf("loadManifest present=%v err=%v", present, err) + } + m.BaseCommit = c1 + if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { + t.Fatalf("writeManifest(rewritten base): %v", err) + } + + // Publish must succeed exactly as it does for an untouched ready workspace + // (success facts lifted from TestIntegrationWorkspaceLifecyclePublishAbsentRefCreates). + if _, ok := originFeatCommit(t, r); ok { + t.Fatalf("fixture: origin feat ref already exists before publish") + } + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishPublished { + t.Errorf("Disposition = %q; want published", res.Disposition) + } + if res.Head != head { + t.Errorf("Head = %q; want local head %q", res.Head, head) + } + remote, ok := originFeatCommit(t, r) + if !ok { + t.Fatalf("origin feat ref absent after publish") + } + if remote != head { + t.Errorf("origin feat ref = %q; want local head %q", remote, head) + } + if res.Remote != head { + t.Errorf("result Remote = %q; want %q", res.Remote, head) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRepeatAlreadyPublished proves a second PublishHead with the remote +// already at the local HEAD returns already-published and performs no second +// update: the origin ref value is unchanged. +func TestIntegrationWorkspaceLifecyclePublishRepeatAlreadyPublished(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("first PublishHead = %q err=%v; want published", res.Disposition, err) + } + before, ok := originFeatCommit(t, r) + if !ok { + t.Fatalf("origin feat ref absent after first publish") + } + + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("second PublishHead: %v", err) + } + if res.Disposition != PublishAlreadyPublished { + t.Errorf("Disposition = %q; want already-published", res.Disposition) + } + if res.Remote != head { + t.Errorf("Remote = %q; want %q", res.Remote, head) + } + after, _ := originFeatCommit(t, r) + if after != before { + t.Errorf("origin feat ref changed on already-published: before=%q after=%q", before, after) + } + }) +} + +// TestIntegrationWorkspaceLifecyclePublishFastForward proves an existing remote ref that is an ancestor of +// the local HEAD is fast-forwarded under an expected-old lease: after a first +// publish and a further local commit, a second publish moves the origin ref to +// the new HEAD. +func TestIntegrationWorkspaceLifecyclePublishFastForward(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + + head1 := commitInWorkspace(t, ws, "feature.txt", "first\n") + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("first publish = %q err=%v; want published", res.Disposition, err) + } + if got, _ := originFeatCommit(t, r); got != head1 { + t.Fatalf("origin after first publish = %q; want %q", got, head1) + } + + head2 := commitInWorkspace(t, ws, "feature.txt", "first\nsecond\n") + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("second PublishHead: %v", err) + } + if res.Disposition != PublishPublished { + t.Errorf("Disposition = %q; want published (fast-forward)", res.Disposition) + } + if res.Head != head2 { + t.Errorf("Head = %q; want %q", res.Head, head2) + } + if got, _ := originFeatCommit(t, r); got != head2 { + t.Errorf("origin after fast-forward = %q; want %q", got, head2) + } +} + +// TestIntegrationWorkspaceLifecyclePublishDivergentContended proves a remote ref holding a commit that is +// neither equal to nor an ancestor of the local HEAD is refused as contended: +// PublishHead never force-pushes, and the origin ref keeps the interloper. +func TestIntegrationWorkspaceLifecyclePublishDivergentContended(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + localHead := commitInWorkspace(t, ws, "feature.txt", "local work\n") + + // The writer publishes a divergent commit onto the feature ref out-of-band. + gitOut(t, r.Writer, "checkout", "-q", "-B", "feat/"+prepSlug, "origin/main") + writeWorktreeFile(t, r.Writer, "divergent.txt", "divergent work\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "divergent") + divergent := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) + gitOut(t, r.Writer, "push", "-f", "-q", "origin", "feat/"+prepSlug) + gitOut(t, r.Writer, "checkout", "-q", "main") + + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishContended { + t.Errorf("Disposition = %q; want contended", res.Disposition) + } + if res.Head != localHead { + t.Errorf("Head = %q; want %q", res.Head, localHead) + } + if res.Remote != divergent { + t.Errorf("Remote = %q; want observed divergent %q", res.Remote, divergent) + } + // Origin still holds the interloper: no force push overwrote it. + if got, _ := originFeatCommit(t, r); got != divergent { + t.Errorf("origin feat ref = %q; want unchanged divergent %q", got, divergent) + } +} + +// TestIntegrationWorkspaceLifecyclePublishLostResponseAdopted proves the idempotency key is the remote state: +// a HEAD already pushed out-of-band (a lost push response) is adopted as +// already-published, not pushed again. +func TestIntegrationWorkspaceLifecyclePublishLostResponseAdopted(t *testing.T) { + eachTopology(t, func(t *testing.T, r *wsRepos) { + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Simulate our own push whose response was lost: the ref is already at HEAD. + gitOut(t, ws, "push", "-q", "origin", "feat/"+prepSlug) + + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishAlreadyPublished { + t.Errorf("Disposition = %q; want already-published (adopted)", res.Disposition) + } + if res.Remote != head { + t.Errorf("Remote = %q; want %q", res.Remote, head) + } + }) +} + +// TestIntegrationWorkspaceLifecyclePublishLocalProxiesNotConsulted repeats the lost-response case with a +// clean tree AND an upstream configured, proving PublishHead keys on remote +// equality and never on those local proxies (absent from its signature). +func TestIntegrationWorkspaceLifecyclePublishLocalProxiesNotConsulted(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + gitOut(t, ws, "push", "-q", "origin", "feat/"+prepSlug) + // Configure the upstream and confirm the tree is clean: pure local proxies. + gitOut(t, ws, "branch", "--set-upstream-to=origin/feat/"+prepSlug, "feat/"+prepSlug) + if status := gitOut(t, ws, "status", "--porcelain"); status != "" { + t.Fatalf("fixture: workspace not clean:\n%s", status) + } + + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishAlreadyPublished { + t.Errorf("Disposition = %q; want already-published regardless of local proxies", res.Disposition) + } + if res.Remote != head { + t.Errorf("Remote = %q; want %q", res.Remote, head) + } +} + +// TestIntegrationWorkspaceLifecyclePublishDirtyRefused proves a dirty workspace is refused as an invalid-state +// failure and the origin ref is untouched. +func TestIntegrationWorkspaceLifecyclePublishDirtyRefused(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + commitInWorkspace(t, ws, "feature.txt", "feature work\n") + // An untracked file makes the workspace dirty. + writeWorktreeFile(t, ws, "scratch.txt", "unsaved\n") + + res, err := publishHead(t, svc, repo, tgt) + if err == nil { + t.Fatalf("PublishHead on dirty workspace = nil error; want invalid-state failure") + } + if res.Disposition != PublishFailed { + t.Errorf("Disposition = %q; want failed", res.Disposition) + } + f, ok := AsFailure(err) + if !ok || f.Kind != KindInvalidState { + t.Errorf("error = %v; want *Failure invalid-state", err) + } + if _, ok := originFeatCommit(t, r); ok { + t.Errorf("origin feat ref created for a dirty workspace; must be untouched") + } +} + +// TestIntegrationWorkspaceLifecyclePublishDetachedRefused proves a workspace whose HEAD is detached (off the +// feature ref) is refused as an invalid-state failure with the origin untouched. +func TestIntegrationWorkspaceLifecyclePublishDetachedRefused(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + commitInWorkspace(t, ws, "feature.txt", "feature work\n") + gitOut(t, ws, "checkout", "-q", "--detach", "HEAD") + + res, err := publishHead(t, svc, repo, tgt) + if err == nil { + t.Fatalf("PublishHead on detached HEAD = nil error; want invalid-state failure") + } + if res.Disposition != PublishFailed { + t.Errorf("Disposition = %q; want failed", res.Disposition) + } + if f, ok := AsFailure(err); !ok || f.Kind != KindInvalidState { + t.Errorf("error = %v; want *Failure invalid-state", err) + } + if _, ok := originFeatCommit(t, r); ok { + t.Errorf("origin feat ref created for a detached workspace; must be untouched") + } +} + +// TestIntegrationWorkspaceLifecyclePublishUnprobeableRemoteUnknown proves an unobservable remote yields +// unknown with no fabricated Remote id. The remote URL is broken after +// preparation, so the authoritative probe cannot establish the remote state. +func TestIntegrationWorkspaceLifecyclePublishUnprobeableRemoteUnknown(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Break the remote URL: the name stays configured, but ls-remote/push fail. + gitOut(t, r.Primary, "remote", "set-url", "origin", filepath.Join(testsupport.TempDir(t), "nonexistent.git")) + + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishUnknown { + t.Errorf("Disposition = %q; want unknown", res.Disposition) + } + if res.Head != head { + t.Errorf("Head = %q; want %q", res.Head, head) + } + if res.Remote != "" { + t.Errorf("Remote = %q; want empty (no fabricated id)", res.Remote) + } +} + +// TestIntegrationWorkspaceLifecyclePublishPushFailsRefAbsentFailed drives the not-structurally-conclusive +// push branch: an unwritable origin makes the create push fail, and the +// re-probe shows the ref still cleanly absent — a definite failed, never a +// false published or a silent unknown. +func TestIntegrationWorkspaceLifecyclePublishPushFailsRefAbsentFailed(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Make the origin recursively read-only so a push cannot write objects while + // ls-remote can still read the refs. Restored on cleanup so TempDir removal + // succeeds. + chmodTree(t, r.Origin, 0o500) + t.Cleanup(func() { chmodTree(t, r.Origin, 0o700) }) + + res, err := publishHead(t, svc, repo, tgt) + if err == nil { + t.Fatalf("PublishHead with unwritable origin = nil error; want failed") + } + if res.Disposition != PublishFailed { + t.Errorf("Disposition = %q; want failed", res.Disposition) + } + if _, ok := originFeatCommit(t, r); ok { + t.Errorf("origin feat ref exists; the push must not have landed") + } +} + +// TestIntegrationWorkspaceLifecyclePublishExpectedHeadMoved proves PublishHead refuses under its own +// operation lock when the reinspected local head is not the caller's +// ExpectedHead — the moved-head window between the app-level check and the +// locked reinspect (change 0451). The refusal mirrors the PR path's +// moved-head gate in EnsurePullRequest ("GitHub reports a head commit other +// than the expected published head"): failed + invalid-state, and NOTHING is +// pushed — the origin feature ref stays absent. +func TestIntegrationWorkspaceLifecyclePublishExpectedHeadMoved(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + + // The head the app-level check approved… + checked := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + // …and a commit that lands after that check, before the locked publish. + moved := commitInWorkspace(t, ws, "late.txt", "late work\n") + if moved == checked { + t.Fatalf("fixture: the second commit did not move the head") + } + + res, err := svc.PublishHead(context.Background(), PublishRequest{ + Repository: repo, + Remote: "origin", + Target: tgt, + ExpectedHead: checked, + }) + if err == nil { + t.Fatalf("PublishHead accepted a moved head; result %+v", res) + } + f, ok := AsFailure(err) + if !ok || f.Kind != KindInvalidState { + t.Errorf("error = %v; want a Failure of kind %q", err, KindInvalidState) + } + if res.Disposition != PublishFailed { + t.Errorf("Disposition = %q; want failed", res.Disposition) + } + if _, exists := originFeatCommit(t, r); exists { + t.Errorf("origin feat ref exists after a refused publish; nothing must be pushed") + } +} + +// TestIntegrationWorkspaceLifecyclePublishExpectedHeadMatches pins the other side of the change 0451 gate: +// an ExpectedHead equal to the reinspected head is no obstacle — the exact +// checked commit is published onto the origin feature ref. +func TestIntegrationWorkspaceLifecyclePublishExpectedHeadMatches(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + checked := commitInWorkspace(t, wsPathOf(repo), "feature.txt", "feature work\n") + + res, err := svc.PublishHead(context.Background(), PublishRequest{ + Repository: repo, + Remote: "origin", + Target: tgt, + ExpectedHead: checked, + }) + if err != nil { + t.Fatalf("PublishHead refused a matching expected head: %v", err) + } + if res.Disposition != PublishPublished || res.Head != checked { + t.Errorf("result = %+v; want published head %s", res, checked) + } + if got, ok := originFeatCommit(t, r); !ok || got != checked { + t.Errorf("origin feat = %q (exists %v); want %s", got, ok, checked) + } +} diff --git a/internal/workspace/publish_test.go b/internal/workspace/publish_test.go index 5cc463f8d..324b42cdd 100644 --- a/internal/workspace/publish_test.go +++ b/internal/workspace/publish_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/testsupport" ) // The PublishHead tests drive the idempotent feature-branch publication flow @@ -78,443 +77,3 @@ func publishHead(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) t.Helper() return svc.PublishHead(context.Background(), PublishRequest{Repository: repo, Remote: "origin", Target: tgt}) } - -// TestPublishAbsentRefCreates proves an absent remote feature ref is created -// under the absent-ref lease: the disposition is published and the origin ref -// equals the exact local HEAD. Proven on both topologies. -func TestPublishAbsentRefCreates(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - if _, ok := originFeatCommit(t, r); ok { - t.Fatalf("fixture: origin feat ref already exists before publish") - } - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishPublished { - t.Errorf("Disposition = %q; want published", res.Disposition) - } - if res.Head != head { - t.Errorf("Head = %q; want local head %q", res.Head, head) - } - remote, ok := originFeatCommit(t, r) - if !ok { - t.Fatalf("origin feat ref absent after publish") - } - if remote != head { - t.Errorf("origin feat ref = %q; want local head %q", remote, head) - } - if res.Remote != head { - t.Errorf("result Remote = %q; want %q", res.Remote, head) - } - }) -} - -// TestPublishReadyAfterParentRebase proves a clean, registered, on-ref ready -// workspace publishes even when its head no longer reaches the recorded -// creation base (a manual parent rebase). The stale-head, detached, dirty, and -// identity refusals are unchanged (change 0429). -func TestPublishReadyAfterParentRebase(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Rewrite the recorded base to a commit the feature head does not reach (a - // later origin-main commit, fetched into the object store) — the same - // construction inspect_test's parent-rebase row uses, now a legitimate state. - c1 := r.advanceMain(t) - gitOut(t, r.Primary, "fetch", "-q", "origin", "main") - m, present, err := loadManifest(metaDirOf(repo, tgt)) - if err != nil || !present { - t.Fatalf("loadManifest present=%v err=%v", present, err) - } - m.BaseCommit = c1 - if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { - t.Fatalf("writeManifest(rewritten base): %v", err) - } - - // Publish must succeed exactly as it does for an untouched ready workspace - // (success facts lifted from TestPublishAbsentRefCreates). - if _, ok := originFeatCommit(t, r); ok { - t.Fatalf("fixture: origin feat ref already exists before publish") - } - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishPublished { - t.Errorf("Disposition = %q; want published", res.Disposition) - } - if res.Head != head { - t.Errorf("Head = %q; want local head %q", res.Head, head) - } - remote, ok := originFeatCommit(t, r) - if !ok { - t.Fatalf("origin feat ref absent after publish") - } - if remote != head { - t.Errorf("origin feat ref = %q; want local head %q", remote, head) - } - if res.Remote != head { - t.Errorf("result Remote = %q; want %q", res.Remote, head) - } -} - -// TestPublishRepeatAlreadyPublished proves a second PublishHead with the remote -// already at the local HEAD returns already-published and performs no second -// update: the origin ref value is unchanged. -func TestPublishRepeatAlreadyPublished(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("first PublishHead = %q err=%v; want published", res.Disposition, err) - } - before, ok := originFeatCommit(t, r) - if !ok { - t.Fatalf("origin feat ref absent after first publish") - } - - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("second PublishHead: %v", err) - } - if res.Disposition != PublishAlreadyPublished { - t.Errorf("Disposition = %q; want already-published", res.Disposition) - } - if res.Remote != head { - t.Errorf("Remote = %q; want %q", res.Remote, head) - } - after, _ := originFeatCommit(t, r) - if after != before { - t.Errorf("origin feat ref changed on already-published: before=%q after=%q", before, after) - } - }) -} - -// TestPublishFastForward proves an existing remote ref that is an ancestor of -// the local HEAD is fast-forwarded under an expected-old lease: after a first -// publish and a further local commit, a second publish moves the origin ref to -// the new HEAD. -func TestPublishFastForward(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - - head1 := commitInWorkspace(t, ws, "feature.txt", "first\n") - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("first publish = %q err=%v; want published", res.Disposition, err) - } - if got, _ := originFeatCommit(t, r); got != head1 { - t.Fatalf("origin after first publish = %q; want %q", got, head1) - } - - head2 := commitInWorkspace(t, ws, "feature.txt", "first\nsecond\n") - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("second PublishHead: %v", err) - } - if res.Disposition != PublishPublished { - t.Errorf("Disposition = %q; want published (fast-forward)", res.Disposition) - } - if res.Head != head2 { - t.Errorf("Head = %q; want %q", res.Head, head2) - } - if got, _ := originFeatCommit(t, r); got != head2 { - t.Errorf("origin after fast-forward = %q; want %q", got, head2) - } -} - -// TestPublishDivergentContended proves a remote ref holding a commit that is -// neither equal to nor an ancestor of the local HEAD is refused as contended: -// PublishHead never force-pushes, and the origin ref keeps the interloper. -func TestPublishDivergentContended(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - localHead := commitInWorkspace(t, ws, "feature.txt", "local work\n") - - // The writer publishes a divergent commit onto the feature ref out-of-band. - gitOut(t, r.Writer, "checkout", "-q", "-B", "feat/"+prepSlug, "origin/main") - writeWorktreeFile(t, r.Writer, "divergent.txt", "divergent work\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "divergent") - divergent := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) - gitOut(t, r.Writer, "push", "-f", "-q", "origin", "feat/"+prepSlug) - gitOut(t, r.Writer, "checkout", "-q", "main") - - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishContended { - t.Errorf("Disposition = %q; want contended", res.Disposition) - } - if res.Head != localHead { - t.Errorf("Head = %q; want %q", res.Head, localHead) - } - if res.Remote != divergent { - t.Errorf("Remote = %q; want observed divergent %q", res.Remote, divergent) - } - // Origin still holds the interloper: no force push overwrote it. - if got, _ := originFeatCommit(t, r); got != divergent { - t.Errorf("origin feat ref = %q; want unchanged divergent %q", got, divergent) - } -} - -// TestPublishLostResponseAdopted proves the idempotency key is the remote state: -// a HEAD already pushed out-of-band (a lost push response) is adopted as -// already-published, not pushed again. -func TestPublishLostResponseAdopted(t *testing.T) { - eachTopology(t, func(t *testing.T, r *wsRepos) { - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Simulate our own push whose response was lost: the ref is already at HEAD. - gitOut(t, ws, "push", "-q", "origin", "feat/"+prepSlug) - - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishAlreadyPublished { - t.Errorf("Disposition = %q; want already-published (adopted)", res.Disposition) - } - if res.Remote != head { - t.Errorf("Remote = %q; want %q", res.Remote, head) - } - }) -} - -// TestPublishLocalProxiesNotConsulted repeats the lost-response case with a -// clean tree AND an upstream configured, proving PublishHead keys on remote -// equality and never on those local proxies (absent from its signature). -func TestPublishLocalProxiesNotConsulted(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - gitOut(t, ws, "push", "-q", "origin", "feat/"+prepSlug) - // Configure the upstream and confirm the tree is clean: pure local proxies. - gitOut(t, ws, "branch", "--set-upstream-to=origin/feat/"+prepSlug, "feat/"+prepSlug) - if status := gitOut(t, ws, "status", "--porcelain"); status != "" { - t.Fatalf("fixture: workspace not clean:\n%s", status) - } - - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishAlreadyPublished { - t.Errorf("Disposition = %q; want already-published regardless of local proxies", res.Disposition) - } - if res.Remote != head { - t.Errorf("Remote = %q; want %q", res.Remote, head) - } -} - -// TestPublishDirtyRefused proves a dirty workspace is refused as an invalid-state -// failure and the origin ref is untouched. -func TestPublishDirtyRefused(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - commitInWorkspace(t, ws, "feature.txt", "feature work\n") - // An untracked file makes the workspace dirty. - writeWorktreeFile(t, ws, "scratch.txt", "unsaved\n") - - res, err := publishHead(t, svc, repo, tgt) - if err == nil { - t.Fatalf("PublishHead on dirty workspace = nil error; want invalid-state failure") - } - if res.Disposition != PublishFailed { - t.Errorf("Disposition = %q; want failed", res.Disposition) - } - f, ok := AsFailure(err) - if !ok || f.Kind != KindInvalidState { - t.Errorf("error = %v; want *Failure invalid-state", err) - } - if _, ok := originFeatCommit(t, r); ok { - t.Errorf("origin feat ref created for a dirty workspace; must be untouched") - } -} - -// TestPublishDetachedRefused proves a workspace whose HEAD is detached (off the -// feature ref) is refused as an invalid-state failure with the origin untouched. -func TestPublishDetachedRefused(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - commitInWorkspace(t, ws, "feature.txt", "feature work\n") - gitOut(t, ws, "checkout", "-q", "--detach", "HEAD") - - res, err := publishHead(t, svc, repo, tgt) - if err == nil { - t.Fatalf("PublishHead on detached HEAD = nil error; want invalid-state failure") - } - if res.Disposition != PublishFailed { - t.Errorf("Disposition = %q; want failed", res.Disposition) - } - if f, ok := AsFailure(err); !ok || f.Kind != KindInvalidState { - t.Errorf("error = %v; want *Failure invalid-state", err) - } - if _, ok := originFeatCommit(t, r); ok { - t.Errorf("origin feat ref created for a detached workspace; must be untouched") - } -} - -// TestPublishUnprobeableRemoteUnknown proves an unobservable remote yields -// unknown with no fabricated Remote id. The remote URL is broken after -// preparation, so the authoritative probe cannot establish the remote state. -func TestPublishUnprobeableRemoteUnknown(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Break the remote URL: the name stays configured, but ls-remote/push fail. - gitOut(t, r.Primary, "remote", "set-url", "origin", filepath.Join(testsupport.TempDir(t), "nonexistent.git")) - - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishUnknown { - t.Errorf("Disposition = %q; want unknown", res.Disposition) - } - if res.Head != head { - t.Errorf("Head = %q; want %q", res.Head, head) - } - if res.Remote != "" { - t.Errorf("Remote = %q; want empty (no fabricated id)", res.Remote) - } -} - -// TestPublishPushFailsRefAbsentFailed drives the not-structurally-conclusive -// push branch: an unwritable origin makes the create push fail, and the -// re-probe shows the ref still cleanly absent — a definite failed, never a -// false published or a silent unknown. -func TestPublishPushFailsRefAbsentFailed(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Make the origin recursively read-only so a push cannot write objects while - // ls-remote can still read the refs. Restored on cleanup so TempDir removal - // succeeds. - chmodTree(t, r.Origin, 0o500) - t.Cleanup(func() { chmodTree(t, r.Origin, 0o700) }) - - res, err := publishHead(t, svc, repo, tgt) - if err == nil { - t.Fatalf("PublishHead with unwritable origin = nil error; want failed") - } - if res.Disposition != PublishFailed { - t.Errorf("Disposition = %q; want failed", res.Disposition) - } - if _, ok := originFeatCommit(t, r); ok { - t.Errorf("origin feat ref exists; the push must not have landed") - } -} - -// TestPublishExpectedHeadMoved proves PublishHead refuses under its own -// operation lock when the reinspected local head is not the caller's -// ExpectedHead — the moved-head window between the app-level check and the -// locked reinspect (change 0451). The refusal mirrors the PR path's -// moved-head gate in EnsurePullRequest ("GitHub reports a head commit other -// than the expected published head"): failed + invalid-state, and NOTHING is -// pushed — the origin feature ref stays absent. -func TestPublishExpectedHeadMoved(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - - // The head the app-level check approved… - checked := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - // …and a commit that lands after that check, before the locked publish. - moved := commitInWorkspace(t, ws, "late.txt", "late work\n") - if moved == checked { - t.Fatalf("fixture: the second commit did not move the head") - } - - res, err := svc.PublishHead(context.Background(), PublishRequest{ - Repository: repo, - Remote: "origin", - Target: tgt, - ExpectedHead: checked, - }) - if err == nil { - t.Fatalf("PublishHead accepted a moved head; result %+v", res) - } - f, ok := AsFailure(err) - if !ok || f.Kind != KindInvalidState { - t.Errorf("error = %v; want a Failure of kind %q", err, KindInvalidState) - } - if res.Disposition != PublishFailed { - t.Errorf("Disposition = %q; want failed", res.Disposition) - } - if _, exists := originFeatCommit(t, r); exists { - t.Errorf("origin feat ref exists after a refused publish; nothing must be pushed") - } -} - -// TestPublishExpectedHeadMatches pins the other side of the change 0451 gate: -// an ExpectedHead equal to the reinspected head is no obstacle — the exact -// checked commit is published onto the origin feature ref. -func TestPublishExpectedHeadMatches(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - checked := commitInWorkspace(t, wsPathOf(repo), "feature.txt", "feature work\n") - - res, err := svc.PublishHead(context.Background(), PublishRequest{ - Repository: repo, - Remote: "origin", - Target: tgt, - ExpectedHead: checked, - }) - if err != nil { - t.Fatalf("PublishHead refused a matching expected head: %v", err) - } - if res.Disposition != PublishPublished || res.Head != checked { - t.Errorf("result = %+v; want published head %s", res, checked) - } - if got, ok := originFeatCommit(t, r); !ok || got != checked { - t.Errorf("origin feat = %q (exists %v); want %s", got, ok, checked) - } -} diff --git a/internal/workspace/rewrite_integration_test.go b/internal/workspace/rewrite_integration_test.go new file mode 100644 index 000000000..2d8137f2a --- /dev/null +++ b/internal/workspace/rewrite_integration_test.go @@ -0,0 +1,347 @@ +//go:build integration + +package workspace + +import ( + "context" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestIntegrationWorkspaceLifecyclePublishRewriteLease proves the happy path: with the remote at the receipt's +// OrigRemoteHead, PublishRewrite lands exactly NewHead under the lease and +// reprobes to equality, reporting published. +func TestIntegrationWorkspaceLifecyclePublishRewriteLease(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Establish the remote feature ref at head1. + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) + } + + // Rewrite the local branch to a divergent new head. + newHead := rewriteWorkspaceHead(t, ws) + if newHead == head1 { + t.Fatalf("fixture: rewrite did not change the head") + } + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewritePublished { + t.Errorf("outcome = %q; want published", outcome) + } + if got, ok := originFeatCommit(t, r); !ok || got != newHead { + t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteLeaseWithGatePair proves publish still authorizes a rewrite +// from a receipt that carries the gate-continuation pair: the on-disk receipt and +// the caller's expected receipt are the same value, pair included, so the +// equality gate holds (every field is a scalar string; the whole value compares). +func TestIntegrationWorkspaceLifecyclePublishRewriteLeaseWithGatePair(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + // Establish the remote feature ref at head1. + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) + } + + // Rewrite the local branch to a divergent new head. + newHead := rewriteWorkspaceHead(t, ws) + if newHead == head1 { + t.Fatalf("fixture: rewrite did not change the head") + } + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + rec.GateDriveID = "drive-01" + rec.GateOwnerGeneration = "gen-01" + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewritePublished { + t.Errorf("outcome = %q; want published", outcome) + } + if got, ok := originFeatCommit(t, r); !ok || got != newHead { + t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteLeaseWithPublishCheckpoint proves publish still authorizes +// a rewrite from a receipt carrying the completed-gate publish checkpoint +// (change 0408): the on-disk receipt and the caller's expected receipt are the +// same value, checkpoint included, so the equality gate holds — and the exact +// lease/reprobe behavior is unchanged. +func TestIntegrationWorkspaceLifecyclePublishRewriteLeaseWithPublishCheckpoint(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) + } + newHead := rewriteWorkspaceHead(t, ws) + if newHead == head1 { + t.Fatalf("fixture: rewrite did not change the head") + } + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + rec.PublishCheckpointHead = string(newHead) + rec.PublishCheckpointBaseHead = string(base) + rec.PublishCheckpointCommand = "go test ./..." + rec.PublishCheckpointGate = "local" + rec.PublishCheckpointPRNumber = "7" + rec.PublishCheckpointEvidence = "result: green\n" + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewritePublished { + t.Errorf("outcome = %q; want published", outcome) + } + if got, ok := originFeatCommit(t, r); !ok || got != newHead { + t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteNoop proves the idempotency key is the remote state: a remote +// already holding NewHead (a completed rewrite / adopted lost response) is a noop +// with no push issued, even though the remote no longer sits at OrigRemoteHead. +func TestIntegrationWorkspaceLifecyclePublishRewriteNoop(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + newHead := rewriteWorkspaceHead(t, ws) + + // The rewrite already reached the remote out of band; the local branch is at + // newHead, so a force push publishes exactly it. + gitOut(t, ws, "push", "-f", "-q", "origin", "feat/"+prepSlug) + before, ok := originFeatCommit(t, r) + if !ok || before != newHead { + t.Fatalf("fixture: origin feat ref = %q (ok=%v); want %q", before, ok, newHead) + } + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewriteNoop { + t.Errorf("outcome = %q; want noop", outcome) + } + if after, _ := originFeatCommit(t, r); after != before { + t.Errorf("origin feat ref changed on a noop: before=%q after=%q", before, after) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteContention proves a remote moved off OrigRemoteHead (and not +// at NewHead) is contended with the remote untouched: no push is issued, so the +// interloper survives — the lease is never widened past the exact old value. +func TestIntegrationWorkspaceLifecyclePublishRewriteContention(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) + } + newHead := rewriteWorkspaceHead(t, ws) + + // A third party force-pushes a divergent commit onto the feature ref. + gitOut(t, r.Writer, "checkout", "-q", "-B", "feat/"+prepSlug, "origin/main") + writeWorktreeFile(t, r.Writer, "divergent.txt", "divergent work\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "divergent") + divergent := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) + gitOut(t, r.Writer, "push", "-f", "-q", "origin", "feat/"+prepSlug) + gitOut(t, r.Writer, "checkout", "-q", "main") + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewriteContended { + t.Errorf("outcome = %q; want contended", outcome) + } + if got, _ := originFeatCommit(t, r); got != divergent { + t.Errorf("origin feat ref = %q; want unchanged divergent %q", got, divergent) + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteRefusesWithoutReceipt proves the receipt gate: a missing +// receipt, a receipt whose repo identity does not match the workspace, and a +// receipt whose change id does not match the workspace are each refused with an +// error before any push, leaving the remote uncreated. +func TestIntegrationWorkspaceLifecyclePublishRewriteRefusesWithoutReceipt(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + newHead := rewriteWorkspaceHead(t, ws) + dir := metaDirOf(repo, tgt) + ctx := context.Background() + + // (a) No receipt on disk at all. + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}); err == nil { + t.Errorf("missing receipt: PublishRewrite = nil error; want refusal") + } + + // (b) Receipt present but its repo identity is a different repository. + wrongRepo := receiptFor(repo, tgt, head1, base, "attempt-01") + wrongRepo.RepoIdentity = filepath.Join(testsupport.TempDir(t), "other-common.git") + if err := svc.WriteRebaseReceipt(ctx, dir, wrongRepo); err != nil { + t.Fatalf("WriteRebaseReceipt(wrongRepo): %v", err) + } + if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: wrongRepo, NewHead: string(newHead)}); err == nil { + t.Errorf("wrong repo identity: PublishRewrite = nil error; want refusal") + } + + // (c) Receipt present but its change id is a different change. + wrongChange := receiptFor(repo, tgt, head1, base, "attempt-01") + wrongChange.ChangeID = "999" + if err := svc.WriteRebaseReceipt(ctx, dir, wrongChange); err != nil { + t.Fatalf("WriteRebaseReceipt(wrongChange): %v", err) + } + if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: wrongChange, NewHead: string(newHead)}); err == nil { + t.Errorf("wrong change id: PublishRewrite = nil error; want refusal") + } + + // (d) On-disk receipt does not match the caller's expected receipt. + onDisk := receiptFor(repo, tgt, head1, base, "attempt-on-disk") + if err := svc.WriteRebaseReceipt(ctx, dir, onDisk); err != nil { + t.Fatalf("WriteRebaseReceipt(onDisk): %v", err) + } + mismatched := onDisk + mismatched.Attempt = "attempt-expected" + if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: mismatched, NewHead: string(newHead)}); err == nil { + t.Errorf("receipt/caller mismatch: PublishRewrite = nil error; want refusal") + } + + if _, ok := originFeatCommit(t, r); ok { + t.Errorf("origin feat ref created despite every refusal; no push may have been issued") + } +} + +// TestIntegrationWorkspaceLifecyclePublishRewriteUnknownRetains proves an unobservable remote yields unknown +// with no mutation: the effect is retained for a later attempt, never forced on a +// probe that could not establish the remote state. +func TestIntegrationWorkspaceLifecyclePublishRewriteUnknownRetains(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + newHead := rewriteWorkspaceHead(t, ws) + + dir := metaDirOf(repo, tgt) + rec := receiptFor(repo, tgt, head1, base, "attempt-01") + if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { + t.Fatalf("WriteRebaseReceipt: %v", err) + } + + // Break the origin URL so the remote probe cannot establish the ref state. + gitOut(t, r.Primary, "remote", "set-url", "origin", filepath.Join(testsupport.TempDir(t), "nonexistent.git")) + + outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) + if err != nil { + t.Fatalf("PublishRewrite: %v", err) + } + if outcome != RewriteUnknown { + t.Errorf("outcome = %q; want unknown", outcome) + } +} + +// TestIntegrationWorkspaceLifecycleGeneralPublishStillRefusesRewrite proves the general PublishHead is not +// weakened by the rewrite path: a divergent local head (a rewrite of the +// published remote head) is still refused as contended, never force-published — +// only the receipt-scoped PublishRewrite may force, and only under its lease. +func TestIntegrationWorkspaceLifecycleGeneralPublishStillRefusesRewrite(t *testing.T) { + r := mainModeRepo(t) + svc, repo := r.newService(t) + tgt := freshTarget(t, 7) + prepareOK(t, svc, repo, tgt) + ws := wsPathOf(repo) + head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") + if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { + t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) + } + before, ok := originFeatCommit(t, r) + if !ok || before != head1 { + t.Fatalf("fixture: origin feat ref = %q (ok=%v); want %q", before, ok, head1) + } + + // Rewrite the local branch; the general publish must refuse, not force. + rewriteWorkspaceHead(t, ws) + res, err := publishHead(t, svc, repo, tgt) + if err != nil { + t.Fatalf("PublishHead: %v", err) + } + if res.Disposition != PublishContended { + t.Errorf("Disposition = %q; want contended (general publish must never force a rewrite)", res.Disposition) + } + if after, _ := originFeatCommit(t, r); after != before { + t.Errorf("origin feat ref changed: before=%q after=%q; general publish force-published a rewrite", before, after) + } +} diff --git a/internal/workspace/rewrite_test.go b/internal/workspace/rewrite_test.go index 2b3ef752f..6162d1a80 100644 --- a/internal/workspace/rewrite_test.go +++ b/internal/workspace/rewrite_test.go @@ -1,14 +1,11 @@ package workspace import ( - "context" - "path/filepath" "strconv" "testing" "time" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/testsupport" ) // This file drives PublishRewrite: the narrow, receipt-scoped force-with-lease @@ -47,338 +44,3 @@ func rewriteWorkspaceHead(t *testing.T, ws string) gitcli.ObjectID { gitOut(t, ws, "commit", "-q", "--amend", "--no-edit") return gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) } - -// TestPublishRewriteLease proves the happy path: with the remote at the receipt's -// OrigRemoteHead, PublishRewrite lands exactly NewHead under the lease and -// reprobes to equality, reporting published. -func TestPublishRewriteLease(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Establish the remote feature ref at head1. - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) - } - - // Rewrite the local branch to a divergent new head. - newHead := rewriteWorkspaceHead(t, ws) - if newHead == head1 { - t.Fatalf("fixture: rewrite did not change the head") - } - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewritePublished { - t.Errorf("outcome = %q; want published", outcome) - } - if got, ok := originFeatCommit(t, r); !ok || got != newHead { - t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) - } -} - -// TestPublishRewriteLeaseWithGatePair proves publish still authorizes a rewrite -// from a receipt that carries the gate-continuation pair: the on-disk receipt and -// the caller's expected receipt are the same value, pair included, so the -// equality gate holds (every field is a scalar string; the whole value compares). -func TestPublishRewriteLeaseWithGatePair(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - // Establish the remote feature ref at head1. - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) - } - - // Rewrite the local branch to a divergent new head. - newHead := rewriteWorkspaceHead(t, ws) - if newHead == head1 { - t.Fatalf("fixture: rewrite did not change the head") - } - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - rec.GateDriveID = "drive-01" - rec.GateOwnerGeneration = "gen-01" - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewritePublished { - t.Errorf("outcome = %q; want published", outcome) - } - if got, ok := originFeatCommit(t, r); !ok || got != newHead { - t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) - } -} - -// TestPublishRewriteLeaseWithPublishCheckpoint proves publish still authorizes -// a rewrite from a receipt carrying the completed-gate publish checkpoint -// (change 0408): the on-disk receipt and the caller's expected receipt are the -// same value, checkpoint included, so the equality gate holds — and the exact -// lease/reprobe behavior is unchanged. -func TestPublishRewriteLeaseWithPublishCheckpoint(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) - } - newHead := rewriteWorkspaceHead(t, ws) - if newHead == head1 { - t.Fatalf("fixture: rewrite did not change the head") - } - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - rec.PublishCheckpointHead = string(newHead) - rec.PublishCheckpointBaseHead = string(base) - rec.PublishCheckpointCommand = "go test ./..." - rec.PublishCheckpointGate = "local" - rec.PublishCheckpointPRNumber = "7" - rec.PublishCheckpointEvidence = "result: green\n" - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewritePublished { - t.Errorf("outcome = %q; want published", outcome) - } - if got, ok := originFeatCommit(t, r); !ok || got != newHead { - t.Errorf("origin feat ref = %q (ok=%v); want rewritten head %q", got, ok, newHead) - } -} - -// TestPublishRewriteNoop proves the idempotency key is the remote state: a remote -// already holding NewHead (a completed rewrite / adopted lost response) is a noop -// with no push issued, even though the remote no longer sits at OrigRemoteHead. -func TestPublishRewriteNoop(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - newHead := rewriteWorkspaceHead(t, ws) - - // The rewrite already reached the remote out of band; the local branch is at - // newHead, so a force push publishes exactly it. - gitOut(t, ws, "push", "-f", "-q", "origin", "feat/"+prepSlug) - before, ok := originFeatCommit(t, r) - if !ok || before != newHead { - t.Fatalf("fixture: origin feat ref = %q (ok=%v); want %q", before, ok, newHead) - } - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewriteNoop { - t.Errorf("outcome = %q; want noop", outcome) - } - if after, _ := originFeatCommit(t, r); after != before { - t.Errorf("origin feat ref changed on a noop: before=%q after=%q", before, after) - } -} - -// TestPublishRewriteContention proves a remote moved off OrigRemoteHead (and not -// at NewHead) is contended with the remote untouched: no push is issued, so the -// interloper survives — the lease is never widened past the exact old value. -func TestPublishRewriteContention(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) - } - newHead := rewriteWorkspaceHead(t, ws) - - // A third party force-pushes a divergent commit onto the feature ref. - gitOut(t, r.Writer, "checkout", "-q", "-B", "feat/"+prepSlug, "origin/main") - writeWorktreeFile(t, r.Writer, "divergent.txt", "divergent work\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "divergent") - divergent := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) - gitOut(t, r.Writer, "push", "-f", "-q", "origin", "feat/"+prepSlug) - gitOut(t, r.Writer, "checkout", "-q", "main") - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewriteContended { - t.Errorf("outcome = %q; want contended", outcome) - } - if got, _ := originFeatCommit(t, r); got != divergent { - t.Errorf("origin feat ref = %q; want unchanged divergent %q", got, divergent) - } -} - -// TestPublishRewriteRefusesWithoutReceipt proves the receipt gate: a missing -// receipt, a receipt whose repo identity does not match the workspace, and a -// receipt whose change id does not match the workspace are each refused with an -// error before any push, leaving the remote uncreated. -func TestPublishRewriteRefusesWithoutReceipt(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - newHead := rewriteWorkspaceHead(t, ws) - dir := metaDirOf(repo, tgt) - ctx := context.Background() - - // (a) No receipt on disk at all. - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}); err == nil { - t.Errorf("missing receipt: PublishRewrite = nil error; want refusal") - } - - // (b) Receipt present but its repo identity is a different repository. - wrongRepo := receiptFor(repo, tgt, head1, base, "attempt-01") - wrongRepo.RepoIdentity = filepath.Join(testsupport.TempDir(t), "other-common.git") - if err := svc.WriteRebaseReceipt(ctx, dir, wrongRepo); err != nil { - t.Fatalf("WriteRebaseReceipt(wrongRepo): %v", err) - } - if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: wrongRepo, NewHead: string(newHead)}); err == nil { - t.Errorf("wrong repo identity: PublishRewrite = nil error; want refusal") - } - - // (c) Receipt present but its change id is a different change. - wrongChange := receiptFor(repo, tgt, head1, base, "attempt-01") - wrongChange.ChangeID = "999" - if err := svc.WriteRebaseReceipt(ctx, dir, wrongChange); err != nil { - t.Fatalf("WriteRebaseReceipt(wrongChange): %v", err) - } - if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: wrongChange, NewHead: string(newHead)}); err == nil { - t.Errorf("wrong change id: PublishRewrite = nil error; want refusal") - } - - // (d) On-disk receipt does not match the caller's expected receipt. - onDisk := receiptFor(repo, tgt, head1, base, "attempt-on-disk") - if err := svc.WriteRebaseReceipt(ctx, dir, onDisk); err != nil { - t.Fatalf("WriteRebaseReceipt(onDisk): %v", err) - } - mismatched := onDisk - mismatched.Attempt = "attempt-expected" - if _, err := svc.PublishRewrite(ctx, RewriteRequest{Dir: dir, Receipt: mismatched, NewHead: string(newHead)}); err == nil { - t.Errorf("receipt/caller mismatch: PublishRewrite = nil error; want refusal") - } - - if _, ok := originFeatCommit(t, r); ok { - t.Errorf("origin feat ref created despite every refusal; no push may have been issued") - } -} - -// TestPublishRewriteUnknownRetains proves an unobservable remote yields unknown -// with no mutation: the effect is retained for a later attempt, never forced on a -// probe that could not establish the remote state. -func TestPublishRewriteUnknownRetains(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - base := gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - newHead := rewriteWorkspaceHead(t, ws) - - dir := metaDirOf(repo, tgt) - rec := receiptFor(repo, tgt, head1, base, "attempt-01") - if err := svc.WriteRebaseReceipt(context.Background(), dir, rec); err != nil { - t.Fatalf("WriteRebaseReceipt: %v", err) - } - - // Break the origin URL so the remote probe cannot establish the ref state. - gitOut(t, r.Primary, "remote", "set-url", "origin", filepath.Join(testsupport.TempDir(t), "nonexistent.git")) - - outcome, err := svc.PublishRewrite(context.Background(), RewriteRequest{Dir: dir, Receipt: rec, NewHead: string(newHead)}) - if err != nil { - t.Fatalf("PublishRewrite: %v", err) - } - if outcome != RewriteUnknown { - t.Errorf("outcome = %q; want unknown", outcome) - } -} - -// TestGeneralPublishStillRefusesRewrite proves the general PublishHead is not -// weakened by the rewrite path: a divergent local head (a rewrite of the -// published remote head) is still refused as contended, never force-published — -// only the receipt-scoped PublishRewrite may force, and only under its lease. -func TestGeneralPublishStillRefusesRewrite(t *testing.T) { - r := mainModeRepo(t) - svc, repo := r.newService(t) - tgt := freshTarget(t, 7) - prepareOK(t, svc, repo, tgt) - ws := wsPathOf(repo) - head1 := commitInWorkspace(t, ws, "feature.txt", "feature work\n") - if res, err := publishHead(t, svc, repo, tgt); err != nil || res.Disposition != PublishPublished { - t.Fatalf("seed publish = %q err=%v; want published", res.Disposition, err) - } - before, ok := originFeatCommit(t, r) - if !ok || before != head1 { - t.Fatalf("fixture: origin feat ref = %q (ok=%v); want %q", before, ok, head1) - } - - // Rewrite the local branch; the general publish must refuse, not force. - rewriteWorkspaceHead(t, ws) - res, err := publishHead(t, svc, repo, tgt) - if err != nil { - t.Fatalf("PublishHead: %v", err) - } - if res.Disposition != PublishContended { - t.Errorf("Disposition = %q; want contended (general publish must never force a rewrite)", res.Disposition) - } - if after, _ := originFeatCommit(t, r); after != before { - t.Errorf("origin feat ref changed: before=%q after=%q; general publish force-published a rewrite", before, after) - } -} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index a34a64cd6..5409c30b1 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -78,6 +78,7 @@ tests/test_go_integration_transaction_recovery.sh 20 parallel tests/test_go_integration_transaction_race.sh 25 parallel tests/test_go_integration_workspace_setup.sh 25 parallel tests/test_go_integration_workspace_race.sh 10 parallel +tests/test_go_integration_workspace_lifecycle.sh 30 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_workspace_lifecycle.sh b/tests/test_go_integration_workspace_lifecycle.sh new file mode 100755 index 000000000..ddca1b27f --- /dev/null +++ b/tests/test_go_integration_workspace_lifecycle.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_workspace_lifecycle.sh — Go integration shard (change 0466, extending +# change 0333's partition): the workspace lifecycle real-git tests after Prepare (Inspect, +# Publish, rewrite-lease publication, and Cleanup) — moved out of the default +# internal/workspace corpus, which must never start real git (testsupport.InstallNoGitGuard, +# installed from the package's TestMain) — behind the `integration` build tag, prefix +# ^TestIntegrationWorkspaceLifecycle. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/workspace" +SHARD_PREFIX="TestIntegrationWorkspaceLifecycle" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From 809a7d6882f220c18cdd86d19513c0a5d805f86d Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:38:17 -0400 Subject: [PATCH 09/16] test(workspace): install the no-real-git guard in the default corpus (change 0466) --- .../workspace/cleanup_integration_test.go | 43 ++ internal/workspace/cleanup_test.go | 51 --- .../workspace/harness_integration_test.go | 375 +++++++++++++++++ internal/workspace/harness_test.go | 378 +----------------- .../workspace/inspect_integration_test.go | 34 ++ internal/workspace/inspect_test.go | 43 -- internal/workspace/main_test.go | 25 ++ internal/workspace/nogit_guard_test.go | 31 ++ .../workspace/prepare_integration_test.go | 327 +++++++++++++++ internal/workspace/prepare_test.go | 331 --------------- .../workspace/publish_integration_test.go | 70 ++++ internal/workspace/publish_test.go | 79 ---- .../workspace/rewrite_integration_test.go | 39 ++ internal/workspace/rewrite_test.go | 46 --- 14 files changed, 948 insertions(+), 924 deletions(-) delete mode 100644 internal/workspace/cleanup_test.go delete mode 100644 internal/workspace/inspect_test.go create mode 100644 internal/workspace/main_test.go create mode 100644 internal/workspace/nogit_guard_test.go delete mode 100644 internal/workspace/publish_test.go delete mode 100644 internal/workspace/rewrite_test.go diff --git a/internal/workspace/cleanup_integration_test.go b/internal/workspace/cleanup_integration_test.go index 74f4a485f..54f44af57 100644 --- a/internal/workspace/cleanup_integration_test.go +++ b/internal/workspace/cleanup_integration_test.go @@ -409,3 +409,46 @@ func TestIntegrationWorkspaceLifecycleCleanupNeverPrunes(t *testing.T) { t.Errorf("prunable registration disappeared; cleanup must never `git worktree prune`") } } + +// The Cleanup tests build each proof-gated scenario against the real-Git harness. +// Cleanup removes ONLY the checkout — never a local or remote branch — and only +// when the manifest, live registration, feature-ref attachment, base reachability, +// and an exact clean tracked/untracked delta all prove out. Every blocked case is +// asserted byte-untouched (the colliding artifact hashed before/after); the local +// branch always survives; and probe failures are `failed`, never a false clean. + +// cleanupResult runs Cleanup and returns the result, failing on an unexpected +// error only when wantErr is false. +func cleanupOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) CleanupResult { + t.Helper() + res, err := svc.Cleanup(context.Background(), CleanupRequest{Repository: repo, Target: tgt}) + if err != nil { + t.Fatalf("Cleanup: %v", err) + } + return res +} + +// registeredLine reports whether the porcelain worktree list contains a +// `worktree ` stanza header for exactly path. Unlike containsWorktreePath +// it does not canonicalize, so it can assert on a registration whose on-disk +// directory has been removed (a prunable entry). +func registeredLine(porcelain, path string) bool { + for _, line := range splitLines(porcelain) { + if p, ok := cutPrefix(line, "worktree "); ok && p == path { + return true + } + } + return false +} + +// assertReadyManifestKept asserts the target's manifest is still present and +// ready (never advanced to a tombstone) and its branch survives. +func assertReadyManifestKept(t *testing.T, r *wsRepos, repo gitcli.Repository, tgt Target) { + t.Helper() + if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { + t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) + } + if !branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("feat branch deleted; must be preserved") + } +} diff --git a/internal/workspace/cleanup_test.go b/internal/workspace/cleanup_test.go deleted file mode 100644 index 56ee2f917..000000000 --- a/internal/workspace/cleanup_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package workspace - -import ( - "context" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// The Cleanup tests build each proof-gated scenario against the real-Git harness. -// Cleanup removes ONLY the checkout — never a local or remote branch — and only -// when the manifest, live registration, feature-ref attachment, base reachability, -// and an exact clean tracked/untracked delta all prove out. Every blocked case is -// asserted byte-untouched (the colliding artifact hashed before/after); the local -// branch always survives; and probe failures are `failed`, never a false clean. - -// cleanupResult runs Cleanup and returns the result, failing on an unexpected -// error only when wantErr is false. -func cleanupOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) CleanupResult { - t.Helper() - res, err := svc.Cleanup(context.Background(), CleanupRequest{Repository: repo, Target: tgt}) - if err != nil { - t.Fatalf("Cleanup: %v", err) - } - return res -} - -// registeredLine reports whether the porcelain worktree list contains a -// `worktree ` stanza header for exactly path. Unlike containsWorktreePath -// it does not canonicalize, so it can assert on a registration whose on-disk -// directory has been removed (a prunable entry). -func registeredLine(porcelain, path string) bool { - for _, line := range splitLines(porcelain) { - if p, ok := cutPrefix(line, "worktree "); ok && p == path { - return true - } - } - return false -} - -// assertReadyManifestKept asserts the target's manifest is still present and -// ready (never advanced to a tombstone) and its branch survives. -func assertReadyManifestKept(t *testing.T, r *wsRepos, repo gitcli.Repository, tgt Target) { - t.Helper() - if m, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || !present || m.Phase != PhaseReady { - t.Errorf("manifest present=%v phase=%v err=%v; want present ready", present, m.Phase, err) - } - if !branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("feat branch deleted; must be preserved") - } -} diff --git a/internal/workspace/harness_integration_test.go b/internal/workspace/harness_integration_test.go index 61f1087e3..4fcae9004 100644 --- a/internal/workspace/harness_integration_test.go +++ b/internal/workspace/harness_integration_test.go @@ -3,8 +3,17 @@ package workspace import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" "strings" "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" ) // TestIntegrationWorkspaceSetupHarnessBuildersProduceExpectedTopology is the harness self-test: it proves @@ -44,3 +53,369 @@ func TestIntegrationWorkspaceSetupHarnessBuildersProduceExpectedTopology(t *test } }) } + +// This file builds real temporary Git repositories for the workspace Prepare +// tests. Two topologies are produced — a plain "main mode" repo and a +// docket-style repo carrying an orphan "docket" branch plus a registered +// `.docket/` worktree, one detached transaction-style worktree, and one sibling +// feature worktree — each backed by a bare file remote plus an independent +// writer clone that advances the remote. All paths live under testsupport.TempDir(t); the +// builders return the raw testsupport.TempDir(t) spelling (never filepath.EvalSymlinks- +// canonicalized) so the tests exercise the macOS /tmp -> /private/tmp symlinked +// case Prepare must canonicalize through. Everything here is _test.go only and +// is never referenced by product code. +// +// A workspace's checkout lands at /.worktrees/; both fixtures +// gitignore `.worktrees/` and `.docket/` so a newly attached workspace stays +// invisible to the primary worktree's own git status, which is exactly what the +// preservation proofs assert. + +// wsRepos is a bare origin, a writer clone that pushes to advance the origin, +// and the primary clone under test. Preserve lists every worktree whose bytes a +// Prepare must leave untouched (the primary itself plus, in docket mode, the +// .docket/transaction/sibling worktrees). +type wsRepos struct { + Origin string + Writer string + Primary string + Preserve []string +} + +// useBackgroundOffGit points the git children these tests spawn at a per-fixture +// GIT_CONFIG_GLOBAL (testsupport.GitEnv) that disables auto-gc, auto-maintenance, +// and fsmonitor. The direct oracle helpers (gitOut/gitOutRaw/gitTry) inherit the +// test-process environment, so this reaches them; without it a detached git +// housekeeping child spawned by a fixture commit can outlive the test and keep +// writing into a testsupport.TempDir, racing RemoveAll teardown to "directory +// not empty" under parallel load (change 0373). Git spawned through the product +// gitcli client scrubs GIT_CONFIG, so its housekeeping children are instead +// absorbed by the fixture's drain-then-retry removal. Set process-wide via +// t.Setenv because gitTry takes no *testing.T; safe because this package runs no +// test in parallel. Call it from every repo builder before the first git spawn. +func useBackgroundOffGit(t *testing.T) { + t.Helper() + for _, kv := range testsupport.GitEnv(t) { + if v, ok := strings.CutPrefix(kv, "GIT_CONFIG_GLOBAL="); ok { + t.Setenv("GIT_CONFIG_GLOBAL", v) + } + } +} + +// gitOut runs real git directly (independent of the adapter under test) with +// -C , returns trimmed stdout, and fails the test on a non-zero exit. It is +// the plumbing oracle the fixtures and assertions compare against. +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + out, err := gitTry(dir, args...) + if err != nil { + t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) + } + return strings.TrimSpace(out) +} + +// gitOutRaw is gitOut without trimming, so NUL-delimited output survives intact. +func gitOutRaw(t *testing.T, dir string, args ...string) []byte { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git -C %s %s: %v: %s", dir, strings.Join(args, " "), err, stderr.String()) + } + return []byte(stdout.String()) +} + +// gitTry runs git -C and returns raw stdout plus an error carrying the +// captured stderr; it never touches testing.T so callers can probe for an +// expected failure (e.g. branch existence). +func gitTry(dir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), &gitError{err: err, stderr: stderr.String()} + } + return stdout.String(), nil +} + +type gitError struct { + err error + stderr string +} + +func (e *gitError) Error() string { return e.err.Error() + ": " + strings.TrimSpace(e.stderr) } + +// configRepoIdentity pins a deterministic committer identity and disables gpg +// signing so a developer's global config cannot perturb the fixtures. +func configRepoIdentity(t *testing.T, dir string) { + t.Helper() + gitOut(t, dir, "config", "user.name", "t") + gitOut(t, dir, "config", "user.email", "t@t") + gitOut(t, dir, "config", "commit.gpgsign", "false") +} + +// writeWorktreeFile writes content (creating parent directories) at a +// repo-relative path. +func writeWorktreeFile(t *testing.T, root, rel, content string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// branchExists reports whether refs/heads/ exists in the repo at dir. +func branchExists(dir, branch string) bool { + _, err := gitTry(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +// mainModeRepo builds a bare origin whose main branch holds README.md, +// .docket.yml, main.go, and a .gitignore excluding .docket/ and .worktrees/. A +// writer clone advances origin; the primary clone under test is checked out on +// main. The only worktree whose bytes a Prepare must preserve is the primary. +func mainModeRepo(t *testing.T) *wsRepos { + t.Helper() + requireGit(t) + useBackgroundOffGit(t) + root := testsupport.TempDir(t) + r := &wsRepos{ + Origin: filepath.Join(root, "origin.git"), + Writer: filepath.Join(root, "writer"), + Primary: filepath.Join(root, "primary"), + } + + gitOut(t, root, "init", "--bare", "-b", "main", r.Origin) + + gitOut(t, root, "init", "-b", "main", r.Writer) + configRepoIdentity(t, r.Writer) + writeWorktreeFile(t, r.Writer, "README.md", "readme\n") + writeWorktreeFile(t, r.Writer, ".docket.yml", "version: 1\n") + writeWorktreeFile(t, r.Writer, "main.go", "package main\n") + writeWorktreeFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "main content") + gitOut(t, r.Writer, "remote", "add", "origin", r.Origin) + gitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") + + gitOut(t, root, "clone", "-q", r.Origin, r.Primary) + configRepoIdentity(t, r.Primary) + + r.Preserve = []string{r.Primary} + return r +} + +// docketModeRepo builds a bare origin with branch main (as in main mode) plus an +// orphan "docket" branch holding planning files. The primary clone adds three +// linked worktrees: ".docket" parked on docket, a detached transaction-style +// worktree outside the primary, and a sibling ".worktrees/other" feature +// worktree — four registered worktrees in total, all of which a Prepare must +// leave byte-identical. +func docketModeRepo(t *testing.T) *wsRepos { + t.Helper() + requireGit(t) + useBackgroundOffGit(t) + root := testsupport.TempDir(t) + r := &wsRepos{ + Origin: filepath.Join(root, "origin.git"), + Writer: filepath.Join(root, "writer"), + Primary: filepath.Join(root, "primary"), + } + + gitOut(t, root, "init", "--bare", "-b", "main", r.Origin) + + gitOut(t, root, "init", "-b", "main", r.Writer) + configRepoIdentity(t, r.Writer) + writeWorktreeFile(t, r.Writer, ".docket.yml", "version: 1\n") + writeWorktreeFile(t, r.Writer, "main.go", "package main\n") + writeWorktreeFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "main content") + gitOut(t, r.Writer, "remote", "add", "origin", r.Origin) + gitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") + + // Orphan docket branch: unrelated history, planning files only. + gitOut(t, r.Writer, "checkout", "-q", "--orphan", "docket") + gitOut(t, r.Writer, "rm", "-rfq", "--cached", ".") + for _, name := range []string{".docket.yml", "main.go", ".gitignore"} { + if err := os.Remove(filepath.Join(r.Writer, name)); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + writeWorktreeFile(t, r.Writer, "docs/changes/active/0001-plan.md", "plan\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "docket planning") + gitOut(t, r.Writer, "push", "-q", "-u", "origin", "docket") + gitOut(t, r.Writer, "checkout", "-q", "main") + + gitOut(t, root, "clone", "-q", r.Origin, r.Primary) + configRepoIdentity(t, r.Primary) + + docketWt := filepath.Join(r.Primary, ".docket") + txnWt := filepath.Join(root, "txn") + siblingWt := filepath.Join(r.Primary, ".worktrees", "other") + gitOut(t, r.Primary, "worktree", "add", "-q", "-B", "docket", docketWt, "origin/docket") + gitOut(t, r.Primary, "worktree", "add", "-q", "--detach", txnWt, "main") + gitOut(t, r.Primary, "worktree", "add", "-q", "-b", "feat/other", siblingWt, "main") + + r.Preserve = []string{r.Primary, docketWt, txnWt, siblingWt} + return r +} + +// advanceMain commits a new file on main in the writer clone, pushes it to +// origin, and returns the new origin-main commit. The primary clone's +// origin/main tracking ref is deliberately left stale, so a Prepare that reports +// this commit as its base proves it performed a real fetch rather than trusting +// the cached tracking ref. +func (r *wsRepos) advanceMain(t *testing.T) gitcli.ObjectID { + t.Helper() + gitOut(t, r.Writer, "checkout", "-q", "main") + writeWorktreeFile(t, r.Writer, "advanced.txt", "moved forward\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "advance main") + gitOut(t, r.Writer, "push", "-q", "origin", "main") + return gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) +} + +// pushBranch creates in the writer clone from (a ref the writer +// can resolve, e.g. "main" or another branch), adds one distinguishing file, +// commits, pushes it to origin, and returns the new commit. It lets a stacked +// scenario give the resolved base branch a real remote commit distinct from +// main's tip, so a Prepare that starts the workspace there is observable. +func (r *wsRepos) pushBranch(t *testing.T, branch, from string) gitcli.ObjectID { + t.Helper() + gitOut(t, r.Writer, "checkout", "-q", "-B", branch, from) + writeWorktreeFile(t, r.Writer, "on-"+strings.ReplaceAll(branch, "/", "-")+".txt", "commit on "+branch+"\n") + gitOut(t, r.Writer, "add", "-A") + gitOut(t, r.Writer, "commit", "-q", "-m", "branch "+branch) + gitOut(t, r.Writer, "push", "-q", "origin", branch) + head := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) + gitOut(t, r.Writer, "checkout", "-q", "main") + return head +} + +// newService builds a real gitcli.Client, wraps it in a Service, and discovers +// the canonical Repository from the primary worktree. The returned Repository is +// symlink-canonical (Discover resolves every hop), which every path identity +// comparison in Prepare depends on. +func (r *wsRepos) newService(t *testing.T) (*Service, gitcli.Repository) { + t.Helper() + c, err := gitcli.NewClient() + if err != nil { + t.Fatalf("gitcli.NewClient: %v", err) + } + svc, err := NewService(c) + if err != nil { + t.Fatalf("NewService: %v", err) + } + repo, err := c.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Primary}) + if err != nil { + t.Fatalf("Discover: %v", err) + } + return svc, repo +} + +// symbolicHead returns the branch HEAD points at, or "DETACHED" for a detached +// HEAD, so a snapshot records which branch a worktree is on. +func symbolicHead(t *testing.T, dir string) string { + t.Helper() + out, err := gitTry(dir, "symbolic-ref", "--quiet", "HEAD") + if err != nil { + return "DETACHED" + } + return strings.TrimSpace(out) +} + +// snapshotTree captures a worktree's observable state as a path->hash map: its +// HEAD commit, symbolic branch, porcelain-v2 status, staged index, and a content +// hash of every tracked or non-ignored-untracked file. It reads through git +// (respecting .gitignore) so a freshly attached, ignored .worktrees/ is +// invisible to a preservation comparison of the primary worktree. +func snapshotTree(t *testing.T, dir string) map[string]string { + t.Helper() + m := map[string]string{ + "HEAD": gitOut(t, dir, "rev-parse", "HEAD"), + "symbolic": symbolicHead(t, dir), + "status": string(gitOutRaw(t, dir, "status", "--porcelain=v2", "-z", "--untracked-files=all")), + "index": string(gitOutRaw(t, dir, "ls-files", "--stage", "-z")), + } + raw := gitOutRaw(t, dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + for _, name := range strings.Split(string(raw), "\x00") { + if name == "" { + continue + } + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + m["file:"+name] = "unreadable:" + err.Error() + continue + } + sum := sha256.Sum256(b) + m["file:"+name] = hex.EncodeToString(sum[:]) + } + return m +} + +// assertUnchanged fails the test if the current snapshot of dir differs in any +// key from before, naming every drifted key. It is the uninvolved-worktree +// preservation proof used after each Prepare scenario. +func assertUnchanged(t *testing.T, before map[string]string, dir string) { + t.Helper() + after := snapshotTree(t, dir) + for k, bv := range before { + av, ok := after[k] + if !ok { + t.Errorf("preservation: %s: key %q disappeared", dir, k) + continue + } + if av != bv { + t.Errorf("preservation: %s: key %q changed\n before=%q\n after =%q", dir, k, bv, av) + } + } + for k := range after { + if _, ok := before[k]; !ok { + t.Errorf("preservation: %s: key %q appeared", dir, k) + } + } +} + +// snapshotAll snapshots every worktree a Prepare must preserve. +func (r *wsRepos) snapshotAll(t *testing.T) map[string]map[string]string { + t.Helper() + out := make(map[string]map[string]string, len(r.Preserve)) + for _, wt := range r.Preserve { + out[wt] = snapshotTree(t, wt) + } + return out +} + +// assertAllUnchanged replays every preserved worktree's snapshot. +func (r *wsRepos) assertAllUnchanged(t *testing.T, before map[string]map[string]string) { + t.Helper() + for wt, snap := range before { + assertUnchanged(t, snap, wt) + } +} + +// eachTopology runs fn against both fixtures, so every core scenario is proven +// on the plain repo and the docket-style repo with its extra worktrees. +func eachTopology(t *testing.T, fn func(t *testing.T, r *wsRepos)) { + t.Helper() + t.Run("main", func(t *testing.T) { fn(t, mainModeRepo(t)) }) + t.Run("docket", func(t *testing.T) { fn(t, docketModeRepo(t)) }) +} + +// countWorktrees counts "worktree " stanza headers in porcelain output. +func countWorktrees(porcelain string) int { + n := 0 + for _, line := range strings.Split(porcelain, "\n") { + if strings.HasPrefix(line, "worktree ") { + n++ + } + } + return n +} diff --git a/internal/workspace/harness_test.go b/internal/workspace/harness_test.go index a1c8849d8..c4e9b8ee3 100644 --- a/internal/workspace/harness_test.go +++ b/internal/workspace/harness_test.go @@ -1,45 +1,14 @@ package workspace import ( - "context" - "crypto/sha256" - "encoding/hex" - "os" "os/exec" - "path/filepath" - "strings" "testing" - - "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/testsupport" ) -// This file builds real temporary Git repositories for the workspace Prepare -// tests. Two topologies are produced — a plain "main mode" repo and a -// docket-style repo carrying an orphan "docket" branch plus a registered -// `.docket/` worktree, one detached transaction-style worktree, and one sibling -// feature worktree — each backed by a bare file remote plus an independent -// writer clone that advances the remote. All paths live under testsupport.TempDir(t); the -// builders return the raw testsupport.TempDir(t) spelling (never filepath.EvalSymlinks- -// canonicalized) so the tests exercise the macOS /tmp -> /private/tmp symlinked -// case Prepare must canonicalize through. Everything here is _test.go only and -// is never referenced by product code. -// -// A workspace's checkout lands at /.worktrees/; both fixtures -// gitignore `.worktrees/` and `.docket/` so a newly attached workspace stays -// invisible to the primary worktree's own git status, which is exactly what the -// preservation proofs assert. - -// wsRepos is a bare origin, a writer clone that pushes to advance the origin, -// and the primary clone under test. Preserve lists every worktree whose bytes a -// Prepare must leave untouched (the primary itself plus, in docket mode, the -// .docket/transaction/sibling worktrees). -type wsRepos struct { - Origin string - Writer string - Primary string - Preserve []string -} +// The real-git fixture builders (wsRepos, mainModeRepo, docketModeRepo, and the +// git oracle helpers) live behind //go:build integration in +// harness_integration_test.go (change 0466); only requireGit is shared with the +// default corpus. // requireGit skips when no real git is on PATH. func requireGit(t *testing.T) { @@ -48,342 +17,3 @@ func requireGit(t *testing.T) { t.Skip("git not found on PATH") } } - -// useBackgroundOffGit points the git children these tests spawn at a per-fixture -// GIT_CONFIG_GLOBAL (testsupport.GitEnv) that disables auto-gc, auto-maintenance, -// and fsmonitor. The direct oracle helpers (gitOut/gitOutRaw/gitTry) inherit the -// test-process environment, so this reaches them; without it a detached git -// housekeeping child spawned by a fixture commit can outlive the test and keep -// writing into a testsupport.TempDir, racing RemoveAll teardown to "directory -// not empty" under parallel load (change 0373). Git spawned through the product -// gitcli client scrubs GIT_CONFIG, so its housekeeping children are instead -// absorbed by the fixture's drain-then-retry removal. Set process-wide via -// t.Setenv because gitTry takes no *testing.T; safe because this package runs no -// test in parallel. Call it from every repo builder before the first git spawn. -func useBackgroundOffGit(t *testing.T) { - t.Helper() - for _, kv := range testsupport.GitEnv(t) { - if v, ok := strings.CutPrefix(kv, "GIT_CONFIG_GLOBAL="); ok { - t.Setenv("GIT_CONFIG_GLOBAL", v) - } - } -} - -// gitOut runs real git directly (independent of the adapter under test) with -// -C , returns trimmed stdout, and fails the test on a non-zero exit. It is -// the plumbing oracle the fixtures and assertions compare against. -func gitOut(t *testing.T, dir string, args ...string) string { - t.Helper() - out, err := gitTry(dir, args...) - if err != nil { - t.Fatalf("git -C %s %s: %v", dir, strings.Join(args, " "), err) - } - return strings.TrimSpace(out) -} - -// gitOutRaw is gitOut without trimming, so NUL-delimited output survives intact. -func gitOutRaw(t *testing.T, dir string, args ...string) []byte { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("git -C %s %s: %v: %s", dir, strings.Join(args, " "), err, stderr.String()) - } - return []byte(stdout.String()) -} - -// gitTry runs git -C and returns raw stdout plus an error carrying the -// captured stderr; it never touches testing.T so callers can probe for an -// expected failure (e.g. branch existence). -func gitTry(dir string, args ...string) (string, error) { - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return stdout.String(), &gitError{err: err, stderr: stderr.String()} - } - return stdout.String(), nil -} - -type gitError struct { - err error - stderr string -} - -func (e *gitError) Error() string { return e.err.Error() + ": " + strings.TrimSpace(e.stderr) } - -// configRepoIdentity pins a deterministic committer identity and disables gpg -// signing so a developer's global config cannot perturb the fixtures. -func configRepoIdentity(t *testing.T, dir string) { - t.Helper() - gitOut(t, dir, "config", "user.name", "t") - gitOut(t, dir, "config", "user.email", "t@t") - gitOut(t, dir, "config", "commit.gpgsign", "false") -} - -// writeWorktreeFile writes content (creating parent directories) at a -// repo-relative path. -func writeWorktreeFile(t *testing.T, root, rel, content string) { - t.Helper() - p := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -// branchExists reports whether refs/heads/ exists in the repo at dir. -func branchExists(dir, branch string) bool { - _, err := gitTry(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) - return err == nil -} - -// mainModeRepo builds a bare origin whose main branch holds README.md, -// .docket.yml, main.go, and a .gitignore excluding .docket/ and .worktrees/. A -// writer clone advances origin; the primary clone under test is checked out on -// main. The only worktree whose bytes a Prepare must preserve is the primary. -func mainModeRepo(t *testing.T) *wsRepos { - t.Helper() - requireGit(t) - useBackgroundOffGit(t) - root := testsupport.TempDir(t) - r := &wsRepos{ - Origin: filepath.Join(root, "origin.git"), - Writer: filepath.Join(root, "writer"), - Primary: filepath.Join(root, "primary"), - } - - gitOut(t, root, "init", "--bare", "-b", "main", r.Origin) - - gitOut(t, root, "init", "-b", "main", r.Writer) - configRepoIdentity(t, r.Writer) - writeWorktreeFile(t, r.Writer, "README.md", "readme\n") - writeWorktreeFile(t, r.Writer, ".docket.yml", "version: 1\n") - writeWorktreeFile(t, r.Writer, "main.go", "package main\n") - writeWorktreeFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "main content") - gitOut(t, r.Writer, "remote", "add", "origin", r.Origin) - gitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") - - gitOut(t, root, "clone", "-q", r.Origin, r.Primary) - configRepoIdentity(t, r.Primary) - - r.Preserve = []string{r.Primary} - return r -} - -// docketModeRepo builds a bare origin with branch main (as in main mode) plus an -// orphan "docket" branch holding planning files. The primary clone adds three -// linked worktrees: ".docket" parked on docket, a detached transaction-style -// worktree outside the primary, and a sibling ".worktrees/other" feature -// worktree — four registered worktrees in total, all of which a Prepare must -// leave byte-identical. -func docketModeRepo(t *testing.T) *wsRepos { - t.Helper() - requireGit(t) - useBackgroundOffGit(t) - root := testsupport.TempDir(t) - r := &wsRepos{ - Origin: filepath.Join(root, "origin.git"), - Writer: filepath.Join(root, "writer"), - Primary: filepath.Join(root, "primary"), - } - - gitOut(t, root, "init", "--bare", "-b", "main", r.Origin) - - gitOut(t, root, "init", "-b", "main", r.Writer) - configRepoIdentity(t, r.Writer) - writeWorktreeFile(t, r.Writer, ".docket.yml", "version: 1\n") - writeWorktreeFile(t, r.Writer, "main.go", "package main\n") - writeWorktreeFile(t, r.Writer, ".gitignore", ".docket/\n.worktrees/\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "main content") - gitOut(t, r.Writer, "remote", "add", "origin", r.Origin) - gitOut(t, r.Writer, "push", "-q", "-u", "origin", "main") - - // Orphan docket branch: unrelated history, planning files only. - gitOut(t, r.Writer, "checkout", "-q", "--orphan", "docket") - gitOut(t, r.Writer, "rm", "-rfq", "--cached", ".") - for _, name := range []string{".docket.yml", "main.go", ".gitignore"} { - if err := os.Remove(filepath.Join(r.Writer, name)); err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - } - writeWorktreeFile(t, r.Writer, "docs/changes/active/0001-plan.md", "plan\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "docket planning") - gitOut(t, r.Writer, "push", "-q", "-u", "origin", "docket") - gitOut(t, r.Writer, "checkout", "-q", "main") - - gitOut(t, root, "clone", "-q", r.Origin, r.Primary) - configRepoIdentity(t, r.Primary) - - docketWt := filepath.Join(r.Primary, ".docket") - txnWt := filepath.Join(root, "txn") - siblingWt := filepath.Join(r.Primary, ".worktrees", "other") - gitOut(t, r.Primary, "worktree", "add", "-q", "-B", "docket", docketWt, "origin/docket") - gitOut(t, r.Primary, "worktree", "add", "-q", "--detach", txnWt, "main") - gitOut(t, r.Primary, "worktree", "add", "-q", "-b", "feat/other", siblingWt, "main") - - r.Preserve = []string{r.Primary, docketWt, txnWt, siblingWt} - return r -} - -// advanceMain commits a new file on main in the writer clone, pushes it to -// origin, and returns the new origin-main commit. The primary clone's -// origin/main tracking ref is deliberately left stale, so a Prepare that reports -// this commit as its base proves it performed a real fetch rather than trusting -// the cached tracking ref. -func (r *wsRepos) advanceMain(t *testing.T) gitcli.ObjectID { - t.Helper() - gitOut(t, r.Writer, "checkout", "-q", "main") - writeWorktreeFile(t, r.Writer, "advanced.txt", "moved forward\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "advance main") - gitOut(t, r.Writer, "push", "-q", "origin", "main") - return gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) -} - -// pushBranch creates in the writer clone from (a ref the writer -// can resolve, e.g. "main" or another branch), adds one distinguishing file, -// commits, pushes it to origin, and returns the new commit. It lets a stacked -// scenario give the resolved base branch a real remote commit distinct from -// main's tip, so a Prepare that starts the workspace there is observable. -func (r *wsRepos) pushBranch(t *testing.T, branch, from string) gitcli.ObjectID { - t.Helper() - gitOut(t, r.Writer, "checkout", "-q", "-B", branch, from) - writeWorktreeFile(t, r.Writer, "on-"+strings.ReplaceAll(branch, "/", "-")+".txt", "commit on "+branch+"\n") - gitOut(t, r.Writer, "add", "-A") - gitOut(t, r.Writer, "commit", "-q", "-m", "branch "+branch) - gitOut(t, r.Writer, "push", "-q", "origin", branch) - head := gitcli.ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) - gitOut(t, r.Writer, "checkout", "-q", "main") - return head -} - -// newService builds a real gitcli.Client, wraps it in a Service, and discovers -// the canonical Repository from the primary worktree. The returned Repository is -// symlink-canonical (Discover resolves every hop), which every path identity -// comparison in Prepare depends on. -func (r *wsRepos) newService(t *testing.T) (*Service, gitcli.Repository) { - t.Helper() - c, err := gitcli.NewClient() - if err != nil { - t.Fatalf("gitcli.NewClient: %v", err) - } - svc, err := NewService(c) - if err != nil { - t.Fatalf("NewService: %v", err) - } - repo, err := c.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Primary}) - if err != nil { - t.Fatalf("Discover: %v", err) - } - return svc, repo -} - -// symbolicHead returns the branch HEAD points at, or "DETACHED" for a detached -// HEAD, so a snapshot records which branch a worktree is on. -func symbolicHead(t *testing.T, dir string) string { - t.Helper() - out, err := gitTry(dir, "symbolic-ref", "--quiet", "HEAD") - if err != nil { - return "DETACHED" - } - return strings.TrimSpace(out) -} - -// snapshotTree captures a worktree's observable state as a path->hash map: its -// HEAD commit, symbolic branch, porcelain-v2 status, staged index, and a content -// hash of every tracked or non-ignored-untracked file. It reads through git -// (respecting .gitignore) so a freshly attached, ignored .worktrees/ is -// invisible to a preservation comparison of the primary worktree. -func snapshotTree(t *testing.T, dir string) map[string]string { - t.Helper() - m := map[string]string{ - "HEAD": gitOut(t, dir, "rev-parse", "HEAD"), - "symbolic": symbolicHead(t, dir), - "status": string(gitOutRaw(t, dir, "status", "--porcelain=v2", "-z", "--untracked-files=all")), - "index": string(gitOutRaw(t, dir, "ls-files", "--stage", "-z")), - } - raw := gitOutRaw(t, dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard") - for _, name := range strings.Split(string(raw), "\x00") { - if name == "" { - continue - } - b, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - m["file:"+name] = "unreadable:" + err.Error() - continue - } - sum := sha256.Sum256(b) - m["file:"+name] = hex.EncodeToString(sum[:]) - } - return m -} - -// assertUnchanged fails the test if the current snapshot of dir differs in any -// key from before, naming every drifted key. It is the uninvolved-worktree -// preservation proof used after each Prepare scenario. -func assertUnchanged(t *testing.T, before map[string]string, dir string) { - t.Helper() - after := snapshotTree(t, dir) - for k, bv := range before { - av, ok := after[k] - if !ok { - t.Errorf("preservation: %s: key %q disappeared", dir, k) - continue - } - if av != bv { - t.Errorf("preservation: %s: key %q changed\n before=%q\n after =%q", dir, k, bv, av) - } - } - for k := range after { - if _, ok := before[k]; !ok { - t.Errorf("preservation: %s: key %q appeared", dir, k) - } - } -} - -// snapshotAll snapshots every worktree a Prepare must preserve. -func (r *wsRepos) snapshotAll(t *testing.T) map[string]map[string]string { - t.Helper() - out := make(map[string]map[string]string, len(r.Preserve)) - for _, wt := range r.Preserve { - out[wt] = snapshotTree(t, wt) - } - return out -} - -// assertAllUnchanged replays every preserved worktree's snapshot. -func (r *wsRepos) assertAllUnchanged(t *testing.T, before map[string]map[string]string) { - t.Helper() - for wt, snap := range before { - assertUnchanged(t, snap, wt) - } -} - -// eachTopology runs fn against both fixtures, so every core scenario is proven -// on the plain repo and the docket-style repo with its extra worktrees. -func eachTopology(t *testing.T, fn func(t *testing.T, r *wsRepos)) { - t.Helper() - t.Run("main", func(t *testing.T) { fn(t, mainModeRepo(t)) }) - t.Run("docket", func(t *testing.T) { fn(t, docketModeRepo(t)) }) -} - -// countWorktrees counts "worktree " stanza headers in porcelain output. -func countWorktrees(porcelain string) int { - n := 0 - for _, line := range strings.Split(porcelain, "\n") { - if strings.HasPrefix(line, "worktree ") { - n++ - } - } - return n -} diff --git a/internal/workspace/inspect_integration_test.go b/internal/workspace/inspect_integration_test.go index 46dcadaaa..258a0cb21 100644 --- a/internal/workspace/inspect_integration_test.go +++ b/internal/workspace/inspect_integration_test.go @@ -385,3 +385,37 @@ func TestIntegrationWorkspaceLifecycleInspectUnreadableIsError(t *testing.T) { t.Errorf("Kind = %q; want external", f.Kind) } } + +// The Inspect tests build each StateKind with the real-Git harness primitives +// and assert the classification, the exact DirtyPaths summary, and that Inspect +// mutates nothing — a full tree snapshot before and after every Inspect is +// identical. Malformed and foreign manifests are data (StateForeign with a +// parse detail), never an error; only an unreadable manifest slot is an error. + +// inspectOK runs Inspect and fails on error, returning the Inspection. +func inspectOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) Inspection { + t.Helper() + insp, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + return insp +} + +// assertInspectReadOnly runs Inspect and proves it changed no observable byte of +// the workspace or any preserved worktree. +func assertInspectReadOnly(t *testing.T, svc *Service, r *wsRepos, repo gitcli.Repository, tgt Target) Inspection { + t.Helper() + ws := wsPathOf(repo) + var beforeWs map[string]string + if _, err := os.Stat(ws); err == nil { + beforeWs = snapshotTree(t, ws) + } + beforePreserve := r.snapshotAll(t) + insp := inspectOK(t, svc, repo, tgt) + if beforeWs != nil { + assertUnchanged(t, beforeWs, ws) + } + r.assertAllUnchanged(t, beforePreserve) + return insp +} diff --git a/internal/workspace/inspect_test.go b/internal/workspace/inspect_test.go deleted file mode 100644 index 2b2bc6d2f..000000000 --- a/internal/workspace/inspect_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package workspace - -import ( - "context" - "os" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// The Inspect tests build each StateKind with the real-Git harness primitives -// and assert the classification, the exact DirtyPaths summary, and that Inspect -// mutates nothing — a full tree snapshot before and after every Inspect is -// identical. Malformed and foreign manifests are data (StateForeign with a -// parse detail), never an error; only an unreadable manifest slot is an error. - -// inspectOK runs Inspect and fails on error, returning the Inspection. -func inspectOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) Inspection { - t.Helper() - insp, err := svc.Inspect(context.Background(), InspectRequest{Repository: repo, Target: tgt}) - if err != nil { - t.Fatalf("Inspect: %v", err) - } - return insp -} - -// assertInspectReadOnly runs Inspect and proves it changed no observable byte of -// the workspace or any preserved worktree. -func assertInspectReadOnly(t *testing.T, svc *Service, r *wsRepos, repo gitcli.Repository, tgt Target) Inspection { - t.Helper() - ws := wsPathOf(repo) - var beforeWs map[string]string - if _, err := os.Stat(ws); err == nil { - beforeWs = snapshotTree(t, ws) - } - beforePreserve := r.snapshotAll(t) - insp := inspectOK(t, svc, repo, tgt) - if beforeWs != nil { - assertUnchanged(t, beforeWs, ws) - } - r.assertAllUnchanged(t, beforePreserve) - return insp -} diff --git a/internal/workspace/main_test.go b/internal/workspace/main_test.go new file mode 100644 index 000000000..1a7edc110 --- /dev/null +++ b/internal/workspace/main_test.go @@ -0,0 +1,25 @@ +package workspace + +import ( + "os" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// nogitPkg and nogitShardGlob name this package to the shared no-real-git guard +// (change 0466): the default-tag internal/workspace test corpus never starts a real +// git; real-git tests live behind //go:build integration in the +// tests/test_go_integration_workspace_*.sh shards. +const ( + nogitPkg = "internal/workspace" + nogitShardGlob = "tests/test_go_integration_workspace_*.sh" +) + +// TestMain installs the no-real-git guard (testsupport.InstallNoGitGuard) around +// m.Run in the default build; the integration build gets testsupport's identity +// finisher, so the tagged shards run real git as before. +func TestMain(m *testing.M) { + finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + os.Exit(finish(m.Run())) +} diff --git a/internal/workspace/nogit_guard_test.go b/internal/workspace/nogit_guard_test.go new file mode 100644 index 000000000..2fd9b70d7 --- /dev/null +++ b/internal/workspace/nogit_guard_test.go @@ -0,0 +1,31 @@ +//go:build !integration && !e2e + +package workspace + +// The no-real-git guard's proving tests for internal/workspace (change 0466). The +// guard lives in internal/testsupport (InstallNoGitGuard) and is installed from +// TestMain in main_test.go. These tests prove it is installed in THIS package's +// binary and fails the package on any real-git exec, even one a test tolerates. +// Real-git tests live behind //go:build integration in the +// tests/test_go_integration_workspace_*.sh shards. + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` resolves the shim. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + testsupport.AssertNoGitGuardShadowsGit(t) +} + +// TestNoGitGuardRefusesBareExec: a bare git exec gets the guard's exit code and diagnostic. +func TestNoGitGuardRefusesBareExec(t *testing.T) { + testsupport.AssertNoGitGuardRefusesBareExec(t, nogitPkg) +} + +// TestNoGitGuardFailsTolerantTest: a test that swallows the git failure still fails the package. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + testsupport.NoGitGuardTolerantProbe(t, nogitPkg, "TestNoGitGuardFailsTolerantTest") +} diff --git a/internal/workspace/prepare_integration_test.go b/internal/workspace/prepare_integration_test.go index 8dad1e14f..e48f931a8 100644 --- a/internal/workspace/prepare_integration_test.go +++ b/internal/workspace/prepare_integration_test.go @@ -734,3 +734,330 @@ func TestIntegrationWorkspaceSetupPrepareProbeFailureCreatesNothing(t *testing.T assertNothingCreated(t, r, repo.CommonDir) r.assertAllUnchanged(t, before) } + +// The Prepare tests exercise the fresh-allocation path against real temporary +// Git repositories with local bare remotes, on both the plain and the +// docket-style topologies. Existing/resume/blocked arms are Task 6. + +// prepSlug is the feature slug every fresh-allocation scenario prepares. Its +// derived feature ref is refs/heads/feat/. +const prepSlug = "fix-the-thing" + +func prepFeatureRef() gitcli.RefName { return gitcli.RefName("refs/heads/feat/" + prepSlug) } + +// resolveBase wires a real domain.ResolveEffectiveBase outcome — proving the +// service consumes the resolver rather than shadowing its rules — and asserts +// the outcome actually resolved (a fixture bug otherwise). +func resolveBase(t *testing.T, specs []domain.ChangeSpec, branches []string, subject domain.ChangeID) domain.EffectiveBase { + t.Helper() + changes := make([]domain.Change, 0, len(specs)) + for _, sp := range specs { + changes = append(changes, domain.NewChange(sp)) + } + snap := domain.NewSnapshot(domain.SnapshotSpec{ + Policy: domain.RepositoryPolicy{IntegrationBranch: "main"}, + Changes: changes, + }) + set := make(map[string]bool, len(branches)) + for _, b := range branches { + set[b] = true + } + facts := domain.NewBranchFacts(set) + c, out := snap.Change(subject) + if out != domain.LookupFound { + t.Fatalf("Change(%d) = %v; want found", subject, out) + } + base := domain.ResolveEffectiveBase(snap, c, facts) + if base.Kind != domain.BaseResolved { + t.Fatalf("resolver base kind = %q; want resolved (fixture bug)", base.Kind) + } + return base +} + +// assertNothingCreated proves a rejected Prepare left no checkout, no local +// feature branch, and no manifest under the real repository. +func assertNothingCreated(t *testing.T, r *wsRepos, commonDir string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(r.Primary, ".worktrees", prepSlug)); !os.IsNotExist(err) { + t.Errorf(".worktrees/%s exists or is unstattable (%v); want absent", prepSlug, err) + } + if branchExists(r.Primary, "feat/"+prepSlug) { + t.Errorf("local branch feat/%s exists; want absent", prepSlug) + } + if commonDir != "" { + if _, present, err := loadManifest(workspaceDir(commonDir, prepFeatureRef())); err != nil || present { + t.Errorf("manifest present=%v err=%v; want cleanly absent", present, err) + } + } +} + +// assertFreshCreated asserts the full created-workspace postcondition: the +// returned disposition and facts, live Git registration at the canonical path on +// the feature branch whose tip is wantBase, and a ready manifest recording the +// exact base commit. +func assertFreshCreated(t *testing.T, r *wsRepos, repo gitcli.Repository, ws Workspace, wantBase gitcli.ObjectID) { + t.Helper() + if ws.Disposition != PrepareCreated { + t.Fatalf("Disposition = %q; want %q", ws.Disposition, PrepareCreated) + } + wantPath := filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) + if ws.Path != wantPath { + t.Errorf("Path = %q; want %q", ws.Path, wantPath) + } + if ws.FeatureRef != prepFeatureRef() { + t.Errorf("FeatureRef = %q; want %q", ws.FeatureRef, prepFeatureRef()) + } + if ws.BaseCommit != wantBase { + t.Errorf("BaseCommit = %q; want %q", ws.BaseCommit, wantBase) + } + if ws.HeadCommit != wantBase { + t.Errorf("HeadCommit = %q; want %q (fresh head == base)", ws.HeadCommit, wantBase) + } + if ws.Dirty { + t.Errorf("Dirty = true; want false on a fresh checkout") + } + + // Live Git: registered at the canonical path, symbolic HEAD is the feature + // ref, and the branch tip equals the fetched base. + if got := symbolicHead(t, wantPath); got != string(prepFeatureRef()) { + t.Errorf("workspace symbolic HEAD = %q; want %q", got, prepFeatureRef()) + } + if got := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))); got != wantBase { + t.Errorf("branch tip = %q; want %q", got, wantBase) + } + wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") + if !containsWorktreePath(t, wl, wantPath) { + t.Errorf("worktree list does not register %q:\n%s", wantPath, wl) + } + + // Manifest advanced to ready with the exact base commit. + m, present, err := loadManifest(workspaceDir(repo.CommonDir, prepFeatureRef())) + if err != nil || !present { + t.Fatalf("loadManifest present=%v err=%v; want present", present, err) + } + if m.Phase != PhaseReady { + t.Errorf("manifest phase = %q; want ready", m.Phase) + } + if m.BaseCommit != wantBase { + t.Errorf("manifest BaseCommit = %q; want %q", m.BaseCommit, wantBase) + } + if m.Path != wantPath { + t.Errorf("manifest Path = %q; want %q", m.Path, wantPath) + } +} + +// containsWorktreePath reports whether the porcelain worktree list registers a +// worktree whose canonical path equals want. +func containsWorktreePath(t *testing.T, porcelain, want string) bool { + t.Helper() + for _, line := range splitLines(porcelain) { + if p, ok := cutPrefix(line, "worktree "); ok { + cp, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + if cp == want { + return true + } + } + } + return false +} + +func splitLines(s string) []string { + var out []string + cur := "" + for _, c := range s { + if c == '\n' { + out = append(out, cur) + cur = "" + continue + } + cur += string(c) + } + if cur != "" { + out = append(out, cur) + } + return out +} + +func cutPrefix(s, prefix string) (string, bool) { + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):], true + } + return "", false +} + +// --------------------------------------------------------------------------- +// Task 6: existing / resume / blocked matrix. +// +// These tests construct the on-disk states a crash or a collision leaves and +// assert Prepare's disposition and its byte-for-byte preservation guarantees. +// Blocked is a value disposition (PrepareBlocked, nil error); a probe that +// cannot see a resource is an error. Manual manifests are published through the +// production writeManifest so a constructed state is one loadManifest accepts. +// --------------------------------------------------------------------------- + +// countWorktreePathOccurrences counts how many registered worktrees resolve to +// want, canonicalizing each porcelain path through every symlink hop. +func countWorktreePathOccurrences(porcelain, want string) int { + n := 0 + for _, line := range splitLines(porcelain) { + p, ok := cutPrefix(line, "worktree ") + if !ok { + continue + } + if canon, err := filepath.EvalSymlinks(p); err == nil && canon == want { + n++ + } + } + return n +} + +// freshTarget builds the unstacked target every matrix scenario prepares. +func freshTarget(t *testing.T, id int) Target { + t.Helper() + base := resolveBase(t, []domain.ChangeSpec{{ID: domain.ChangeID(id), Status: domain.StatusProposed}}, nil, domain.ChangeID(id)) + tgt, err := NewTarget(domain.ChangeID(id), prepSlug, base, "feat/"+prepSlug) + if err != nil { + t.Fatalf("NewTarget: %v", err) + } + return tgt +} + +// prepareOK runs Prepare and fails the test on any error, returning the result. +func prepareOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) Workspace { + t.Helper() + ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + return ws +} + +// wsPathOf is the canonical checkout path a target's workspace lands at. +func wsPathOf(repo gitcli.Repository) string { + return filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) +} + +// metaDirOf is the hashed workspace metadata directory for a target. +func metaDirOf(repo gitcli.Repository, tgt Target) string { + return workspaceDir(repo.CommonDir, tgt.FeatureRef) +} + +// writeStateManifest publishes a manifest in the target's metadata directory at +// the requested phase and recorded base, via the production writer. It is how a +// crash-left partial state is constructed for the resume tests. +func writeStateManifest(t *testing.T, repo gitcli.Repository, tgt Target, base gitcli.ObjectID, phase Phase) { + t.Helper() + m := Manifest{ + Schema: manifestSchemaVersion, + ID: workspaceID(tgt.FeatureRef), + CommonDir: repo.CommonDir, + ChangeID: tgt.ChangeID, + Slug: tgt.Slug, + FeatureRef: tgt.FeatureRef, + BaseRef: tgt.BaseRef, + BaseCommit: base, + Path: wsPathOf(repo), + Phase: phase, + CreatedUTC: time.Now().UTC().Format(time.RFC3339), + UpdatedUTC: time.Now().UTC().Format(time.RFC3339), + } + if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { + t.Fatalf("writeManifest: %v", err) + } +} + +// localBranchTip returns refs/heads/feat/'s tip in the primary clone. +func localBranchTip(t *testing.T, r *wsRepos) gitcli.ObjectID { + t.Helper() + return gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))) +} + +// readFileBytes reads a file as a string, failing the test on error. +func readFileBytes(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +// freshTopology rebuilds the same fixture kind as r for an isolated subtest, so +// each blocked-matrix case runs against its own repository. +func freshTopology(t *testing.T, r *wsRepos) *wsRepos { + t.Helper() + if len(r.Preserve) > 1 { + return docketModeRepo(t) + } + return mainModeRepo(t) +} + +// assertBlocked runs Prepare and asserts a PrepareBlocked disposition with no error. +func assertBlocked(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) { + t.Helper() + out, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) + if err != nil { + t.Fatalf("Prepare = error %v; want blocked disposition", err) + } + if out.Disposition != PrepareBlocked { + t.Errorf("disposition = %q; want blocked", out.Disposition) + } +} + +// assertNoManifest asserts Prepare published no manifest of its own. +func assertNoManifest(t *testing.T, repo gitcli.Repository, tgt Target) { + t.Helper() + if _, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || present { + t.Errorf("manifest present=%v err=%v; want cleanly absent (none published)", present, err) + } +} + +// countPathOccurrences counts porcelain "worktree " lines whose canonical +// path equals want. +func countPathOccurrences(porcelain, want string) int { + n := 0 + for _, line := range splitLines(porcelain) { + if p, ok := cutPrefix(line, "worktree "); ok { + if cp, err := filepath.EvalSymlinks(p); err == nil && cp == want { + n++ + } + } + } + return n +} + +// writeFailingGit writes an executable git wrapper that forwards to the real git +// on PATH except for the named subcommand, which it fails with exit 1. The +// wrapper is invoked by absolute path, so PATH still resolves the real git. +func writeFailingGit(t *testing.T, failSubcommand string) string { + t.Helper() + dir := testsupport.TempDir(t) + p := filepath.Join(dir, "git") + script := "#!/bin/sh\nif [ \"$1\" = \"" + failSubcommand + "\" ]; then\n echo \"fake git: $1 disabled for test\" >&2\n exit 1\nfi\nexec git \"$@\"\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +// newServiceWithGit builds a Service whose gitcli.Client uses the given git +// executable, discovering the canonical Repository through that same client. +func (r *wsRepos) newServiceWithGit(t *testing.T, exe string) (*Service, gitcli.Repository) { + t.Helper() + c, err := gitcli.NewClient(gitcli.WithExecutable(exe)) + if err != nil { + t.Fatalf("gitcli.NewClient(WithExecutable): %v", err) + } + svc, err := NewService(c) + if err != nil { + t.Fatalf("NewService: %v", err) + } + repo, err := c.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Primary}) + if err != nil { + t.Fatalf("Discover: %v", err) + } + return svc, repo +} diff --git a/internal/workspace/prepare_test.go b/internal/workspace/prepare_test.go index 884422899..330808270 100644 --- a/internal/workspace/prepare_test.go +++ b/internal/workspace/prepare_test.go @@ -1,344 +1,13 @@ package workspace import ( - "context" - "os" "path/filepath" "testing" - "time" - "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/testsupport" ) -// The Prepare tests exercise the fresh-allocation path against real temporary -// Git repositories with local bare remotes, on both the plain and the -// docket-style topologies. Existing/resume/blocked arms are Task 6. - -// prepSlug is the feature slug every fresh-allocation scenario prepares. Its -// derived feature ref is refs/heads/feat/. -const prepSlug = "fix-the-thing" - -func prepFeatureRef() gitcli.RefName { return gitcli.RefName("refs/heads/feat/" + prepSlug) } - -// resolveBase wires a real domain.ResolveEffectiveBase outcome — proving the -// service consumes the resolver rather than shadowing its rules — and asserts -// the outcome actually resolved (a fixture bug otherwise). -func resolveBase(t *testing.T, specs []domain.ChangeSpec, branches []string, subject domain.ChangeID) domain.EffectiveBase { - t.Helper() - changes := make([]domain.Change, 0, len(specs)) - for _, sp := range specs { - changes = append(changes, domain.NewChange(sp)) - } - snap := domain.NewSnapshot(domain.SnapshotSpec{ - Policy: domain.RepositoryPolicy{IntegrationBranch: "main"}, - Changes: changes, - }) - set := make(map[string]bool, len(branches)) - for _, b := range branches { - set[b] = true - } - facts := domain.NewBranchFacts(set) - c, out := snap.Change(subject) - if out != domain.LookupFound { - t.Fatalf("Change(%d) = %v; want found", subject, out) - } - base := domain.ResolveEffectiveBase(snap, c, facts) - if base.Kind != domain.BaseResolved { - t.Fatalf("resolver base kind = %q; want resolved (fixture bug)", base.Kind) - } - return base -} - -// assertNothingCreated proves a rejected Prepare left no checkout, no local -// feature branch, and no manifest under the real repository. -func assertNothingCreated(t *testing.T, r *wsRepos, commonDir string) { - t.Helper() - if _, err := os.Lstat(filepath.Join(r.Primary, ".worktrees", prepSlug)); !os.IsNotExist(err) { - t.Errorf(".worktrees/%s exists or is unstattable (%v); want absent", prepSlug, err) - } - if branchExists(r.Primary, "feat/"+prepSlug) { - t.Errorf("local branch feat/%s exists; want absent", prepSlug) - } - if commonDir != "" { - if _, present, err := loadManifest(workspaceDir(commonDir, prepFeatureRef())); err != nil || present { - t.Errorf("manifest present=%v err=%v; want cleanly absent", present, err) - } - } -} - -// assertFreshCreated asserts the full created-workspace postcondition: the -// returned disposition and facts, live Git registration at the canonical path on -// the feature branch whose tip is wantBase, and a ready manifest recording the -// exact base commit. -func assertFreshCreated(t *testing.T, r *wsRepos, repo gitcli.Repository, ws Workspace, wantBase gitcli.ObjectID) { - t.Helper() - if ws.Disposition != PrepareCreated { - t.Fatalf("Disposition = %q; want %q", ws.Disposition, PrepareCreated) - } - wantPath := filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) - if ws.Path != wantPath { - t.Errorf("Path = %q; want %q", ws.Path, wantPath) - } - if ws.FeatureRef != prepFeatureRef() { - t.Errorf("FeatureRef = %q; want %q", ws.FeatureRef, prepFeatureRef()) - } - if ws.BaseCommit != wantBase { - t.Errorf("BaseCommit = %q; want %q", ws.BaseCommit, wantBase) - } - if ws.HeadCommit != wantBase { - t.Errorf("HeadCommit = %q; want %q (fresh head == base)", ws.HeadCommit, wantBase) - } - if ws.Dirty { - t.Errorf("Dirty = true; want false on a fresh checkout") - } - - // Live Git: registered at the canonical path, symbolic HEAD is the feature - // ref, and the branch tip equals the fetched base. - if got := symbolicHead(t, wantPath); got != string(prepFeatureRef()) { - t.Errorf("workspace symbolic HEAD = %q; want %q", got, prepFeatureRef()) - } - if got := gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))); got != wantBase { - t.Errorf("branch tip = %q; want %q", got, wantBase) - } - wl := gitOut(t, r.Primary, "worktree", "list", "--porcelain") - if !containsWorktreePath(t, wl, wantPath) { - t.Errorf("worktree list does not register %q:\n%s", wantPath, wl) - } - - // Manifest advanced to ready with the exact base commit. - m, present, err := loadManifest(workspaceDir(repo.CommonDir, prepFeatureRef())) - if err != nil || !present { - t.Fatalf("loadManifest present=%v err=%v; want present", present, err) - } - if m.Phase != PhaseReady { - t.Errorf("manifest phase = %q; want ready", m.Phase) - } - if m.BaseCommit != wantBase { - t.Errorf("manifest BaseCommit = %q; want %q", m.BaseCommit, wantBase) - } - if m.Path != wantPath { - t.Errorf("manifest Path = %q; want %q", m.Path, wantPath) - } -} - -// containsWorktreePath reports whether the porcelain worktree list registers a -// worktree whose canonical path equals want. -func containsWorktreePath(t *testing.T, porcelain, want string) bool { - t.Helper() - for _, line := range splitLines(porcelain) { - if p, ok := cutPrefix(line, "worktree "); ok { - cp, err := filepath.EvalSymlinks(p) - if err != nil { - continue - } - if cp == want { - return true - } - } - } - return false -} - -func splitLines(s string) []string { - var out []string - cur := "" - for _, c := range s { - if c == '\n' { - out = append(out, cur) - cur = "" - continue - } - cur += string(c) - } - if cur != "" { - out = append(out, cur) - } - return out -} - -func cutPrefix(s, prefix string) (string, bool) { - if len(s) >= len(prefix) && s[:len(prefix)] == prefix { - return s[len(prefix):], true - } - return "", false -} - -// --------------------------------------------------------------------------- -// Task 6: existing / resume / blocked matrix. -// -// These tests construct the on-disk states a crash or a collision leaves and -// assert Prepare's disposition and its byte-for-byte preservation guarantees. -// Blocked is a value disposition (PrepareBlocked, nil error); a probe that -// cannot see a resource is an error. Manual manifests are published through the -// production writeManifest so a constructed state is one loadManifest accepts. -// --------------------------------------------------------------------------- - -// countWorktreePathOccurrences counts how many registered worktrees resolve to -// want, canonicalizing each porcelain path through every symlink hop. -func countWorktreePathOccurrences(porcelain, want string) int { - n := 0 - for _, line := range splitLines(porcelain) { - p, ok := cutPrefix(line, "worktree ") - if !ok { - continue - } - if canon, err := filepath.EvalSymlinks(p); err == nil && canon == want { - n++ - } - } - return n -} - -// freshTarget builds the unstacked target every matrix scenario prepares. -func freshTarget(t *testing.T, id int) Target { - t.Helper() - base := resolveBase(t, []domain.ChangeSpec{{ID: domain.ChangeID(id), Status: domain.StatusProposed}}, nil, domain.ChangeID(id)) - tgt, err := NewTarget(domain.ChangeID(id), prepSlug, base, "feat/"+prepSlug) - if err != nil { - t.Fatalf("NewTarget: %v", err) - } - return tgt -} - -// prepareOK runs Prepare and fails the test on any error, returning the result. -func prepareOK(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) Workspace { - t.Helper() - ws, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - return ws -} - -// wsPathOf is the canonical checkout path a target's workspace lands at. -func wsPathOf(repo gitcli.Repository) string { - return filepath.Join(repo.PrimaryWorktree, ".worktrees", prepSlug) -} - -// metaDirOf is the hashed workspace metadata directory for a target. -func metaDirOf(repo gitcli.Repository, tgt Target) string { - return workspaceDir(repo.CommonDir, tgt.FeatureRef) -} - -// writeStateManifest publishes a manifest in the target's metadata directory at -// the requested phase and recorded base, via the production writer. It is how a -// crash-left partial state is constructed for the resume tests. -func writeStateManifest(t *testing.T, repo gitcli.Repository, tgt Target, base gitcli.ObjectID, phase Phase) { - t.Helper() - m := Manifest{ - Schema: manifestSchemaVersion, - ID: workspaceID(tgt.FeatureRef), - CommonDir: repo.CommonDir, - ChangeID: tgt.ChangeID, - Slug: tgt.Slug, - FeatureRef: tgt.FeatureRef, - BaseRef: tgt.BaseRef, - BaseCommit: base, - Path: wsPathOf(repo), - Phase: phase, - CreatedUTC: time.Now().UTC().Format(time.RFC3339), - UpdatedUTC: time.Now().UTC().Format(time.RFC3339), - } - if err := writeManifest(metaDirOf(repo, tgt), m); err != nil { - t.Fatalf("writeManifest: %v", err) - } -} - -// localBranchTip returns refs/heads/feat/'s tip in the primary clone. -func localBranchTip(t *testing.T, r *wsRepos) gitcli.ObjectID { - t.Helper() - return gitcli.ObjectID(gitOut(t, r.Primary, "rev-parse", string(prepFeatureRef()))) -} - -// readFileBytes reads a file as a string, failing the test on error. -func readFileBytes(t *testing.T, path string) string { - t.Helper() - b, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - return string(b) -} - -// freshTopology rebuilds the same fixture kind as r for an isolated subtest, so -// each blocked-matrix case runs against its own repository. -func freshTopology(t *testing.T, r *wsRepos) *wsRepos { - t.Helper() - if len(r.Preserve) > 1 { - return docketModeRepo(t) - } - return mainModeRepo(t) -} - -// assertBlocked runs Prepare and asserts a PrepareBlocked disposition with no error. -func assertBlocked(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) { - t.Helper() - out, err := svc.Prepare(context.Background(), PrepareRequest{Repository: repo, Remote: "origin", Target: tgt}) - if err != nil { - t.Fatalf("Prepare = error %v; want blocked disposition", err) - } - if out.Disposition != PrepareBlocked { - t.Errorf("disposition = %q; want blocked", out.Disposition) - } -} - -// assertNoManifest asserts Prepare published no manifest of its own. -func assertNoManifest(t *testing.T, repo gitcli.Repository, tgt Target) { - t.Helper() - if _, present, err := loadManifest(metaDirOf(repo, tgt)); err != nil || present { - t.Errorf("manifest present=%v err=%v; want cleanly absent (none published)", present, err) - } -} - -// countPathOccurrences counts porcelain "worktree " lines whose canonical -// path equals want. -func countPathOccurrences(porcelain, want string) int { - n := 0 - for _, line := range splitLines(porcelain) { - if p, ok := cutPrefix(line, "worktree "); ok { - if cp, err := filepath.EvalSymlinks(p); err == nil && cp == want { - n++ - } - } - } - return n -} - -// writeFailingGit writes an executable git wrapper that forwards to the real git -// on PATH except for the named subcommand, which it fails with exit 1. The -// wrapper is invoked by absolute path, so PATH still resolves the real git. -func writeFailingGit(t *testing.T, failSubcommand string) string { - t.Helper() - dir := testsupport.TempDir(t) - p := filepath.Join(dir, "git") - script := "#!/bin/sh\nif [ \"$1\" = \"" + failSubcommand + "\" ]; then\n echo \"fake git: $1 disabled for test\" >&2\n exit 1\nfi\nexec git \"$@\"\n" - if err := os.WriteFile(p, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - return p -} - -// newServiceWithGit builds a Service whose gitcli.Client uses the given git -// executable, discovering the canonical Repository through that same client. -func (r *wsRepos) newServiceWithGit(t *testing.T, exe string) (*Service, gitcli.Repository) { - t.Helper() - c, err := gitcli.NewClient(gitcli.WithExecutable(exe)) - if err != nil { - t.Fatalf("gitcli.NewClient(WithExecutable): %v", err) - } - svc, err := NewService(c) - if err != nil { - t.Fatalf("NewService: %v", err) - } - repo, err := c.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: r.Primary}) - if err != nil { - t.Fatalf("Discover: %v", err) - } - return svc, repo -} - // TestClassifyRegistrationAbsence pins the fail-closed three-outcome // registration probe (change 0368). It needs no git: it drives the pure // classifier over synthetic []gitcli.WorktreeInfo. Two intended-path shapes are diff --git a/internal/workspace/publish_integration_test.go b/internal/workspace/publish_integration_test.go index ac90c8a9a..db8bada62 100644 --- a/internal/workspace/publish_integration_test.go +++ b/internal/workspace/publish_integration_test.go @@ -4,7 +4,9 @@ package workspace import ( "context" + "os" "path/filepath" + "strings" "testing" "github.com/danielhanold/docket/internal/gitcli" @@ -450,3 +452,71 @@ func TestIntegrationWorkspaceLifecyclePublishExpectedHeadMatches(t *testing.T) { t.Errorf("origin feat = %q (exists %v); want %s", got, ok, checked) } } + +// The PublishHead tests drive the idempotent feature-branch publication flow +// against real bare origins. PublishHead reinspects the owned ready workspace, +// refuses a dirty or inconsistent one, probes the authoritative remote feature +// ref, and reaches the exact local HEAD onto the exact remote ref under an +// absent-ref or expected-old lease — never a force, reset, merge, or rebase. The +// idempotency key is the remote state (the exact commit at the exact remote +// ref), never a clean tree, a local branch, or an upstream configuration. + +// commitInWorkspace commits one file on the workspace's feature branch and +// returns the new HEAD, so a test can advance the branch past its base. +func commitInWorkspace(t *testing.T, ws, rel, content string) gitcli.ObjectID { + t.Helper() + writeWorktreeFile(t, ws, rel, content) + gitOut(t, ws, "add", rel) + gitOut(t, ws, "commit", "-q", "-m", "work: "+rel) + return gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) +} + +// originFeatCommit returns the origin's feat/ ref commit and whether it +// exists. Origin is bare, so it is read directly with rev-parse. +func originFeatCommit(t *testing.T, r *wsRepos) (gitcli.ObjectID, bool) { + t.Helper() + out, err := gitTry(r.Origin, "rev-parse", "--verify", "--quiet", string(prepFeatureRef())) + if err != nil { + return "", false + } + return gitcli.ObjectID(strings.TrimSpace(out)), true +} + +// chmodTree recursively sets mode on dir and everything beneath it. Directories +// are chmod'd after their contents so a read-only parent does not block +// descent on the way down; on restore the parent must be writable first, so it +// is applied to dir itself last regardless. +func chmodTree(t *testing.T, dir string, mode os.FileMode) { + t.Helper() + // Ensure directories are traversable/writable enough to walk when restoring. + restore := mode&0o200 != 0 + if restore { + _ = os.Chmod(dir, 0o700) + } + entries, err := os.ReadDir(dir) + if err != nil { + // On the read-only pass the dir may already be unreadable; that is fine. + if restore { + t.Fatalf("ReadDir %s: %v", dir, err) + } + } + for _, e := range entries { + p := filepath.Join(dir, e.Name()) + if e.IsDir() { + chmodTree(t, p, mode) + continue + } + if err := os.Chmod(p, mode); err != nil && restore { + t.Fatalf("chmod %s: %v", p, err) + } + } + if err := os.Chmod(dir, mode); err != nil && restore { + t.Fatalf("chmod %s: %v", dir, err) + } +} + +// publishHead runs PublishHead and returns the result plus error verbatim. +func publishHead(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) (PublishResult, error) { + t.Helper() + return svc.PublishHead(context.Background(), PublishRequest{Repository: repo, Remote: "origin", Target: tgt}) +} diff --git a/internal/workspace/publish_test.go b/internal/workspace/publish_test.go deleted file mode 100644 index 324b42cdd..000000000 --- a/internal/workspace/publish_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package workspace - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// The PublishHead tests drive the idempotent feature-branch publication flow -// against real bare origins. PublishHead reinspects the owned ready workspace, -// refuses a dirty or inconsistent one, probes the authoritative remote feature -// ref, and reaches the exact local HEAD onto the exact remote ref under an -// absent-ref or expected-old lease — never a force, reset, merge, or rebase. The -// idempotency key is the remote state (the exact commit at the exact remote -// ref), never a clean tree, a local branch, or an upstream configuration. - -// commitInWorkspace commits one file on the workspace's feature branch and -// returns the new HEAD, so a test can advance the branch past its base. -func commitInWorkspace(t *testing.T, ws, rel, content string) gitcli.ObjectID { - t.Helper() - writeWorktreeFile(t, ws, rel, content) - gitOut(t, ws, "add", rel) - gitOut(t, ws, "commit", "-q", "-m", "work: "+rel) - return gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) -} - -// originFeatCommit returns the origin's feat/ ref commit and whether it -// exists. Origin is bare, so it is read directly with rev-parse. -func originFeatCommit(t *testing.T, r *wsRepos) (gitcli.ObjectID, bool) { - t.Helper() - out, err := gitTry(r.Origin, "rev-parse", "--verify", "--quiet", string(prepFeatureRef())) - if err != nil { - return "", false - } - return gitcli.ObjectID(strings.TrimSpace(out)), true -} - -// chmodTree recursively sets mode on dir and everything beneath it. Directories -// are chmod'd after their contents so a read-only parent does not block -// descent on the way down; on restore the parent must be writable first, so it -// is applied to dir itself last regardless. -func chmodTree(t *testing.T, dir string, mode os.FileMode) { - t.Helper() - // Ensure directories are traversable/writable enough to walk when restoring. - restore := mode&0o200 != 0 - if restore { - _ = os.Chmod(dir, 0o700) - } - entries, err := os.ReadDir(dir) - if err != nil { - // On the read-only pass the dir may already be unreadable; that is fine. - if restore { - t.Fatalf("ReadDir %s: %v", dir, err) - } - } - for _, e := range entries { - p := filepath.Join(dir, e.Name()) - if e.IsDir() { - chmodTree(t, p, mode) - continue - } - if err := os.Chmod(p, mode); err != nil && restore { - t.Fatalf("chmod %s: %v", p, err) - } - } - if err := os.Chmod(dir, mode); err != nil && restore { - t.Fatalf("chmod %s: %v", dir, err) - } -} - -// publishHead runs PublishHead and returns the result plus error verbatim. -func publishHead(t *testing.T, svc *Service, repo gitcli.Repository, tgt Target) (PublishResult, error) { - t.Helper() - return svc.PublishHead(context.Background(), PublishRequest{Repository: repo, Remote: "origin", Target: tgt}) -} diff --git a/internal/workspace/rewrite_integration_test.go b/internal/workspace/rewrite_integration_test.go index 2d8137f2a..932898bb1 100644 --- a/internal/workspace/rewrite_integration_test.go +++ b/internal/workspace/rewrite_integration_test.go @@ -5,7 +5,9 @@ package workspace import ( "context" "path/filepath" + "strconv" "testing" + "time" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/testsupport" @@ -345,3 +347,40 @@ func TestIntegrationWorkspaceLifecycleGeneralPublishStillRefusesRewrite(t *testi t.Errorf("origin feat ref changed: before=%q after=%q; general publish force-published a rewrite", before, after) } } + +// This file drives PublishRewrite: the narrow, receipt-scoped force-with-lease +// publication of a rewritten (rebased) feature head. PublishRewrite refuses +// without a matching receipt, pushes exactly the caller's NewHead onto the exact +// remote feature ref under a --force-with-lease keyed on the receipt's +// OrigRemoteHead, and reprobes to equality before it reports published. The +// idempotency key is the remote state (learnings: idempotency-keying): a remote +// already at NewHead is a noop, and a remote moved off OrigRemoteHead is +// contended with the remote left untouched — no force beyond the exact lease. + +// receiptFor builds a receipt that matches repo/tgt, recording origRemote as both +// the pre-rebase head and the remote head the lease is keyed to, and base as the +// rebase target. +func receiptFor(repo gitcli.Repository, tgt Target, origRemote, base gitcli.ObjectID, attempt string) RebaseReceipt { + return RebaseReceipt{ + RepoIdentity: repo.CommonDir, + ChangeID: strconv.Itoa(int(tgt.ChangeID)), + OrigHead: string(origRemote), + OrigRemoteHead: string(origRemote), + BaseRef: string(tgt.BaseRef), + BaseHead: string(base), + Attempt: attempt, + CreatedUTC: time.Now().UTC().Format(time.RFC3339), + } +} + +// rewriteWorkspaceHead amends the workspace tip into a divergent new commit and +// returns it. The new head shares the base parent but is neither an ancestor nor +// a descendant of the pre-amend head — a genuine history rewrite that only a +// force update can publish. +func rewriteWorkspaceHead(t *testing.T, ws string) gitcli.ObjectID { + t.Helper() + writeWorktreeFile(t, ws, "feature.txt", "rewritten feature work\n") + gitOut(t, ws, "add", "feature.txt") + gitOut(t, ws, "commit", "-q", "--amend", "--no-edit") + return gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) +} diff --git a/internal/workspace/rewrite_test.go b/internal/workspace/rewrite_test.go deleted file mode 100644 index 6162d1a80..000000000 --- a/internal/workspace/rewrite_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package workspace - -import ( - "strconv" - "testing" - "time" - - "github.com/danielhanold/docket/internal/gitcli" -) - -// This file drives PublishRewrite: the narrow, receipt-scoped force-with-lease -// publication of a rewritten (rebased) feature head. PublishRewrite refuses -// without a matching receipt, pushes exactly the caller's NewHead onto the exact -// remote feature ref under a --force-with-lease keyed on the receipt's -// OrigRemoteHead, and reprobes to equality before it reports published. The -// idempotency key is the remote state (learnings: idempotency-keying): a remote -// already at NewHead is a noop, and a remote moved off OrigRemoteHead is -// contended with the remote left untouched — no force beyond the exact lease. - -// receiptFor builds a receipt that matches repo/tgt, recording origRemote as both -// the pre-rebase head and the remote head the lease is keyed to, and base as the -// rebase target. -func receiptFor(repo gitcli.Repository, tgt Target, origRemote, base gitcli.ObjectID, attempt string) RebaseReceipt { - return RebaseReceipt{ - RepoIdentity: repo.CommonDir, - ChangeID: strconv.Itoa(int(tgt.ChangeID)), - OrigHead: string(origRemote), - OrigRemoteHead: string(origRemote), - BaseRef: string(tgt.BaseRef), - BaseHead: string(base), - Attempt: attempt, - CreatedUTC: time.Now().UTC().Format(time.RFC3339), - } -} - -// rewriteWorkspaceHead amends the workspace tip into a divergent new commit and -// returns it. The new head shares the base parent but is neither an ancestor nor -// a descendant of the pre-amend head — a genuine history rewrite that only a -// force update can publish. -func rewriteWorkspaceHead(t *testing.T, ws string) gitcli.ObjectID { - t.Helper() - writeWorktreeFile(t, ws, "feature.txt", "rewritten feature work\n") - gitOut(t, ws, "add", "feature.txt") - gitOut(t, ws, "commit", "-q", "--amend", "--no-edit") - return gitcli.ObjectID(gitOut(t, ws, "rev-parse", "HEAD")) -} From a085d5d65cdb542d62dcb606c7b8939566b51fbd Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:50:13 -0400 Subject: [PATCH 10/16] test(gatedrive): move real-supervisor and real-git tests behind the integration tag (TestIntegrationGatedrive, TestRaceIntegrationGatedrive, change 0466) --- ...est.go => fingerprint_integration_test.go} | 218 +++++------ .../gatedrive/handoff_integration_test.go | 182 +++++++++ internal/gatedrive/handoff_test.go | 192 +--------- ...ry_test.go => history_integration_test.go} | 102 +---- .../history_race_integration_test.go | 107 ++++++ ...e_test.go => sequence_integration_test.go} | 352 ++---------------- .../sequence_race_integration_test.go | 324 ++++++++++++++++ ...test.go => supervisor_integration_test.go} | 23 +- ...r_test.go => takeover_integration_test.go} | 104 +----- .../takeover_race_integration_test.go | 92 +++++ tests/runtime-budgets.tsv | 2 + .../test_go_integration_gatedrive_process.sh | 26 ++ tests/test_go_integration_gatedrive_race.sh | 24 ++ 13 files changed, 935 insertions(+), 813 deletions(-) rename internal/gatedrive/{fingerprint_test.go => fingerprint_integration_test.go} (77%) create mode 100644 internal/gatedrive/handoff_integration_test.go rename internal/gatedrive/{integration_history_test.go => history_integration_test.go} (55%) create mode 100644 internal/gatedrive/history_race_integration_test.go rename internal/gatedrive/{integration_sequence_test.go => sequence_integration_test.go} (56%) create mode 100644 internal/gatedrive/sequence_race_integration_test.go rename internal/gatedrive/{integration_test.go => supervisor_integration_test.go} (96%) rename internal/gatedrive/{integration_takeover_test.go => takeover_integration_test.go} (63%) create mode 100644 internal/gatedrive/takeover_race_integration_test.go create mode 100755 tests/test_go_integration_gatedrive_process.sh create mode 100755 tests/test_go_integration_gatedrive_race.sh diff --git a/internal/gatedrive/fingerprint_test.go b/internal/gatedrive/fingerprint_integration_test.go similarity index 77% rename from internal/gatedrive/fingerprint_test.go rename to internal/gatedrive/fingerprint_integration_test.go index 85ab5905a..916537a90 100644 --- a/internal/gatedrive/fingerprint_test.go +++ b/internal/gatedrive/fingerprint_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package gatedrive import ( @@ -9,99 +11,9 @@ import ( "github.com/danielhanold/docket/internal/testsupport" ) -// newDirtyRepo seeds a temporary git repository with a committed regular file, -// a second committed file, and a committed symlink, then returns its path. The -// repository is clean at return; each test mutates exactly one dimension so the -// resulting inequality isolates that dimension. -func newDirtyRepo(t *testing.T) string { - t.Helper() - repo := testsupport.TempDir(t) - gitInit(t, repo) - writeFile(t, repo, "x.sh", "echo hello\n") - writeFile(t, repo, "keep.txt", "keep\n") - symlink(t, repo, "x.sh", "link") - gitAdd(t, repo, "x.sh", "keep.txt", "link") - gitCommit(t, repo, "seed") - return repo -} - -func git(t *testing.T, repo string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = repo - cmd.Env = append(os.Environ(), - "GIT_CONFIG_NOSYSTEM=1", - "GIT_TERMINAL_PROMPT=0", - "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", - "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", - ) - // Also point at testsupport.GitEnv's background-off GIT_CONFIG_GLOBAL so a - // detached gc/maintenance/fsmonitor child cannot outlive the test and race - // the fixture's RemoveAll into "directory not empty" (change 0373). - cmd.Env = append(cmd.Env, testsupport.GitEnv(t)...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return string(out) -} - -func gitInit(t *testing.T, repo string) { - t.Helper() - git(t, repo, "init", "-q", "-b", "main") - git(t, repo, "config", "core.fileMode", "true") - git(t, repo, "config", "core.symlinks", "true") -} - -func gitAdd(t *testing.T, repo string, paths ...string) { - t.Helper() - git(t, repo, append([]string{"add", "--"}, paths...)...) -} - -func gitCommit(t *testing.T, repo, msg string) { - t.Helper() - git(t, repo, "commit", "-q", "-m", msg) -} - -func writeFile(t *testing.T, repo, rel, content string) { - t.Helper() - p := filepath.Join(repo, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func symlink(t *testing.T, repo, target, rel string) { - t.Helper() - p := filepath.Join(repo, rel) - _ = os.Remove(p) - if err := os.Symlink(target, p); err != nil { - t.Fatal(err) - } -} - -func chmod(t *testing.T, repo, rel string, mode os.FileMode) { - t.Helper() - if err := os.Chmod(filepath.Join(repo, rel), mode); err != nil { - t.Fatal(err) - } -} - -func fingerprint(t *testing.T, repo string) Fingerprint { - t.Helper() - fp, err := ComputeFingerprint(repo, realGit{}) - if err != nil { - t.Fatalf("ComputeFingerprint: %v", err) - } - return fp -} - -// TestFingerprintStagedByteChange proves that staging a content change to a +// TestIntegrationGatedriveFingerprintStagedByteChange proves that staging a content change to a // tracked file alters the fingerprint. -func TestFingerprintStagedByteChange(t *testing.T) { +func TestIntegrationGatedriveFingerprintStagedByteChange(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) writeFile(t, repo, "x.sh", "echo goodbye\n") @@ -112,9 +24,9 @@ func TestFingerprintStagedByteChange(t *testing.T) { } } -// TestFingerprintUnstagedByteChange proves that an unstaged content change to a +// TestIntegrationGatedriveFingerprintUnstagedByteChange proves that an unstaged content change to a // tracked file alters the fingerprint. -func TestFingerprintUnstagedByteChange(t *testing.T) { +func TestIntegrationGatedriveFingerprintUnstagedByteChange(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) writeFile(t, repo, "x.sh", "echo goodbye\n") // not staged @@ -124,9 +36,9 @@ func TestFingerprintUnstagedByteChange(t *testing.T) { } } -// TestFingerprintUntrackedFileAdded proves that adding an untracked file alters +// TestIntegrationGatedriveFingerprintUntrackedFileAdded proves that adding an untracked file alters // the fingerprint. -func TestFingerprintUntrackedFileAdded(t *testing.T) { +func TestIntegrationGatedriveFingerprintUntrackedFileAdded(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) writeFile(t, repo, "new.txt", "brand new\n") @@ -136,9 +48,9 @@ func TestFingerprintUntrackedFileAdded(t *testing.T) { } } -// TestFingerprintDetectsModeChange proves an executable-bit change alters the +// TestIntegrationGatedriveFingerprintDetectsModeChange proves an executable-bit change alters the // fingerprint. (Plan Task 3 snippet.) -func TestFingerprintDetectsModeChange(t *testing.T) { +func TestIntegrationGatedriveFingerprintDetectsModeChange(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) chmod(t, repo, "x.sh", 0o755) @@ -148,9 +60,9 @@ func TestFingerprintDetectsModeChange(t *testing.T) { } } -// TestFingerprintFileDeleted proves that deleting a tracked file from the +// TestIntegrationGatedriveFingerprintFileDeleted proves that deleting a tracked file from the // worktree alters the fingerprint. -func TestFingerprintFileDeleted(t *testing.T) { +func TestIntegrationGatedriveFingerprintFileDeleted(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) if err := os.Remove(filepath.Join(repo, "keep.txt")); err != nil { @@ -162,9 +74,9 @@ func TestFingerprintFileDeleted(t *testing.T) { } } -// TestFingerprintFileRenamed proves that renaming a tracked file alters the +// TestIntegrationGatedriveFingerprintFileRenamed proves that renaming a tracked file alters the // fingerprint. -func TestFingerprintFileRenamed(t *testing.T) { +func TestIntegrationGatedriveFingerprintFileRenamed(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) git(t, repo, "mv", "keep.txt", "kept.txt") @@ -174,9 +86,9 @@ func TestFingerprintFileRenamed(t *testing.T) { } } -// TestFingerprintSymlinkTargetChanged proves that repointing a symlink alters +// TestIntegrationGatedriveFingerprintSymlinkTargetChanged proves that repointing a symlink alters // the fingerprint — the link is hashed by its value. -func TestFingerprintSymlinkTargetChanged(t *testing.T) { +func TestIntegrationGatedriveFingerprintSymlinkTargetChanged(t *testing.T) { repo := newDirtyRepo(t) a := fingerprint(t, repo) symlink(t, repo, "keep.txt", "link") // was -> x.sh @@ -186,9 +98,9 @@ func TestFingerprintSymlinkTargetChanged(t *testing.T) { } } -// TestFingerprintIdenticalDirtyStateEqual proves that recomputing the +// TestIntegrationGatedriveFingerprintIdenticalDirtyStateEqual proves that recomputing the // fingerprint over an unchanged (dirty) worktree yields Equal. -func TestFingerprintIdenticalDirtyStateEqual(t *testing.T) { +func TestIntegrationGatedriveFingerprintIdenticalDirtyStateEqual(t *testing.T) { repo := newDirtyRepo(t) // Make it genuinely dirty: an unstaged edit plus an untracked file. writeFile(t, repo, "x.sh", "echo dirty\n") @@ -200,10 +112,10 @@ func TestFingerprintIdenticalDirtyStateEqual(t *testing.T) { } } -// TestFingerprintDanglingSymlinkHashedByValue proves that a symlink is hashed by +// TestIntegrationGatedriveFingerprintDanglingSymlinkHashedByValue proves that a symlink is hashed by // its link value and never followed: a dangling symlink still fingerprints, and // changing its (nonexistent) target changes the fingerprint. -func TestFingerprintDanglingSymlinkHashedByValue(t *testing.T) { +func TestIntegrationGatedriveFingerprintDanglingSymlinkHashedByValue(t *testing.T) { repo := newDirtyRepo(t) symlink(t, repo, "does-not-exist-1", "dangling") // untracked, dangling a, err := ComputeFingerprint(repo, realGit{}) @@ -219,3 +131,93 @@ func TestFingerprintDanglingSymlinkHashedByValue(t *testing.T) { t.Fatalf("dangling symlink value change must alter fingerprint (link not followed)") } } + +// newDirtyRepo seeds a temporary git repository with a committed regular file, +// a second committed file, and a committed symlink, then returns its path. The +// repository is clean at return; each test mutates exactly one dimension so the +// resulting inequality isolates that dimension. +func newDirtyRepo(t *testing.T) string { + t.Helper() + repo := testsupport.TempDir(t) + gitInit(t, repo) + writeFile(t, repo, "x.sh", "echo hello\n") + writeFile(t, repo, "keep.txt", "keep\n") + symlink(t, repo, "x.sh", "link") + gitAdd(t, repo, "x.sh", "keep.txt", "link") + gitCommit(t, repo, "seed") + return repo +} + +func git(t *testing.T, repo string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", + ) + // Also point at testsupport.GitEnv's background-off GIT_CONFIG_GLOBAL so a + // detached gc/maintenance/fsmonitor child cannot outlive the test and race + // the fixture's RemoveAll into "directory not empty" (change 0373). + cmd.Env = append(cmd.Env, testsupport.GitEnv(t)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +func gitInit(t *testing.T, repo string) { + t.Helper() + git(t, repo, "init", "-q", "-b", "main") + git(t, repo, "config", "core.fileMode", "true") + git(t, repo, "config", "core.symlinks", "true") +} + +func gitAdd(t *testing.T, repo string, paths ...string) { + t.Helper() + git(t, repo, append([]string{"add", "--"}, paths...)...) +} + +func gitCommit(t *testing.T, repo, msg string) { + t.Helper() + git(t, repo, "commit", "-q", "-m", msg) +} + +func writeFile(t *testing.T, repo, rel, content string) { + t.Helper() + p := filepath.Join(repo, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func symlink(t *testing.T, repo, target, rel string) { + t.Helper() + p := filepath.Join(repo, rel) + _ = os.Remove(p) + if err := os.Symlink(target, p); err != nil { + t.Fatal(err) + } +} + +func chmod(t *testing.T, repo, rel string, mode os.FileMode) { + t.Helper() + if err := os.Chmod(filepath.Join(repo, rel), mode); err != nil { + t.Fatal(err) + } +} + +func fingerprint(t *testing.T, repo string) Fingerprint { + t.Helper() + fp, err := ComputeFingerprint(repo, realGit{}) + if err != nil { + t.Fatalf("ComputeFingerprint: %v", err) + } + return fp +} diff --git a/internal/gatedrive/handoff_integration_test.go b/internal/gatedrive/handoff_integration_test.go new file mode 100644 index 000000000..511442f0e --- /dev/null +++ b/internal/gatedrive/handoff_integration_test.go @@ -0,0 +1,182 @@ +//go:build integration + +package gatedrive + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestIntegrationGatedriveRepoHandoffPerDimensionDriftRejectsClaim proves the core Task-8 property: +// a clean drive is handed off, then a single real git-level mutation in ANY +// fingerprint dimension makes the fresh claim reject, and — because a claim that +// no longer matches acquires no partial authority — the single-use receipt +// survives intact for a correct claimant. Each subtest mutates exactly one +// dimension so the resulting rejection isolates that dimension. +func TestIntegrationGatedriveRepoHandoffPerDimensionDriftRejectsClaim(t *testing.T) { + cases := []struct { + name string + mutate func(t *testing.T, repo string) + }{ + {"staged bytes", func(t *testing.T, repo string) { + writeFile(t, repo, "x.sh", "echo staged-drift\n") + gitAdd(t, repo, "x.sh") + }}, + {"unstaged bytes", func(t *testing.T, repo string) { + writeFile(t, repo, "x.sh", "echo unstaged-drift\n") // not staged + }}, + {"untracked file", func(t *testing.T, repo string) { + writeFile(t, repo, "loose.txt", "brand new\n") + }}, + {"rename", func(t *testing.T, repo string) { + git(t, repo, "mv", "keep.txt", "kept.txt") + }}, + {"deletion", func(t *testing.T, repo string) { + if err := os.Remove(filepath.Join(repo, "keep.txt")); err != nil { + t.Fatalf("remove: %v", err) + } + }}, + {"exec mode", func(t *testing.T, repo string) { + chmod(t, repo, "x.sh", 0o755) + }}, + {"symlink value", func(t *testing.T, repo string) { + symlink(t, repo, "keep.txt", "link") // was -> x.sh + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := newDirtyRepo(t) // clean at return + s, id, _, startFP := newRepoDrive(t, repo) + + // A clean handoff succeeds over the undrifted worktree. + receipt, err := s.writeHandoffReceipt(id, sampleRecord().OwnerGeneration, startFP) + if err != nil { + t.Fatalf("clean handoff must succeed: %v", err) + } + + // Drift exactly one dimension, then recompute the claim-time identity. + tc.mutate(t, repo) + driftFP := fingerprint(t, repo) + if startFP.Equal(driftFP) { + t.Fatalf("mutating %q must change the fingerprint (test is vacuous otherwise)", tc.name) + } + + // The drifted claim rejects and acquires no generation. + got, err := s.consumeHandoffCAS(id, receipt.HandoffGeneration, driftFP) + if oe, ok := AsOwnershipError(err); !ok || oe.Kind != ErrFingerprintMismatch { + t.Fatalf("a %q drift must reject the claim with ErrFingerprintMismatch, got %v", tc.name, err) + } + if got != "" { + t.Fatalf("a rejected claim must acquire no generation, got %q", got) + } + + // The receipt is untouched: a correct claimant could still consume it. + rec, err := s.Load(id) + if err != nil { + t.Fatalf("Load: %v", err) + } + if rec.HandoffGeneration != receipt.HandoffGeneration { + t.Fatalf("a rejected claim must preserve the receipt, got %q", rec.HandoffGeneration) + } + if rec.OwnerGeneration != "" { + t.Fatalf("a rejected claim must install no owner, got %q", rec.OwnerGeneration) + } + }) + } +} + +// TestIntegrationGatedriveDirtyHandoffIdenticalStateClaimsWithoutWIPCommit proves that dirty +// pre-commit task work is a supported handoff state: a drive started over a +// genuinely dirty worktree hands off and a fresh claimant whose worktree is +// byte-for-byte identical claims it — and NO WIP commit is created to move the +// ownership (HEAD and the dirty status are unchanged across the whole transfer). +func TestIntegrationGatedriveDirtyHandoffIdenticalStateClaimsWithoutWIPCommit(t *testing.T) { + repo := newDirtyRepo(t) + // Make it genuinely dirty across several dimensions before the drive starts: + // a staged edit, an unstaged edit, an untracked file, a mode flip, and a + // symlink retarget. This is the drive-start identity a handoff must preserve. + writeFile(t, repo, "x.sh", "echo staged\n") + gitAdd(t, repo, "x.sh") + writeFile(t, repo, "keep.txt", "unstaged edit\n") + writeFile(t, repo, "untracked.txt", "loose\n") + chmod(t, repo, "x.sh", 0o755) + symlink(t, repo, "keep.txt", "link") + + headBefore := headOID(t, repo) + statusBefore := porcelain(t, repo) + if statusBefore == "" { + t.Fatalf("test setup is not dirty: git status is clean") + } + + s, id, owner, startFP := newRepoDrive(t, repo) + receipt, err := s.writeHandoffReceipt(id, owner, startFP) + if err != nil { + t.Fatalf("a dirty handoff must succeed: %v", err) + } + + // The claimant recomputes identity over the UNCHANGED dirty worktree; it must + // match exactly and consume the receipt. + claimFP := fingerprint(t, repo) + if !startFP.Equal(claimFP) { + t.Fatalf("identical dirty state must fingerprint Equal at claim time") + } + newOwner, err := s.consumeHandoffCAS(id, receipt.HandoffGeneration, claimFP) + if err != nil { + t.Fatalf("an identical-dirty-state claim must succeed: %v", err) + } + if newOwner == "" || newOwner == owner || newOwner == receipt.HandoffGeneration { + t.Fatalf("claim must mint a fresh owner generation distinct from the chain, got %q", newOwner) + } + + // No WIP commit was created to move ownership, and nothing was staged away or + // cleaned: HEAD and the dirty worktree are exactly as they were. + if got := headOID(t, repo); got != headBefore { + t.Fatalf("handoff must not create a WIP commit: HEAD moved %q -> %q", headBefore, got) + } + if got := porcelain(t, repo); got != statusBefore { + t.Fatalf("handoff must not alter the dirty worktree:\n before: %q\n after: %q", statusBefore, got) + } +} + +// newRepoDrive persists a drive whose drive-start execution identity is the REAL +// fingerprint of repo (computed through realGit), so a handoff/claim over it +// exercises the actual repository dimensions rather than a synthetic struct. It +// returns the store, drive id, owner generation, and the drive-start fingerprint. +func newRepoDrive(t *testing.T, repo string) (s *Store, id, owner string, startFP Fingerprint) { + t.Helper() + startFP = fingerprint(t, repo) + rec := sampleRecord() + rec.RepoIdentity = repo + rec.WorktreePath = repo + rec.HeadOID = startFP.Head + rec.Fingerprint = startFP + owner = rec.OwnerGeneration + s = OpenStore(testsupport.TempDir(t)) + var err error + id, _, err = s.NewDrive(rec) + if err != nil { + t.Fatalf("NewDrive: %v", err) + } + return s, id, owner, startFP +} + +// headOID returns the repo's current HEAD object id (empty on an unborn branch), +// so a test can prove no WIP commit was created across a handoff. +func headOID(t *testing.T, repo string) string { + t.Helper() + oid, err := realGit{}.HeadOID(repo) + if err != nil { + t.Fatalf("HeadOID: %v", err) + } + return oid +} + +// porcelain returns `git status --porcelain=v2` for repo, the stable textual +// witness that the dirty worktree is unchanged across a handoff. +func porcelain(t *testing.T, repo string) string { + t.Helper() + return git(t, repo, "status", "--porcelain=v2", "--untracked-files=all") +} diff --git a/internal/gatedrive/handoff_test.go b/internal/gatedrive/handoff_test.go index 0a563c3e0..bc2e68462 100644 --- a/internal/gatedrive/handoff_test.go +++ b/internal/gatedrive/handoff_test.go @@ -4,15 +4,16 @@ // handoff. // // ownership_test.go (Task 5) proves the handoff/claim CAS logic against SYNTHETIC -// fingerprints (matchingFP/mismatchFP mutate one struct field). This file proves -// the binding those synthetic tests take on faith: that a genuine git-level -// mutation in each dimension — staged bytes, unstaged bytes, an untracked file, a -// rename, a deletion, an executable-mode flip, a symlink retarget — produces a -// ComputeFingerprint value the claim path rejects, one dimension at a time. It -// also proves the two properties spec "Explicit handoff and nearest-owner -// continuation" names for dirty task work: identical dirty state claims WITHOUT a -// WIP commit, and a fingerprint that drifted between drive-start and claim never -// grants partial authority. +// fingerprints (matchingFP/mismatchFP mutate one struct field). +// handoff_integration_test.go (behind the integration tag since change 0466, as it +// runs real git) proves the binding those synthetic tests take on faith: that a +// genuine git-level mutation in each dimension — staged bytes, unstaged bytes, an +// untracked file, a rename, a deletion, an executable-mode flip, a symlink +// retarget — produces a ComputeFingerprint value the claim path rejects, one +// dimension at a time. It also proves the two properties spec "Explicit handoff +// and nearest-owner continuation" names for dirty task work: identical dirty state +// claims WITHOUT a WIP commit, and a fingerprint that drifted between drive-start +// and claim never grants partial authority. // // The single-winner race and the plain-WAITING rejection are already proven by // ownership_test.go (TestRaceOneReceiptSingleWinner, @@ -23,8 +24,6 @@ package gatedrive import ( - "os" - "path/filepath" "strings" "testing" @@ -32,159 +31,6 @@ import ( "github.com/danielhanold/docket/internal/testsupport" ) -// newRepoDrive persists a drive whose drive-start execution identity is the REAL -// fingerprint of repo (computed through realGit), so a handoff/claim over it -// exercises the actual repository dimensions rather than a synthetic struct. It -// returns the store, drive id, owner generation, and the drive-start fingerprint. -func newRepoDrive(t *testing.T, repo string) (s *Store, id, owner string, startFP Fingerprint) { - t.Helper() - startFP = fingerprint(t, repo) - rec := sampleRecord() - rec.RepoIdentity = repo - rec.WorktreePath = repo - rec.HeadOID = startFP.Head - rec.Fingerprint = startFP - owner = rec.OwnerGeneration - s = OpenStore(testsupport.TempDir(t)) - var err error - id, _, err = s.NewDrive(rec) - if err != nil { - t.Fatalf("NewDrive: %v", err) - } - return s, id, owner, startFP -} - -// TestRepoHandoffPerDimensionDriftRejectsClaim proves the core Task-8 property: -// a clean drive is handed off, then a single real git-level mutation in ANY -// fingerprint dimension makes the fresh claim reject, and — because a claim that -// no longer matches acquires no partial authority — the single-use receipt -// survives intact for a correct claimant. Each subtest mutates exactly one -// dimension so the resulting rejection isolates that dimension. -func TestRepoHandoffPerDimensionDriftRejectsClaim(t *testing.T) { - cases := []struct { - name string - mutate func(t *testing.T, repo string) - }{ - {"staged bytes", func(t *testing.T, repo string) { - writeFile(t, repo, "x.sh", "echo staged-drift\n") - gitAdd(t, repo, "x.sh") - }}, - {"unstaged bytes", func(t *testing.T, repo string) { - writeFile(t, repo, "x.sh", "echo unstaged-drift\n") // not staged - }}, - {"untracked file", func(t *testing.T, repo string) { - writeFile(t, repo, "loose.txt", "brand new\n") - }}, - {"rename", func(t *testing.T, repo string) { - git(t, repo, "mv", "keep.txt", "kept.txt") - }}, - {"deletion", func(t *testing.T, repo string) { - if err := os.Remove(filepath.Join(repo, "keep.txt")); err != nil { - t.Fatalf("remove: %v", err) - } - }}, - {"exec mode", func(t *testing.T, repo string) { - chmod(t, repo, "x.sh", 0o755) - }}, - {"symlink value", func(t *testing.T, repo string) { - symlink(t, repo, "keep.txt", "link") // was -> x.sh - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := newDirtyRepo(t) // clean at return - s, id, _, startFP := newRepoDrive(t, repo) - - // A clean handoff succeeds over the undrifted worktree. - receipt, err := s.writeHandoffReceipt(id, sampleRecord().OwnerGeneration, startFP) - if err != nil { - t.Fatalf("clean handoff must succeed: %v", err) - } - - // Drift exactly one dimension, then recompute the claim-time identity. - tc.mutate(t, repo) - driftFP := fingerprint(t, repo) - if startFP.Equal(driftFP) { - t.Fatalf("mutating %q must change the fingerprint (test is vacuous otherwise)", tc.name) - } - - // The drifted claim rejects and acquires no generation. - got, err := s.consumeHandoffCAS(id, receipt.HandoffGeneration, driftFP) - if oe, ok := AsOwnershipError(err); !ok || oe.Kind != ErrFingerprintMismatch { - t.Fatalf("a %q drift must reject the claim with ErrFingerprintMismatch, got %v", tc.name, err) - } - if got != "" { - t.Fatalf("a rejected claim must acquire no generation, got %q", got) - } - - // The receipt is untouched: a correct claimant could still consume it. - rec, err := s.Load(id) - if err != nil { - t.Fatalf("Load: %v", err) - } - if rec.HandoffGeneration != receipt.HandoffGeneration { - t.Fatalf("a rejected claim must preserve the receipt, got %q", rec.HandoffGeneration) - } - if rec.OwnerGeneration != "" { - t.Fatalf("a rejected claim must install no owner, got %q", rec.OwnerGeneration) - } - }) - } -} - -// TestDirtyHandoffIdenticalStateClaimsWithoutWIPCommit proves that dirty -// pre-commit task work is a supported handoff state: a drive started over a -// genuinely dirty worktree hands off and a fresh claimant whose worktree is -// byte-for-byte identical claims it — and NO WIP commit is created to move the -// ownership (HEAD and the dirty status are unchanged across the whole transfer). -func TestDirtyHandoffIdenticalStateClaimsWithoutWIPCommit(t *testing.T) { - repo := newDirtyRepo(t) - // Make it genuinely dirty across several dimensions before the drive starts: - // a staged edit, an unstaged edit, an untracked file, a mode flip, and a - // symlink retarget. This is the drive-start identity a handoff must preserve. - writeFile(t, repo, "x.sh", "echo staged\n") - gitAdd(t, repo, "x.sh") - writeFile(t, repo, "keep.txt", "unstaged edit\n") - writeFile(t, repo, "untracked.txt", "loose\n") - chmod(t, repo, "x.sh", 0o755) - symlink(t, repo, "keep.txt", "link") - - headBefore := headOID(t, repo) - statusBefore := porcelain(t, repo) - if statusBefore == "" { - t.Fatalf("test setup is not dirty: git status is clean") - } - - s, id, owner, startFP := newRepoDrive(t, repo) - receipt, err := s.writeHandoffReceipt(id, owner, startFP) - if err != nil { - t.Fatalf("a dirty handoff must succeed: %v", err) - } - - // The claimant recomputes identity over the UNCHANGED dirty worktree; it must - // match exactly and consume the receipt. - claimFP := fingerprint(t, repo) - if !startFP.Equal(claimFP) { - t.Fatalf("identical dirty state must fingerprint Equal at claim time") - } - newOwner, err := s.consumeHandoffCAS(id, receipt.HandoffGeneration, claimFP) - if err != nil { - t.Fatalf("an identical-dirty-state claim must succeed: %v", err) - } - if newOwner == "" || newOwner == owner || newOwner == receipt.HandoffGeneration { - t.Fatalf("claim must mint a fresh owner generation distinct from the chain, got %q", newOwner) - } - - // No WIP commit was created to move ownership, and nothing was staged away or - // cleaned: HEAD and the dirty worktree are exactly as they were. - if got := headOID(t, repo); got != headBefore { - t.Fatalf("handoff must not create a WIP commit: HEAD moved %q -> %q", headBefore, got) - } - if got := porcelain(t, repo); got != statusBefore { - t.Fatalf("handoff must not alter the dirty worktree:\n before: %q\n after: %q", statusBefore, got) - } -} - // TestOldOwnerCannotAdvanceAfterHandoff proves the driver-level consequence of a // handoff: once the current owner has handed off (its generation invalidated), // an Advance presenting that old generation is an identity disagreement that @@ -288,24 +134,6 @@ func TestFreshOwnerConsumesTerminalWrittenWhileNoAgentActive(t *testing.T) { } } -// headOID returns the repo's current HEAD object id (empty on an unborn branch), -// so a test can prove no WIP commit was created across a handoff. -func headOID(t *testing.T, repo string) string { - t.Helper() - oid, err := realGit{}.HeadOID(repo) - if err != nil { - t.Fatalf("HeadOID: %v", err) - } - return oid -} - -// porcelain returns `git status --porcelain=v2` for repo, the stable textual -// witness that the dirty worktree is unchanged across a handoff. -func porcelain(t *testing.T, repo string) string { - t.Helper() - return git(t, repo, "status", "--porcelain=v2", "--untracked-files=all") -} - // TestScopedWaitingHandoffClaimClosesScope proves the WAITING → handoff → claim // path mid-sequence (spec verification 7): after a completed predecessor, the // current WAITING successor is handed off and cooperatively claimed; Claim closes diff --git a/internal/gatedrive/integration_history_test.go b/internal/gatedrive/history_integration_test.go similarity index 55% rename from internal/gatedrive/integration_history_test.go rename to internal/gatedrive/history_integration_test.go index 8751e1bb4..2dc928e89 100644 --- a/internal/gatedrive/integration_history_test.go +++ b/internal/gatedrive/history_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package gatedrive // End-to-end acceptance tests for the legacy-history recovery composition (change @@ -15,10 +17,6 @@ package gatedrive // is strictly a first-admission event, never an outcome-driven one. import ( - "bytes" - "os" - "path/filepath" - "sync" "testing" "time" @@ -39,98 +37,6 @@ func (p *tornDownProc) ClassifyRun(runDir string, mark bool) (process.RecoveryEn return process.RecoveryEntry{Disposition: "already-abandoned"}, nil } -// TestRaceConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe is Criterion 5: -// two concurrent scoped Starts on ONE worktree over a store seeded with nonblocking -// legacy history admit exactly one launch (the loser refused ErrWorktreeBusy, never -// a legacy-inventory refusal), and a manual CleanupHistory racing them completes -// without deadlock and leaves every seeded record byte-identical. Run under -race. -func TestRaceConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe(t *testing.T) { - store := OpenStore(testsupport.TempDir(t)) - - // Seed nonblocking legacy history: two completed drives plus a HALTED drive the - // recovery seam reports already torn down. None blocks admission, so the two - // concurrent starts contend solely on the worktree execution slot. - seeded := []string{"passed", "failed", "halted"} - before := map[string][]byte{} - for _, name := range seeded { - id := copyLegacyFixture(t, store, name) - p := filepath.Join(store.root, id, recordFileName) - buf, err := os.ReadFile(p) - if err != nil { - t.Fatalf("read seeded %s record: %v", name, err) - } - before[p] = buf - } - - wt := sampleWorktree() - _, reqA := prepareScopedStartAt(t, store, wt, "0342") - _, reqB := prepareScopedStartAt(t, store, wt, "0343") - reqs := []StartRequest{reqA, reqB} - - proc := &tornDownProc{countingProc: &countingProc{}} - var barrier sync.WaitGroup - barrier.Add(2) // only the two Starts fingerprint; CleanupHistory never touches git. - git := &barrierGit{wg: &barrier, head: "HEAD1"} - mkDriver := func() *Driver { - clk := &fakeClock{now: startEpoch()} - d := NewDriver(store, clk, proc, git) - d.slice = 4 * pollTick - d.pollInterval = pollTick - d.sleep = func(dur time.Duration) { clk.advance(dur) } - return d - } - drivers := []*Driver{mkDriver(), mkDriver()} - - // A concurrent manual history cleanup racing the two starts must neither hang - // (it acquires no admission/scope/drive lock, so it cannot deadlock against the - // starts' lock chain) nor mutate any record. It runs with apply=true (non - // -dry-run) so it exercises the recovery-write path, yet the seeded records are - // never rewritten (any marker the process layer would write lands in a run dir, - // never in record.json — and here every group reports already torn down). - cleanup := mkDriver() - cleanupDone := make(chan struct{}) - go func() { - defer close(cleanupDone) - for i := 0; i < 25; i++ { - if _, err := cleanup.CleanupHistory(HistoryCleanupRequest{}); err != nil { - t.Errorf("concurrent CleanupHistory returned an error: %v", err) - return - } - } - }() - - docs := make([]DriveDoc, 2) - errs := make([]error, 2) - var wg sync.WaitGroup - wg.Add(2) - for i := range drivers { - go func(i int) { - defer wg.Done() - docs[i], errs[i] = drivers[i].Start(reqs[i]) - }(i) - } - wg.Wait() - <-cleanupDone // completing at all is the no-deadlock proof. - - // Exactly one launch total: the worktree slot arbitrates over the legacy-seeded - // store; the seeded nonblocking history never adds a launch or a refusal. - if got := proc.launches(); got != 1 { - t.Fatalf("two concurrent starts over a legacy-seeded store must launch EXACTLY once, got %d", got) - } - assertOneWorktreeStartWinner(t, docs, errs) - - // The concurrent cleanup neither deleted nor rewrote any seeded record. - for p, want := range before { - got, err := os.ReadFile(p) - if err != nil { - t.Fatalf("seeded record %s vanished after a concurrent cleanup: %v", p, err) - } - if !bytes.Equal(got, want) { - t.Fatalf("seeded record %s was mutated by a concurrent cleanup", p) - } - } -} - // censusCountingProc counts every legacy-recovery seam consultation so a test can // prove a drive OUTCOME never triggers the first-admission census. Launch/Observe/ // Stop/ResolveReservation are the scriptable fakeProc; only ClassifyRun is counted. @@ -156,7 +62,7 @@ func newCensusDriver(t *testing.T, clk *fakeClock, proc *censusCountingProc) *Dr return d } -// TestIntegrationOutcomeTriggersNoLegacyCensusOrSecondStart is Criterion 7's negative space: a +// TestIntegrationGatedriveOutcomeTriggersNoLegacyCensusOrSecondStart is Criterion 7's negative space: a // FAILED suite verdict and a post-launch HALTED (deadline) outcome each launch // exactly once and consult the legacy-recovery seam ZERO times. The census is a // first-admission-only event (it runs under the worktree slot lock solely on the @@ -164,7 +70,7 @@ func newCensusDriver(t *testing.T, clk *fakeClock, proc *censusCountingProc) *Dr // the stronger guard: the drive persists its own HALTED record before returning, so // any spurious outcome-driven re-inventory would enumerate that record and consult // the seam on its recorded run dir — which classifyN==0 forbids. -func TestIntegrationOutcomeTriggersNoLegacyCensusOrSecondStart(t *testing.T) { +func TestIntegrationGatedriveOutcomeTriggersNoLegacyCensusOrSecondStart(t *testing.T) { t.Run("failed", func(t *testing.T) { clk := &fakeClock{now: startEpoch()} proc := &censusCountingProc{fakeProc: &fakeProc{ diff --git a/internal/gatedrive/history_race_integration_test.go b/internal/gatedrive/history_race_integration_test.go new file mode 100644 index 000000000..8cd9d4cec --- /dev/null +++ b/internal/gatedrive/history_race_integration_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package gatedrive + +import ( + "bytes" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestRaceIntegrationGatedriveConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe is Criterion 5: +// two concurrent scoped Starts on ONE worktree over a store seeded with nonblocking +// legacy history admit exactly one launch (the loser refused ErrWorktreeBusy, never +// a legacy-inventory refusal), and a manual CleanupHistory racing them completes +// without deadlock and leaves every seeded record byte-identical. Run under -race. +// Race shard (change 0466): concurrent Starts and a CleanupHistory race over one legacy-seeded store. +func TestRaceIntegrationGatedriveConcurrentStartsOverLegacySeededStoreArbitrateAndCleanupSafe(t *testing.T) { + store := OpenStore(testsupport.TempDir(t)) + + // Seed nonblocking legacy history: two completed drives plus a HALTED drive the + // recovery seam reports already torn down. None blocks admission, so the two + // concurrent starts contend solely on the worktree execution slot. + seeded := []string{"passed", "failed", "halted"} + before := map[string][]byte{} + for _, name := range seeded { + id := copyLegacyFixture(t, store, name) + p := filepath.Join(store.root, id, recordFileName) + buf, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read seeded %s record: %v", name, err) + } + before[p] = buf + } + + wt := sampleWorktree() + _, reqA := prepareScopedStartAt(t, store, wt, "0342") + _, reqB := prepareScopedStartAt(t, store, wt, "0343") + reqs := []StartRequest{reqA, reqB} + + proc := &tornDownProc{countingProc: &countingProc{}} + var barrier sync.WaitGroup + barrier.Add(2) // only the two Starts fingerprint; CleanupHistory never touches git. + git := &barrierGit{wg: &barrier, head: "HEAD1"} + mkDriver := func() *Driver { + clk := &fakeClock{now: startEpoch()} + d := NewDriver(store, clk, proc, git) + d.slice = 4 * pollTick + d.pollInterval = pollTick + d.sleep = func(dur time.Duration) { clk.advance(dur) } + return d + } + drivers := []*Driver{mkDriver(), mkDriver()} + + // A concurrent manual history cleanup racing the two starts must neither hang + // (it acquires no admission/scope/drive lock, so it cannot deadlock against the + // starts' lock chain) nor mutate any record. It runs with apply=true (non + // -dry-run) so it exercises the recovery-write path, yet the seeded records are + // never rewritten (any marker the process layer would write lands in a run dir, + // never in record.json — and here every group reports already torn down). + cleanup := mkDriver() + cleanupDone := make(chan struct{}) + go func() { + defer close(cleanupDone) + for i := 0; i < 25; i++ { + if _, err := cleanup.CleanupHistory(HistoryCleanupRequest{}); err != nil { + t.Errorf("concurrent CleanupHistory returned an error: %v", err) + return + } + } + }() + + docs := make([]DriveDoc, 2) + errs := make([]error, 2) + var wg sync.WaitGroup + wg.Add(2) + for i := range drivers { + go func(i int) { + defer wg.Done() + docs[i], errs[i] = drivers[i].Start(reqs[i]) + }(i) + } + wg.Wait() + <-cleanupDone // completing at all is the no-deadlock proof. + + // Exactly one launch total: the worktree slot arbitrates over the legacy-seeded + // store; the seeded nonblocking history never adds a launch or a refusal. + if got := proc.launches(); got != 1 { + t.Fatalf("two concurrent starts over a legacy-seeded store must launch EXACTLY once, got %d", got) + } + assertOneWorktreeStartWinner(t, docs, errs) + + // The concurrent cleanup neither deleted nor rewrote any seeded record. + for p, want := range before { + got, err := os.ReadFile(p) + if err != nil { + t.Fatalf("seeded record %s vanished after a concurrent cleanup: %v", p, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("seeded record %s was mutated by a concurrent cleanup", p) + } + } +} diff --git a/internal/gatedrive/integration_sequence_test.go b/internal/gatedrive/sequence_integration_test.go similarity index 56% rename from internal/gatedrive/integration_sequence_test.go rename to internal/gatedrive/sequence_integration_test.go index 8073fe8cc..a2210e613 100644 --- a/internal/gatedrive/integration_sequence_test.go +++ b/internal/gatedrive/sequence_integration_test.go @@ -1,10 +1,12 @@ +//go:build integration + // Real-git, real-process integration for a scope that carries a SEQUENCE of // task-owned drives (change 0405 Task 9, spec verifications "2 (real git)" and 6). // // Every other scope-sequence test in this package drives a fakeProc and either a // fakeGit or a real GitSeam. This file proves the composition a double cannot // vouch for: the real native process supervisor (internal/process.Service, the -// same seam integration_test.go composes) drives real `/bin/sh -c 'exit N'` +// same seam supervisor_integration_test.go composes) drives real `/bin/sh -c 'exit N'` // commands to genuine PASSED/FAILED verdicts, while the real git seam (realGit) // fingerprints real linked worktrees so a git-visible edit between RED and GREEN // produces genuinely distinct per-drive fingerprints. @@ -29,17 +31,19 @@ // comment: `exit N` is a shell builtin, so sh never execs it away and the marker // survives on the sh process's own argv (exec-optimization-erases-the-process-marker). // -// It reuses this package's real-git fixture helpers (fingerprint_test.go: gitInit, -// writeFile, gitAdd, gitCommit, git) and the real-process fixtures -// (integration_test.go: mustService, mustExe, skipUnlessSupported, reapSupervisors, -// stopAllRuns, advanceUntilTerminal, runDirsUnder), plus the scope helper -// scopeReqFor (takeover_test.go). TestMain (integration_test.go) already routes the -// supervisor re-exec role for the whole package. +// It reuses this package's real-git fixture helpers +// (fingerprint_integration_test.go: gitInit, writeFile, gitAdd, gitCommit, git) and +// the real-process fixtures (supervisor_integration_test.go: mustService, mustExe, +// skipUnlessSupported, reapSupervisors, stopAllRuns, advanceUntilTerminal, +// runDirsUnder), plus the scope helper scopeReqFor (takeover_test.go). TestMain +// (supervisor_integration_test.go) already routes the supervisor re-exec role for +// the whole integration-tagged build. The two concurrent tests of the second +// property live in sequence_race_integration_test.go (the race shard, change 0466); +// this file keeps their shared fixtures. package gatedrive import ( "fmt" - "os" "path/filepath" "strings" "sync" @@ -55,9 +59,9 @@ import ( // --------------------------------------------------------------------------- // realSeqDriver wires a driver over the REAL process service and the REAL git -// seam with the injected short slice (integration_test.go's intSlice/intPoll), so -// slices are bounded by real wall-clock time against a live child and fingerprints -// are computed over a real worktree. It is newIntDriver with realGit{} instead of +// seam with the injected short slice (supervisor_integration_test.go's +// intSlice/intPoll), so slices are bounded by real wall-clock time against a live +// child and fingerprints are computed over a real worktree. It is newIntDriver with realGit{} instead of // the fake stableGit. func realSeqDriver(store *Store, svc *process.Service) *Driver { d := NewDriver(store, systemClock{}, svc, realGit{}) @@ -213,14 +217,14 @@ func idsContain(ids []string, want string) bool { // Verification 2 (real git): baseline → RED → GREEN as a real-git sequence. // --------------------------------------------------------------------------- -// TestIntegrationSequenceRealGitBaselineRedGreen drives a real recovery-scope +// TestIntegrationGatedriveSequenceRealGitBaselineRedGreen drives a real recovery-scope // sequence over real git and the real process supervisor: a PASSED baseline, a // FAILED RED, a real git-visible edit, then a PASSED GREEN. It proves three // distinct drives run over one slot with exactly one execution each, each // fingerprinting the current worktree independently — so the edit between RED and // GREEN yields a genuinely different persisted GREEN fingerprint — while the scope // chains the slot and retires each acknowledged predecessor's authority. -func TestIntegrationSequenceRealGitBaselineRedGreen(t *testing.T) { +func TestIntegrationGatedriveSequenceRealGitBaselineRedGreen(t *testing.T) { skipUnlessSupported(t) repo := seedSeqRepo(t) wt := addLinkedWorktree(t, repo, "wt", "feat/seq") @@ -255,7 +259,7 @@ func TestIntegrationSequenceRealGitBaselineRedGreen(t *testing.T) { } // A real, git-visible edit between RED and GREEN: an untracked file changes the - // worktree's fingerprint (see TestFingerprintUntrackedFileAdded). Each drive + // worktree's fingerprint (see TestIntegrationGatedriveFingerprintUntrackedFileAdded). Each drive // fingerprints the current worktree independently. writeFile(t, wt, "between-red-and-green.txt", "edited between RED and GREEN\n") @@ -328,184 +332,11 @@ func TestIntegrationSequenceRealGitBaselineRedGreen(t *testing.T) { // --------------------------------------------------------------------------- // Verification 6: concurrent scopes in linked worktrees over one shared common -// dir; each parent resolves only its own scope's current work. +// dir; each parent resolves only its own scope's current work. The test itself, +// TestRaceIntegrationGatedriveSequenceConcurrentScopesResolveOwnWork, lives in +// sequence_race_integration_test.go (race shard, change 0466). // --------------------------------------------------------------------------- -// TestIntegrationSequenceConcurrentScopesResolveOwnWork runs two scopes in two -// linked worktrees that share ONE git common dir, concurrently, with -// distinguishable commands and opposite verdicts, and proves each parent's -// takeover and enumeration resolve only its own scope's current drive — repeated -// across two tasks of one change, two different changes, and with acknowledged -// historical drives present in the store. Concurrency is real (two goroutines -// driving the shared store); -race is the oracle for cross-scope interference. -func TestIntegrationSequenceConcurrentScopesResolveOwnWork(t *testing.T) { - skipUnlessSupported(t) - repo := seedSeqRepo(t) - wtA := addLinkedWorktree(t, repo, "wtA", "feat/a") - wtB := addLinkedWorktree(t, repo, "wtB", "feat/b") - common := commonDirOf(t, wtA) - if other := commonDirOf(t, wtB); other != common { - t.Fatalf("two linked worktrees must share ONE git common dir: %q vs %q", common, other) - } - store := OpenStore(common) - svc := mustService(t) - - // Each scope launches under its OWN process run root: the native supervisor's - // registry lock is a per-root non-blocking allocation probe (internal/process - // lock.go LOCK_EX|LOCK_NB), so two concurrent launches sharing one root would - // contend — and in production each parent picks its own scratch run root anyway. - // The SHARED resource under test is the git common dir (one drive/scope store), - // not the process allocation root. - scopeRunRoot := func(t *testing.T) string { - t.Helper() - rr := filepath.Join(testsupport.TempDir(t), "runs") - t.Cleanup(func() { stopAllRuns(t, svc, rr) }) - reapSupervisors(t, rr) - return rr - } - - // prep prepares a task scope bound to a worktree and its own run root, and returns - // the grant plus a start request wired to it (scope id + child capability + gate - // context). - prep := func(t *testing.T, wt, branch, changeID, taskID, gateCtx string, cmd []string) (ScopeGrant, StartRequest) { - t.Helper() - req := realSeqStart(wt, branch, scopeRunRoot(t), changeID, taskID, cmd) - grant, err := store.PrepareScope(scopeReqFor(req, gateCtx)) - if err != nil { - t.Fatalf("PrepareScope: %v", err) - } - req.ScopeID = grant.ScopeID - req.ChildCapability = grant.ChildCapability - req.GateContext = gateCtx - return grant, req - } - - // runPair drives reqA and reqB concurrently to their terminals over the shared - // store, joining before it returns the two docs. - runPair := func(t *testing.T, dA, dB *Driver, reqA, reqB StartRequest) (DriveDoc, DriveDoc) { - t.Helper() - var wg sync.WaitGroup - var docA, docB DriveDoc - var errA, errB error - wg.Add(2) - go func() { defer wg.Done(); docA, errA = driveSeqToTerminalErr(dA, reqA) }() - go func() { defer wg.Done(); docB, errB = driveSeqToTerminalErr(dB, reqB) }() - wg.Wait() - if errA != nil { - t.Fatalf("scope A concurrent drive: %v", errA) - } - if errB != nil { - t.Fatalf("scope B concurrent drive: %v", errB) - } - return docA, docB - } - - // assertResolvesOwn proves each scope's enumeration and each parent's takeover - // resolve ONLY that scope's own current drive. Enumeration is asserted before the - // takeovers, which close each scope and mint fresh parent owners. - assertResolvesOwn := func(t *testing.T, dA, dB *Driver, - gA ScopeGrant, changeA, gateA, driveA string, - gB ScopeGrant, changeB, gateB, driveB string) { - t.Helper() - if driveA == driveB { - t.Fatalf("the two concurrent scopes must resolve DISTINCT drives, both %q", driveA) - } - idsA, err := store.FindScopeDriveIDs(changeA, capHash(gateA)) - if err != nil { - t.Fatalf("FindScopeDriveIDs A: %v", err) - } - if !idsContain(idsA, driveA) || idsContain(idsA, driveB) { - t.Fatalf("scope A enumeration must resolve only its own drive %q, got %v", driveA, idsA) - } - idsB, err := store.FindScopeDriveIDs(changeB, capHash(gateB)) - if err != nil { - t.Fatalf("FindScopeDriveIDs B: %v", err) - } - if !idsContain(idsB, driveB) || idsContain(idsB, driveA) { - t.Fatalf("scope B enumeration must resolve only its own drive %q, got %v", driveB, idsB) - } - - tookA, err := dA.Takeover(gA.ScopeID, gA.ParentCapability, "") - if err != nil { - t.Fatalf("Takeover A: %v", err) - } - if tookA.DriveID != driveA { - t.Fatalf("scope A takeover must resolve its own current drive %q, got %q", driveA, tookA.DriveID) - } - tookB, err := dB.Takeover(gB.ScopeID, gB.ParentCapability, "") - if err != nil { - t.Fatalf("Takeover B: %v", err) - } - if tookB.DriveID != driveB { - t.Fatalf("scope B takeover must resolve its own current drive %q, got %q", driveB, tookB.DriveID) - } - if tookA.DriveID == tookB.DriveID { - t.Fatalf("the two parents' takeovers must resolve distinct drives") - } - } - - t.Run("two tasks of one change", func(t *testing.T) { - gA, rA := prep(t, wtA, "feat/a", "0540", "task-1", "gate-0540-a", seqPassCmd("2tasks-A")) - gB, rB := prep(t, wtB, "feat/b", "0540", "task-2", "gate-0540-b", seqFailCmd("2tasks-B")) - dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) - docA, docB := runPair(t, dA, dB, rA, rB) - if docA.Outcome != PASSED { - t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) - } - if docB.Outcome != FAILED { - t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) - } - assertCommandMarker(t, mustLoad(t, store, docA.DriveID), "2tasks-A") - assertCommandMarker(t, mustLoad(t, store, docB.DriveID), "2tasks-B") - assertResolvesOwn(t, dA, dB, gA, "0540", "gate-0540-a", docA.DriveID, gB, "0540", "gate-0540-b", docB.DriveID) - }) - - t.Run("two different changes", func(t *testing.T) { - gA, rA := prep(t, wtA, "feat/a", "0541", "task-1", "gate-0541-a", seqPassCmd("2chg-A")) - gB, rB := prep(t, wtB, "feat/b", "0542", "task-1", "gate-0542-b", seqFailCmd("2chg-B")) - dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) - docA, docB := runPair(t, dA, dB, rA, rB) - if docA.Outcome != PASSED { - t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) - } - if docB.Outcome != FAILED { - t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) - } - assertResolvesOwn(t, dA, dB, gA, "0541", "gate-0541-a", docA.DriveID, gB, "0542", "gate-0542-b", docB.DriveID) - }) - - t.Run("with acknowledged historical drives present", func(t *testing.T) { - // Build acked history under change 0543 / gate-0543-a on wtA: a - // baseline+successor sequence, terminally acknowledged, leaving two - // owner-cleared historical drives in the shared store. - ackedIDs := makeAckedHistory(t, realSeqDriver(store, svc), store, wtA, "feat/a", scopeRunRoot(t), "0543", "task-1", "gate-0543-a") - - // A fresh CURRENT scope under the SAME change+gate must still resolve to - // exactly its own current drive despite the acked history. - gA, rA := prep(t, wtA, "feat/a", "0543", "task-1", "gate-0543-a", seqPassCmd("acked-current-A")) - gB, rB := prep(t, wtB, "feat/b", "0544", "task-1", "gate-0544-b", seqFailCmd("acked-current-B")) - dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) - docA, docB := runPair(t, dA, dB, rA, rB) - if docA.Outcome != PASSED { - t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) - } - if docB.Outcome != FAILED { - t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) - } - - // Enumeration by (0543, gate-0543-a) resolves EXACTLY the current drive; the - // two acknowledged historical drives are terminal-and-consumed and excluded. - ids, err := store.FindScopeDriveIDs("0543", capHash("gate-0543-a")) - if err != nil { - t.Fatalf("FindScopeDriveIDs with history: %v", err) - } - if len(ids) != 1 || !idsContain(ids, docA.DriveID) { - t.Fatalf("enumeration with acked history must resolve exactly the current drive %q, got %v (acked history %v)", docA.DriveID, ids, ackedIDs) - } - assertResolvesOwn(t, dA, dB, gA, "0543", "gate-0543-a", docA.DriveID, gB, "0544", "gate-0544-b", docB.DriveID) - }) -} - // makeAckedHistory drives a baseline+successor sequence over a fresh scope and then // terminally acknowledges it, leaving two owner-cleared (consumed) historical drives // in the store, and returns their ids. It runs on the test goroutine. @@ -559,12 +390,12 @@ func makeAckedHistory(t *testing.T, d *Driver, store *Store, wt, branch, runRoot // id cannot steal another scope's work. // --------------------------------------------------------------------------- -// TestIntegrationSequenceCredentialTheftRejected proves that, across two scopes in +// TestIntegrationGatedriveSequenceCredentialTheftRejected proves that, across two scopes in // two linked worktrees sharing one common dir, scope A's child capability cannot // authorize a start on scope B (ErrScopeCapabilityMismatch, consuming nothing), and // scope A's drive id cannot be presented as scope B's takeover target (a HALTED // stale/identity rejection that supersedes nothing). -func TestIntegrationSequenceCredentialTheftRejected(t *testing.T) { +func TestIntegrationGatedriveSequenceCredentialTheftRejected(t *testing.T) { skipUnlessSupported(t) repo := seedSeqRepo(t) wtA := addLinkedWorktree(t, repo, "wtA", "feat/a") @@ -654,7 +485,9 @@ func TestIntegrationSequenceCredentialTheftRejected(t *testing.T) { // --------------------------------------------------------------------------- // Change 0446 Task 10 — spec AC2: same-worktree generations over real git and -// the real process supervisor. +// the real process supervisor. The test itself, +// TestRaceIntegrationGatedriveSameWorktreeGenerations, lives in +// sequence_race_integration_test.go (race shard, change 0466). // --------------------------------------------------------------------------- // genSettled is a thread-safe scripted EpochSettledFunc: an epoch is settled once @@ -675,136 +508,3 @@ func (g *genSettled) resolve(epochID string) (bool, error) { defer g.mu.Unlock() return g.settled[epochID], nil } - -// TestIntegrationSameWorktreeGenerations (spec AC2) drives real generations of -// executions through ONE worktree path over real git and the real process -// supervisor, proving old records for that path never block the next permitted -// drive once release or replacement is proven: -// -// - drive → release → re-admit across several successive run epochs: a live -// epoch still owns the worktree between drives (a different epoch is fenced), -// and once it settles the next epoch admits over its released slot; -// - a symlink alias of the worktree reaches the SAME slot and admits; -// - the live-incumbent counter-case: a genuinely executing run on the canonical -// path refuses a start through the alias, and admits it once the run finishes; -// - the worktree is removed while its released slot still names the completed -// epoch: retirement reaches the slot through its stored identity (never -// re-canonicalization of the missing path), and a recreated worktree at the -// same path admits a new epoch; -// - a second remove/recreate leaves the settled epoch on the inherited slot, and -// the next (epoch-less) start settles it at admission instead of refusing. -func TestIntegrationSameWorktreeGenerations(t *testing.T) { - skipUnlessSupported(t) - repo := seedSeqRepo(t) - wt := addLinkedWorktree(t, repo, "wt", "feat/gen") - store := OpenStore(commonDirOf(t, wt)) - svc := mustService(t) - runRoot := filepath.Join(testsupport.TempDir(t), "runs") - t.Cleanup(func() { stopAllRuns(t, svc, runRoot) }) - reapSupervisors(t, runRoot) - d := realSeqDriver(store, svc) - epochs := &genSettled{settled: map[string]bool{}} - d.SetEpochSettledResolver(epochs.resolve) - - slotOf := func(path string) admissionRecord { - t.Helper() - slot, _, err := store.LoadWorktreeExecution(path) - if err != nil { - t.Fatalf("load slot for %s: %v", path, err) - } - return slot - } - passAt := func(path, branch, epoch, marker string) DriveDoc { - t.Helper() - req := realSeqStart(path, branch, runRoot, "0446", marker, seqPassCmd(marker)) - req.RunEpochID = epoch - doc := driveSeqToTerminal(t, d, req) - if doc.Outcome != PASSED { - t.Fatalf("%s must PASS, got %s (%s)", marker, doc.Outcome, doc.Cause) - } - return doc - } - - // 1. Several successive epochs on one path. - lastGen := 0 - for i, epoch := range []string{"epoch-g1", "epoch-g2", "epoch-g3"} { - passAt(wt, "feat/gen", epoch, fmt.Sprintf("gen-%d", i+1)) - slot := slotOf(wt) - if slot.State != admissionReleased || slot.RunEpochID != epoch { - t.Fatalf("after %s: slot %s/%q, want released/%s", epoch, slot.State, slot.RunEpochID, epoch) - } - if slot.ExecutionGen <= lastGen { - t.Fatalf("after %s: execution generation %d did not advance past %d", epoch, slot.ExecutionGen, lastGen) - } - lastGen = slot.ExecutionGen - if i == 0 { - // Unsettled: the live epoch owns its worktree between drives. - foreign := realSeqStart(wt, "feat/gen", runRoot, "0446", "foreign", seqPassCmd("foreign")) - foreign.RunEpochID = "epoch-foreign" - if _, err := d.Start(foreign); !isOwnershipKind(err, ErrStaleRunEpoch) { - t.Fatalf("an unsettled epoch must fence a different epoch, got %v", err) - } - } - epochs.settle(epoch) - } - - // 2. A symlink alias reaches the same slot and admits (epoch-less start over the - // settled epoch-g3's released slot). - alias := filepath.Join(testsupport.TempDir(t), "wt-alias") - if err := os.Symlink(wt, alias); err != nil { - t.Fatalf("symlink alias: %v", err) - } - passAt(alias, "feat/gen", "", "gen-alias") - if slot := slotOf(wt); slot.ExecutionGen != lastGen+1 || slot.RunEpochID != "" { - t.Fatalf("the alias must reuse the canonical slot: gen %d (want %d) epoch %q", slot.ExecutionGen, lastGen+1, slot.RunEpochID) - } - - // 3. Live-incumbent counter-case at the same canonical path. - release := filepath.Join(testsupport.TempDir(t), "release-live-run") - liveReq := realSeqStart(wt, "feat/gen", runRoot, "0446", "gen-live", - []string{"/bin/sh", "-c", `while [ ! -f "$1" ]; do sleep 0.02; done`, "gen-live", release}) - liveDoc, err := d.Start(liveReq) - if err != nil || liveDoc.Outcome != WAITING { - t.Fatalf("live run must start and WAIT: doc=%+v err=%v", liveDoc, err) - } - blocked := realSeqStart(alias, "feat/gen", runRoot, "0446", "gen-blocked", seqPassCmd("gen-blocked")) - if _, err := d.Start(blocked); !isOwnershipKind(err, ErrWorktreeBusy) && !isOwnershipKind(err, ErrUnresolvedExecution) { - t.Fatalf("a genuinely live incumbent must still refuse a start through the alias, got %v", err) - } - if slot := slotOf(wt); slot.State != admissionExecuting { - t.Fatalf("the refused start must leave the live incumbent executing, got %s", slot.State) - } - writeFile(t, filepath.Dir(release), filepath.Base(release), "go\n") - if term, _ := advanceUntilTerminal(t, d, liveDoc.DriveID, liveDoc.Generation); term.Outcome != PASSED { - t.Fatalf("live run must PASS once released, got %s (%s)", term.Outcome, term.Cause) - } - passAt(alias, "feat/gen", "", "gen-after-live") - - // 4. Remove the worktree while its released slot names a completed epoch; retire - // through the stored identity; recreate the path; a new epoch admits. - passAt(wt, "feat/gen", "epoch-g4", "gen-4") - g4 := slotOf(wt) - git(t, repo, "worktree", "remove", "--force", wt) - if _, err := os.Stat(wt); !os.IsNotExist(err) { - t.Fatalf("worktree must be removed, stat err = %v", err) - } - removedSlot := slotOf(wt) // stored-identity addressing, not re-canonicalization - if removedSlot.RunEpochID != "epoch-g4" || removedSlot.ReservationToken != g4.ReservationToken { - t.Fatalf("the removed worktree's slot must resolve to its stored record, got epoch %q", removedSlot.RunEpochID) - } - if err := store.RetireWorktreeExecutionEpoch(wt, "epoch-g4", g4.ReservationToken); err != nil { - t.Fatalf("retire a removed worktree's epoch through its stored identity: %v", err) - } - git(t, repo, "worktree", "add", wt, "-b", "feat/gen-r1") - passAt(wt, "feat/gen-r1", "epoch-g5", "gen-5") - - // 5. Remove and recreate again WITHOUT retiring: the settled epoch-g5 left on the - // inherited released slot is settled at admission, never refused. - epochs.settle("epoch-g5") - git(t, repo, "worktree", "remove", "--force", wt) - git(t, repo, "worktree", "add", wt, "-b", "feat/gen-r2") - passAt(wt, "feat/gen-r2", "", "gen-6") - if slot := slotOf(wt); slot.RunEpochID != "" || slot.State != admissionReleased { - t.Fatalf("final slot = %s/%q, want released and epoch-free", slot.State, slot.RunEpochID) - } -} diff --git a/internal/gatedrive/sequence_race_integration_test.go b/internal/gatedrive/sequence_race_integration_test.go new file mode 100644 index 000000000..3cfe40a3a --- /dev/null +++ b/internal/gatedrive/sequence_race_integration_test.go @@ -0,0 +1,324 @@ +//go:build integration + +package gatedrive + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestRaceIntegrationGatedriveSequenceConcurrentScopesResolveOwnWork runs two scopes in two +// linked worktrees that share ONE git common dir, concurrently, with +// distinguishable commands and opposite verdicts, and proves each parent's +// takeover and enumeration resolve only its own scope's current drive — repeated +// across two tasks of one change, two different changes, and with acknowledged +// historical drives present in the store. Concurrency is real (two goroutines +// driving the shared store); -race is the oracle for cross-scope interference. +// Race shard (change 0466): two goroutines drive distinct scopes to terminal concurrently. +func TestRaceIntegrationGatedriveSequenceConcurrentScopesResolveOwnWork(t *testing.T) { + skipUnlessSupported(t) + repo := seedSeqRepo(t) + wtA := addLinkedWorktree(t, repo, "wtA", "feat/a") + wtB := addLinkedWorktree(t, repo, "wtB", "feat/b") + common := commonDirOf(t, wtA) + if other := commonDirOf(t, wtB); other != common { + t.Fatalf("two linked worktrees must share ONE git common dir: %q vs %q", common, other) + } + store := OpenStore(common) + svc := mustService(t) + + // Each scope launches under its OWN process run root: the native supervisor's + // registry lock is a per-root non-blocking allocation probe (internal/process + // lock.go LOCK_EX|LOCK_NB), so two concurrent launches sharing one root would + // contend — and in production each parent picks its own scratch run root anyway. + // The SHARED resource under test is the git common dir (one drive/scope store), + // not the process allocation root. + scopeRunRoot := func(t *testing.T) string { + t.Helper() + rr := filepath.Join(testsupport.TempDir(t), "runs") + t.Cleanup(func() { stopAllRuns(t, svc, rr) }) + reapSupervisors(t, rr) + return rr + } + + // prep prepares a task scope bound to a worktree and its own run root, and returns + // the grant plus a start request wired to it (scope id + child capability + gate + // context). + prep := func(t *testing.T, wt, branch, changeID, taskID, gateCtx string, cmd []string) (ScopeGrant, StartRequest) { + t.Helper() + req := realSeqStart(wt, branch, scopeRunRoot(t), changeID, taskID, cmd) + grant, err := store.PrepareScope(scopeReqFor(req, gateCtx)) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + req.GateContext = gateCtx + return grant, req + } + + // runPair drives reqA and reqB concurrently to their terminals over the shared + // store, joining before it returns the two docs. + runPair := func(t *testing.T, dA, dB *Driver, reqA, reqB StartRequest) (DriveDoc, DriveDoc) { + t.Helper() + var wg sync.WaitGroup + var docA, docB DriveDoc + var errA, errB error + wg.Add(2) + go func() { defer wg.Done(); docA, errA = driveSeqToTerminalErr(dA, reqA) }() + go func() { defer wg.Done(); docB, errB = driveSeqToTerminalErr(dB, reqB) }() + wg.Wait() + if errA != nil { + t.Fatalf("scope A concurrent drive: %v", errA) + } + if errB != nil { + t.Fatalf("scope B concurrent drive: %v", errB) + } + return docA, docB + } + + // assertResolvesOwn proves each scope's enumeration and each parent's takeover + // resolve ONLY that scope's own current drive. Enumeration is asserted before the + // takeovers, which close each scope and mint fresh parent owners. + assertResolvesOwn := func(t *testing.T, dA, dB *Driver, + gA ScopeGrant, changeA, gateA, driveA string, + gB ScopeGrant, changeB, gateB, driveB string) { + t.Helper() + if driveA == driveB { + t.Fatalf("the two concurrent scopes must resolve DISTINCT drives, both %q", driveA) + } + idsA, err := store.FindScopeDriveIDs(changeA, capHash(gateA)) + if err != nil { + t.Fatalf("FindScopeDriveIDs A: %v", err) + } + if !idsContain(idsA, driveA) || idsContain(idsA, driveB) { + t.Fatalf("scope A enumeration must resolve only its own drive %q, got %v", driveA, idsA) + } + idsB, err := store.FindScopeDriveIDs(changeB, capHash(gateB)) + if err != nil { + t.Fatalf("FindScopeDriveIDs B: %v", err) + } + if !idsContain(idsB, driveB) || idsContain(idsB, driveA) { + t.Fatalf("scope B enumeration must resolve only its own drive %q, got %v", driveB, idsB) + } + + tookA, err := dA.Takeover(gA.ScopeID, gA.ParentCapability, "") + if err != nil { + t.Fatalf("Takeover A: %v", err) + } + if tookA.DriveID != driveA { + t.Fatalf("scope A takeover must resolve its own current drive %q, got %q", driveA, tookA.DriveID) + } + tookB, err := dB.Takeover(gB.ScopeID, gB.ParentCapability, "") + if err != nil { + t.Fatalf("Takeover B: %v", err) + } + if tookB.DriveID != driveB { + t.Fatalf("scope B takeover must resolve its own current drive %q, got %q", driveB, tookB.DriveID) + } + if tookA.DriveID == tookB.DriveID { + t.Fatalf("the two parents' takeovers must resolve distinct drives") + } + } + + t.Run("two tasks of one change", func(t *testing.T) { + gA, rA := prep(t, wtA, "feat/a", "0540", "task-1", "gate-0540-a", seqPassCmd("2tasks-A")) + gB, rB := prep(t, wtB, "feat/b", "0540", "task-2", "gate-0540-b", seqFailCmd("2tasks-B")) + dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) + docA, docB := runPair(t, dA, dB, rA, rB) + if docA.Outcome != PASSED { + t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) + } + if docB.Outcome != FAILED { + t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) + } + assertCommandMarker(t, mustLoad(t, store, docA.DriveID), "2tasks-A") + assertCommandMarker(t, mustLoad(t, store, docB.DriveID), "2tasks-B") + assertResolvesOwn(t, dA, dB, gA, "0540", "gate-0540-a", docA.DriveID, gB, "0540", "gate-0540-b", docB.DriveID) + }) + + t.Run("two different changes", func(t *testing.T) { + gA, rA := prep(t, wtA, "feat/a", "0541", "task-1", "gate-0541-a", seqPassCmd("2chg-A")) + gB, rB := prep(t, wtB, "feat/b", "0542", "task-1", "gate-0542-b", seqFailCmd("2chg-B")) + dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) + docA, docB := runPair(t, dA, dB, rA, rB) + if docA.Outcome != PASSED { + t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) + } + if docB.Outcome != FAILED { + t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) + } + assertResolvesOwn(t, dA, dB, gA, "0541", "gate-0541-a", docA.DriveID, gB, "0542", "gate-0542-b", docB.DriveID) + }) + + t.Run("with acknowledged historical drives present", func(t *testing.T) { + // Build acked history under change 0543 / gate-0543-a on wtA: a + // baseline+successor sequence, terminally acknowledged, leaving two + // owner-cleared historical drives in the shared store. + ackedIDs := makeAckedHistory(t, realSeqDriver(store, svc), store, wtA, "feat/a", scopeRunRoot(t), "0543", "task-1", "gate-0543-a") + + // A fresh CURRENT scope under the SAME change+gate must still resolve to + // exactly its own current drive despite the acked history. + gA, rA := prep(t, wtA, "feat/a", "0543", "task-1", "gate-0543-a", seqPassCmd("acked-current-A")) + gB, rB := prep(t, wtB, "feat/b", "0544", "task-1", "gate-0544-b", seqFailCmd("acked-current-B")) + dA, dB := realSeqDriver(store, svc), realSeqDriver(store, svc) + docA, docB := runPair(t, dA, dB, rA, rB) + if docA.Outcome != PASSED { + t.Fatalf("scope A must PASS, got %s (%s)", docA.Outcome, docA.Cause) + } + if docB.Outcome != FAILED { + t.Fatalf("scope B must FAIL (opposite verdict), got %s (%s)", docB.Outcome, docB.Cause) + } + + // Enumeration by (0543, gate-0543-a) resolves EXACTLY the current drive; the + // two acknowledged historical drives are terminal-and-consumed and excluded. + ids, err := store.FindScopeDriveIDs("0543", capHash("gate-0543-a")) + if err != nil { + t.Fatalf("FindScopeDriveIDs with history: %v", err) + } + if len(ids) != 1 || !idsContain(ids, docA.DriveID) { + t.Fatalf("enumeration with acked history must resolve exactly the current drive %q, got %v (acked history %v)", docA.DriveID, ids, ackedIDs) + } + assertResolvesOwn(t, dA, dB, gA, "0543", "gate-0543-a", docA.DriveID, gB, "0544", "gate-0544-b", docB.DriveID) + }) +} + +// TestRaceIntegrationGatedriveSameWorktreeGenerations (spec AC2) drives real generations of +// executions through ONE worktree path over real git and the real process +// supervisor, proving old records for that path never block the next permitted +// drive once release or replacement is proven: +// +// - drive → release → re-admit across several successive run epochs: a live +// epoch still owns the worktree between drives (a different epoch is fenced), +// and once it settles the next epoch admits over its released slot; +// - a symlink alias of the worktree reaches the SAME slot and admits; +// - the live-incumbent counter-case: a genuinely executing run on the canonical +// path refuses a start through the alias, and admits it once the run finishes; +// - the worktree is removed while its released slot still names the completed +// epoch: retirement reaches the slot through its stored identity (never +// re-canonicalization of the missing path), and a recreated worktree at the +// same path admits a new epoch; +// - a second remove/recreate leaves the settled epoch on the inherited slot, and +// the next (epoch-less) start settles it at admission instead of refusing. +// +// Race shard (change 0466): a live incumbent run holds the worktree slot while a start through its alias contends for it. +func TestRaceIntegrationGatedriveSameWorktreeGenerations(t *testing.T) { + skipUnlessSupported(t) + repo := seedSeqRepo(t) + wt := addLinkedWorktree(t, repo, "wt", "feat/gen") + store := OpenStore(commonDirOf(t, wt)) + svc := mustService(t) + runRoot := filepath.Join(testsupport.TempDir(t), "runs") + t.Cleanup(func() { stopAllRuns(t, svc, runRoot) }) + reapSupervisors(t, runRoot) + d := realSeqDriver(store, svc) + epochs := &genSettled{settled: map[string]bool{}} + d.SetEpochSettledResolver(epochs.resolve) + + slotOf := func(path string) admissionRecord { + t.Helper() + slot, _, err := store.LoadWorktreeExecution(path) + if err != nil { + t.Fatalf("load slot for %s: %v", path, err) + } + return slot + } + passAt := func(path, branch, epoch, marker string) DriveDoc { + t.Helper() + req := realSeqStart(path, branch, runRoot, "0446", marker, seqPassCmd(marker)) + req.RunEpochID = epoch + doc := driveSeqToTerminal(t, d, req) + if doc.Outcome != PASSED { + t.Fatalf("%s must PASS, got %s (%s)", marker, doc.Outcome, doc.Cause) + } + return doc + } + + // 1. Several successive epochs on one path. + lastGen := 0 + for i, epoch := range []string{"epoch-g1", "epoch-g2", "epoch-g3"} { + passAt(wt, "feat/gen", epoch, fmt.Sprintf("gen-%d", i+1)) + slot := slotOf(wt) + if slot.State != admissionReleased || slot.RunEpochID != epoch { + t.Fatalf("after %s: slot %s/%q, want released/%s", epoch, slot.State, slot.RunEpochID, epoch) + } + if slot.ExecutionGen <= lastGen { + t.Fatalf("after %s: execution generation %d did not advance past %d", epoch, slot.ExecutionGen, lastGen) + } + lastGen = slot.ExecutionGen + if i == 0 { + // Unsettled: the live epoch owns its worktree between drives. + foreign := realSeqStart(wt, "feat/gen", runRoot, "0446", "foreign", seqPassCmd("foreign")) + foreign.RunEpochID = "epoch-foreign" + if _, err := d.Start(foreign); !isOwnershipKind(err, ErrStaleRunEpoch) { + t.Fatalf("an unsettled epoch must fence a different epoch, got %v", err) + } + } + epochs.settle(epoch) + } + + // 2. A symlink alias reaches the same slot and admits (epoch-less start over the + // settled epoch-g3's released slot). + alias := filepath.Join(testsupport.TempDir(t), "wt-alias") + if err := os.Symlink(wt, alias); err != nil { + t.Fatalf("symlink alias: %v", err) + } + passAt(alias, "feat/gen", "", "gen-alias") + if slot := slotOf(wt); slot.ExecutionGen != lastGen+1 || slot.RunEpochID != "" { + t.Fatalf("the alias must reuse the canonical slot: gen %d (want %d) epoch %q", slot.ExecutionGen, lastGen+1, slot.RunEpochID) + } + + // 3. Live-incumbent counter-case at the same canonical path. + release := filepath.Join(testsupport.TempDir(t), "release-live-run") + liveReq := realSeqStart(wt, "feat/gen", runRoot, "0446", "gen-live", + []string{"/bin/sh", "-c", `while [ ! -f "$1" ]; do sleep 0.02; done`, "gen-live", release}) + liveDoc, err := d.Start(liveReq) + if err != nil || liveDoc.Outcome != WAITING { + t.Fatalf("live run must start and WAIT: doc=%+v err=%v", liveDoc, err) + } + blocked := realSeqStart(alias, "feat/gen", runRoot, "0446", "gen-blocked", seqPassCmd("gen-blocked")) + if _, err := d.Start(blocked); !isOwnershipKind(err, ErrWorktreeBusy) && !isOwnershipKind(err, ErrUnresolvedExecution) { + t.Fatalf("a genuinely live incumbent must still refuse a start through the alias, got %v", err) + } + if slot := slotOf(wt); slot.State != admissionExecuting { + t.Fatalf("the refused start must leave the live incumbent executing, got %s", slot.State) + } + writeFile(t, filepath.Dir(release), filepath.Base(release), "go\n") + if term, _ := advanceUntilTerminal(t, d, liveDoc.DriveID, liveDoc.Generation); term.Outcome != PASSED { + t.Fatalf("live run must PASS once released, got %s (%s)", term.Outcome, term.Cause) + } + passAt(alias, "feat/gen", "", "gen-after-live") + + // 4. Remove the worktree while its released slot names a completed epoch; retire + // through the stored identity; recreate the path; a new epoch admits. + passAt(wt, "feat/gen", "epoch-g4", "gen-4") + g4 := slotOf(wt) + git(t, repo, "worktree", "remove", "--force", wt) + if _, err := os.Stat(wt); !os.IsNotExist(err) { + t.Fatalf("worktree must be removed, stat err = %v", err) + } + removedSlot := slotOf(wt) // stored-identity addressing, not re-canonicalization + if removedSlot.RunEpochID != "epoch-g4" || removedSlot.ReservationToken != g4.ReservationToken { + t.Fatalf("the removed worktree's slot must resolve to its stored record, got epoch %q", removedSlot.RunEpochID) + } + if err := store.RetireWorktreeExecutionEpoch(wt, "epoch-g4", g4.ReservationToken); err != nil { + t.Fatalf("retire a removed worktree's epoch through its stored identity: %v", err) + } + git(t, repo, "worktree", "add", wt, "-b", "feat/gen-r1") + passAt(wt, "feat/gen-r1", "epoch-g5", "gen-5") + + // 5. Remove and recreate again WITHOUT retiring: the settled epoch-g5 left on the + // inherited released slot is settled at admission, never refused. + epochs.settle("epoch-g5") + git(t, repo, "worktree", "remove", "--force", wt) + git(t, repo, "worktree", "add", wt, "-b", "feat/gen-r2") + passAt(wt, "feat/gen-r2", "", "gen-6") + if slot := slotOf(wt); slot.RunEpochID != "" || slot.State != admissionReleased { + t.Fatalf("final slot = %s/%q, want released and epoch-free", slot.State, slot.RunEpochID) + } +} diff --git a/internal/gatedrive/integration_test.go b/internal/gatedrive/supervisor_integration_test.go similarity index 96% rename from internal/gatedrive/integration_test.go rename to internal/gatedrive/supervisor_integration_test.go index ad6943560..aaa7bdf6b 100644 --- a/internal/gatedrive/integration_test.go +++ b/internal/gatedrive/supervisor_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + // Process-integration tests: the gate driver driven against the REAL native // process supervisor (internal/process.Service), not a scripted double. // @@ -56,6 +58,11 @@ import ( // TestMain routes the three re-exec roles of the gatedrive test binary. go test // itself sets neither the supervisor env nor the child argv marker, so an // ordinary run falls through to m.Run. +// +// Change 0466 moved this file (formerly integration_test.go) behind the integration +// tag. The default gatedrive build has no TestMain: none of its tests re-execs the +// test binary as a supervisor or child (only this tagged corpus drives the real +// process.Service), so Go's default m.Run is exactly right there. func TestMain(m *testing.M) { if process.SupervisorRequested() { os.Exit(process.RunSupervisorFromEnv()) @@ -464,12 +471,12 @@ func advanceUntilTerminal(t *testing.T, d *Driver, id, gen string) (DriveDoc, in // Tests. // --------------------------------------------------------------------------- -// TestIntegrationDriverSlicesAcrossLiveChildThenPasses drives a real child that +// TestIntegrationGatedriveDriverSlicesAcrossLiveChildThenPasses drives a real child that // outlives several short slices. It proves the slice bound holds on every // invocation, the supervised identity is stable across invocations, the child is // never duplicated, and the eventual pass exposes a usable raw run directory with // durable logs and the exact terminal receipt. -func TestIntegrationDriverSlicesAcrossLiveChildThenPasses(t *testing.T) { +func TestIntegrationGatedriveDriverSlicesAcrossLiveChildThenPasses(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) runRoot := filepath.Join(testsupport.TempDir(t), "runs") @@ -533,14 +540,14 @@ func TestIntegrationDriverSlicesAcrossLiveChildThenPasses(t *testing.T) { } } -// TestIntegrationFreshProcessResumesAndChildSurvives runs the drive-start in a +// TestIntegrationGatedriveFreshProcessResumesAndChildSurvives runs the drive-start in a // SEPARATE CLI-shaped process that exits the moment it has launched the child. // It proves that ending that invocation neither kills nor duplicates the detached // child, and that a fresh driver process resumes the drive purely from the // durable record and consumes the exact terminal status the child produced while // no driver was watching — with the supervised identity stable across the process // boundary. -func TestIntegrationFreshProcessResumesAndChildSurvives(t *testing.T) { +func TestIntegrationGatedriveFreshProcessResumesAndChildSurvives(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) gitCommon := testsupport.TempDir(t) @@ -591,12 +598,12 @@ func TestIntegrationFreshProcessResumesAndChildSurvives(t *testing.T) { } } -// TestIntegrationDeadlineExpiryStopsOwnedTree proves that when the fixed deadline +// TestIntegrationGatedriveDeadlineExpiryStopsOwnedTree proves that when the fixed deadline // has already passed at the first observation (a zero budget), the driver takes // exactly one observation of the live tree, stops the whole owned tree, and // returns HALTED — never a fabricated verdict — leaving the child's process group // dead. -func TestIntegrationDeadlineExpiryStopsOwnedTree(t *testing.T) { +func TestIntegrationGatedriveDeadlineExpiryStopsOwnedTree(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) runRoot := filepath.Join(testsupport.TempDir(t), "runs") @@ -633,13 +640,13 @@ func TestIntegrationDeadlineExpiryStopsOwnedTree(t *testing.T) { } } -// TestIntegrationProcessDeathPermitsAtMostOneRelaunch drives a child that dies by +// TestIntegrationGatedriveProcessDeathPermitsAtMostOneRelaunch drives a child that dies by // a signal with no stop intent (a genuine tree death). The single-relaunch policy // admits exactly one non-overlapping second raw run under the original deadline; // when that one also dies, the driver HALTs with relaunch-exhausted rather than // launching a third. Both raw runs' groups are dead, and the two runs are // distinct — the first proven gone before the second launched. -func TestIntegrationProcessDeathPermitsAtMostOneRelaunch(t *testing.T) { +func TestIntegrationGatedriveProcessDeathPermitsAtMostOneRelaunch(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) runRoot := filepath.Join(testsupport.TempDir(t), "runs") diff --git a/internal/gatedrive/integration_takeover_test.go b/internal/gatedrive/takeover_integration_test.go similarity index 63% rename from internal/gatedrive/integration_takeover_test.go rename to internal/gatedrive/takeover_integration_test.go index 61ce4bb39..7ff0f9c4b 100644 --- a/internal/gatedrive/integration_takeover_test.go +++ b/internal/gatedrive/takeover_integration_test.go @@ -1,9 +1,12 @@ +//go:build integration + // Process-integration coverage for the change-0359 mechanism: fast completion, // the production slice bound, and event-authorized takeover — all driven against // the REAL native process supervisor (internal/process.Service), never a scripted // double. It shares TestMain, the re-exec child helper, and every fixture with -// integration_test.go (same package); this file adds the invariants a fake proc -// cannot vouch for: +// supervisor_integration_test.go (same package); this file and +// takeover_race_integration_test.go (the takeover-identity test, in the race shard +// since change 0466) add the invariants a fake proc cannot vouch for: // // - a fast child returns PASSED as soon as the pass is observed, paying NO slice // or budget floor (proved against a DELIBERATELY long slice); @@ -18,9 +21,9 @@ // relaunch. // // The identity oracle is ALWAYS the native receipt (manifest pid/pgid/sid), never -// process-name matching, exactly as integration_test.go documents. +// process-name matching, exactly as supervisor_integration_test.go documents. // -// This file is split out of integration_test.go (per the plan's budget guidance): +// This file is split out of supervisor_integration_test.go (per the plan's budget guidance): // it adds four real-process tests, and grouping the takeover-identity pair here // keeps each file's real-process wall clock modest. package gatedrive @@ -33,12 +36,12 @@ import ( "github.com/danielhanold/docket/internal/testsupport" ) -// TestIntegrationFastCompletionReturnsImmediately proves a fast child that exits 0 +// TestIntegrationGatedriveFastCompletionReturnsImmediately proves a fast child that exits 0 // returns PASSED on the very first call and pays NO floor: the driver runs with a // deliberately LONG (30s) slice, so if a slice were a floor the call would block // for it. A completed run instead returns as soon as the pass is observed — far // under the slice and the 30-minute budget. -func TestIntegrationFastCompletionReturnsImmediately(t *testing.T) { +func TestIntegrationGatedriveFastCompletionReturnsImmediately(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) runRoot := filepath.Join(testsupport.TempDir(t), "runs") @@ -68,12 +71,12 @@ func TestIntegrationFastCompletionReturnsImmediately(t *testing.T) { } } -// TestIntegrationSliceBoundIsProductionThirtySeconds pins the production slice at +// TestIntegrationGatedriveSliceBoundIsProductionThirtySeconds pins the production slice at // exactly 30s AND proves a live child that outlives the (shrunk) slice returns // WAITING within one slice plus scheduling margin — not after the 30-minute // budget. Together these establish "the first slice returns by 30s" without ever // sleeping 30s. -func TestIntegrationSliceBoundIsProductionThirtySeconds(t *testing.T) { +func TestIntegrationGatedriveSliceBoundIsProductionThirtySeconds(t *testing.T) { // The constant is a spec-fixed invariant (Global Constraints): a change to it is // a visible failure here, not a silent drift. if productionSlice != 30*time.Second { @@ -103,94 +106,13 @@ func TestIntegrationSliceBoundIsProductionThirtySeconds(t *testing.T) { } } -// TestIntegrationTakeoverKeepsRunIdentity drives a REAL scope-bound run: a parent -// prepares a recovery scope, a scope-bound Start launches a real slow child, and a -// parent Takeover then supersedes the child owner and advances the SAME run to its -// terminal pass. It proves the takeover continues one stable supervised identity — -// same raw run dir, raw ownership, attempt, and native pid/pgid/sid — with exactly -// one run slot throughout: no relaunch, no duplicate child. -func TestIntegrationTakeoverKeepsRunIdentity(t *testing.T) { - skipUnlessSupported(t) - svc := mustService(t) - runRoot := filepath.Join(testsupport.TempDir(t), "runs") - store := OpenStore(testsupport.TempDir(t)) - reapSupervisors(t, runRoot) - t.Cleanup(func() { stopAllRuns(t, svc, runRoot) }) - d := newIntDriver(store, svc) - - // A real scope-bound start over a child that outlives several short slices and - // is still live when the takeover happens. - req := intStartRequest(mustExe(t), runRoot, testsupport.TempDir(t), "pass-after", "500") - grant, err := store.PrepareScope(scopeReqFor(req, "")) - if err != nil { - t.Fatalf("PrepareScope: %v", err) - } - req.ScopeID = grant.ScopeID - req.ChildCapability = grant.ChildCapability - started, err := d.Start(req) - if err != nil { - t.Fatalf("Start: %v", err) - } - if started.Outcome != WAITING { - t.Fatalf("scope-bound first slice over a live child must WAIT, got %s (%s)", started.Outcome, started.Cause) - } - - // Identity BEFORE takeover: the durable raw run identity plus the native - // manifest pid/pgid/sid (the driver-independent oracle). - runDir := soleRunDir(t, runRoot) - recBefore, err := store.Load(started.DriveID) - if err != nil { - t.Fatalf("load before: %v", err) - } - idBefore := readManifestIdentity(t, runDir) - - // Event-authorized takeover: it supersedes the child owner without launching or - // stopping any process. - took, err := d.Takeover(grant.ScopeID, grant.ParentCapability, started.DriveID) - if err != nil { - t.Fatalf("Takeover: %v", err) - } - if took.Outcome == HALTED { - t.Fatalf("a valid takeover of a live scope-bound drive must not HALT: %s", took.Cause) - } - if took.Generation == "" || took.Generation == started.Generation { - t.Fatalf("takeover must mint a fresh owner generation distinct from the child's, got %q", took.Generation) - } - - // The fresh owner drives the SAME live run to its terminal pass. - term, _ := advanceUntilTerminal(t, d, started.DriveID, took.Generation) - if term.Outcome != PASSED { - t.Fatalf("post-takeover terminal %s (cause %q), want PASSED", term.Outcome, term.Cause) - } - - // Same run throughout: one run slot, identical raw run dir + raw ownership + - // attempt, identical native supervisor identity. The takeover CONTINUED the run. - recAfter, err := store.Load(started.DriveID) - if err != nil { - t.Fatalf("load after: %v", err) - } - if got := soleRunDir(t, runRoot); got != runDir { - t.Fatalf("run dir changed across takeover: %s -> %s", runDir, got) - } - if recAfter.RawRunDir != recBefore.RawRunDir || recAfter.RawOwnership != recBefore.RawOwnership { - t.Fatalf("takeover changed the raw run identity: dir %q->%q ownership %q->%q", - recBefore.RawRunDir, recAfter.RawRunDir, recBefore.RawOwnership, recAfter.RawOwnership) - } - if recAfter.Attempt != recBefore.Attempt { - t.Fatalf("takeover changed the attempt %d->%d — it relaunched", recBefore.Attempt, recAfter.Attempt) - } - if idAfter := readManifestIdentity(t, runDir); idAfter != idBefore { - t.Fatalf("supervised identity drifted across takeover: %+v -> %+v", idBefore, idAfter) - } -} - -// TestIntegrationTerminalConsumedFromFreshProcess drives a real scope-bound child +// TestIntegrationGatedriveTerminalConsumedFromFreshProcess drives a real scope-bound child // to a terminal PASS in one driver process, then builds a BRAND-NEW Store+Driver // over the same Git common dir (a fresh process) and Takeover+Advances the // terminal-unconsumed drive. It proves the fresh process consumes the exact // recorded verdict from the durable record alone, with no relaunch and no // duplicate run slot. -func TestIntegrationTerminalConsumedFromFreshProcess(t *testing.T) { +func TestIntegrationGatedriveTerminalConsumedFromFreshProcess(t *testing.T) { skipUnlessSupported(t) svc := mustService(t) gitCommon := testsupport.TempDir(t) diff --git a/internal/gatedrive/takeover_race_integration_test.go b/internal/gatedrive/takeover_race_integration_test.go new file mode 100644 index 000000000..944df51ed --- /dev/null +++ b/internal/gatedrive/takeover_race_integration_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package gatedrive + +import ( + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestRaceIntegrationGatedriveTakeoverKeepsRunIdentity drives a REAL scope-bound run: a parent +// prepares a recovery scope, a scope-bound Start launches a real slow child, and a +// parent Takeover then supersedes the child owner and advances the SAME run to its +// terminal pass. It proves the takeover continues one stable supervised identity — +// same raw run dir, raw ownership, attempt, and native pid/pgid/sid — with exactly +// one run slot throughout: no relaunch, no duplicate child. +// Race shard (change 0466): a parent takeover supersedes the owner of a live supervised child mid-run. +func TestRaceIntegrationGatedriveTakeoverKeepsRunIdentity(t *testing.T) { + skipUnlessSupported(t) + svc := mustService(t) + runRoot := filepath.Join(testsupport.TempDir(t), "runs") + store := OpenStore(testsupport.TempDir(t)) + reapSupervisors(t, runRoot) + t.Cleanup(func() { stopAllRuns(t, svc, runRoot) }) + d := newIntDriver(store, svc) + + // A real scope-bound start over a child that outlives several short slices and + // is still live when the takeover happens. + req := intStartRequest(mustExe(t), runRoot, testsupport.TempDir(t), "pass-after", "500") + grant, err := store.PrepareScope(scopeReqFor(req, "")) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + started, err := d.Start(req) + if err != nil { + t.Fatalf("Start: %v", err) + } + if started.Outcome != WAITING { + t.Fatalf("scope-bound first slice over a live child must WAIT, got %s (%s)", started.Outcome, started.Cause) + } + + // Identity BEFORE takeover: the durable raw run identity plus the native + // manifest pid/pgid/sid (the driver-independent oracle). + runDir := soleRunDir(t, runRoot) + recBefore, err := store.Load(started.DriveID) + if err != nil { + t.Fatalf("load before: %v", err) + } + idBefore := readManifestIdentity(t, runDir) + + // Event-authorized takeover: it supersedes the child owner without launching or + // stopping any process. + took, err := d.Takeover(grant.ScopeID, grant.ParentCapability, started.DriveID) + if err != nil { + t.Fatalf("Takeover: %v", err) + } + if took.Outcome == HALTED { + t.Fatalf("a valid takeover of a live scope-bound drive must not HALT: %s", took.Cause) + } + if took.Generation == "" || took.Generation == started.Generation { + t.Fatalf("takeover must mint a fresh owner generation distinct from the child's, got %q", took.Generation) + } + + // The fresh owner drives the SAME live run to its terminal pass. + term, _ := advanceUntilTerminal(t, d, started.DriveID, took.Generation) + if term.Outcome != PASSED { + t.Fatalf("post-takeover terminal %s (cause %q), want PASSED", term.Outcome, term.Cause) + } + + // Same run throughout: one run slot, identical raw run dir + raw ownership + + // attempt, identical native supervisor identity. The takeover CONTINUED the run. + recAfter, err := store.Load(started.DriveID) + if err != nil { + t.Fatalf("load after: %v", err) + } + if got := soleRunDir(t, runRoot); got != runDir { + t.Fatalf("run dir changed across takeover: %s -> %s", runDir, got) + } + if recAfter.RawRunDir != recBefore.RawRunDir || recAfter.RawOwnership != recBefore.RawOwnership { + t.Fatalf("takeover changed the raw run identity: dir %q->%q ownership %q->%q", + recBefore.RawRunDir, recAfter.RawRunDir, recBefore.RawOwnership, recAfter.RawOwnership) + } + if recAfter.Attempt != recBefore.Attempt { + t.Fatalf("takeover changed the attempt %d->%d — it relaunched", recBefore.Attempt, recAfter.Attempt) + } + if idAfter := readManifestIdentity(t, runDir); idAfter != idBefore { + t.Fatalf("supervised identity drifted across takeover: %+v -> %+v", idBefore, idAfter) + } +} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 5409c30b1..55a0e28a6 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -79,6 +79,8 @@ tests/test_go_integration_transaction_race.sh 25 parallel tests/test_go_integration_workspace_setup.sh 25 parallel tests/test_go_integration_workspace_race.sh 10 parallel tests/test_go_integration_workspace_lifecycle.sh 30 parallel +tests/test_go_integration_gatedrive_process.sh 15 parallel +tests/test_go_integration_gatedrive_race.sh 15 parallel tests/test_go_integration_release.sh 45 parallel tests/test_go_finalize_e2e.sh 30 parallel tests/test_go_race.sh 60 parallel diff --git a/tests/test_go_integration_gatedrive_process.sh b/tests/test_go_integration_gatedrive_process.sh new file mode 100755 index 000000000..04af84fe8 --- /dev/null +++ b/tests/test_go_integration_gatedrive_process.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_gatedrive_process.sh — Go integration shard (change 0466, extending +# change 0333's partition): the gate driver's real-process and real-git tests (driving the REAL +# native supervisor internal/process.Service across slices, fresh-process resume, deadline and +# death handling, real-git sequences, and the worktree fingerprint/handoff proofs over real +# repositories) — moved out of the default internal/gatedrive corpus behind the `integration` +# build tag, prefix ^TestIntegrationGatedrive. internal/gatedrive has no no-real-git guard (its +# slow tests are process-bound, not git-bound): the budget row of tests/test_go_race.sh is its +# growth detector. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/gatedrive" +SHARD_PREFIX="TestIntegrationGatedrive" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_gatedrive_race.sh b/tests/test_go_integration_gatedrive_race.sh new file mode 100755 index 000000000..1815c4cbd --- /dev/null +++ b/tests/test_go_integration_gatedrive_race.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_gatedrive_race.sh — Go integration shard (change 0466, extending +# change 0333's partition): the gate driver's real-concurrency integration tests (takeover of a +# live supervised run, concurrent scopes driven to terminal, same-worktree generations against a +# live incumbent, concurrent Starts over a legacy-seeded store) — moved out of the default +# internal/gatedrive corpus behind the `integration` build tag, prefix +# ^TestRaceIntegrationGatedrive, run in RACE mode. Declarations only — execution and inspection +# live in tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/gatedrive" +SHARD_PREFIX="TestRaceIntegrationGatedrive" +SHARD_MODE="race" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From 38f3511c2d3771553de48dec040a633091664986 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:04:32 -0400 Subject: [PATCH 11/16] fix(testsupport): return the no-real-git guard's setup error; each TestMain exits (change 0466, repair-1) InstallNoGitGuard is library code and called os.Exit on its setup-failure paths, tripping TestProcessExitSitesAreAllowlisted. It now returns (finisher, error) with the same ": " text, removing the shim dir on a post-create failure; the TestMains of internal/app, internal/repository/transaction, and internal/workspace print it and exit 1, so internal/app's diagnostic and exit code are unchanged. TestInstallNoGitGuardReturnsSetupError pins the returned-error contract. --- internal/app/gate_test.go | 8 ++++- internal/repository/transaction/main_test.go | 8 ++++- internal/testsupport/nogit_install.go | 32 +++++++++---------- internal/testsupport/nogit_install_off.go | 6 ++-- internal/testsupport/nogit_install_test.go | 33 ++++++++++++++++++++ internal/workspace/main_test.go | 8 ++++- 6 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 internal/testsupport/nogit_install_test.go diff --git a/internal/app/gate_test.go b/internal/app/gate_test.go index d920e29dc..5666c16c3 100644 --- a/internal/app/gate_test.go +++ b/internal/app/gate_test.go @@ -3,6 +3,7 @@ package app import ( "encoding/json" "errors" + "fmt" "github.com/danielhanold/docket/internal/gatedrive" "github.com/danielhanold/docket/internal/process" "github.com/danielhanold/docket/internal/testsupport" @@ -38,7 +39,12 @@ func TestMain(m *testing.M) { // guard (testsupport.InstallNoGitGuard) AFTER the re-exec routing above, so the // supervisor and guardian roles behave exactly as before; tagged builds get // testsupport's no-op twin. Its proving tests are in nogit_guard_test.go. - finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + finish, err := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + if err != nil { + // The library returns the setup failure; this TestMain ends the process. + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } os.Exit(finish(m.Run())) } diff --git a/internal/repository/transaction/main_test.go b/internal/repository/transaction/main_test.go index 039262599..0106976ef 100644 --- a/internal/repository/transaction/main_test.go +++ b/internal/repository/transaction/main_test.go @@ -1,6 +1,7 @@ package transaction import ( + "fmt" "os" "testing" @@ -20,6 +21,11 @@ const ( // m.Run in the default build; the integration build gets testsupport's identity // finisher, so the tagged shards run real git as before. func TestMain(m *testing.M) { - finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + finish, err := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + if err != nil { + // The library returns the setup failure; this TestMain ends the process. + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } os.Exit(finish(m.Run())) } diff --git a/internal/testsupport/nogit_install.go b/internal/testsupport/nogit_install.go index d9697d2a8..20aaec2b5 100644 --- a/internal/testsupport/nogit_install.go +++ b/internal/testsupport/nogit_install.go @@ -14,43 +14,43 @@ import ( // module-relative dir, e.g. "internal/app") at the front of PATH and returns the // finisher TestMain wraps around m.Run. shardGlob names the package's integration // shard runners in the remedy text. Setup failure, or a pkg/shardGlob the shim -// cannot carry, exits the binary non-zero: a guard that silently failed to install -// would certify nothing. -func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { +// cannot carry, returns an error already prefixed with NoGitGuardDiagnostic(pkg) +// and a nil finisher; the calling TestMain prints it and exits non-zero (a guard +// that silently failed to install would certify nothing). A library never ends +// the process itself (cmd/docket's TestProcessExitSitesAreAllowlisted). +func InstallNoGitGuard(pkg, shardGlob string) (func(code int) int, error) { diag := NoGitGuardDiagnostic(pkg) if err := validateNoGitGuardArgs(pkg, shardGlob); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", diag, err) - os.Exit(1) + return nil, fmt.Errorf("%s: %w", diag, err) } // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. dir, err := os.MkdirTemp("", "docket-"+path.Base(pkg)+"-nogit-") if err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", diag, err) - os.Exit(1) + return nil, fmt.Errorf("%s: cannot create the shim directory: %w", diag, err) + } + fail := func(err error) (func(code int) int, error) { + _ = os.RemoveAll(dir) + return nil, err } logPath := filepath.Join(dir, "violations.log") if strings.ContainsAny(logPath, "'\n") { - fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", diag, logPath) - os.Exit(1) + return fail(fmt.Errorf("%s: shim log path %q is not single-quote safe", diag, logPath)) } shim := filepath.Join(dir, "git") if err := os.WriteFile(shim, []byte(NoGitShimScript(pkg, shardGlob, logPath)), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", diag, err) - os.Exit(1) + return fail(fmt.Errorf("%s: cannot write the shim: %w", diag, err)) } // Explicit chmod: a create-time mode is masked by the umask. if err := os.Chmod(shim, 0o755); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", diag, err) - os.Exit(1) + return fail(fmt.Errorf("%s: cannot chmod the shim: %w", diag, err)) } if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { - fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", diag, err) - os.Exit(1) + return fail(fmt.Errorf("%s: cannot prepend the shim to PATH: %w", diag, err)) } noGitGuardDir = dir return func(code int) int { verdict := NoGitVerdict(pkg, logPath, code, os.Stderr) _ = os.RemoveAll(dir) return verdict - } + }, nil } diff --git a/internal/testsupport/nogit_install_off.go b/internal/testsupport/nogit_install_off.go index b8e4289f8..41a6ab561 100644 --- a/internal/testsupport/nogit_install_off.go +++ b/internal/testsupport/nogit_install_off.go @@ -5,8 +5,8 @@ package testsupport // InstallNoGitGuard is the tagged builds' no-op twin of the default-build guard in // nogit_install.go (change 0466, formerly internal/app/nogit_guard_off_test.go). // The integration and e2e corpora exist to run real git, so they install no shim -// and the finisher returns m.Run's code as-is. Exactly one of the two files +// and the finisher returns m.Run's code as-is (never an error). Exactly one of the two files // compiles for any tag set, so every guarded TestMain stays single-sourced. -func InstallNoGitGuard(pkg, shardGlob string) func(code int) int { - return func(code int) int { return code } +func InstallNoGitGuard(pkg, shardGlob string) (func(code int) int, error) { + return func(code int) int { return code }, nil } diff --git a/internal/testsupport/nogit_install_test.go b/internal/testsupport/nogit_install_test.go new file mode 100644 index 000000000..fb40e1341 --- /dev/null +++ b/internal/testsupport/nogit_install_test.go @@ -0,0 +1,33 @@ +//go:build !integration && !e2e + +package testsupport + +import ( + "os" + "testing" +) + +// TestInstallNoGitGuardReturnsSetupError: a refused install is an error the +// calling TestMain prints and exits on, not a process exit inside the library +// (change 0466 repair; cmd/docket's TestProcessExitSitesAreAllowlisted). The error +// carries the same ": " text the TestMain writes to stderr, and +// a refusal installs nothing: PATH and NoGitGuardDir stay untouched. +func TestInstallNoGitGuardReturnsSetupError(t *testing.T) { + pathBefore, dirBefore := os.Getenv("PATH"), NoGitGuardDir() + finish, err := InstallNoGitGuard("internal/app", "") + if err == nil { + t.Fatal("InstallNoGitGuard with an empty shard glob = nil error, want a refusal") + } + if finish != nil { + t.Fatal("a refused install must return a nil finisher") + } + if want := NoGitGuardDiagnostic("internal/app") + ": the shard glob is empty"; err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } + if got := os.Getenv("PATH"); got != pathBefore { + t.Fatalf("a refused install changed PATH: %q -> %q", pathBefore, got) + } + if got := NoGitGuardDir(); got != dirBefore { + t.Fatalf("a refused install recorded a shim dir %q", got) + } +} diff --git a/internal/workspace/main_test.go b/internal/workspace/main_test.go index 1a7edc110..db7cf9b39 100644 --- a/internal/workspace/main_test.go +++ b/internal/workspace/main_test.go @@ -1,6 +1,7 @@ package workspace import ( + "fmt" "os" "testing" @@ -20,6 +21,11 @@ const ( // m.Run in the default build; the integration build gets testsupport's identity // finisher, so the tagged shards run real git as before. func TestMain(m *testing.M) { - finish := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + finish, err := testsupport.InstallNoGitGuard(nogitPkg, nogitShardGlob) + if err != nil { + // The library returns the setup failure; this TestMain ends the process. + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } os.Exit(finish(m.Run())) } From 7cad5160e7d6f14e8a3eacf4369c00c813eb1d25 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:16:00 -0400 Subject: [PATCH 12/16] test(race): record the post-partition worst package and re-confirm budgets (change 0466) --- tests/runtime-budgets.tsv | 2 +- tests/test_go_race.sh | 30 +++++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 55a0e28a6..dd8864eeb 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -74,7 +74,7 @@ tests/test_go_integration_githubcli_merge.sh 10 parallel tests/test_go_integration_githubcli_probe.sh 10 parallel tests/test_go_integration_githubcli_prbatch.sh 10 parallel tests/test_go_integration_transaction_apply.sh 25 parallel -tests/test_go_integration_transaction_recovery.sh 20 parallel +tests/test_go_integration_transaction_recovery.sh 25 parallel tests/test_go_integration_transaction_race.sh 25 parallel tests/test_go_integration_workspace_setup.sh 25 parallel tests/test_go_integration_workspace_race.sh 10 parallel diff --git a/tests/test_go_race.sh b/tests/test_go_race.sh index 0a01f363a..a4283d29b 100755 --- a/tests/test_go_race.sh +++ b/tests/test_go_race.sh @@ -17,23 +17,31 @@ # corpus only. Change 0465 made that an enforced invariant for internal/app, the # package whose default corpus had regrown to ~920 tests (337 of them real-git, # 225s of its 237s under -race): the default-tag internal/app test corpus never -# starts a real `git` process. installNoGitGuard (internal/app/nogit_guard_test.go) -# shadows git on PATH in the default build and fails the package on any attempt, -# so a new real-git test cannot land here unnoticed. With that tail gone, `go test -# -race`'s GOMAXPROCS-wide race workers do not oversubscribe the cores the other -# parallel jobs need (change 0332's reason for the serial lane, and change 0329's +# starts a real `git` process. testsupport.InstallNoGitGuard (internal/testsupport, +# installed from the package's TestMain) shadows git on PATH in the default build +# and fails the package on any attempt, so a new real-git test cannot land here +# unnoticed. Change 0466 extended the partition and the guard to +# internal/repository/transaction and internal/workspace, and moved +# internal/gatedrive's real-supervisor and real-git tests behind the tag too. +# gatedrive is process-bound rather than git-bound, so it has no git guard; this +# file's budget row is its growth detector. With that tail gone, `go test -race`'s +# GOMAXPROCS-wide race workers do not oversubscribe the cores the other parallel +# jobs need (change 0332's reason for the serial lane, and change 0329's # load-dependent build-gate halt), so this gate rides the PARALLEL lane under an # ordinary row in tests/runtime-budgets.tsv like every other file. # # BACKSTOP TIMEOUT (change 0465). `go test` is given an explicit -timeout # (RACE_TIMEOUT below) of 8m, sized from CI-projected data rather than an idle # local run. The measured post-partition worst package is -# internal/repository/transaction at 48.7s (local, idle, -p 2). The change's own -# CI data puts the macos-15 runner at ~2.4-3.8x slower than local (this gate: -# 238s local vs 581-908s on passing CI runs), projecting that package to ~120-186s -# in CI (up to ~230s with load noise). 8m is at least twice that worst projection, -# so a loaded runner does not trip the backstop, and still below Go's 10m -# per-package default. TestRaceGatePassesTimeoutBackstopBelowGoDefault +# internal/repository/transaction at 48.7s (local, idle, -p 2). Change 0466 then +# partitioned that package; the measured worst default-corpus package is now +# internal/cli at 24.0s (same shape). The backstop and its floor in +# internal/repoguard keep 0465's larger 48.7s input, so the margin only grew. +# The change's own CI data puts the macos-15 runner at ~2.4-3.8x slower than +# local (this gate: 238s local vs 581-908s on passing CI runs), projecting that +# package to ~120-186s in CI (up to ~230s with load noise). 8m is at least twice +# that worst projection, so a loaded runner does not trip the backstop, and still +# below Go's 10m per-package default. TestRaceGatePassesTimeoutBackstopBelowGoDefault # (internal/repoguard) pins both bounds. It is NOT a growth allowance — the # guard and the budget row are the growth detectors. It exists so an overrun fails with the named # "no package ran past the … -timeout backstop" assert and the offending FAIL line, From 768d0c7f49bedb91ac1abede529287b43720d546 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:17:00 -0400 Subject: [PATCH 13/16] docs(results): change 0466 results --- ...-its-60s-budget-row-transaction-results.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md diff --git a/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md b/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md new file mode 100644 index 000000000..0dd17043f --- /dev/null +++ b/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md @@ -0,0 +1,90 @@ + +> ↩ **[Change 0466 — Bring test_go_race back under its 60s budget row (transaction, workspace, gatedrive)](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0466-bring-test-go-race-back-under-its-60s-budget-row-transaction.md)** + +# Bring test_go_race back under its 60s budget row (transaction, workspace, gatedrive) — Results + +**Human action:** No action is required before merge. The budget numbers below were taken on a busy machine (load 2–5), so one optional idle-machine re-measure is suggested if you want an extra margin check. + +## Outcome + +Before this change, `tests/test_go_race.sh` took about 66–67s against its 60s budget row, and `tests/test_go_toolchain.sh` took 62–66s on a cold test cache against its 55s row. Most of that time came from three packages that ran dozens of slow real-git or real-process tests one after another: `internal/repository/transaction`, `internal/workspace`, and `internal/gatedrive`. + +Those tests now sit behind the `integration` build tag, in the same partition that changes 0333 and 0465 set up for `internal/app`. Eight new shard runners run them: + +- transaction: `apply`, `recovery`, `race` +- workspace: `setup`, `lifecycle`, `race` +- gatedrive: `process`, `race` + +Tests that exercise concurrency stay under `-race` in the `race` shards (`TestRaceIntegration…`). Everything else goes to `mode=normal` shards (`TestIntegration…`). The integration contract found the new packages on its own, with no allowlist edit. + +The no-real-git guard from 0465 now lives in `internal/testsupport` as `InstallNoGitGuard`. It is installed in `internal/app` (unchanged behavior), `internal/repository/transaction`, and `internal/workspace`, so those default test corpora fail loudly if a real-git test is added to them again. `internal/gatedrive` does not get the guard, as the spec decided. Its budget row is what catches growth there. + +The race and toolchain rows (60/55) and the 8m race backstop are unchanged. + +One departure from the plan: the first version of `InstallNoGitGuard` called `os.Exit` from a library file, and the repo's exit-site guard (`TestProcessExitSitesAreAllowlisted`) rejected that. The full-suite gate caught it, and a repair commit fixed it. The helper now returns an error, and each `TestMain` does the exit itself. `internal/app` still prints the same diagnostic and uses the same exit code. + +## Human actions and testing + +### Optional — re-measure the two gates on an idle machine + +Every measurement below was taken with a load average of 2–5, never on an idle machine. The margins are wide (about 19s and 21s), so this is not required. It confirms the numbers if you want certainty. + +Prerequisites: a checkout of this branch, Go installed, and nothing else running. + +1. Run `time bash tests/test_go_race.sh`. + Expected: exit 0, and a real time well under 60s. This change measured 37–41s. +2. Run `go clean -testcache`, then `time bash tests/test_go_toolchain.sh`. + Expected: exit 0, and a real time well under 55s. This change measured 32–34s. + +## Verification performed + +Both gates were measured serially, twice each, on the green tree: + +| Gate | Row | Worse of two runs | Margin | +|---|---|---|---| +| `tests/test_go_race.sh` (solo) | 60s | 41.26s (other run 37.15s) | 18.74s | +| `tests/test_go_toolchain.sh` (cold test cache) | 55s | 34.08s (other run 32.49s) | 20.92s | + +Default-corpus `-race` time per package, from the `GOMAXPROCS=2 -p 2` ranking: + +| Package | Before (groom) | After | +|---|---|---| +| `internal/repository/transaction` | ~46s | 1.24s | +| `internal/workspace` | ~42s | 1.42s | +| `internal/gatedrive` | ~42s | 20.85s | + +The slowest package in the race gate is now `internal/cli` (24.0s), and the `test_go_race.sh` header names it. + +New shard rows. Each was sized from a timed solo run after a warm-up run, rounded up to the next 5s, plus 5s: + +| Shard runner | Measured | Row | Margin | +|---|---|---|---| +| `test_go_integration_transaction_apply.sh` | 18.02s | 25 | 6.98s | +| `test_go_integration_transaction_recovery.sh` | 16.21s | 25 | 8.79s | +| `test_go_integration_transaction_race.sh` | 19.85s | 25 | 5.15s | +| `test_go_integration_workspace_setup.sh` | 17.68s | 25 | 7.32s | +| `test_go_integration_workspace_lifecycle.sh` | 22.01s | 30 | 7.99s | +| `test_go_integration_workspace_race.sh` | 2.30s | 10 | 7.70s | +| `test_go_integration_gatedrive_process.sh` | 6.68s | 15 | 8.32s | +| `test_go_integration_gatedrive_race.sh` | 6.16s | 15 | 8.84s | + +The guard was mutation-tested in each guarded package: + +- A throwaway default-build test that runs `git status` turned `internal/repository/transaction`, `internal/workspace`, and `internal/app` red, each with the guard diagnostic. +- Removing the `InstallNoGitGuard` call from `TestMain` turned the `TestNoGitGuard*` proving tests red. + +Other checks: + +- `tests/test_go_integration_contract.sh` passes. +- `go vet` is clean in both the default and the `integration` build for every touched package. +- The whole suite runs at the build gate. Its evidence goes in the PR body. + +## Known issues and follow-ups + +### `internal/gatedrive` is still the third-slowest package in the race gate + +Its default corpus went from about 42s to about 21s under `-race`. It still has 265 fast unit tests, and it has no no-real-git guard (by design: its slow tests start processes, not git). This is confirmed, but it is not a problem today: the race gate has an 18.7s margin. If the race gate creeps toward 60s again, `internal/cli` (24.0s) and `internal/gatedrive` are the next candidates. No change exists for this yet. + +### Plan's stale-reference grep always has one hit + +Task 1's stale-reference search in the plan matches a comment in `internal/testsupport/nogit_install_off.go` that says where the file used to live. The comment is accurate, so it was kept. The plan is a frozen record, so this is only noted here. From 6f642649c1b9b1569f4616261d3e5ceedc7b6a54 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:32:16 -0400 Subject: [PATCH 14/16] docs(race): fix backstop projection attribution and gatedrive guard rationale (change 0466, review fix) - BACKSTOP TIMEOUT: attribute the CI projection to change 0465's data and internal/repository/transaction's 48.7s; state internal/cli's 24.0s conditions. - gatedrive's moved corpus is mixed real-process and real-git; the guard was left out by the spec's decision and the budget row is the growth detector (test_go_race.sh, gatedrive shard header, testsupport/nogit.go). --- internal/testsupport/nogit.go | 5 ++- .../test_go_integration_gatedrive_process.sh | 5 +-- tests/test_go_race.sh | 33 ++++++++++--------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/internal/testsupport/nogit.go b/internal/testsupport/nogit.go index 34dc070fe..89673be45 100644 --- a/internal/testsupport/nogit.go +++ b/internal/testsupport/nogit.go @@ -5,7 +5,10 @@ package testsupport // process-lifecycle corpus behind `//go:build integration`; this guard makes that // partition an enforced invariant for a package: its default-tag test corpus never // starts a real `git`. Installed from the TestMain of internal/app, -// internal/repository/transaction, and internal/workspace. +// internal/repository/transaction, and internal/workspace. internal/gatedrive is +// deliberately unguarded: its moved corpus is mixed real-process and real-git, the +// spec left the guard out, and its budget row in tests/test_go_race.sh is the +// growth detector. // // Mechanism (keyed on the exec itself, never on spellings): InstallNoGitGuard, // called from TestMain before m.Run, puts a directory holding a refusing `git` shim diff --git a/tests/test_go_integration_gatedrive_process.sh b/tests/test_go_integration_gatedrive_process.sh index 04af84fe8..7a1b2c35e 100755 --- a/tests/test_go_integration_gatedrive_process.sh +++ b/tests/test_go_integration_gatedrive_process.sh @@ -6,8 +6,9 @@ # death handling, real-git sequences, and the worktree fingerprint/handoff proofs over real # repositories) — moved out of the default internal/gatedrive corpus behind the `integration` # build tag, prefix ^TestIntegrationGatedrive. internal/gatedrive has no no-real-git guard (its -# slow tests are process-bound, not git-bound): the budget row of tests/test_go_race.sh is its -# growth detector. Declarations only — execution and inspection live in +# moved corpus is mixed real-process and real-git; the spec omitted the guard): the budget +# row of tests/test_go_race.sh is its growth detector. Declarations only — execution and +# inspection live in # tests/lib/go-integration-shard.sh; the completeness contract is # tests/test_go_integration_contract.sh. set -uo pipefail diff --git a/tests/test_go_race.sh b/tests/test_go_race.sh index a4283d29b..37246f36b 100755 --- a/tests/test_go_race.sh +++ b/tests/test_go_race.sh @@ -23,27 +23,30 @@ # unnoticed. Change 0466 extended the partition and the guard to # internal/repository/transaction and internal/workspace, and moved # internal/gatedrive's real-supervisor and real-git tests behind the tag too. -# gatedrive is process-bound rather than git-bound, so it has no git guard; this -# file's budget row is its growth detector. With that tail gone, `go test -race`'s -# GOMAXPROCS-wide race workers do not oversubscribe the cores the other parallel -# jobs need (change 0332's reason for the serial lane, and change 0329's -# load-dependent build-gate halt), so this gate rides the PARALLEL lane under an -# ordinary row in tests/runtime-budgets.tsv like every other file. +# gatedrive's moved corpus is mixed real-process and real-git; the spec left it +# without a git guard, and this file's budget row is its growth detector. With +# that tail gone, `go test -race`'s GOMAXPROCS-wide race workers do not +# oversubscribe the cores the other parallel jobs need (change 0332's reason +# for the serial lane, and change 0329's load-dependent build-gate halt), so +# this gate rides the PARALLEL lane under an ordinary row in +# tests/runtime-budgets.tsv like every other file. # # BACKSTOP TIMEOUT (change 0465). `go test` is given an explicit -timeout # (RACE_TIMEOUT below) of 8m, sized from CI-projected data rather than an idle # local run. The measured post-partition worst package is # internal/repository/transaction at 48.7s (local, idle, -p 2). Change 0466 then # partitioned that package; the measured worst default-corpus package is now -# internal/cli at 24.0s (same shape). The backstop and its floor in -# internal/repoguard keep 0465's larger 48.7s input, so the margin only grew. -# The change's own CI data puts the macos-15 runner at ~2.4-3.8x slower than -# local (this gate: 238s local vs 581-908s on passing CI runs), projecting that -# package to ~120-186s in CI (up to ~230s with load noise). 8m is at least twice -# that worst projection, so a loaded runner does not trip the backstop, and still -# below Go's 10m per-package default. TestRaceGatePassesTimeoutBackstopBelowGoDefault -# (internal/repoguard) pins both bounds. It is NOT a growth allowance — the -# guard and the budget row are the growth detectors. It exists so an overrun fails with the named +# internal/cli at 24.0s (-p 2, GOMAXPROCS=2, load 2-5). The backstop and its +# floor in internal/repoguard keep 0465's larger 48.7s input, so the margin +# only grew. +# Change 0465's CI data puts the macos-15 runner at ~2.4-3.8x slower than +# local (this gate: 238s local vs 581-908s on passing CI runs), projecting +# internal/repository/transaction's 48.7s to ~120-186s in CI (up to ~230s +# with load noise). 8m is at least twice that worst projection, so a loaded +# runner does not trip the backstop, and still below Go's 10m per-package +# default. TestRaceGatePassesTimeoutBackstopBelowGoDefault (internal/repoguard) +# pins both bounds. It is NOT a growth allowance — the guard and the budget +# row are the growth detectors. It exists so an overrun fails with the named # "no package ran past the … -timeout backstop" assert and the offending FAIL line, # instead of a 10m goroutine-dump panic. Never raise it to make a slow package fit; # move the slow tests behind the integration tag instead. From 2fe01634f0508e6d08140380de294dbf56d0a344 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:34:48 -0400 Subject: [PATCH 15/16] test(budgets): size transaction_race row from its worst solo reading, 22.21s -> 30 (change 0466, review fix) --- tests/runtime-budgets.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index dd8864eeb..aaf100617 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -75,7 +75,7 @@ tests/test_go_integration_githubcli_probe.sh 10 parallel tests/test_go_integration_githubcli_prbatch.sh 10 parallel tests/test_go_integration_transaction_apply.sh 25 parallel tests/test_go_integration_transaction_recovery.sh 25 parallel -tests/test_go_integration_transaction_race.sh 25 parallel +tests/test_go_integration_transaction_race.sh 30 parallel tests/test_go_integration_workspace_setup.sh 25 parallel tests/test_go_integration_workspace_race.sh 10 parallel tests/test_go_integration_workspace_lifecycle.sh 30 parallel From 726b834f16d52908a15655f34a0389beaaf96ed8 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 18:35:18 -0400 Subject: [PATCH 16/16] docs(results): change 0466 review dispositions --- ...under-its-60s-budget-row-transaction-results.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md b/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md index 0dd17043f..a9f80263e 100644 --- a/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md +++ b/docs/results/2026-09-28-bring-test-go-race-back-under-its-60s-budget-row-transaction-results.md @@ -55,13 +55,13 @@ Default-corpus `-race` time per package, from the `GOMAXPROCS=2 -p 2` ranking: The slowest package in the race gate is now `internal/cli` (24.0s), and the `test_go_race.sh` header names it. -New shard rows. Each was sized from a timed solo run after a warm-up run, rounded up to the next 5s, plus 5s: +New shard rows. Each was sized from a timed solo run after a warm-up run, rounded up to the next 5s, plus 5s. The transaction race shard was re-measured three more times after review, because its first reading sat just under a rounding boundary. The worst reading came while another suite was running on the machine, and it set the row: | Shard runner | Measured | Row | Margin | |---|---|---|---| | `test_go_integration_transaction_apply.sh` | 18.02s | 25 | 6.98s | | `test_go_integration_transaction_recovery.sh` | 16.21s | 25 | 8.79s | -| `test_go_integration_transaction_race.sh` | 19.85s | 25 | 5.15s | +| `test_go_integration_transaction_race.sh` | 22.21s (worst of 19.85, 22.21, 19.88, 19.22) | 30 | 7.79s | | `test_go_integration_workspace_setup.sh` | 17.68s | 25 | 7.32s | | `test_go_integration_workspace_lifecycle.sh` | 22.01s | 30 | 7.99s | | `test_go_integration_workspace_race.sh` | 2.30s | 10 | 7.70s | @@ -77,7 +77,11 @@ Other checks: - `tests/test_go_integration_contract.sh` passes. - `go vet` is clean in both the default and the `integration` build for every touched package. -- The whole suite runs at the build gate. Its evidence goes in the PR body. +- The whole suite passed at the build gate (74 of 74 files). Its evidence is in the PR body. The budget report printed no `SERIAL CONFIRMED OVER BUDGET` line. It did print `PARALLEL-SENSITIVE` for `test_go_race.sh` (195s under -j11), but that compares against a stale 69s solo record from before this change. The serial solo runs above measured 37–41s. +- A whole-branch review found no blockers and no important issues. It raised three minor findings, and all three were fixed on the branch: + - The backstop paragraph in `test_go_race.sh` attributed its projection to the wrong change and package (fixed in 175498a9c). + - The comments said gatedrive was left unguarded because its tests are "process-bound, not git-bound". About 12 of its moved tests actually use real git, so the comments now say it was left unguarded by the spec's decision (fixed in 175498a9c). + - The transaction race row was sized from a single reading (fixed in f7267b3df, row 25 → 30). ## Known issues and follow-ups @@ -85,6 +89,10 @@ Other checks: Its default corpus went from about 42s to about 21s under `-race`. It still has 265 fast unit tests, and it has no no-real-git guard (by design: its slow tests start processes, not git). This is confirmed, but it is not a problem today: the race gate has an 18.7s margin. If the race gate creeps toward 60s again, `internal/cli` (24.0s) and `internal/gatedrive` are the next candidates. No change exists for this yet. +### `internal/gatedrive` has no no-real-git guard + +The spec decided to leave gatedrive unguarded. Its moved corpus is mixed, though: about 12 of its 25 moved tests use real git. So if someone adds a real-git test back to gatedrive's default corpus, only its wall-clock budget row will notice. This is confirmed. The suggested next action is to consider installing `testsupport.InstallNoGitGuard` in gatedrive, which takes one `TestMain` and three proving tests. No change exists for this yet. + ### Plan's stale-reference grep always has one hit Task 1's stale-reference search in the plan matches a comment in `internal/testsupport/nogit_install_off.go` that says where the file used to live. The comment is accurate, so it was kept. The plan is a frozen record, so this is only noted here.