Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ summary: Chronological history of repository and skill changes.

# Changelog

## 2026-07-21 — Carve-changesets identity and operating contract
## 2026-07-21 — Carve-changesets live validation, identity, and contract

- fix: bind changeset validation to current live refs
- feat: validate changeset chains from live git
(`df24771983819b05110670a8d03d43e003d23d28`)
- feat: add self-describing changeset identity
(`e88bf87e9cb1a4e04bdf8b051ce8ca0f0dcb96e6`)
- fix: clarify published terminal evidence
(`edeb2f5f5f7b4cfa4e73e8289d34157b192f92ab`)
- docs: define the carve-changesets operating contract
Expand Down
27 changes: 21 additions & 6 deletions skills/carve-changesets/references/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ the reconstructed full chain. A temporary local integration branch may be used
for this proof. Any local reference created to simplify comparison must remain
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.

### Plain git and GitHub stack shape

Every materialized and published chain must remain ordinary git and GitHub:
Expand Down Expand Up @@ -446,12 +461,12 @@ Return `blocked` without widening scope when:

### Compatibility

No backwards compatibility is provided for `.prepare-changesets/state.json`, old
plan files, old commit conventions, or chains created by `prepare-changesets`.
Those artifacts are neither migrated nor 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.
No backwards compatibility is provided for cached `prepare-changesets` chain
snapshots, old plan files, old commit conventions, or chains created by
`prepare-changesets`. 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.

### Non-goals

Expand Down
6 changes: 4 additions & 2 deletions skills/carve-changesets/scripts/rehydrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ def _git(cwd: Path, *args: str) -> str:
return result.stdout


