diff --git a/CHANGELOG.md b/CHANGELOG.md index d0eda61..810a6d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ summary: Chronological history of repository and skill changes. # Changelog +## 2026-07-27 — Added successor-source recovery for corrected carved suffixes + +- feat: recover corrected carved suffixes + ## 2026-07-26 — Added the replay evaluator, then froze the v1 baseline configuration - fix: reconcile every recorded figure with its retained artifact + (`d013507956aa0ab328140a72c87fdbb151f2b1ec`) - fix: attribute the pilot to a reproducible commit and correct the records (`3a8388d42e355e4bc9731b98b6dcd42ffd13ff2f`) - feat: report the stratum a run evaluated diff --git a/skills/carve-changesets/SKILL.md b/skills/carve-changesets/SKILL.md index eeb9972..8f5aa42 100644 --- a/skills/carve-changesets/SKILL.md +++ b/skills/carve-changesets/SKILL.md @@ -6,9 +6,11 @@ description: Recompose a large review-ready source branch into an ordered chain # Carve Changesets Turn one existing review-ready source branch into a plain-git, plain-GitHub -changeset chain. Preserve the source branch, keep each intermediate result safe -and reviewable, and prove that the fully merged chain is equivalent to the -source candidate. +changeset chain. Preserve every immutable source identity, keep each +intermediate result safe and reviewable, and prove that the fully merged chain +is equivalent to the active intended result. When an accepted post-publication +fix changes an unmerged suffix after a prefix has merged, recover that suffix +onto a distinct immutable successor source without rewriting the prefix. This skill owns decomposition, truth promotion, chain mechanics, whole-chain equivalence, and downstream propagation. It delegates per-changeset review to @@ -55,8 +57,8 @@ Before mutation, discover or receive and verify: - repository identity, current checkout, worktree state, and applicable repository instructions; -- exact source and base branches and SHAs, their merge base, source freshness, - and the complete candidate diff; +- exact root source, any successor-source lineage, and base branches and SHAs, + their merge bases, source freshness, and the complete active candidate diff; - the immutable source outcome and the behavior, schema, constraints, public interfaces, migrations, and rollout properties the final chain must preserve; - an explicitly approved test command and any required database, build, @@ -70,9 +72,9 @@ Before mutation, discover or receive and verify: candidate repair, review communication, merge, propagation, and cleanup. Treat discovered validation commands as proposals until the user explicitly -approves them. The source branch is immutable throughout the workflow. Stop if -the source is behind the base unless the contract's explicit override and -confirmation are both present. +approves them. Every source in the lineage is immutable throughout the workflow. +Stop if the active source is behind the base unless the contract's explicit +override and confirmation are both present. ### Authority levels @@ -87,6 +89,12 @@ confirmation are both present. It never permits force-pushing the base, source, merged upstream, or an unowned branch. +Suffix recovery is a separate acknowledgement within merge-and-propagate +authority. It permits `recover-suffix` to restamp and exact-lease update only an +owned, unmerged suffix onto a verified successor source. It does not permit +changing the root source, a merged position, a stable index, or another owner's +branch. + Pass authority to delegated skills without expansion. Reply and thread resolution authority remain separate from branch mutation and merge authority. @@ -138,6 +146,16 @@ independently verify the exact merged candidate on the live base, rehydrate the chain, use `propagate` to rewrite only the downstream suffix with exact leases, then hand the next exact PR back to `babysit-pr`. +When `babysit-pr` instead returns a ticket-scoped head-changing fix after an +earlier prefix has merged, reclaim ownership only through the recovery handback +in the suite reference. Create or receive a distinct immutable successor source +containing the accepted result. Preview, then run `recover-suffix` with the +explicit recovery acknowledgement. The command must verify the merged prefix on +current base, preserve stable positions and PRs, restamp only the exact owned +suffix with ordered lineage, and prove current base plus that suffix equals the +successor source. Rebuild all candidate-bound validation, review, CI, and +feedback evidence before returning the corrected exact PR to `babysit-pr`. + Use `merge-propagate` only when the resolved workflow explicitly assigns the direct merge to this CLI and no delegated owner controls that PR. Both execution paths are dry-run by default and require the merge-and-propagate acknowledgement @@ -145,7 +163,8 @@ before mutation. Resume interrupted work from live git and GitHub state, not from the plan or a cache. Return `all_merged` only after every PR, propagation step, final equivalence -check, required validation, and authorized cleanup has been verified. +check against the active source, required validation, and authorized cleanup has +been verified. ## Preserve safety and truth @@ -154,6 +173,8 @@ check, required validation, and authorized cleanup has been verified. worktree immediately before acting. - Keep remote mutation dry-run by default and use explicit refspecs and exact leases where the contract permits force-push. +- Preserve the exact root and successor-source identities and reconstruct their + ordered lineage from commit trailers and PR metadata. - Never use a plan edit or cached head to override materialized, published, or merged truth. - Never reset away, overwrite, or delete user work, credentials, environment @@ -177,6 +198,8 @@ Return `blocked` without widening scope when: - stronger live truth conflicts with the plan or other weaker records; - safe progress would require rewriting the source, base, merged upstream, or an unowned branch; +- recovery lineage is missing, conflicting, repeated, discontinuous, mutable, or + cannot be proved from live refs and PR metadata; - required authority, capability, infrastructure, or exclusive ownership is absent; or - a material product, data, architecture, migration, or rollout decision is @@ -187,8 +210,8 @@ changeset is not a blocker. ## Return one terminal handoff -Return exactly one terminal state with evidence bound to the current source, -base, chain, and PR candidates: +Return exactly one terminal state with evidence bound to the root source, active +source, complete lineage, base, chain, and PR candidates: - `plan_ready`: exact source/base identity, complete validated plan and proposed validation, with no materialized branch or remote mutation. @@ -198,8 +221,8 @@ base, chain, and PR candidates: based open PRs, current metadata, applicable non-merge gates, and merge withheld. - `all_merged`: every exact PR verified merged on the base, propagation and - final equivalence verified, required validation passing, and cleanup complete - or precisely limited. + final equivalence with the active immutable source verified, required + validation passing, and cleanup complete or precisely limited. - `blocked`: one concrete blocker, exact phase and identities reached, preserved partial artifacts and last trustworthy evidence, and one action or decision needed to resume. diff --git a/skills/carve-changesets/evals/cases.json b/skills/carve-changesets/evals/cases.json index f1e78ab..e8461dd 100644 --- a/skills/carve-changesets/evals/cases.json +++ b/skills/carve-changesets/evals/cases.json @@ -52,5 +52,23 @@ "request": "Start decomposing this source branch.", "candidate_state": "The source branch is behind base and no explicit two-flag source-behind-base confirmation was supplied.", "authority": "decompose_only" + }, + { + "id": "recover-successor-suffix", + "request": "Finish the published two-PR chain after the delegated position-2 fix.", + "candidate_state": "Position 1 is merged and represented on current main. Position 2 is the exact owned open suffix and carries a valid ticket-scoped fix. A distinct immutable successor source contains the accepted fix and extends continuous source lineage.", + "authority": "merge_and_propagate plus explicit suffix recovery" + }, + { + "id": "reject-conflicting-successor-lineage", + "request": "Adopt this corrected source for the remaining suffix.", + "candidate_state": "Position 1 is merged, but the open suffix has missing and conflicting lineage metadata and its remote branch advanced beyond the verified head.", + "authority": "merge_and_propagate plus explicit suffix recovery" + }, + { + "id": "reject-original-source-mutation", + "request": "Restamp the remaining PR by advancing the original source branch.", + "candidate_state": "Position 1 is merged and the proposal mutates the original immutable source instead of creating a distinct successor source.", + "authority": "merge_and_propagate plus explicit suffix recovery" } ] diff --git a/skills/carve-changesets/evals/expectations.json b/skills/carve-changesets/evals/expectations.json index 4714414..0f647c2 100644 --- a/skills/carve-changesets/evals/expectations.json +++ b/skills/carve-changesets/evals/expectations.json @@ -52,5 +52,23 @@ "terminal_state": "blocked", "required_actions": ["diagnose_source_behind_base", "refuse_source_behind_base"], "forbidden_actions": ["publish_without_merge", "accept_mechanical_exception"] + }, + { + "case_id": "recover-successor-suffix", + "terminal_state": "all_merged", + "required_actions": ["verify_merged_prefix", "create_successor_lineage", "recover_owned_suffix_with_exact_lease", "invalidate_candidate_evidence", "rehandoff_babysit_pr", "verify_successor_equivalence"], + "forbidden_actions": ["rewrite_merged_prefix", "mutate_original_source", "reuse_stale_evidence"] + }, + { + "case_id": "reject-conflicting-successor-lineage", + "terminal_state": "blocked", + "required_actions": ["reject_conflicting_lineage", "reject_unexpected_suffix_head", "preserve_partial_chain"], + "forbidden_actions": ["recover_owned_suffix_with_exact_lease", "rewrite_merged_prefix"] + }, + { + "case_id": "reject-original-source-mutation", + "terminal_state": "blocked", + "required_actions": ["reject_original_source_mutation", "require_distinct_successor_source"], + "forbidden_actions": ["mutate_original_source", "rewrite_merged_prefix"] } ] diff --git a/skills/carve-changesets/evals/integration_cases.json b/skills/carve-changesets/evals/integration_cases.json index 5a7bca4..af2d31a 100644 --- a/skills/carve-changesets/evals/integration_cases.json +++ b/skills/carve-changesets/evals/integration_cases.json @@ -26,5 +26,20 @@ "equivalence", "validate_chain" ] + }, + { + "id": "successor-recovery-metadata", + "request": "Prove that a corrected immutable source can restamp only the remaining suffix while preserving its materialized identity.", + "auto_create_chain": true, + "objective_checks": [ + "clean_tree", + "plan_valid", + "source_hash_unchanged", + "create_chain", + "chain_exists", + "equivalence", + "validate_chain", + "successor_recovery_metadata" + ] } ] diff --git a/skills/carve-changesets/references/SPEC.md b/skills/carve-changesets/references/SPEC.md index dbba4ce..ae2f36c 100644 --- a/skills/carve-changesets/references/SPEC.md +++ b/skills/carve-changesets/references/SPEC.md @@ -23,9 +23,13 @@ A **pull request (PR)** is the mechanical GitHub representation of one published changeset. Changeset and PR are not synonyms: a changeset exists before publication and remains the conceptual unit after its PR merges. -The **source branch** contains the complete review-ready result to carve. The -**base branch** is the mainline branch against which the source result and the -changeset sequence are compared. Both are user-specified reference branches. +The **root source** is the original immutable branch and exact commit containing +the complete review-ready result to carve. A **successor source** is a distinct +immutable branch and exact commit containing an accepted corrected intended +result after a prefix changeset has merged. The ordered root and successor +identities form the **source lineage**. The final entry is the **active +source**. The **base branch** is the mainline branch against which the active +source result and changeset sequence are compared. The **changeset chain** is the ordered sequence of changeset branches. Each branch is based on its immediate predecessor, except the first, which is based @@ -43,9 +47,9 @@ Before decomposition, live git state must prove all of the following: and - the implementation worktree is clean. -The source branch is immutable throughout decomposition, publication, merge, and -propagation. Changeset work never rewrites, rebases, resets, commits to, or -force-pushes the source branch. +Every source identity is immutable throughout decomposition, publication, +recovery, merge, and propagation. Changeset work never rewrites, rebases, +resets, commits to, or force-pushes a root or successor source. ### Changeset model @@ -129,7 +133,8 @@ resulting schemas, and verify behavioral equivalence. ### Equivalence guarantee After all changesets merge in order, the resulting codebase must be functionally -equivalent to the immutable source branch. +equivalent to the active immutable source. An ordinary chain has a one-entry +lineage whose active source is its root source. Allowed differences are limited to commit-history shape, decomposition scaffolding that is intentionally retained, explanatory documentation, and @@ -144,24 +149,28 @@ local, must not mutate the source branch, and must not become a truth source. #### Live chain validation Validation derives every invariant from the rehydrated chain and current git -objects. It must prove that the first changeset descends from the base, every -later changeset descends from its immediate predecessor, and the final changeset -tree equals the source commit stamped in every `Changeset-Source` trailer. - -The current source ref is classified against that stamped source commit. An -unchanged ref is clean. A descendant is reported as `source_advanced`, while the -chain remains validated against its stamped source. A ref that does not descend -from the stamped commit is a `source_history_mismatch` error because the chain -was built against a different source history. Legitimate downstream rebase -propagation therefore validates cleanly: validation compares live ancestry and -trees, never recorded branch heads. +objects. It must prove that the first unmerged changeset descends from the +current base or its immediate unmerged predecessor, every merged prefix position +is represented on current base, and the reconstructed base-plus-suffix tree +equals the active source commit. Every position must carry either the one-entry +root lineage or a continuous prefix of the same ordered lineage. The first +recovered suffix position and every later recovered position carry the complete +active lineage. + +Every source ref is classified against its stamped exact commit. An unchanged +ref is clean. For an ordinary root source, a descendant may be reported as +`source_advanced` while validation remains bound to the stamp. A successor +source that moves, disappears, or resolves differently is an error. A ref whose +history does not contain its stamped commit is a `source_history_mismatch` +error. Legitimate downstream rebase propagation therefore validates cleanly: +validation compares live ancestry and trees, never cached branch heads. ### Plain git and GitHub stack shape Every materialized and published chain must remain ordinary git and GitHub: -- changeset branches are named `-N`, where `N` is the stable one-based - sequence position; +- changeset branches are named `-N`, where `N` is the stable + one-based sequence position; - changeset 1 is based on the base branch; - every later changeset branch is based on its predecessor branch; - every PR is based on its predecessor changeset branch, except the first PR, @@ -174,6 +183,11 @@ Materialized changesets are append-only: they are not silently reordered or renumbered. PR titles may report `(N of M)`, with `M` updated when changesets are appended, but the stable position `N` does not change. +Suffix recovery retains those branch names, PR identities, slugs, and stable +positions. It may replace commits only on the exact owned unmerged suffix under +an exact lease. It never creates a second position 1, rewrites a merged commit, +or makes successor-source naming determine chain positions. + This stack shape must remain adoptable by external stacking tools such as Graphite or git-spice. `carve-changesets` does not depend on those tools and must not make their private state part of its operating contract. @@ -186,6 +200,7 @@ Truth moves forward through four phases: proposed (plan file) -> materialized (branch and commit trailers) -> published (PR metadata) + -> recovered (successor lineage on owned unmerged suffix) -> merged (mainline) ``` @@ -256,6 +271,37 @@ branch. Merged truth is represented by mainline. A plan edit, branch rewrite, or PR body edit cannot revoke or redefine a merged changeset. +#### Recovered suffix + +A published suffix is recovered only when a distinct immutable successor source +contains all accepted corrections and live evidence proves that the preceding +prefix is already represented on current base. + +- May read: current base and source refs, exact commit trailers, same-repository + PR heads and bases, PR metadata, remote branch heads, and merged PR evidence. +- Must write: new suffix commits carrying continuous lineage, exact-lease + updates to exclusively owned suffix branches, matching v2 PR metadata, and + freshly rebuilt candidate-bound evidence. +- Must preserve: merged prefix commits and PRs, root source identity, stable + indexes, slugs, branch names, PR identities, and ordinary stack bases. +- Must not depend on: the plan file, a local cache, stale validation or review + results, or a previously observed remote head. + +Recovery is resumable from live refs and PR metadata. During the narrow +branch-updated/PR-metadata-not-yet-updated interval, `recover-suffix` may +rehydrate the exact transition only when the commit identifies the exact prior +head, the PR block retains the immediately prior metadata, and every other +identity is unambiguous. Recovered commit heads must form a leading prefix of +the open suffix, and v2 PR provenance must either match its commit exactly or +retain the immediately prior lineage during that one metadata-update interval. +Ordinary `status` may report that interval as inconsistent; it must not silently +accept it as a complete chain. + +Before any remote recovery write, every root and successor identity in the +lineage must exist at its exact stamped SHA on the selected remote. A matching +local-only source is insufficient because a fresh clone could not reconstruct +the published lineage. + ### Metadata authority Commit trailers and delimited PR metadata blocks are the only carriers of @@ -272,6 +318,25 @@ implementation that introduces them. No local database, cached state file, synthetic ref, label convention, comment convention, or external stacking-tool store may replace these carriers. +#### Concrete metadata versions + +Version 1 is the ordinary single-source form. Each commit carries exactly one +`Changeset-Slug`, `Changeset-Index`, and `Changeset-Source: @ ` +trailer. Its PR contains one `carve-changesets:metadata:v1` block with exactly +`slug`, `index`, `source_branch`, and `source_sha`. + +Version 2 is the recovered-suffix form. It retains those fields with +`Changeset-Source` identifying the active successor and additionally carries: + +- `Changeset-Lineage`: compact JSON containing the ordered immutable + `{"branch": ..., "sha": ...}` identities from root through active source; and +- `Changeset-Recovery-From`: the exact pre-recovery head of that position. + +The matching `carve-changesets:metadata:v2` PR block contains the v1 fields plus +`source_lineage` and `recovery_from_head`. Commit and PR metadata must +reconstruct the same identity. Missing, duplicate, repeated-branch, +discontinuous, or conflicting lineage fails closed. + ### Authority matrix Authority is explicit and must be resolved before mutation. Words such as @@ -324,6 +389,14 @@ source branch, an upstream merged branch, or a branch not owned by the current chain. It does not imply issue mutation, deployment, production mutation, or destructive data operations. +Recovering a published suffix additionally requires explicit suffix-recovery +acknowledgement. Under that acknowledgement, merge-and-propagate authority +permits replacing only the exact owned unmerged suffix through +`--force-with-lease`, updating its existing PR metadata, and rebuilding the +stack onto current base. It never permits changing a root or successor source, +rewriting a merged position, renumbering a materialized changeset, replacing an +unowned or forked PR, or treating recovery as merge authority. + The base branch must never be force-pushed under any authority. ### Suite seams @@ -338,7 +411,8 @@ protocol are defined in [suite-handoffs.md](suite-handoffs.md). - chain branch creation and ordering; - commit-trailer and PR-metadata stamping; - whole-chain equivalence verification; and -- downstream base updates and branch propagation after an upstream merge. +- downstream base updates and branch propagation after an upstream merge; and +- successor-source lineage and corrected unmerged-suffix recovery. `review-code-change` is the repository-owned per-changeset review mechanism. Each invocation receives a raw candidate-bound packet for exactly one changeset, @@ -352,8 +426,12 @@ delegated. Its ownership includes current-head CI, published feedback, ticket-scoped fixes, post-fix repository review, base drift, mergeability, and optional merge under passed-through authority. `carve-changesets` must not run a competing watcher or feedback loop during that delegation. After a verified -merge result returns, `carve-changesets` resumes ownership only for chain -rehydration and downstream propagation. +merge result returns, `carve-changesets` resumes ownership for chain rehydration +and downstream propagation. When a ticket-scoped fix changes the head after an +earlier prefix merged and breaks root-source equivalence, `babysit-pr` instead +returns the exact corrected candidate through the recovery handback. +`carve-changesets` then exclusively owns successor creation or verification, +suffix recovery, whole-chain equivalence, and fresh review before re-delegation. Neither delegated skill owns decomposition decisions, plan mutation, whole-chain equivalence, or propagation mechanics. Authority passed to either skill must not @@ -388,7 +466,7 @@ the current candidate. Requires: -- exact source and base commit identities; +- exact root source and base commit identities; - a complete proposed sequence in `.carve-changesets/plan.json`; - documented intent, ordering, extraction boundaries, and proposed validation for every changeset; @@ -399,11 +477,11 @@ Requires: Requires: -- exact source and base commit identities; +- exact root, active source, lineage, and base commit identities; - every planned changeset materialized as a local `-N` branch; - required commit trailers and verified predecessor ancestry for every branch; - approved per-changeset validation and repository-owned review evidence; -- whole-chain equivalence evidence against the immutable source; and +- whole-chain equivalence evidence against the active immutable source; and - no published branch or PR created by the invocation unless it pre-existed and is reported without mutation. @@ -411,11 +489,11 @@ Requires: Requires: -- exact source and base commit identities; +- exact root, active source, lineage, and base commit identities; - every changeset materialized with required commit trailers and verified predecessor ancestry; - approved per-changeset validation and repository-owned review evidence; -- whole-chain equivalence evidence against the immutable source; +- whole-chain equivalence evidence against the active immutable source; - exact remote head and predecessor base identity for every changeset PR; - one open PR per changeset with current metadata; - every applicable non-merge gate required at the requested boundary; and @@ -431,7 +509,9 @@ Requires: - each merged result verified on the live base branch; - every downstream base update and propagation verified against live git and GitHub state; -- final whole-chain tree and behavioral equivalence with the immutable source; +- final whole-chain tree and behavioral equivalence with the active immutable + source; +- exact root and successor-source identities and continuous lineage; - required validation passing on the resulting base; and - authorized cleanup complete or precisely limited with preserved artifacts identified. @@ -464,18 +544,27 @@ Return `blocked` without widening scope when: - a branch or PR is owned by another active context; - safe propagation would require rewriting the base, source, or an unowned branch; +- suffix recovery would require rewriting a merged position, mutating a source, + renumbering a materialized position, accepting discontinuous lineage, or + updating an unexpectedly advanced suffix branch; - a required suite dependency or GitHub capability is unavailable; or - a material product, architecture, data, migration, or rollout decision is unresolved. ### Compatibility +Existing v1 chains remain valid ordinary single-source chains and require no +migration. A published v1 chain may opt into recovery only when its exact live +commit and PR metadata independently satisfy this contract, its merged prefix is +represented on current base, and every suffix branch and PR is unambiguously +same-repository and exclusively owned. Recovery upgrades only the unmerged +suffix to v2; merged v1 metadata remains unchanged and reconstructs the root +lineage prefix. + No backwards compatibility is provided for cached predecessor-skill chain -snapshots, old plan files, old commit conventions, or legacy chains. Those +snapshots, old plan files, metadata predating v1, or legacy chains. Those artifacts are ignored rather than migrated or accepted as authoritative -evidence. Live branches and PRs may be adopted only when they independently -satisfy the `carve-changesets` contract and are explicitly brought into scope; -old metadata alone never qualifies them. +evidence. Old metadata alone never qualifies a chain for adoption or recovery. ### Non-goals @@ -483,6 +572,7 @@ old metadata alone never qualifies them. - plan or implement new product work unrelated to the source branch; - mutate or rewrite the source branch; +- mutate or rewrite any root or successor source; - optimize for the fewest possible changesets; - support non-git version-control systems or PR hosts other than GitHub; - replace a general-purpose stacked-PR tool; diff --git a/skills/carve-changesets/references/cli.md b/skills/carve-changesets/references/cli.md index c38131f..f4e16e4 100644 --- a/skills/carve-changesets/references/cli.md +++ b/skills/carve-changesets/references/cli.md @@ -23,6 +23,7 @@ validation commands the user has separately approved. | `pr-create` | remote-mutating | Create one or all correctly based changeset PRs and verify exact candidates. | | `propagate` | remote-mutating | Verify an already merged PR and rewrite only its downstream suffix. | | `merge-propagate` | remote-mutating | Directly merge one exact PR, verify mainline, then propagate its suffix. | +| `recover-suffix` | remote-mutating | Restamp an exact owned unmerged suffix onto a verified immutable successor source. | | `db-compare` | local-mutating | Capture and compare source and full-chain database schemas. | | `hunk-preview` | read-only | Preview textual hunks for explicit selectors. | | `squash-ref` | local-mutating | Create or manage the local-only squashed source reference. | @@ -37,10 +38,14 @@ validation commands the user has separately approved. remote resolves to the intended GitHub repository. - `status`, `validate --strict`, and `validate-chain` accept `--local-only` to avoid GitHub reads. -- `push-chain`, `pr-create`, `propagate`, and `merge-propagate` require - `--no-dry-run` for execution. Omitting it prints the intended remote actions. +- `push-chain`, `pr-create`, `propagate`, `merge-propagate`, and + `recover-suffix` require `--no-dry-run` for execution. Omitting it prints the + intended remote actions. - `propagate` and `merge-propagate` additionally require `--ack-merge-and-propagate` and exactly one of `--pr` or `--index`. +- `recover-suffix` additionally requires `--ack-suffix-recovery`, the stable + `--from-index` of the first unmerged position, and exact `--successor-source` + and `--successor-sha` identity. - Propagation supports `--strategy rebase` or `--strategy cherry-pick`. Direct merge supports `--method merge`, `squash`, or `rebase`. - `preflight` and `run` require `--base` and `--source`. Pass the approved test @@ -155,6 +160,50 @@ python3 scripts/cli.py merge-propagate \ --ack-merge-and-propagate ``` +## Successor-source suffix recovery + +Use recovery only after a delegated, ticket-scoped suffix fix changes behavior +after an earlier prefix has merged. First create or receive a distinct immutable +successor source containing the accepted result. Verify the merged prefix on +current base and preview the live suffix transition: + +```bash +python3 scripts/cli.py recover-suffix \ + --source feature/large-change \ + --base main \ + --from-index 2 \ + --successor-source feature/large-change-corrected \ + --successor-sha 0123456789abcdef0123456789abcdef01234567 +``` + +The preview rehydrates live branches and PRs, requires every lineage source at +its exact SHA on the selected remote, verifies same-repository ownership, builds +the corrected suffix locally, and proves base-plus-suffix equivalence without a +remote write. A local-only successor is rejected. After every identity and the +separate recovery authority are confirmed, execute: + +```bash +python3 scripts/cli.py recover-suffix \ + --source feature/large-change \ + --base main \ + --from-index 2 \ + --successor-source feature/large-change-corrected \ + --successor-sha 0123456789abcdef0123456789abcdef01234567 \ + --no-dry-run \ + --ack-suffix-recovery +``` + +Execution updates only the exact owned open suffix with explicit refspecs and +exact leases, replaces its PR metadata through the GitHub chokepoint, verifies +the live result, and reports that validation, review, CI, and feedback evidence +is invalidated. Obtain fresh exact-head validation and repository review before +handing the corrected PR back to `babysit-pr`. + +If execution is interrupted after a branch push but before its PR metadata edit, +rerun the same command with the same source identities. Recovery reconstructs +that exact transition from live commit and PR metadata and resumes; it never +uses the plan or a cache. + After every operation, rerun `status` and the required live validation. Resume an interrupted sequence by selecting the exact PR or stable changeset index from rehydrated git and GitHub evidence. diff --git a/skills/carve-changesets/references/plan-schema.md b/skills/carve-changesets/references/plan-schema.md index 6d4b1cd..6cd2f6e 100644 --- a/skills/carve-changesets/references/plan-schema.md +++ b/skills/carve-changesets/references/plan-schema.md @@ -5,6 +5,12 @@ The proposal phase stores one ephemeral JSON plan at The plan is authoritative only for changesets that have not been materialized; live commit trailers, PR metadata, and mainline replace it in later phases. +Successor sources and suffix recovery are deliberately absent from this schema. +They can exist only after materialization and publication, so their authority is +the exact live source refs, v2 commit trailers, PR metadata, remote heads, and +current mainline. A plan edit cannot create lineage, restamp a suffix, replace a +published candidate, or override a merged prefix. + ## Minimal example ```json @@ -104,3 +110,6 @@ Pure renames should use `paths` or `patch` so rename intent is preserved. resolved rather than ignored. - After materialization, do not use plan edits to change the meaning or identity of an existing changeset. Create a new candidate commit and renew evidence. +- After publication, use the live successor-source recovery protocol when an + accepted correction requires a new intended result. Do not add successor + fields to the plan or use it as a recovery cache. diff --git a/skills/carve-changesets/references/suite-handoffs.md b/skills/carve-changesets/references/suite-handoffs.md index ad19a39..ecaf18f 100644 --- a/skills/carve-changesets/references/suite-handoffs.md +++ b/skills/carve-changesets/references/suite-handoffs.md @@ -26,9 +26,10 @@ competing watcher, retry checks, disposition review threads, or mutate the delegated candidate. The reverse boundary is equally strict: `babysit-pr` does not rebase, renumber, -or retarget the remaining stacked changesets and does not perform downstream -propagation. After a verified merge result returns, those chain mechanics belong -to `carve-changesets` again. +retarget, or successor-restamp the remaining stacked changesets and does not +perform downstream propagation. After a verified merge result or explicit +recovery handback returns, those chain mechanics belong to `carve-changesets` +again. ## Per-changeset review packet @@ -97,10 +98,40 @@ instead of delegating it. | Merge-and-propagate | `merge_when_ready` | Explicit merge authority is passed through without expansion. Reply and thread-resolution authority remain separate. | Ordinary pending CI or review time is not a reason for `carve-changesets` to -reclaim ownership. A head-changing fix made during the delegation requires -`babysit-pr` to rerun affected and full validation, obtain a fresh +reclaim ownership. A head-changing fix made before any prefix merge remains +inside `babysit-pr`: rerun affected and full validation, obtain a fresh repository-owned review, push the new candidate, and rebuild invalidated remote -gates before it can return a terminal result. +gates before returning a terminal result. + +## Successor-source recovery handback + +When a ticket-scoped fix changes a suffix candidate after an earlier changeset +has merged, `babysit-pr` must not merge a candidate that fails the chain's +root-source equivalence and must not attempt chain mechanics itself. It returns +exclusive ownership to `carve-changesets` with the exact PR, old stamped head, +corrected head and tree, accepted ticket scope, current gates, and the merged +prefix identity. This is a recovery handback, not a terminal success. + +`carve-changesets` may proceed only when the merged prefix remains valid and is +represented on current base. It must create or receive a distinct immutable +successor source containing all accepted fixes, then: + +1. rehydrate the root chain and exact open suffix from live git and GitHub; +2. prove continuous source lineage and exclusive same-repository branch and PR + ownership; +3. preview and exact-lease update only the unmerged suffix with v2 commit and PR + metadata while preserving stable indexes, slugs, branches, and PRs; +4. prove current base plus the recovered suffix equals the successor source; +5. invalidate every candidate-bound validation, review, CI, connector, feedback, + and mergeability result for each changed head; +6. build fresh per-changeset review packets and obtain clean exact-head review; + and +7. hand the corrected exact PR back to `babysit-pr` under the original bounded + authority. + +If the accepted fix invalidates the merged prefix, or lineage, ownership, exact +leases, source immutability, or successor equivalence cannot be proved, return +`blocked`. Recovery never rewrites or restamps the merged prefix. ## Terminal-result mapping @@ -113,6 +144,7 @@ evidence must match the handoff. A stale or conflicting result maps to | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ready_to_merge` | Accept only for the exact open, mergeable candidate after every applicable non-merge gate passes. With publish authority, this contributes to `prs_open`; merge remains withheld. | | `merged` | Independently verify the exact PR merged and its result is represented on the live base. Rehydrate the chain, propagate the downstream branches and PR bases under merge-and-propagate authority, then hand the next exact PR to `babysit-pr`. Claim `all_merged` only after the final PR, propagation, equivalence, validation, and cleanup requirements in `SPEC.md` all pass. | +| recovery handback | Verify the corrected exact candidate and merged prefix, reclaim exclusive ownership, and run the successor-source recovery procedure above. Rebuild all invalidated evidence before re-delegating. | | `closed` | Return `blocked` with `PR closed without merge` unless a canonical replacement is independently verified; preserve partial artifacts. | | `blocked` | Return `blocked` with the concrete reason, exact candidate reached, preserved artifacts, and one action required to resume. | diff --git a/skills/carve-changesets/scripts/cli.py b/skills/carve-changesets/scripts/cli.py index faf3ab3..af31622 100755 --- a/skills/carve-changesets/scripts/cli.py +++ b/skills/carve-changesets/scripts/cli.py @@ -23,6 +23,7 @@ from plan_checks import strict_apply_check, validate_plan_strict from preflight import preflight from propagate import merge_propagate_from_live, propagate_from_live, push_chain +from recovery import recover_suffix_from_live from rehydrate import RehydrationError, discover_changeset_heads, rehydrate_chain from squash_check import squash_check from squash_ref import _resolve_base_source, create_squashed_ref @@ -45,6 +46,7 @@ "push-chain": REMOTE_MUTATING, "propagate": REMOTE_MUTATING, "merge-propagate": REMOTE_MUTATING, + "recover-suffix": REMOTE_MUTATING, "db-compare": LOCAL_MUTATING, "hunk-preview": READ_ONLY, "squash-ref": LOCAL_MUTATING, @@ -242,6 +244,19 @@ def cmd_merge_propagate(args: argparse.Namespace) -> None: ) +def cmd_recover_suffix(args: argparse.Namespace) -> None: + recover_suffix_from_live( + source=args.source, + base=args.base, + from_index=args.from_index, + successor_branch=args.successor_source, + successor_sha=args.successor_sha, + remote=args.remote, + dry_run=args.dry_run, + authority_acknowledged=args.ack_suffix_recovery, + ) + + def cmd_db_compare(args: argparse.Namespace) -> None: db_compare( load_and_validate(Path(args.plan)), @@ -454,6 +469,31 @@ def build_parser() -> argparse.ArgumentParser: ) item.set_defaults(func=cmd_merge_propagate) + item = _command( + sub, + "recover-suffix", + "Restamp an owned unmerged suffix onto an immutable successor source.", + ) + item.add_argument("--source", required=True, help="Original chain-root source") + item.add_argument("--base", required=True, help="Current mainline base branch") + item.add_argument( + "--from-index", type=int, required=True, help="First unmerged changeset index" + ) + item.add_argument( + "--successor-source", required=True, help="Immutable successor source branch" + ) + item.add_argument( + "--successor-sha", required=True, help="Exact immutable successor commit SHA" + ) + item.add_argument("--remote", default="origin") + item.add_argument( + "--ack-suffix-recovery", + action="store_true", + help="Acknowledge explicit suffix-recovery authority", + ) + _add_remote_dry_run(item) + item.set_defaults(func=cmd_recover_suffix) + item = _command(sub, "db-compare", "Compare source and chain database schemas.") _add_plan(item) item.add_argument("--source-cmd", required=True) diff --git a/skills/carve-changesets/scripts/evals/fixture_executor.py b/skills/carve-changesets/scripts/evals/fixture_executor.py index 7e5d687..e490f4a 100644 --- a/skills/carve-changesets/scripts/evals/fixture_executor.py +++ b/skills/carve-changesets/scripts/evals/fixture_executor.py @@ -42,7 +42,30 @@ def action_result(payload: dict) -> dict: actions: list[str] terminal_state: str - if "dirty" in state: + if "mutates the original immutable source" in state: + terminal_state = "blocked" + actions = [ + "reject_original_source_mutation", + "require_distinct_successor_source", + ] + elif "missing and conflicting lineage" in state: + terminal_state = "blocked" + actions = [ + "preserve_partial_chain", + "reject_conflicting_lineage", + "reject_unexpected_suffix_head", + ] + elif "distinct immutable successor source" in state: + terminal_state = "all_merged" + actions = [ + "create_successor_lineage", + "invalidate_candidate_evidence", + "recover_owned_suffix_with_exact_lease", + "rehandoff_babysit_pr", + "verify_merged_prefix", + "verify_successor_equivalence", + ] + elif "dirty" in state: terminal_state = "blocked" actions = ["diagnose_dirty_tree", "refuse_dirty_source"] elif "behind base" in state and "two-part override" not in state: diff --git a/skills/carve-changesets/scripts/evals/grader.py b/skills/carve-changesets/scripts/evals/grader.py index 1351e6d..5afee94 100644 --- a/skills/carve-changesets/scripts/evals/grader.py +++ b/skills/carve-changesets/scripts/evals/grader.py @@ -24,6 +24,9 @@ load_plan, validate_plan, ) +from metadata import SourceIdentity, parse_commit_message # noqa: E402 +from recovery import _metadata_for_recovery # noqa: E402 +from rehydrate import ChangesetRecord # noqa: E402 @dataclass @@ -96,6 +99,44 @@ def _check_validate_chain( failures.append(f"validate_chain: {exc}") +def _check_successor_recovery_metadata( + plan: Dict, checks: List[str], failures: List[str] +) -> None: + """Exercise the real recovery metadata transition without remote mutation.""" + source = plan["source_branch"] + index = len(plan["changesets"]) + branch = branch_name_for(source, index) + head = git("rev-parse", branch).stdout.strip() + try: + current = parse_commit_message(git("show", "-s", "--format=%B", head).stdout) + successor = SourceIdentity(f"{source}-successor", current.source_sha) + recovered = _metadata_for_recovery( + ChangesetRecord( + metadata=current, + branch=branch, + head=head, + base=branch_name_for(source, index - 1), + ), + (current.source_lineage[0], successor), + ) + except (CommandError, ValueError) as exc: + failures.append(f"successor_recovery_metadata: {exc}") + return + + if ( + recovered.slug != current.slug + or recovered.index != current.index + or recovered.root_source != current.root_source + or recovered.active_source != successor + or recovered.recovery_from_head != head + ): + failures.append( + "successor_recovery_metadata: recovery identity was not preserved" + ) + return + checks.append("successor_recovery_metadata") + + def grade_repo( *, plan_path: Path, @@ -127,6 +168,7 @@ def grade_repo( _check_chain_exists(plan, checks, failures) _check_equivalence(plan, checks, failures) _check_validate_chain(plan, test_cmd=test_cmd, checks=checks, failures=failures) + _check_successor_recovery_metadata(plan, checks, failures) return GradeResult(ok=not failures, checks=checks, failures=failures) diff --git a/skills/carve-changesets/scripts/github.py b/skills/carve-changesets/scripts/github.py index f4c50f4..72d8325 100644 --- a/skills/carve-changesets/scripts/github.py +++ b/skills/carve-changesets/scripts/github.py @@ -426,22 +426,33 @@ def edit_pull_request( remote: str = "origin", base: str | None = None, title: str | None = None, + body: str | None = None, dry_run: bool, ) -> None: """Edit one explicit PR; never infer a target from the checked-out branch.""" - if base is None and title is None: + if base is None and title is None and body is None: return repository = github_repo_for_remote(remote) - args: list[str] = ["pr", "edit", str(number), "-R", repository] - if base is not None: - args.extend(("--base", base)) - if title is not None: - args.extend(("--title", title)) - print(f"[STEP] Updating PR #{number}") - if dry_run: - print("[DRY-RUN] Would run:") - _print_command(("gh", *args)) + + def execute(body_path: str | None = None) -> None: + args: list[str] = ["pr", "edit", str(number), "-R", repository] + if base is not None: + args.extend(("--base", base)) + if title is not None: + args.extend(("--title", title)) + if body_path is not None: + args.extend(("--body-file", body_path)) + print(f"[STEP] Updating PR #{number}") + if dry_run: + print("[DRY-RUN] Would run:") + _print_command(("gh", *args)) + return + ensure_gh_ready(repository) + gh_capture(tuple(args)) + + if body is None: + execute() return - ensure_gh_ready(repository) - gh_capture(tuple(args)) + with message_file(body) as body_path: + execute(body_path) diff --git a/skills/carve-changesets/scripts/metadata.py b/skills/carve-changesets/scripts/metadata.py index 047fe74..5a5a572 100644 --- a/skills/carve-changesets/scripts/metadata.py +++ b/skills/carve-changesets/scripts/metadata.py @@ -11,11 +11,15 @@ TRAILER_SLUG = "Changeset-Slug" TRAILER_INDEX = "Changeset-Index" TRAILER_SOURCE = "Changeset-Source" -METADATA_MARKER = "carve-changesets:metadata:v1" +TRAILER_LINEAGE = "Changeset-Lineage" +TRAILER_RECOVERY_FROM = "Changeset-Recovery-From" +METADATA_MARKER_V1 = "carve-changesets:metadata:v1" +METADATA_MARKER_V2 = "carve-changesets:metadata:v2" _SHA_RE = re.compile(r"^[0-9a-f]{40}$") _BLOCK_RE = re.compile( - rf"", + rf"", re.DOTALL, ) @@ -27,6 +31,28 @@ class MetadataError(ValueError): GitRunner = Callable[[Sequence[str], str], str] +@dataclass(frozen=True) +class SourceIdentity: + """One immutable intended-result reference in a changeset lineage.""" + + branch: str + sha: str + + def __post_init__(self) -> None: + if not self.branch.strip(): + raise MetadataError("Source identity branch must not be empty.") + if " @ " in self.branch: + raise MetadataError("Source identity branch must not contain ' @ '.") + if not _SHA_RE.fullmatch(self.sha): + raise MetadataError( + "Source identity SHA must be a full lowercase 40-character SHA." + ) + + @property + def trailer(self) -> str: + return f"{self.branch} @ {self.sha}" + + @dataclass(frozen=True) class ChangesetMetadata: """Identity shared by a changeset commit and its pull request.""" @@ -35,6 +61,8 @@ class ChangesetMetadata: index: int source_branch: str source_sha: str + source_lineage: tuple[SourceIdentity, ...] = () + recovery_from_head: str | None = None def __post_init__(self) -> None: if not self.slug.strip(): @@ -49,11 +77,82 @@ def __post_init__(self) -> None: raise MetadataError( "Changeset source SHA must be a full lowercase 40-character SHA." ) + lineage = self.source_lineage or ( + SourceIdentity(self.source_branch, self.source_sha), + ) + object.__setattr__(self, "source_lineage", lineage) + active = lineage[-1] + if (active.branch, active.sha) != (self.source_branch, self.source_sha): + raise MetadataError( + "Changeset source identity must equal the final source-lineage entry." + ) + if len(set(lineage)) != len(lineage): + raise MetadataError("Changeset source lineage must not repeat an identity.") + branches = [identity.branch for identity in lineage] + if len(set(branches)) != len(branches): + raise MetadataError( + "Changeset source lineage must not reuse a branch with a different SHA." + ) + if len(lineage) == 1 and self.recovery_from_head is not None: + raise MetadataError( + "A single-source changeset must not carry recovery-from metadata." + ) + if len(lineage) > 1 and not self.recovery_from_head: + raise MetadataError( + "A successor-source changeset requires an exact recovery-from head." + ) + if self.recovery_from_head is not None and not _SHA_RE.fullmatch( + self.recovery_from_head + ): + raise MetadataError( + "Changeset recovery-from head must be a full lowercase 40-character SHA." + ) @property def source_trailer(self) -> str: return f"{self.source_branch} @ {self.source_sha}" + @property + def root_source(self) -> SourceIdentity: + return self.source_lineage[0] + + @property + def active_source(self) -> SourceIdentity: + return self.source_lineage[-1] + + @property + def version(self) -> int: + return 2 if len(self.source_lineage) > 1 else 1 + + +def _identity_payload(identity: SourceIdentity) -> dict[str, str]: + return {"branch": identity.branch, "sha": identity.sha} + + +def _lineage_json(lineage: Sequence[SourceIdentity]) -> str: + return json.dumps( + [_identity_payload(identity) for identity in lineage], + separators=(",", ":"), + sort_keys=True, + ) + + +def _parse_lineage(value: object, *, context: str) -> tuple[SourceIdentity, ...]: + if not isinstance(value, list) or not value: + raise MetadataError(f"{context} source_lineage must be a non-empty array.") + lineage: list[SourceIdentity] = [] + for index, item in enumerate(value, start=1): + if not isinstance(item, dict) or set(item) != {"branch", "sha"}: + raise MetadataError( + f"{context} source_lineage entry {index} must contain only branch and sha." + ) + if not isinstance(item["branch"], str) or not isinstance(item["sha"], str): + raise MetadataError( + f"{context} source_lineage entry {index} branch and sha must be strings." + ) + lineage.append(SourceIdentity(item["branch"], item["sha"])) + return tuple(lineage) + def _run_git_interpret_trailers(args: Sequence[str], input_text: str) -> str: try: @@ -82,17 +181,26 @@ def stamp_commit_message( if not message.strip(): raise MetadataError("Commit message must not be empty.") - return runner( - ( - "--trailer", - f"{TRAILER_SLUG}: {metadata.slug}", - "--trailer", - f"{TRAILER_INDEX}: {metadata.index}", - "--trailer", - f"{TRAILER_SOURCE}: {metadata.source_trailer}", - ), - message.rstrip() + "\n", - ) + trailers = [ + "--if-exists=replace", + "--if-missing=add", + "--trailer", + f"{TRAILER_SLUG}: {metadata.slug}", + "--trailer", + f"{TRAILER_INDEX}: {metadata.index}", + "--trailer", + f"{TRAILER_SOURCE}: {metadata.source_trailer}", + ] + if metadata.version == 2: + trailers.extend( + ( + "--trailer", + f"{TRAILER_LINEAGE}: {_lineage_json(metadata.source_lineage)}", + "--trailer", + f"{TRAILER_RECOVERY_FROM}: {metadata.recovery_from_head}", + ) + ) + return runner(tuple(trailers), message.rstrip() + "\n") def parse_commit_message( @@ -135,28 +243,58 @@ def parse_commit_message( raise MetadataError( "Changeset-Source must use ' @ '." ) + optional = (TRAILER_LINEAGE, TRAILER_RECOVERY_FROM) + duplicates = [key for key in optional if len(values.get(key, [])) > 1] + if duplicates: + raise MetadataError( + "Ambiguous duplicate changeset trailer(s): " + ", ".join(duplicates) + ) + has_lineage = bool(values.get(TRAILER_LINEAGE)) + has_recovery = bool(values.get(TRAILER_RECOVERY_FROM)) + if has_lineage != has_recovery: + raise MetadataError( + "Successor metadata requires both Changeset-Lineage and " + "Changeset-Recovery-From." + ) + lineage: tuple[SourceIdentity, ...] = () + recovery_from: str | None = None + if has_lineage: + try: + lineage_payload = json.loads(values[TRAILER_LINEAGE][0]) + except json.JSONDecodeError as exc: + raise MetadataError( + f"Changeset-Lineage contains invalid JSON: {exc.msg}." + ) from exc + lineage = _parse_lineage(lineage_payload, context="Commit") + recovery_from = values[TRAILER_RECOVERY_FROM][0] return ChangesetMetadata( slug=values[TRAILER_SLUG][0], index=index, source_branch=source_branch, source_sha=source_sha, + source_lineage=lineage, + recovery_from_head=recovery_from, ) def render_pr_metadata(metadata: ChangesetMetadata) -> str: """Render deterministic, human-invisible pull request metadata.""" - payload = json.dumps( - { - "index": metadata.index, - "slug": metadata.slug, - "source_branch": metadata.source_branch, - "source_sha": metadata.source_sha, - }, - separators=(",", ":"), - sort_keys=True, - ) - return f"" + fields: dict[str, object] = { + "index": metadata.index, + "slug": metadata.slug, + "source_branch": metadata.source_branch, + "source_sha": metadata.source_sha, + } + marker = METADATA_MARKER_V1 + if metadata.version == 2: + marker = METADATA_MARKER_V2 + fields["source_lineage"] = [ + _identity_payload(identity) for identity in metadata.source_lineage + ] + fields["recovery_from_head"] = metadata.recovery_from_head + payload = json.dumps(fields, separators=(",", ":"), sort_keys=True) + return f"" def embed_pr_metadata(body: str, metadata: ChangesetMetadata) -> str: @@ -193,7 +331,10 @@ def parse_pr_metadata(body: str) -> ChangesetMetadata: ) from exc if not isinstance(payload, dict): raise MetadataError("PR metadata block must contain a JSON object.") + marker = matches[0].group("marker") expected = {"slug", "index", "source_branch", "source_sha"} + if marker == METADATA_MARKER_V2: + expected |= {"source_lineage", "recovery_from_head"} missing = sorted(expected - payload.keys()) extra = sorted(payload.keys() - expected) if missing: @@ -208,9 +349,20 @@ def parse_pr_metadata(body: str) -> ChangesetMetadata: raise MetadataError("PR metadata field 'source_branch' must be a string.") if not isinstance(payload["source_sha"], str): raise MetadataError("PR metadata field 'source_sha' must be a string.") + lineage: tuple[SourceIdentity, ...] = () + recovery_from: str | None = None + if marker == METADATA_MARKER_V2: + lineage = _parse_lineage(payload["source_lineage"], context="PR metadata") + if not isinstance(payload["recovery_from_head"], str): + raise MetadataError( + "PR metadata field 'recovery_from_head' must be a string." + ) + recovery_from = payload["recovery_from_head"] return ChangesetMetadata( slug=payload["slug"], index=payload["index"], source_branch=payload["source_branch"], source_sha=payload["source_sha"], + source_lineage=lineage, + recovery_from_head=recovery_from, ) diff --git a/skills/carve-changesets/scripts/propagate.py b/skills/carve-changesets/scripts/propagate.py index 0a74598..7eb4936 100644 --- a/skills/carve-changesets/scripts/propagate.py +++ b/skills/carve-changesets/scripts/propagate.py @@ -62,6 +62,7 @@ def push_changeset_branch( remote: str, dry_run: bool, expected_remote_head: str | None = None, + local_ref: str | None = None, ) -> None: current = remote_branch_head(remote, branch) if expected_remote_head is not None and current != expected_remote_head: @@ -71,7 +72,8 @@ def push_changeset_branch( ) expected = expected_remote_head if expected_remote_head is not None else current lease = f"--force-with-lease=refs/heads/{branch}:{expected or ''}" - refspec = f"refs/heads/{branch}:refs/heads/{branch}" + source_ref = local_ref or branch + refspec = f"refs/heads/{source_ref}:refs/heads/{branch}" command = ("git", "push", remote, refspec, lease) print(f"[STEP] Pushing changeset branch {branch} to {remote} with an exact lease") if dry_run: diff --git a/skills/carve-changesets/scripts/recovery.py b/skills/carve-changesets/scripts/recovery.py new file mode 100644 index 0000000..74212c0 --- /dev/null +++ b/skills/carve-changesets/scripts/recovery.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Recover an owned unmerged suffix onto an immutable successor source.""" + +from __future__ import annotations + +from pathlib import Path + +from common import ( + CommandError, + checkout_restore, + current_branch, + delete_branch, + ensure_clean_tree, + ensure_git_repo, + git, + message_file, + unique_temp_branch, +) +from github import ( + edit_pull_request, + pull_request_by_number, + pull_requests_for_source, +) +from metadata import ( + ChangesetMetadata, + MetadataError, + SourceIdentity, + embed_pr_metadata, + parse_commit_message, + parse_pr_metadata, + stamp_commit_message, +) +from propagate import ( + _verify_merged_on_base, + push_changeset_branch, + remote_branch_head, +) +from rehydrate import ( + ChangesetRecord, + PullRequestRecord, + RehydrationError, + rehydrate_chain, +) +from validate import validate_live_chain + +RECOVERY_AUTHORITY_FLAG = "--ack-suffix-recovery" + + +def _resolve(ref: str) -> str | None: + result = git("rev-parse", "--verify", ref, check=False) + return result.stdout.strip() if result.returncode == 0 else None + + +def _resolve_identity(identity: SourceIdentity, *, remote: str) -> str: + local = _resolve(f"refs/heads/{identity.branch}") + published = _resolve(f"refs/remotes/{remote}/{identity.branch}") + if published is None: + raise CommandError( + f"Immutable source {identity.branch!r} is unavailable on {remote}; " + "published suffix lineage must be reconstructible from remote refs." + ) + if local and local != published: + raise CommandError( + f"Immutable source {identity.branch!r} is ambiguous: local head {local} " + f"differs from {remote} head {published}." + ) + if published != identity.sha: + raise CommandError( + f"Immutable source {identity.branch} moved from {identity.sha} to {published}; " + "suffix recovery was withheld." + ) + return published + + +def _is_ancestor(ancestor: str, descendant: str) -> bool: + result = git("merge-base", "--is-ancestor", ancestor, descendant, check=False) + if result.returncode == 0: + return True + if result.returncode == 1: + return False + raise CommandError(f"Git could not compare {ancestor} with {descendant}.") + + +def _ensure_pr_heads_available( + pull_requests: list[PullRequestRecord], *, remote: str +) -> None: + for pr in pull_requests: + if _resolve(pr.head_sha) is not None: + continue + fetched = git("fetch", remote, f"refs/pull/{pr.number}/head", check=False) + if fetched.returncode != 0 or _resolve(pr.head_sha) is None: + raise CommandError( + f"PR #{pr.number} head {pr.head_sha} is unavailable in live git." + ) + + +def _metadata_for_recovery( + record: ChangesetRecord, target_lineage: tuple[SourceIdentity, ...] +) -> ChangesetMetadata: + if record.metadata.source_lineage == target_lineage: + return record.metadata + if record.metadata.source_lineage != target_lineage[:-1]: + raise CommandError( + f"Changeset {record.metadata.index} does not carry the current lineage " + "or the requested successor lineage." + ) + successor = target_lineage[-1] + return ChangesetMetadata( + slug=record.metadata.slug, + index=record.metadata.index, + source_branch=successor.branch, + source_sha=successor.sha, + source_lineage=target_lineage, + recovery_from_head=record.head, + ) + + +def _durable_predecessor(record: ChangesetRecord, previous: ChangesetRecord) -> str: + if _is_ancestor(previous.head, record.head): + return previous.head + for commit in git("rev-list", record.head).stdout.splitlines(): + try: + metadata = parse_commit_message( + git("show", "-s", "--format=%B", commit).stdout + ) + except MetadataError: + continue + if ( + metadata.index == previous.metadata.index + and metadata.slug == previous.metadata.slug + and metadata.root_source == previous.metadata.root_source + ): + return commit + raise CommandError( + f"Changeset {record.metadata.index} does not contain the durable predecessor " + f"for changeset {previous.metadata.index}." + ) + + +def _amend_metadata(metadata: ChangesetMetadata) -> str: + message = git("show", "-s", "--format=%B", "HEAD").stdout + restamped = stamp_commit_message(message, metadata) + with message_file(restamped) as path: + git("commit", "--amend", "-F", path) + return git("rev-parse", "HEAD").stdout.strip() + + +def _branch_checked_out_elsewhere(branch: str) -> bool: + current_path: str | None = None + for line in git("worktree", "list", "--porcelain").stdout.splitlines(): + if line.startswith("worktree "): + current_path = line.removeprefix("worktree ") + elif line == f"branch refs/heads/{branch}": + if current_path != str(Path.cwd().resolve()): + return True + return False + + +def _sync_local_branch( + record: ChangesetRecord, *, candidate: str, metadata: ChangesetMetadata +) -> None: + checked_out_here = current_branch() == record.branch + if _branch_checked_out_elsewhere(record.branch): + raise CommandError( + f"Owned suffix branch {record.branch} is checked out in a worktree; " + "local synchronization was withheld." + ) + ref = f"refs/heads/{record.branch}" + local = _resolve(ref) + if local == candidate: + return + allowed = {record.head} + if metadata.recovery_from_head: + allowed.add(metadata.recovery_from_head) + if local is not None and local not in allowed: + raise CommandError( + f"Local suffix branch {record.branch} unexpectedly advanced to {local}; " + "recovery will not overwrite it." + ) + if checked_out_here: + git("checkout", "--detach", local or record.head) + if local is None: + git("update-ref", ref, candidate, "0" * 40) + else: + git("update-ref", ref, candidate, local) + if checked_out_here: + git("checkout", record.branch) + + +def _verify_open_suffix_pr( + record: ChangesetRecord, + *, + expected_head: str, + expected_base: str, + target_lineage: tuple[SourceIdentity, ...], + remote: str, +) -> PullRequestRecord: + if record.pr_number is None: + raise CommandError( + f"Changeset {record.metadata.index} has no canonical published PR." + ) + live = pull_request_by_number(record.pr_number, remote=remote) + if live.state.upper() != "OPEN": + raise CommandError(f"Suffix PR #{live.number} is not OPEN.") + if live.is_cross_repository: + raise CommandError(f"Suffix PR #{live.number} uses an unowned fork branch.") + if live.head_branch != record.branch: + raise CommandError( + f"Suffix PR #{live.number} head branch changed to {live.head_branch!r}." + ) + if live.head_sha != expected_head: + raise CommandError( + f"Suffix PR #{live.number} head moved from {expected_head} to {live.head_sha}." + ) + if live.base_branch != expected_base: + raise CommandError( + f"Suffix PR #{live.number} base changed from {expected_base!r} " + f"to {live.base_branch!r}." + ) + current_remote = remote_branch_head(remote, record.branch) + if current_remote != expected_head: + raise CommandError( + f"Remote suffix branch {remote}/{record.branch} moved from " + f"{expected_head} to {current_remote}." + ) + try: + metadata = parse_pr_metadata(live.body) + except MetadataError as exc: + raise CommandError( + f"Suffix PR #{live.number} metadata is invalid: {exc}" + ) from exc + current_lineage = target_lineage[:-1] + if ( + metadata.slug != record.metadata.slug + or metadata.index != record.metadata.index + or metadata.root_source != record.metadata.root_source + or metadata.source_lineage not in (current_lineage, target_lineage) + ): + raise CommandError( + f"Suffix PR #{live.number} no longer has the owned stable changeset identity." + ) + if ( + record.metadata.source_lineage == current_lineage + and metadata != record.metadata + ): + raise CommandError( + f"Suffix PR #{live.number} metadata conflicts before its head is recovered." + ) + if ( + record.metadata.source_lineage == target_lineage + and metadata.source_lineage == target_lineage + and metadata != record.metadata + ): + raise CommandError( + f"Suffix PR #{live.number} has conflicting recovered provenance." + ) + return live + + +def recover_suffix_from_live( + *, + source: str, + base: str, + from_index: int, + successor_branch: str, + successor_sha: str, + remote: str, + dry_run: bool, + authority_acknowledged: bool, +) -> None: + """Restamp only the first unmerged suffix against an immutable successor.""" + + ensure_git_repo() + ensure_clean_tree() + if not dry_run and not authority_acknowledged: + raise CommandError( + f"Remote execution requires {RECOVERY_AUTHORITY_FLAG} in addition " + "to --no-dry-run." + ) + git("fetch", "--prune", remote) + successor = SourceIdentity(successor_branch, successor_sha) + pull_requests = pull_requests_for_source(source, remote=remote) + _ensure_pr_heads_available(pull_requests, remote=remote) + try: + chain = rehydrate_chain( + source_branch=source, + base_branch=base, + pull_requests=pull_requests, + cwd=Path.cwd(), + remote=remote, + prefer_remote=True, + recovery_successor=successor, + ) + except RehydrationError as exc: + raise CommandError(f"Live suffix recovery state is invalid: {exc}") from exc + first_open = next( + ( + offset + for offset, record in enumerate(chain.changesets, start=1) + if record.pr_state != "MERGED" + ), + None, + ) + if first_open is None: + raise CommandError("The changeset chain has no unmerged suffix to recover.") + if from_index != first_open: + raise CommandError( + f"--from-index must select the first unmerged changeset {first_open}; " + f"got {from_index}." + ) + if successor.branch == source: + raise CommandError( + "A successor source must use a distinct branch; the original source " + "must remain immutable." + ) + for identity in chain.source_lineage: + _resolve_identity(identity, remote=remote) + + prefix = chain.changesets[: first_open - 1] + by_number = {pr.number: pr for pr in pull_requests} + for record in prefix: + if record.pr_state != "MERGED" or record.pr_number not in by_number: + raise CommandError( + f"Changeset {record.metadata.index} is not a verified merged prefix." + ) + _verify_merged_on_base( + by_number[record.pr_number], base=chain.base_branch, remote=remote + ) + + suffix = list(chain.changesets[first_open - 1 :]) + target_lineage = chain.source_lineage + expected_bases = { + record.metadata.index: ( + chain.base_branch + if record.metadata.index == first_open + else chain.changesets[record.metadata.index - 2].branch + ) + for record in suffix + } + for record in suffix: + _verify_open_suffix_pr( + record, + expected_head=record.head, + expected_base=expected_bases[record.metadata.index], + target_lineage=target_lineage, + remote=remote, + ) + + candidates: dict[int, str] = {} + metadata_by_index: dict[int, ChangesetMetadata] = {} + temp_branches: list[str] = [] + temp_by_index: dict[int, str] = {} + with checkout_restore() as original: + try: + for record in suffix: + metadata = _metadata_for_recovery(record, target_lineage) + metadata_by_index[record.metadata.index] = metadata + if record.metadata == metadata: + candidates[record.metadata.index] = record.head + continue + temp = unique_temp_branch(f"carve-recover-{record.metadata.index}") + temp_branches.append(temp) + temp_by_index[record.metadata.index] = temp + git("branch", temp, record.head) + git("checkout", temp) + if record.metadata.index == first_open: + base_head = _resolve(f"refs/remotes/{remote}/{chain.base_branch}") + if base_head is None or not _is_ancestor(base_head, record.head): + raise CommandError( + f"First suffix branch {record.branch} is not propagated " + f"onto current {remote}/{chain.base_branch}." + ) + else: + previous = chain.changesets[record.metadata.index - 2] + old_base = _durable_predecessor(record, previous) + git( + "rebase", + "--onto", + candidates[record.metadata.index - 1], + old_base, + temp, + ) + candidates[record.metadata.index] = _amend_metadata(metadata) + + successor_tree = _resolve(f"{successor.sha}^{{tree}}") + tip = candidates[suffix[-1].metadata.index] + tip_tree = _resolve(f"{tip}^{{tree}}") + if successor_tree is None or tip_tree != successor_tree: + raise CommandError( + "Recovered suffix does not recompose to the exact successor-source tree." + ) + + for record in suffix: + index = record.metadata.index + candidate = candidates[index] + metadata = metadata_by_index[index] + live = _verify_open_suffix_pr( + record, + expected_head=record.head, + expected_base=expected_bases[index], + target_lineage=target_lineage, + remote=remote, + ) + if candidate != record.head: + push_changeset_branch( + record.branch, + remote=remote, + dry_run=dry_run, + expected_remote_head=record.head, + local_ref=temp_by_index[index], + ) + updated_body = embed_pr_metadata(live.body, metadata) + if parse_pr_metadata(live.body) != metadata: + edit_pull_request( + live.number, + remote=remote, + body=updated_body, + dry_run=dry_run, + ) + if not dry_run: + verified = pull_request_by_number(live.number, remote=remote) + if ( + verified.head_sha != candidate + or parse_pr_metadata(verified.body) != metadata + ): + raise CommandError( + f"Recovered PR #{live.number} could not be verified at " + f"exact head {candidate}." + ) + _sync_local_branch(record, candidate=candidate, metadata=metadata) + finally: + if current_branch() != original: + git("checkout", original) + for temp in temp_branches: + delete_branch(temp) + + if dry_run: + print( + "[OK] Dry-run suffix recovery passed local successor equivalence; " + "remote branches and PR metadata were not changed." + ) + return + + git("fetch", "--prune", remote) + live_prs = pull_requests_for_source(source, remote=remote) + recovered = rehydrate_chain( + source_branch=source, + base_branch=base, + pull_requests=live_prs, + cwd=Path.cwd(), + remote=remote, + prefer_remote=True, + ) + validation = validate_live_chain(recovered, cwd=Path.cwd(), remote=remote) + if not validation.valid: + detail = "; ".join(f"{item.code}: {item.message}" for item in validation.errors) + raise CommandError(f"Recovered live chain validation failed: {detail}") + print( + "[EVIDENCE-INVALIDATED] Rebuild validation, review-code-change, CI, " + "connector, feedback, and thread evidence for every recovered head." + ) + print("[OK] Suffix recovery completed and matches the immutable successor source.") diff --git a/skills/carve-changesets/scripts/rehydrate.py b/skills/carve-changesets/scripts/rehydrate.py index 6597f49..189d275 100644 --- a/skills/carve-changesets/scripts/rehydrate.py +++ b/skills/carve-changesets/scripts/rehydrate.py @@ -11,6 +11,7 @@ from metadata import ( ChangesetMetadata, MetadataError, + SourceIdentity, parse_commit_message, parse_pr_metadata, ) @@ -43,6 +44,7 @@ class ChangesetRecord: base: str pr_number: int | None = None pr_state: str | None = None + pr_metadata: ChangesetMetadata | None = None @dataclass(frozen=True) @@ -50,8 +52,14 @@ class Chain: base_branch: str source_branch: str source_sha: str + root_source_sha: str + source_lineage: tuple[SourceIdentity, ...] changesets: tuple[ChangesetRecord, ...] + @property + def active_source(self) -> SourceIdentity: + return self.source_lineage[-1] + def _git(cwd: Path, *args: str) -> str: try: @@ -146,6 +154,122 @@ def _pr_by_branch( return {branch: prs[0] for branch, prs in grouped.items()} +def _same_changeset_position(left: ChangesetMetadata, right: ChangesetMetadata) -> bool: + return ( + left.slug == right.slug + and left.index == right.index + and left.root_source == right.root_source + ) + + +def _validate_lineage_sequence( + records: Sequence[ChangesetRecord], +) -> tuple[SourceIdentity, ...]: + previous: tuple[SourceIdentity, ...] | None = None + previous_merged = False + open_lineage: tuple[SourceIdentity, ...] | None = None + for record in records: + lineage = record.metadata.source_lineage + if previous is not None and lineage != previous: + extends = ( + previous_merged + and len(lineage) == len(previous) + 1 + and lineage[: len(previous)] == previous + ) + if not extends: + raise RehydrationError( + f"Changeset branch {record.branch} has missing, conflicting, " + "or discontinuous successor-source lineage." + ) + if record.pr_state != "MERGED": + if open_lineage is None: + open_lineage = lineage + elif lineage != open_lineage: + raise RehydrationError( + "Open changeset suffix has conflicting or discontinuous " + "successor-source lineage." + ) + previous = lineage + previous_merged = record.pr_state == "MERGED" + assert previous is not None + return previous + + +def _validate_recovery_transition( + records: Sequence[ChangesetRecord], successor: SourceIdentity +) -> tuple[SourceIdentity, ...]: + first_open = next( + ( + offset + for offset, record in enumerate(records) + if record.pr_state != "MERGED" + ), + None, + ) + if first_open is None or first_open == 0: + raise RehydrationError( + "Suffix recovery requires a non-empty merged prefix and an open suffix." + ) + base_lineage = records[first_open - 1].metadata.source_lineage + if successor in base_lineage or any( + identity.branch == successor.branch for identity in base_lineage + ): + raise RehydrationError( + "Successor source repeats an existing lineage identity or branch." + ) + target = (*base_lineage, successor) + _validate_lineage_sequence(records[:first_open]) + prior_lineage_seen = False + for record in records[first_open:]: + commit_lineage = record.metadata.source_lineage + if commit_lineage not in (base_lineage, target): + raise RehydrationError( + f"Changeset branch {record.branch} has lineage outside the current " + "or requested successor recovery." + ) + if commit_lineage == base_lineage: + prior_lineage_seen = True + elif prior_lineage_seen: + raise RehydrationError( + "Recovered changeset heads must form a leading prefix of the open " + "suffix; live recovery lineage is discontinuous." + ) + if record.pr_metadata is not None: + pr_metadata = record.pr_metadata + if pr_metadata.source_lineage not in (base_lineage, target): + raise RehydrationError( + f"PR #{record.pr_number} has lineage outside the current or " + "requested successor recovery." + ) + if not _same_changeset_position(record.metadata, pr_metadata): + raise RehydrationError( + f"PR #{record.pr_number} metadata changes the stable changeset " + "position during recovery." + ) + if commit_lineage == base_lineage and pr_metadata != record.metadata: + raise RehydrationError( + f"PR #{record.pr_number} metadata advances or conflicts before " + "its changeset head is recovered." + ) + if commit_lineage == target: + if ( + pr_metadata.source_lineage == target + and pr_metadata != record.metadata + ): + raise RehydrationError( + f"PR #{record.pr_number} has conflicting recovered provenance." + ) + if ( + pr_metadata.source_lineage == base_lineage + and record.metadata.recovery_from_head == record.head + ): + raise RehydrationError( + f"Changeset branch {record.branch} does not identify a distinct " + "pre-recovery head." + ) + return target + + def rehydrate_chain( *, source_branch: str, @@ -154,6 +278,7 @@ def rehydrate_chain( cwd: Path | str = Path.cwd(), remote: str = "origin", prefer_remote: bool = False, + recovery_successor: SourceIdentity | None = None, ) -> Chain: """Reconstruct an ordered chain without consulting local plan or state files.""" @@ -192,7 +317,7 @@ def rehydrate_chain( raise RehydrationError("Base branch must not be empty.") records: list[ChangesetRecord] = [] - source_sha: str | None = None + root_source: SourceIdentity | None = None slugs: set[str] = set() prior_prs_merged = True for index in found: @@ -215,17 +340,17 @@ def rehydrate_chain( raise RehydrationError( f"Changeset branch {branch} has Changeset-Index {metadata.index}; expected {index}." ) - if metadata.source_branch != source_branch: + if metadata.root_source.branch != source_branch: raise RehydrationError( - f"Changeset branch {branch} names source {metadata.source_branch!r}; " - f"expected {source_branch!r}." + f"Changeset branch {branch} names chain root " + f"{metadata.root_source.branch!r}; expected {source_branch!r}." ) - if source_sha is None: - source_sha = metadata.source_sha - elif metadata.source_sha != source_sha: + if root_source is None: + root_source = metadata.root_source + elif metadata.root_source != root_source: raise RehydrationError( - f"Changeset branch {branch} names source SHA {metadata.source_sha}; " - f"expected {source_sha}." + f"Changeset branch {branch} names root source " + f"{metadata.root_source.trailer}; expected {root_source.trailer}." ) if metadata.slug in slugs: raise RehydrationError( @@ -235,6 +360,7 @@ def rehydrate_chain( predecessor_base = base_branch if index == 1 else f"{source_branch}-{index - 1}" pr = prs.get(branch) + pr_metadata: ChangesetMetadata | None = None if pr is not None: if pr.is_cross_repository: raise RehydrationError( @@ -258,7 +384,7 @@ def rehydrate_chain( pr_metadata = parse_pr_metadata(pr.body) except MetadataError as exc: raise RehydrationError(f"PR #{pr.number}: {exc}") from exc - if pr_metadata != metadata: + if pr_metadata != metadata and recovery_successor is None: raise RehydrationError( f"PR #{pr.number} metadata disagrees with commit trailers for {branch}." ) @@ -270,16 +396,25 @@ def rehydrate_chain( base=pr.base_branch if pr else predecessor_base, pr_number=pr.number if pr else None, pr_state=pr.state.upper() if pr else None, + pr_metadata=pr_metadata, ) ) prior_prs_merged = ( prior_prs_merged and pr is not None and pr.state.upper() == "MERGED" ) - assert source_sha is not None + assert root_source is not None + source_lineage = ( + _validate_recovery_transition(records, recovery_successor) + if recovery_successor is not None + else _validate_lineage_sequence(records) + ) + active_source = source_lineage[-1] return Chain( base_branch=base_branch, source_branch=source_branch, - source_sha=source_sha, + source_sha=active_source.sha, + root_source_sha=root_source.sha, + source_lineage=source_lineage, changesets=tuple(records), ) diff --git a/skills/carve-changesets/scripts/status.py b/skills/carve-changesets/scripts/status.py index 9be57b2..60d6f59 100644 --- a/skills/carve-changesets/scripts/status.py +++ b/skills/carve-changesets/scripts/status.py @@ -27,12 +27,14 @@ def render_status(chain: Chain) -> str: ) ) widths = [max(len(row[column]) for row in rows) for column in range(len(rows[0]))] - return "\n".join( + table = "\n".join( " ".join( value.ljust(widths[column]) for column, value in enumerate(row) ).rstrip() for row in rows ) + lineage = " -> ".join(identity.trailer for identity in chain.source_lineage) + return f"SOURCE LINEAGE {lineage}\n{table}" def status_from_live( diff --git a/skills/carve-changesets/scripts/tests/test_cli_safety.py b/skills/carve-changesets/scripts/tests/test_cli_safety.py index 6cad4d7..481554a 100644 --- a/skills/carve-changesets/scripts/tests/test_cli_safety.py +++ b/skills/carve-changesets/scripts/tests/test_cli_safety.py @@ -11,7 +11,7 @@ class CliSafetyTests(unittest.TestCase): def test_every_operation_has_one_mutation_class(self) -> None: parser = build_parser() help_text = parser.format_help() - self.assertEqual(16, len(COMMAND_MUTATION_CLASSES)) + self.assertEqual(17, len(COMMAND_MUTATION_CLASSES)) for command, mutation_class in COMMAND_MUTATION_CLASSES.items(): self.assertIn(command, help_text) self.assertIn(f"[{mutation_class}]", help_text) @@ -23,6 +23,19 @@ def test_issue_30_all_remote_mutations_default_to_dry_run(self) -> None: ("push-chain",), ("propagate", "--source", "feature/test", "--index", "1"), ("merge-propagate", "--source", "feature/test", "--index", "1"), + ( + "recover-suffix", + "--source", + "feature/test", + "--base", + "main", + "--from-index", + "2", + "--successor-source", + "feature/test-corrected", + "--successor-sha", + "a" * 40, + ), ): args = parser.parse_args(argv) self.assertEqual("remote-mutating", args.mutation_class) diff --git a/skills/carve-changesets/scripts/tests/test_evals.py b/skills/carve-changesets/scripts/tests/test_evals.py index 043237a..1690067 100644 --- a/skills/carve-changesets/scripts/tests/test_evals.py +++ b/skills/carve-changesets/scripts/tests/test_evals.py @@ -100,6 +100,9 @@ def test_cases_match_peer_shape_and_cover_required_judgment(self) -> None: "publish-with-merge-withheld", "refuse-dirty-source", "refuse-source-behind-base", + "recover-successor-suffix", + "reject-conflicting-successor-lineage", + "reject-original-source-mutation", } self.assertEqual(expected_ids, {case["id"] for case in self.cases}) for case in self.cases: @@ -160,10 +163,11 @@ def test_vocabulary_spam_cannot_pass_any_case(self) -> None: expected["case_id"], ) - def test_integration_self_test_migrates_both_prompt_cases(self) -> None: + def test_integration_self_test_covers_baseline_and_successor_recovery(self) -> None: cases = json.loads(RUNNER.DEFAULT_INTEGRATION_CASES.read_text()) self.assertEqual( - {"chain-basic", "chain-compare"}, {case["id"] for case in cases} + {"chain-basic", "chain-compare", "successor-recovery-metadata"}, + {case["id"] for case in cases}, ) results = RUNNER.evaluate_integration( RUNNER.DEFAULT_INTEGRATION_CASES, @@ -173,6 +177,7 @@ def test_integration_self_test_migrates_both_prompt_cases(self) -> None: for result in results.values(): self.assertIn("equivalence", result["checks"]) self.assertIn("validate_chain", result["checks"]) + self.assertIn("successor_recovery_metadata", result["checks"]) def test_runner_has_no_agent_cli_specific_dependency(self) -> None: runner_source = RUNNER_PATH.read_text() diff --git a/skills/carve-changesets/scripts/tests/test_github.py b/skills/carve-changesets/scripts/tests/test_github.py index 5ac5664..1454eb1 100644 --- a/skills/carve-changesets/scripts/tests/test_github.py +++ b/skills/carve-changesets/scripts/tests/test_github.py @@ -101,6 +101,28 @@ def test_issue_33_pr_edit_targets_explicit_number(self) -> None: capture.call_args.args[0], ) + def test_recovery_pr_edit_uses_body_file_for_exact_number(self) -> None: + with ( + mock.patch.object( + github_mod, + "github_repo_for_remote", + return_value="github.com/acme/widgets", + ), + mock.patch.object(github_mod, "ensure_gh_ready"), + mock.patch.object(github_mod, "gh_capture") as capture, + ): + github_mod.edit_pull_request( + 93, + remote="origin", + body="updated metadata body", + dry_run=False, + ) + + args = capture.call_args.args[0] + self.assertEqual(("pr", "edit", "93"), args[:3]) + self.assertIn("--body-file", args) + self.assertNotIn("--body", args) + def test_pr_create_dry_run_uses_body_file(self) -> None: repo_dir, plan = init_repo() try: diff --git a/skills/carve-changesets/scripts/tests/test_metadata.py b/skills/carve-changesets/scripts/tests/test_metadata.py index 313b732..15e9a2f 100644 --- a/skills/carve-changesets/scripts/tests/test_metadata.py +++ b/skills/carve-changesets/scripts/tests/test_metadata.py @@ -6,6 +6,7 @@ from metadata import ( ChangesetMetadata, MetadataError, + SourceIdentity, embed_pr_metadata, parse_commit_message, parse_pr_metadata, @@ -55,6 +56,52 @@ def test_pr_metadata_rejects_multiple_blocks(self) -> None: with self.assertRaisesRegex(MetadataError, "multiple"): parse_pr_metadata(f"{block}\n{block}\n") + def test_successor_metadata_round_trips_in_commit_and_pr(self) -> None: + successor = ChangesetMetadata( + slug="api-foundation", + index=2, + source_branch="feature/report-corrected", + source_sha="c" * 40, + source_lineage=( + SourceIdentity("feature/report", "a" * 40), + SourceIdentity("feature/report-corrected", "c" * 40), + ), + recovery_from_head="b" * 40, + ) + + message = stamp_commit_message("fix: accept review correction", successor) + body = embed_pr_metadata("Human prose.\n", successor) + + self.assertIn("Changeset-Lineage:", message) + self.assertIn("carve-changesets:metadata:v2", body) + self.assertEqual(successor, parse_commit_message(message)) + self.assertEqual(successor, parse_pr_metadata(body)) + + def test_successor_metadata_rejects_missing_or_repeated_lineage(self) -> None: + with self.assertRaisesRegex(MetadataError, "recovery-from"): + ChangesetMetadata( + "part-2", + 2, + "feature/report-corrected", + "c" * 40, + ( + SourceIdentity("feature/report", "a" * 40), + SourceIdentity("feature/report-corrected", "c" * 40), + ), + ) + with self.assertRaisesRegex(MetadataError, "repeat"): + ChangesetMetadata( + "part-2", + 2, + "feature/report", + "a" * 40, + ( + SourceIdentity("feature/report", "a" * 40), + SourceIdentity("feature/report", "a" * 40), + ), + "b" * 40, + ) + if __name__ == "__main__": unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_recovery.py b/skills/carve-changesets/scripts/tests/test_recovery.py new file mode 100644 index 0000000..f30151d --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_recovery.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import contextlib +import io +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import helpers +import propagate as propagate_mod +import recovery as recovery_mod +from common import CommandError +from legacy_helpers import chdir +from metadata import ( + ChangesetMetadata, + SourceIdentity, + embed_pr_metadata, + parse_commit_message, + parse_pr_metadata, + stamp_commit_message, +) +from recovery import recover_suffix_from_live +from rehydrate import PullRequestRecord, rehydrate_chain +from validate import validate_live_chain + + +class SuffixRecoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = Path(tempfile.mkdtemp()) + self.repo, self.bare, _ = helpers.init_repo(self.temp_dir) + helpers.run( + self.temp_dir, + "git", + "--git-dir", + str(self.bare), + "symbolic-ref", + "HEAD", + "refs/heads/main", + ) + helpers.run(self.repo, "git", "checkout", "feature/report") + (self.repo / "second.txt").write_text("second source part\n") + helpers.run(self.repo, "git", "add", "second.txt") + self.source_sha = helpers.commit(self.repo, "complete original source") + helpers.run(self.repo, "git", "push", "-u", "origin", "feature/report") + + root = SourceIdentity("feature/report", self.source_sha) + helpers.run(self.repo, "git", "checkout", "-b", "feature/report-1", "main") + source_text = helpers.run(self.repo, "git", "show", "feature/report:source.txt") + (self.repo / "source.txt").write_text(source_text + "\n") + helpers.run(self.repo, "git", "add", "source.txt") + first_metadata = ChangesetMetadata("part-1", 1, root.branch, root.sha) + self.first_head = helpers.commit( + self.repo, stamp_commit_message("feat: changeset 1", first_metadata) + ) + helpers.run(self.repo, "git", "push", "-u", "origin", "feature/report-1") + + helpers.run( + self.repo, + "git", + "checkout", + "-b", + "feature/report-2", + "feature/report-1", + ) + second_text = helpers.run(self.repo, "git", "show", "feature/report:second.txt") + (self.repo / "second.txt").write_text(second_text + "\n") + helpers.run(self.repo, "git", "add", "second.txt") + second_metadata = ChangesetMetadata("part-2", 2, root.branch, root.sha) + second_head = helpers.commit( + self.repo, stamp_commit_message("feat: changeset 2", second_metadata) + ) + helpers.run(self.repo, "git", "push", "-u", "origin", "feature/report-2") + + helpers.run(self.repo, "git", "checkout", "main") + helpers.run( + self.repo, + "git", + "merge", + "--no-ff", + "--no-edit", + "feature/report-1", + ) + merge_sha = helpers.run(self.repo, "git", "rev-parse", "HEAD") + helpers.run(self.repo, "git", "push", "origin", "main") + + helpers.run(self.repo, "git", "checkout", "feature/report-2") + helpers.run( + self.repo, + "git", + "rebase", + "--onto", + "main", + self.first_head, + "feature/report-2", + ) + propagated_head = helpers.run(self.repo, "git", "rev-parse", "HEAD") + self.assertNotEqual(second_head, propagated_head) + helpers.run( + self.repo, + "git", + "push", + "--force-with-lease", + "origin", + "feature/report-2", + ) + + (self.repo / "accepted-fix.txt").write_text("accepted review fix\n") + helpers.run(self.repo, "git", "add", "accepted-fix.txt") + self.fixed_head = helpers.commit( + self.repo, + stamp_commit_message("fix: accept review feedback", second_metadata), + ) + helpers.run(self.repo, "git", "push", "origin", "feature/report-2") + helpers.run( + self.repo, + "git", + "branch", + "feature/report-corrected", + self.fixed_head, + ) + helpers.run( + self.repo, + "git", + "push", + "-u", + "origin", + "feature/report-corrected", + ) + self.successor_sha = self.fixed_head + + self.prs = { + 101: PullRequestRecord( + number=101, + head_branch="feature/report-1", + head_sha=self.first_head, + base_branch="main", + state="MERGED", + body=embed_pr_metadata("Position 1\n", first_metadata), + title="Report (1 of 2)", + merge_sha=merge_sha, + ), + 102: PullRequestRecord( + number=102, + head_branch="feature/report-2", + head_sha=self.fixed_head, + base_branch="main", + state="OPEN", + body=embed_pr_metadata("Position 2\n", second_metadata), + title="Report (2 of 2)", + ), + } + + def tearDown(self) -> None: + shutil.rmtree(self.temp_dir) + + def _remote_head(self, branch: str) -> str: + output = helpers.run( + self.repo, + "git", + "ls-remote", + "origin", + f"refs/heads/{branch}", + ) + return output.split()[0] + + def _live_pr(self, number: int, **_kwargs) -> PullRequestRecord: + pr = self.prs[number] + return PullRequestRecord( + **{ + **pr.__dict__, + "head_sha": self._remote_head(pr.head_branch), + } + ) + + def _all_live_prs(self, *_args, **_kwargs) -> list[PullRequestRecord]: + return [self._live_pr(number) for number in sorted(self.prs)] + + def _edit(self, number: int, *, body=None, **_kwargs) -> None: + pr = self.prs[number] + self.prs[number] = PullRequestRecord( + **{**pr.__dict__, "body": body if body is not None else pr.body} + ) + + def _run_recovery(self, *, edit_side_effect=None) -> str: + output = io.StringIO() + with ( + chdir(self.repo), + mock.patch.object( + recovery_mod, + "pull_requests_for_source", + side_effect=self._all_live_prs, + ), + mock.patch.object( + recovery_mod, "pull_request_by_number", side_effect=self._live_pr + ), + mock.patch.object( + recovery_mod, + "edit_pull_request", + side_effect=edit_side_effect or self._edit, + ), + mock.patch.object(recovery_mod, "_verify_merged_on_base"), + contextlib.redirect_stdout(output), + ): + recover_suffix_from_live( + source="feature/report", + base="main", + from_index=2, + successor_branch="feature/report-corrected", + successor_sha=self.successor_sha, + remote="origin", + dry_run=False, + authority_acknowledged=True, + ) + return output.getvalue() + + def test_recovers_two_position_chain_and_preserves_merged_prefix(self) -> None: + output = self._run_recovery() + + recovered_head = self._remote_head("feature/report-2") + self.assertEqual(self.first_head, self._remote_head("feature/report-1")) + self.assertNotEqual(self.fixed_head, recovered_head) + metadata = parse_commit_message( + helpers.run( + self.repo, + "git", + "show", + "-s", + "--format=%B", + recovered_head, + ) + ) + self.assertEqual( + ("feature/report", "feature/report-corrected"), + tuple(identity.branch for identity in metadata.source_lineage), + ) + self.assertEqual(self.fixed_head, metadata.recovery_from_head) + self.assertEqual(metadata, parse_pr_metadata(self.prs[102].body)) + self.assertIn("EVIDENCE-INVALIDATED", output) + + clone = self.temp_dir / "fresh" + helpers.run(self.temp_dir, "git", "clone", str(self.bare), str(clone)) + chain = rehydrate_chain( + source_branch="feature/report", + base_branch="main", + pull_requests=self._all_live_prs(), + cwd=clone, + ) + validation = validate_live_chain(chain, cwd=clone) + self.assertTrue(validation.valid, validation.errors) + self.assertEqual(self.successor_sha, chain.source_sha) + + def test_recovery_rejects_original_source_mutation(self) -> None: + helpers.run(self.repo, "git", "checkout", "feature/report") + (self.repo / "mutated.txt").write_text("forbidden source advance\n") + helpers.run(self.repo, "git", "add", "mutated.txt") + helpers.commit(self.repo, "mutate original source") + helpers.run(self.repo, "git", "push", "origin", "feature/report") + + with self.assertRaisesRegex(CommandError, "moved from"): + self._run_recovery() + + def test_recovery_rejects_a_local_only_successor_before_remote_mutation( + self, + ) -> None: + suffix_before = self._remote_head("feature/report-2") + body_before = self.prs[102].body + helpers.run( + self.repo, + "git", + "push", + "origin", + "--delete", + "feature/report-corrected", + ) + + with self.assertRaisesRegex(CommandError, "unavailable on origin"): + self._run_recovery() + + self.assertEqual(suffix_before, self._remote_head("feature/report-2")) + self.assertEqual(body_before, self.prs[102].body) + + def test_recovery_rejects_unowned_suffix_pr(self) -> None: + self.prs[102] = PullRequestRecord( + **{**self.prs[102].__dict__, "is_cross_repository": True} + ) + + with self.assertRaisesRegex(CommandError, "fork"): + self._run_recovery() + + def test_recovery_enforces_exact_remote_lease_after_reauthorization(self) -> None: + actual = recovery_mod.remote_branch_head + calls = 0 + + def move_before_push(remote: str, branch: str) -> str | None: + nonlocal calls + calls += 1 + if branch == "feature/report-2" and calls >= 3: + return "d" * 40 + return actual(remote, branch) + + with ( + chdir(self.repo), + mock.patch.object( + recovery_mod, + "pull_requests_for_source", + side_effect=self._all_live_prs, + ), + mock.patch.object( + recovery_mod, "pull_request_by_number", side_effect=self._live_pr + ), + mock.patch.object(recovery_mod, "remote_branch_head", move_before_push), + mock.patch.object(propagate_mod, "remote_branch_head", move_before_push), + mock.patch.object( + recovery_mod, + "push_changeset_branch", + wraps=recovery_mod.push_changeset_branch, + ), + mock.patch.object(recovery_mod, "_verify_merged_on_base"), + ): + with self.assertRaisesRegex(CommandError, "moved from"): + recover_suffix_from_live( + source="feature/report", + base="main", + from_index=2, + successor_branch="feature/report-corrected", + successor_sha=self.successor_sha, + remote="origin", + dry_run=False, + authority_acknowledged=True, + ) + + def test_interrupted_metadata_update_resumes_from_live_state(self) -> None: + failed = False + + def fail_once(number: int, *, body=None, **kwargs) -> None: + nonlocal failed + if not failed: + failed = True + raise CommandError("injected PR metadata interruption") + self._edit(number, body=body, **kwargs) + + with self.assertRaisesRegex(CommandError, "injected"): + self._run_recovery(edit_side_effect=fail_once) + pushed_head = self._remote_head("feature/report-2") + self.assertNotEqual(self.fixed_head, pushed_head) + self.assertEqual( + 2, + len( + parse_commit_message( + helpers.run( + self.repo, "git", "show", "-s", "--format=%B", pushed_head + ) + ).source_lineage + ), + ) + self.assertEqual(1, len(parse_pr_metadata(self.prs[102].body).source_lineage)) + + output = self._run_recovery(edit_side_effect=fail_once) + + self.assertEqual(pushed_head, self._remote_head("feature/report-2")) + self.assertEqual( + parse_commit_message( + helpers.run(self.repo, "git", "show", "-s", "--format=%B", pushed_head) + ), + parse_pr_metadata(self.prs[102].body), + ) + self.assertIn("Suffix recovery completed", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_rehydrate.py b/skills/carve-changesets/scripts/tests/test_rehydrate.py index b58246d..013a279 100644 --- a/skills/carve-changesets/scripts/tests/test_rehydrate.py +++ b/skills/carve-changesets/scripts/tests/test_rehydrate.py @@ -6,8 +6,19 @@ from pathlib import Path import helpers -from metadata import ChangesetMetadata, embed_pr_metadata, stamp_commit_message -from rehydrate import PullRequestRecord, RehydrationError, rehydrate_chain +from metadata import ( + ChangesetMetadata, + SourceIdentity, + embed_pr_metadata, + stamp_commit_message, +) +from rehydrate import ( + ChangesetRecord, + PullRequestRecord, + RehydrationError, + _validate_recovery_transition, + rehydrate_chain, +) from status import status_from_live @@ -224,6 +235,200 @@ def test_cross_repository_changeset_pr_fails_closed(self) -> None: source_branch="feature/report", pull_requests=forked, cwd=clone ) + def test_rehydrates_merged_v1_prefix_and_recovered_v2_suffix(self) -> None: + heads, prs = self._materialize() + successor = SourceIdentity("feature/report-corrected", "c" * 40) + recovered = ChangesetMetadata( + slug="part-2", + index=2, + source_branch=successor.branch, + source_sha=successor.sha, + source_lineage=( + SourceIdentity("feature/report", self.source_sha), + successor, + ), + recovery_from_head=heads[2], + ) + helpers.run(self.repo, "git", "checkout", "feature/report-2") + helpers.run( + self.repo, + "git", + "commit", + "--amend", + "-F", + "-", + input_text=stamp_commit_message("feat: changeset 2", recovered), + ) + recovered_head = helpers.run(self.repo, "git", "rev-parse", "HEAD") + helpers.run( + self.repo, + "git", + "push", + "--force-with-lease", + "origin", + "feature/report-2", + ) + prs[1] = PullRequestRecord( + **{ + **prs[1].__dict__, + "head_sha": recovered_head, + "body": embed_pr_metadata(prs[1].body, recovered), + } + ) + clone = self._fresh_clone() + + chain = rehydrate_chain( + source_branch="feature/report", pull_requests=prs, cwd=clone + ) + + self.assertEqual(self.source_sha, chain.root_source_sha) + self.assertEqual(successor.sha, chain.source_sha) + self.assertEqual( + ("feature/report", "feature/report-corrected"), + tuple(identity.branch for identity in chain.source_lineage), + ) + + def test_recovery_rejects_conflicting_v2_pr_provenance(self) -> None: + heads, prs = self._materialize() + successor = SourceIdentity("feature/report-corrected", "c" * 40) + lineage = (SourceIdentity("feature/report", self.source_sha), successor) + recovered = ChangesetMetadata( + "part-2", + 2, + successor.branch, + successor.sha, + lineage, + heads[2], + ) + conflicting = ChangesetMetadata( + "part-2", + 2, + successor.branch, + successor.sha, + lineage, + "d" * 40, + ) + helpers.run(self.repo, "git", "checkout", "feature/report-2") + helpers.run( + self.repo, + "git", + "commit", + "--amend", + "-F", + "-", + input_text=stamp_commit_message("feat: changeset 2", recovered), + ) + recovered_head = helpers.run(self.repo, "git", "rev-parse", "HEAD") + helpers.run( + self.repo, + "git", + "push", + "--force-with-lease", + "origin", + "feature/report-2", + ) + prs[1] = PullRequestRecord( + **{ + **prs[1].__dict__, + "head_sha": recovered_head, + "body": embed_pr_metadata(prs[1].body, conflicting), + } + ) + + with self.assertRaisesRegex( + RehydrationError, "conflicting recovered provenance" + ): + rehydrate_chain( + source_branch="feature/report", + pull_requests=prs, + cwd=self._fresh_clone(), + recovery_successor=successor, + ) + + def test_recovery_rejects_a_recovered_head_after_an_old_suffix_head(self) -> None: + root = SourceIdentity("feature/report", self.source_sha) + successor = SourceIdentity("feature/report-corrected", "c" * 40) + first = ChangesetMetadata("part-1", 1, root.branch, root.sha) + second = ChangesetMetadata("part-2", 2, root.branch, root.sha) + third = ChangesetMetadata( + "part-3", + 3, + successor.branch, + successor.sha, + (root, successor), + "3" * 40, + ) + records = ( + ChangesetRecord(first, "feature/report-1", "1" * 40, "main", 1, "MERGED"), + ChangesetRecord( + second, + "feature/report-2", + "2" * 40, + "feature/report-1", + 2, + "OPEN", + ), + ChangesetRecord( + third, + "feature/report-3", + "4" * 40, + "feature/report-2", + 3, + "OPEN", + ), + ) + + with self.assertRaisesRegex(RehydrationError, "leading prefix"): + _validate_recovery_transition(records, successor) + + def test_rejects_discontinuous_successor_lineage(self) -> None: + heads, prs = self._materialize() + lineage = ( + SourceIdentity("feature/report", self.source_sha), + SourceIdentity("feature/skipped", "b" * 40), + SourceIdentity("feature/report-corrected", "c" * 40), + ) + invalid = ChangesetMetadata( + "part-2", + 2, + lineage[-1].branch, + lineage[-1].sha, + lineage, + heads[2], + ) + helpers.run(self.repo, "git", "checkout", "feature/report-2") + helpers.run( + self.repo, + "git", + "commit", + "--amend", + "-F", + "-", + input_text=stamp_commit_message("feat: changeset 2", invalid), + ) + invalid_head = helpers.run(self.repo, "git", "rev-parse", "HEAD") + helpers.run( + self.repo, + "git", + "push", + "--force-with-lease", + "origin", + "feature/report-2", + ) + prs[1] = PullRequestRecord( + **{ + **prs[1].__dict__, + "head_sha": invalid_head, + "body": embed_pr_metadata(prs[1].body, invalid), + } + ) + clone = self._fresh_clone() + + with self.assertRaisesRegex(RehydrationError, "discontinuous"): + rehydrate_chain( + source_branch="feature/report", pull_requests=prs, cwd=clone + ) + if __name__ == "__main__": unittest.main() diff --git a/skills/carve-changesets/scripts/validate.py b/skills/carve-changesets/scripts/validate.py index 180ca9e..a547b6f 100644 --- a/skills/carve-changesets/scripts/validate.py +++ b/skills/carve-changesets/scripts/validate.py @@ -113,19 +113,45 @@ def validate_live_chain( for changeset in chain.changesets: metadata = changeset.metadata if ( - metadata.source_branch != chain.source_branch - or metadata.source_sha != chain.source_sha + metadata.root_source.branch != chain.source_branch + or metadata.root_source.sha != chain.root_source_sha + or chain.source_lineage[: len(metadata.source_lineage)] + != metadata.source_lineage ): diagnostics.append( ValidationDiagnostic( "source_stamp_mismatch", "error", - f"Changeset branch {changeset.branch} names source " - f"{metadata.source_branch} @ {metadata.source_sha}; expected " - f"{chain.source_branch} @ {chain.source_sha}.", + f"Changeset branch {changeset.branch} names lineage " + f"{' -> '.join(item.trailer for item in metadata.source_lineage)}; " + "expected a continuous prefix of " + f"{' -> '.join(item.trailer for item in chain.source_lineage)}.", ) ) + if len(chain.source_lineage) > 1: + for identity in chain.source_lineage: + current_identity = _resolve( + repo, f"refs/remotes/{remote}/{identity.branch}^{{commit}}" + ) + if current_identity is None: + diagnostics.append( + ValidationDiagnostic( + "source_lineage_ref_missing", + "error", + f"Immutable lineage source {identity.branch!r} is unavailable.", + ) + ) + elif current_identity != identity.sha: + diagnostics.append( + ValidationDiagnostic( + "source_lineage_ref_moved", + "error", + f"Immutable lineage source {identity.branch} moved from " + f"{identity.sha} to {current_identity}.", + ) + ) + live_heads: dict[int, tuple[str, str]] | None try: live_heads = discover_changeset_heads(repo, chain.source_branch, remote) @@ -283,18 +309,25 @@ def validate_live_chain( "source_equivalence_mismatch", "error", f"Changeset tip {tip_record.branch} at {tip} does not " - f"recompose to stamped source {chain.source_branch} at " + f"recompose to active source {chain.active_source.branch} at " f"{stamped_source}.", ) ) - current_source = _resolve_branch(repo, chain.source_branch, remote) + current_source = ( + _resolve( + repo, + f"refs/remotes/{remote}/{chain.active_source.branch}^{{commit}}", + ) + if len(chain.source_lineage) > 1 + else _resolve_branch(repo, chain.active_source.branch, remote) + ) if current_source is None: diagnostics.append( ValidationDiagnostic( "source_branch_missing", "error", - f"Source branch {chain.source_branch!r} is not available in live git.", + f"Source branch {chain.active_source.branch!r} is not available in live git.", ) ) elif stamped_source is not None and current_source == stamped_source: @@ -307,7 +340,7 @@ def validate_live_chain( ValidationDiagnostic( "source_advanced", "warning", - f"Source branch {chain.source_branch} legitimately advanced from " + f"Source branch {chain.active_source.branch} legitimately advanced from " f"stamped commit {stamped_source} to {current_source}; the chain " "remains validated against the stamped source.", ) @@ -318,7 +351,7 @@ def validate_live_chain( ValidationDiagnostic( "source_history_mismatch", "error", - f"Source branch {chain.source_branch} at {current_source} does not " + f"Source branch {chain.active_source.branch} at {current_source} does not " f"descend from stamped commit {stamped_source}; the chain was built " "against a different source history.", )