diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a35b3c..421f307 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.45.2 - 2026-10-09
+
+- Preserve every selected source commit during integration ancestry reconciliation. Recognize old-parent-only commits carried by a verified two-parent replay, including a 46-commit, ten-stack regression, without assigning ownership to unrelated history. An unselected stack cannot justify dropping its unique product bytes.
+- Recognize exact, already delivered superseded stack history and exact auto-merges of declared source commits. Keep unrelated or unproved commits blocked.
+- Let Agentwheel repair stale derived graph-lock provenance one declared path at a time. A provider operation succeeds only when the stale set shrinks within its requested paths; other validation errors still block.
+
## 0.45.1 - 2026-09-29
- Let `git pack-refs`, and so `git gc --auto` after an ordinary commit in any worktree, through the primary integration ref guard. Packing moves a loose ref into `packed-refs` as `0 -> current`, then prunes the loose copy as `current -> 0`; the guard read both as a move of the integration branch and failed maintenance with `refusing unauthorized primary integration ref move(s)`. A transaction line now passes when the ref still resolves to the same commit afterwards: a new value equal to the current one, or a prune whose old value is the current one and is already in `packed-refs`. Every real move or delete of the integration ref is still refused, including a delete of a packed ref, which always carries a `0 -> 0` line for the packed store. A degraded guard lets packing through for every branch the same way. The check runs before the ref-move authorization, so a Syncwheel command's own `gc --auto` no longer spends its single-use authorization on a no-op.
diff --git a/README.md b/README.md
index deeecc2..de74891 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Keep many long-lived pull requests clean, rebuildable, and publishable from one
manifest.
-Current version: `0.45.1`
+Current version: `0.45.2`
`syncwheel` is a small CLI and workflow model for maintainers who carry several
PR branches against an upstream repository and need those branches to stay
diff --git a/VERSION b/VERSION
index ff12df2..9f3a7d7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.45.1
+0.45.2
diff --git a/docs/index.html b/docs/index.html
index 6a92bcd..56b22f9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1826,7 +1826,7 @@
-
AI-ready Git coordination v0.45.1
+
AI-ready Git coordination v0.45.2
Move at machine speed. Stay in control.
Syncwheel coordinates every Git lane used by humans, AI agents, CI and delivery systems—without sacrificing review, safety or recovery.
@@ -2602,7 +2602,7 @@
Your first verified plan in minutes.
- syncwheel v0.45.1 · MIT · a NestDevLab project
+ syncwheel v0.45.2 · MIT · a NestDevLab project
diff --git a/openpack.json b/openpack.json
index beeb2e5..5cf73e5 100644
--- a/openpack.json
+++ b/openpack.json
@@ -1,7 +1,7 @@
{
"schemaVersion": 2,
"name": "NestDevLab/syncwheel",
- "version": "0.45.1",
+ "version": "0.45.2",
"provides": [
{
"type": "skills",
diff --git a/scripts/syncwheel.py b/scripts/syncwheel.py
index 66e3805..f68e97f 100755
--- a/scripts/syncwheel.py
+++ b/scripts/syncwheel.py
@@ -7090,7 +7090,7 @@ def historical_state_form(state_tip, state):
tombstones = [
item for item in state.get('tombstones') or []
if item.get('stack') == stack_id
- and item.get('reason') in {'absorbed', 'merged', 'abandoned'}
+ and item.get('reason') in {'absorbed', 'merged', 'abandoned', 'superseded'}
and coordination_tombstone_ref(item) not in parent_tombstones
]
parent_stacks = [
@@ -7148,8 +7148,8 @@ def historical_state_form(state_tip, state):
commit_full_sha(repo_root, commit)
for commit in declared if commit_exists(repo_root, commit)
]
- if reason == 'abandoned':
- # Abandonment does not prove delivery. It only explains
+ if reason in {'abandoned', 'superseded'}:
+ # Neither close reason proves delivery. It only explains
# the exact historical commits once every path they
# changed is now supplied by the live delivery base in
# both integration trees. Never admit patch-equivalent
@@ -7432,6 +7432,37 @@ def historically_abandoned_unmapped_commits(repo_root, manifest, commits):
)
+def historically_reconciled_unmapped_commits(repo_root, manifest, commits):
+ """Recognize old-parent history carried by an exact reconciliation proof.
+
+ A reconciliation keeps the old integration as its first parent while its
+ verified second parent supplies the product tree. Only commits exclusive
+ to that old parent can be explained by this proof; later foreign commits
+ and commits in an unproved merge remain unmapped.
+ """
+ if not commits:
+ return set()
+ observation = observe_published_integration_tip(repo_root, manifest)
+ if not observation or observation.get('status') != 'current':
+ return set()
+ tip = ref_tip(repo_root, manifest['integration']['branch'])
+ wanted = set(commits)
+ carried = set()
+ manifest_path = integration_manifest_path(repo_root)
+ base = manifest['integration']['base']
+ for merge in git(repo_root, 'rev-list', '--merges', f'{base}..{tip}').stdout.splitlines():
+ proof = integration_reconciliation_proof(
+ repo_root, merge, manifest_path, observation
+ )
+ if not proof:
+ continue
+ old, replay = proof['parents']
+ carried.update(wanted.intersection(rev_list(repo_root, f'{replay}..{old}')))
+ if carried == wanted:
+ break
+ return carried
+
+
def integration_control_only_transition(repo_root, commit, parent):
"""Prove that a commit changes only valid Syncwheel control projection."""
changed = {
@@ -7495,9 +7526,12 @@ def integration_reconciliation_history(
history_base = historically_delivered_integration_boundary(
repo_root, manifest, tip, observation, delivery_tip,
) or base
+ selected_ids = manifest['integration'].get('stacks')
+ selected = set(selected_ids) if selected_ids is not None else None
declared = {
commit_full_sha(repo_root, commit)
for stack in manifest['stacks']
+ if selected is None or stack.get('id') in selected
for commit in stack_integration_commits(stack)
}
patches = {commit_patch_id(repo_root, commit) for commit in declared}
@@ -7525,6 +7559,11 @@ def integration_reconciliation_history(
proofs = {commit: proof for commit in history
if commit_parent_count(repo_root, commit) > 1
and (proof := integration_reconciliation_proof(repo_root, commit, manifest_path, observation))}
+ carried_history = {
+ old_commit
+ for proof in proofs.values()
+ for old_commit in rev_list(repo_root, f"{proof['parents'][1]}..{proof['parents'][0]}")
+ }
replay_ranges = [(base, tip)] if detached_replay else []
replay_ranges.extend((proof['inputs']['refs'][0]['tip'], proof['parents'][1]) for proof in proofs.values())
replay_merges = set()
@@ -7532,12 +7571,25 @@ def integration_reconciliation_history(
replay_merges.update(git(repo_root, 'rev-list', '--first-parent', '--merges',
f'{replay_base}..{replay_tip}').stdout.split())
for commit in history:
- if commit in declared or commit in closed_commits:
+ if commit in declared or commit in closed_commits or commit in carried_history:
continue
if commit_parent_count(repo_root, commit) != 1:
if commit in proofs or commit in replay_merges:
continue
parents = merge_parents[commit]
+ if len(parents) == 2 and (
+ parents[1] in declared
+ or commit_patch_id(repo_root, parents[1]) in patches
+ ):
+ # An exact auto-merge of a declared or already delivered
+ # source commit retains its ownership even if the historical
+ # merge itself was not recorded in the current manifest.
+ merge = git(
+ repo_root, 'merge-tree', '--write-tree',
+ parents[0], parents[1], check=False,
+ )
+ if merge.returncode == 0 and merge.stdout.strip() == ref_tree(repo_root, commit):
+ continue
if (
len(parents) == 2
and (
@@ -29627,6 +29679,7 @@ def reprepare_index_lease(self, request, journal):
'projectionBaseKind': 'projectionBaseKind',
'integrationCompositionDigest': 'integrationCompositionDigest',
'baselineUnownedDirty': 'unownedDirty',
+ 'baselineStalePaths': 'stalePaths',
'unmappedIntegrationCommits': 'unmappedIntegrationCommits',
'coordination': 'coordination',
}
@@ -29879,13 +29932,13 @@ def _validate_repository(self, repo_root, request, *, require_clean):
) or []
}
declared_paths = {item.path for item in request.paths}
- repairs_all_stale_paths = bool(stale_paths) and (
- request.action == 'check' or stale_paths <= declared_paths
+ repairs_stale_paths = bool(stale_paths) and (
+ request.action == 'check' or bool(stale_paths & declared_paths)
)
blocking_errors = [
error for error in validation['errors']
if not (
- repairs_all_stale_paths
+ repairs_stale_paths
and error.startswith('derived-projection-stale:')
)
]
@@ -29909,7 +29962,13 @@ def _validate_repository(self, repo_root, request, *, require_clean):
unmapped = list(validation['details']['integration'].get('unmapped_commits') or [])
if unmapped:
abandoned = historically_abandoned_unmapped_commits(repo_root, manifest, unmapped)
- unmapped = [commit for commit in unmapped if commit not in abandoned]
+ reconciled = historically_reconciled_unmapped_commits(
+ repo_root, manifest, unmapped
+ )
+ unmapped = [
+ commit for commit in unmapped
+ if commit not in abandoned and commit not in reconciled
+ ]
if unmapped:
self._fail(
'integration already contains unmapped commits: ' + ', '.join(unmapped)
@@ -29964,6 +30023,7 @@ def _validate_repository(self, repo_root, request, *, require_clean):
'indexSha256': index.sha256,
'indexSemantic': index.semantic,
'unmappedIntegrationCommits': unmapped,
+ 'stalePaths': sorted(stale_paths),
'coordination': coordination,
}
@@ -31842,12 +31902,34 @@ def _verify_invariants(self, repo_root, request, journal, expected_head):
'manifest digest changed before terminal verification',
)
validation = validate_manifest(repo_root, manifest)
- if validation['errors']:
- self._fail('post-operation Syncwheel validation failed: ' + '; '.join(validation['errors']))
+ stale_after = {
+ item['path'] for item in validation['details']['integration'].get(
+ 'derived_projection_stale'
+ ) or []
+ }
+ stale_before = set(journal.get('baselineStalePaths') or [])
+ repaired = stale_before - stale_after
+ scoped_repair = bool(repaired) and repaired <= {
+ item.path for item in request.paths
+ } and stale_after < stale_before
+ blocking_errors = [
+ error for error in validation['errors']
+ if not (
+ scoped_repair and error.startswith('derived-projection-stale:')
+ )
+ ]
+ if blocking_errors:
+ self._fail('post-operation Syncwheel validation failed: ' + '; '.join(blocking_errors))
unmapped = list(validation['details']['integration'].get('unmapped_commits') or [])
if unmapped:
abandoned = historically_abandoned_unmapped_commits(repo_root, manifest, unmapped)
- unmapped = [commit for commit in unmapped if commit not in abandoned]
+ reconciled = historically_reconciled_unmapped_commits(
+ repo_root, manifest, unmapped
+ )
+ unmapped = [
+ commit for commit in unmapped
+ if commit not in abandoned and commit not in reconciled
+ ]
if unmapped:
self._fail('operation left unmapped integration commits: ' + ', '.join(unmapped))
if self._worktrees(repo_root) != journal['baselineWorktrees']:
diff --git a/scripts/syncwheel_revision_provider.py b/scripts/syncwheel_revision_provider.py
index 62a2a55..b50293c 100644
--- a/scripts/syncwheel_revision_provider.py
+++ b/scripts/syncwheel_revision_provider.py
@@ -559,6 +559,7 @@ def _new_journal(request: RevisionRequest, observation: dict[str, Any]) -> dict[
"baselineIndexSha256": observation["indexSha256"],
"baselineIndexSemantic": observation.get("indexSemantic"),
"baselineUnownedDirty": observation["unownedDirty"],
+ "baselineStalePaths": list(observation.get("stalePaths") or []),
"productIndexSha256": None,
"controlIndexSha256": None,
"indexAlignments": {},
diff --git a/tests/test_coordination.py b/tests/test_coordination.py
index a3bd63c..00e1a8b 100644
--- a/tests/test_coordination.py
+++ b/tests/test_coordination.py
@@ -3455,6 +3455,44 @@ def test_abandoned_history_requires_exact_current_delivery_for_its_paths(self):
module.tree_path_entry(repo, other, 'other.txt'),
)
+ def test_superseded_history_uses_exact_delivery_path_proof(self):
+ origin = self.create_remote('superseded-history')
+ repo = self.clone(origin, 'superseded-history')
+ self.init_coordinated(repo, integration_membership='required')
+ self.run_cli(repo, 'int', 'push')
+ old = self.commit_on_branch(repo, 'scratch/superseded', 'old.txt')
+ self.run_cli(repo, 'stack', 'create', 'old', old, '--branch', 'pr/old')
+ self.run_cli(repo, 'stack', 'rebuild', 'old')
+ self.run_cli(repo, 'stack', 'push', 'old')
+ self.run_cli(repo, 'int', 'rebuild', '--reason', 'project old stack')
+ self.run_cli(repo, 'int', 'push')
+
+ publisher = self.clone(origin, 'superseded-history-publisher')
+ self.git(publisher, 'cherry-pick', old)
+ self.git(publisher, 'push', '-q', 'origin', 'main')
+ self.git(repo, 'fetch', '-q', 'origin', '+refs/heads/main:refs/remotes/origin/main')
+ self.run_cli(repo, 'stack', 'close', 'old', '--reason', 'superseded', '--force')
+ self.run_cli(repo, 'int', 'push')
+
+ module = self.load_module()
+ manifest, _ = module.load_manifest(repo)
+ observation = module.observe_published_integration_tip(repo, manifest)
+ tip = module.ref_tip(repo, manifest['integration']['branch'])
+ delivery = module.integration_projection_ref_observation(
+ repo, 'base', 'integration', manifest['integration']['base']
+ )
+ self.assertIn(old, module.historically_closed_integration_commits(
+ repo, tip, observation, candidates=[old], delivery_tip=delivery['tip'],
+ delivery_observation=delivery,
+ ))
+ (repo / 'old.txt').write_text('undelivered change\n')
+ self.git(repo, 'add', 'old.txt')
+ self.git(repo, 'commit', '-qm', 'test: undelivered change')
+ self.assertNotIn(old, module.historically_closed_integration_commits(
+ repo, module.ref_tip(repo, 'HEAD'), observation, candidates=[old],
+ delivery_tip=delivery['tip'], delivery_observation=delivery,
+ ))
+
def test_abandoned_path_proof_checks_user_ignore_and_every_commit_parent(self):
origin = self.create_remote('abandoned-paths')
repo = self.clone(origin, 'abandoned-paths')
@@ -3827,6 +3865,110 @@ def test_integration_reconciliation_accepts_delivery_merge_with_control_only_res
repo, manifest, unsafe_merge, {}, delivery_tip=delivery_tip,
)
+ def test_declared_source_auto_merge_requires_exact_tree(self):
+ origin = self.create_remote('declared-merge-history')
+ repo = self.clone(origin, 'declared-merge-history')
+ module = self.load_module()
+ base = module.ref_tip(repo, 'origin/main')
+ source = self.commit_on_branch(repo, 'scratch/declared', 'declared.txt')
+ self.git(repo, 'switch', '-q', '-c', 'integration/declared', base)
+ self.git(repo, 'merge', '-q', '--no-ff', source, '-m', 'merge declared source')
+ merged = module.ref_tip(repo, 'HEAD')
+ manifest = {
+ 'integration': {'base': base},
+ 'stacks': [{'commits': [source]}],
+ }
+ module.integration_reconciliation_history(repo, manifest, merged, {})
+
+ wrong_tree = module.ref_tree(repo, base)
+ unsafe = self.git(
+ repo, 'commit-tree', wrong_tree, '-p', base, '-p', source,
+ '-m', 'merge declared source with rollback',
+ ).stdout.strip()
+ with self.assertRaisesRegex(
+ module.SyncwheelError, 'integration reconciliation has unclassified merge',
+ ):
+ module.integration_reconciliation_history(repo, manifest, unsafe, {})
+
+ def test_replay_keeps_all_46_declared_commits_from_ten_stacks(self):
+ origin = self.create_remote('ten-stack-replay-46')
+ repo = self.clone(origin, 'ten-stack-replay-46')
+ self.init_coordinated(repo)
+ self.run_cli(repo, 'int', 'push')
+ module = self.load_module()
+ base = module.ref_tip(repo, 'origin/main')
+ integration_branch = 'integration/shared'
+ stacks = []
+ for stack_number in range(10):
+ branch = f'scratch/source-{stack_number}'
+ self.git(repo, 'switch', '-q', '-c', branch, base)
+ commits = []
+ for number in range(5 if stack_number < 6 else 4):
+ path = repo / f'stack-{stack_number}.txt'
+ with path.open('a') as handle:
+ handle.write(f'change {number}\n')
+ self.git(repo, 'add', path.name)
+ self.git(repo, 'commit', '-qm',
+ f'test: stack {stack_number} commit {number}')
+ commits.append(module.ref_tip(repo, 'HEAD'))
+ self.git(repo, 'switch', '-q', 'main')
+ stack_id = f'source-{stack_number}'
+ self.run_cli(repo, 'stack', 'create', stack_id, *commits, '--draft')
+ stacks.append((stack_id, commits))
+ self.git(repo, 'switch', '-q', integration_branch)
+ for stack_number in range(10):
+ self.git(repo, 'merge', '-q', '--no-ff',
+ f'scratch/source-{stack_number}',
+ '-m', f'test: retain source {stack_number} ancestry')
+ old = module.ref_tip(repo, integration_branch)
+ self.git(repo, 'switch', '-q', 'main')
+ manifest, manifest_path = module.load_manifest(repo)
+ manifest['integration']['stacks'] = [stack_id for stack_id, _ in stacks]
+ manifest_path.write_text(module.canonical_manifest_file_text(manifest))
+ declared = {commit for _, commits in stacks for commit in commits}
+ self.assertEqual(len(declared), 46)
+ self.assertTrue(module.reconcile_integration_ancestry(
+ repo, manifest_path, manifest, 'test', 'replay ten declared stacks'
+ ))
+ final = module.ref_tip(repo, integration_branch)
+ parents = self.git(repo, 'show', '-s', '--format=%P', final).stdout.split()
+ self.assertEqual(parents[0], old)
+ self.assertEqual(len(parents), 2)
+ for commit in declared:
+ self.git(repo, 'merge-base', '--is-ancestor', commit, final)
+ for stack_number in range(10):
+ self.assertEqual(
+ self.git(repo, 'show', f'{final}:stack-{stack_number}.txt').stdout,
+ ''.join(f'change {number}\n' for number in range(
+ 5 if stack_number < 6 else 4
+ )),
+ )
+ self.assertTrue(module.integration_reconciliation_proof(
+ repo, final, manifest_path,
+ module.observe_published_integration_tip(repo, manifest),
+ ))
+
+ def test_unselected_declared_stack_cannot_explain_lost_product(self):
+ origin = self.create_remote('unselected-declared-product')
+ repo = self.clone(origin, 'unselected-declared-product')
+ module = self.load_module()
+ base = module.ref_tip(repo, 'HEAD')
+ self.git(repo, 'switch', '-q', '-c', 'main-integration')
+ (repo / 'unique.txt').write_text('unique old product\n')
+ self.git(repo, 'add', 'unique.txt')
+ self.git(repo, 'commit', '-qm', 'test: old unselected product')
+ old = module.ref_tip(repo, 'HEAD')
+ manifest = {
+ 'integration': {'base': base, 'branch': 'main-integration', 'stacks': []},
+ 'stacks': [{'id': 'old', 'commits': [old]}],
+ }
+ with self.assertRaisesRegex(
+ module.SyncwheelError, 'unexplained product paths: unique.txt'
+ ):
+ module.integration_reconciliation_history(
+ repo, manifest, old, {}
+ )
+
def test_historical_merged_close_requires_exact_squash_tree(self):
origin = self.create_remote('historical-squash-proof')
repo = self.clone(origin, 'historical-squash-proof')
diff --git a/tests/test_revision_provider.py b/tests/test_revision_provider.py
index 03299f7..f025368 100644
--- a/tests/test_revision_provider.py
+++ b/tests/test_revision_provider.py
@@ -4261,17 +4261,21 @@ def test_manifest_base_ownership_reconciles_a_stale_derived_path(self):
[],
)
- def test_provider_requires_one_update_to_cover_every_stale_derived_path(self):
+ def test_provider_repairs_stale_derived_paths_incrementally(self):
self.fixture.enable_derived_paths('locks/')
base = self.fixture.git('rev-parse', 'HEAD')
(self.fixture.repo / 'locks').mkdir()
(self.fixture.repo / 'locks' / 'a.lock').write_text('a\n')
+ self.fixture.git('add', 'locks/a.lock')
+ self.fixture.commit_derived_projection(
+ 'orphaned-a', ['locks/a.lock'],
+ subject='test: orphaned a projection',
+ )
(self.fixture.repo / 'locks' / 'b.lock').write_text('b\n')
- self.fixture.git('add', 'locks/a.lock', 'locks/b.lock')
- derived = self.fixture.commit_derived_projection(
- 'orphaned-multi-path',
- ['locks/a.lock', 'locks/b.lock'],
- subject='test: orphaned multi-path projection',
+ self.fixture.git('add', 'locks/b.lock')
+ self.fixture.commit_derived_projection(
+ 'orphaned-b', ['locks/b.lock'],
+ subject='test: orphaned b projection',
)
self.fixture.git('reset', '--hard', base)
request = self.fixture.request(
@@ -4285,11 +4289,14 @@ def test_provider_requires_one_update_to_cover_every_stale_derived_path(self):
self.fixture.protocol_request(self.fixture.check_request(request))
(self.fixture.repo / 'locks').mkdir()
(self.fixture.repo / 'locks' / 'a.lock').write_text('a\n')
- rejected, _ = self.fixture.protocol_request(request, expected=2)
-
- self.assertIn('derived-projection-stale', rejected['error'])
- self.assertIn('locks/a.lock', rejected['error'])
- self.assertIn('locks/b.lock', rejected['error'])
+ prepared, _ = self.fixture.protocol_request(request)
+ self.assertEqual(prepared['status'], 'prepared')
+ finalized, _ = self.fixture.protocol_request({**request, 'action': 'finalize'})
+ self.assertEqual(finalized['status'], 'verified')
+ remaining = SYNCWHEEL.stale_derived_projection_records(
+ self.fixture.repo, self.fixture.read_manifest(), 'main-integration'
+ )
+ self.assertEqual([item['path'] for item in remaining], ['locks/b.lock'])
class RevisionProviderRecoveryTest(unittest.TestCase):