def _discover_heads(
def discover_changeset_heads(
cwd: Path, source_branch: str, remote: str
) -> dict[int, tuple[str, str]]:
"""Resolve current changeset refs and reject local/remote ambiguity."""

output = _git(
cwd,
"for-each-ref",
Expand Down Expand Up @@ -147,7 +149,7 @@ def rehydrate_chain(
if not source_branch.strip():
raise RehydrationError("Source branch must not be empty.")
repo = Path(cwd)
heads = _discover_heads(repo, source_branch, remote)
heads = discover_changeset_heads(repo, source_branch, remote)
if not heads:
raise RehydrationError(
f"No changeset branches named {source_branch}-N were found locally or on {remote}."
Expand Down
190 changes: 190 additions & 0 deletions skills/carve-changesets/scripts/tests/test_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
from __future__ import annotations

import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock

import helpers
from metadata import ChangesetMetadata, stamp_commit_message
from rehydrate import rehydrate_chain
from validate import validate_live_chain


class LiveValidationTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = Path(tempfile.mkdtemp())
self.repo, _, _ = helpers.init_repo(self.temp_dir)
helpers.run(self.repo, "git", "checkout", "feature/report")
(self.repo / "second.txt").write_text("second source part\n")
(self.repo / "third.txt").write_text("third source part\n")
helpers.run(self.repo, "git", "add", "second.txt", "third.txt")
self.source_sha = helpers.commit(self.repo, "complete source result")

def tearDown(self) -> None:
shutil.rmtree(self.temp_dir)

def _stamp(self, index: int, *, detail: str | None = None) -> str:
message = f"feat: changeset {index}"
if detail is not None:
message += f"\n\n{detail}"
return stamp_commit_message(
message,
ChangesetMetadata(
slug=f"part-{index}",
index=index,
source_branch="feature/report",
source_sha=self.source_sha,
),
)

def _materialize_equivalent_chain(self) -> dict[int, str]:
heads: dict[int, str] = {}
previous = "main"
files = ("source.txt", "second.txt", "third.txt")
for index, path in enumerate(files, start=1):
branch = f"feature/report-{index}"
helpers.run(self.repo, "git", "checkout", "-b", branch, previous)
content = helpers.run(self.repo, "git", "show", f"feature/report:{path}")
(self.repo / path).write_text(content + "\n")
helpers.run(self.repo, "git", "add", path)
heads[index] = helpers.commit(self.repo, self._stamp(index))
previous = branch
return heads

def _rehydrate(self):
return rehydrate_chain(
source_branch="feature/report", base_branch="main", cwd=self.repo
)

def test_legitimate_propagation_validates_without_drift_diagnostics(self) -> None:
heads = self._materialize_equivalent_chain()
helpers.run(self.repo, "git", "checkout", "feature/report-2")
amended_message = self._stamp(2, detail="Refresh the propagated commit.")
helpers.run(
self.repo,
"git",
"commit",
"--amend",
"-F",
"-",
input_text=amended_message,
)
propagated_second = helpers.run(self.repo, "git", "rev-parse", "HEAD")
self.assertNotEqual(heads[2], propagated_second)
helpers.run(self.repo, "git", "checkout", "feature/report-3")
helpers.run(
self.repo,
"git",
"rebase",
"--onto",
propagated_second,
heads[2],
"feature/report-3",
)
propagated_third = helpers.run(self.repo, "git", "rev-parse", "HEAD")
self.assertNotEqual(heads[3], propagated_third)

result = validate_live_chain(self._rehydrate(), cwd=self.repo)

self.assertTrue(result.valid)
self.assertEqual("unchanged", result.source_status)
self.assertEqual((), result.diagnostics)

def test_rewritten_mid_chain_branch_breaks_live_ancestry(self) -> None:
self._materialize_equivalent_chain()
chain = self._rehydrate()
helpers.run(self.repo, "git", "checkout", "-b", "replacement", "main")
for path in ("source.txt", "second.txt"):
content = helpers.run(self.repo, "git", "show", f"feature/report:{path}")
(self.repo / path).write_text(content + "\n")
helpers.run(self.repo, "git", "add", "source.txt", "second.txt")
replacement = helpers.commit(self.repo, self._stamp(2))
helpers.run(
self.repo,
"git",
"update-ref",
"refs/heads/feature/report-2",
replacement,
)

result = validate_live_chain(chain, cwd=self.repo)

self.assertFalse(result.valid)
self.assertIn("changeset_ref_moved", {item.code for item in result.errors})
self.assertIn(
"predecessor_ancestry_broken", {item.code for item in result.errors}
)

def test_non_equivalent_chain_tip_is_rejected(self) -> None:
self._materialize_equivalent_chain()
helpers.run(self.repo, "git", "checkout", "feature/report-3")
(self.repo / "extra.txt").write_text("not in source\n")
helpers.run(self.repo, "git", "add", "extra.txt")
helpers.commit(self.repo, self._stamp(3, detail="Rewrite the chain tip."))

result = validate_live_chain(self._rehydrate(), cwd=self.repo)

self.assertFalse(result.valid)
self.assertIn(
"source_equivalence_mismatch", {item.code for item in result.errors}
)

def test_advanced_source_is_distinct_from_different_source_history(self) -> None:
self._materialize_equivalent_chain()
helpers.run(self.repo, "git", "checkout", "feature/report")
(self.repo / "later.txt").write_text("later source work\n")
helpers.run(self.repo, "git", "add", "later.txt")
helpers.commit(self.repo, "feat: advance source")

advanced = validate_live_chain(self._rehydrate(), cwd=self.repo)

self.assertTrue(advanced.valid)
self.assertEqual("advanced", advanced.source_status)
self.assertEqual(["source_advanced"], [item.code for item in advanced.warnings])

helpers.run(self.repo, "git", "checkout", "-b", "alternate-source", "main")
(self.repo / "other.txt").write_text("different source\n")
helpers.run(self.repo, "git", "add", "other.txt")
different_head = helpers.commit(self.repo, "feat: different source")
helpers.run(
self.repo,
"git",
"update-ref",
"refs/heads/feature/report",
different_head,
)

different = validate_live_chain(self._rehydrate(), cwd=self.repo)

self.assertFalse(different.valid)
self.assertEqual("different", different.source_status)
self.assertIn(
"source_history_mismatch", {item.code for item in different.errors}
)

def test_source_ancestry_command_failure_is_not_reported_as_divergence(
self,
) -> None:
self._materialize_equivalent_chain()
helpers.run(self.repo, "git", "checkout", "feature/report")
(self.repo / "later.txt").write_text("later source work\n")
helpers.run(self.repo, "git", "add", "later.txt")
helpers.commit(self.repo, "feat: advance source")

with mock.patch("validate._is_ancestor", side_effect=[True, True, True, None]):
result = validate_live_chain(self._rehydrate(), cwd=self.repo)

self.assertFalse(result.valid)
self.assertEqual("unavailable", result.source_status)
self.assertIn(
"source_ancestry_check_failed", {item.code for item in result.errors}
)
self.assertNotIn(
"source_history_mismatch", {item.code for item in result.errors}
)


if __name__ == "__main__":
unittest.main()
Loading
Loading