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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.45.1
0.45.2
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1826,7 +1826,7 @@
<div class="hero-stage" id="top">
<section class="hero" aria-labelledby="hero-heading">
<div class="hero-copy">
<span class="eyebrow"><span class="ai-sigil" aria-hidden="true"></span>AI-ready Git coordination <span class="hero-version">v<!-- syncwheel-version:start -->0.45.1<!-- syncwheel-version:end --></span></span>
<span class="eyebrow"><span class="ai-sigil" aria-hidden="true"></span>AI-ready Git coordination <span class="hero-version">v<!-- syncwheel-version:start -->0.45.2<!-- syncwheel-version:end --></span></span>
<h1 id="hero-heading"><span class="title-line">Move at</span><span class="title-line">machine speed.</span><span class="title-line accent">Stay in control.</span></h1>
<p class="subhead">Syncwheel coordinates every Git lane used by humans, AI agents, CI and delivery systems—without sacrificing review, safety or recovery.</p>
<div class="actions">
Expand Down Expand Up @@ -2602,7 +2602,7 @@ <h2 class="section-title">Your first verified plan in minutes.</h2>
</main>

<footer>
<span>syncwheel v<!-- syncwheel-version:start -->0.45.1<!-- syncwheel-version:end --> · MIT · a NestDevLab project</span>
<span>syncwheel v<!-- syncwheel-version:start -->0.45.2<!-- syncwheel-version:end --> · MIT · a NestDevLab project</span>
<a class="logo-link" href="https://www.nestdev.it/" aria-label="NestDevLab">
<img src="https://www.nestdev.it/assets/logo-nestdev.png" width="22" height="22" alt="">
</a>
Expand Down
2 changes: 1 addition & 1 deletion openpack.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schemaVersion": 2,
"name": "NestDevLab/syncwheel",
"version": "0.45.1",
"version": "0.45.2",
"provides": [
{
"type": "skills",
Expand Down
104 changes: 93 additions & 11 deletions scripts/syncwheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -7525,19 +7559,37 @@ 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()
for replay_base, replay_tip in replay_ranges:
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 (
Expand Down Expand Up @@ -29627,6 +29679,7 @@ def reprepare_index_lease(self, request, journal):
'projectionBaseKind': 'projectionBaseKind',
'integrationCompositionDigest': 'integrationCompositionDigest',
'baselineUnownedDirty': 'unownedDirty',
'baselineStalePaths': 'stalePaths',
'unmappedIntegrationCommits': 'unmappedIntegrationCommits',
'coordination': 'coordination',
}
Expand Down Expand Up @@ -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:')
)
]
Expand All @@ -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)
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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']:
Expand Down
1 change: 1 addition & 0 deletions scripts/syncwheel_revision_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
Loading
Loading