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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.45.0 - 2026-09-24

- Make the governed worktree capacity configurable per repository with an optional manifest block, `"governed_worktree_capacity": {"limit": 4, "enforcement": "error"}`. `limit` is a positive integer and defaults to 4. `enforcement` is `error` by default, which keeps the current refusal; `warn` opens the lane anyway and reports the capacity message, with its `stack add` queue commands, among the lane warnings. Lanes with an expired lease still count toward the limit, because a lease is set once at `worktree open` and never renewed. An invalid block is rejected when the manifest loads.

## 0.44.0 - 2026-09-22

- Stop committing `.syncwheel/manifest.json` for local-only manifests without active-active coordination; drop an unpushed legacy control commit while keeping the file. Such a repository now gets no control commit after an integration rebuild, and an integration tree without the manifest is the convergent state, so reconcile stops planning a rebuild or a push to reach control-manifest parity. A control commit left by an earlier release is dropped the next time the branch is rebuilt. Before the rebuild resets the branch, Syncwheel records the bytes of every manifest source the reset can reach and untracks the file in the checkout that holds the integration branch; afterwards it puts back only the sources the reset removed, so the file survives byte for byte for a default, `--manifest` or `--personal` path, and no deletion is staged in a checkout on another branch. The previous tip stays reachable through the automatic backup ref, and a control commit that was already pushed still needs a reviewed force-push.
Expand Down
7 changes: 5 additions & 2 deletions 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.44.0`
Current version: `0.45.0`

`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 Expand Up @@ -331,7 +331,10 @@ the explicit, bounded choice when dependency installation, builds, tests, or
debugging are necessary. It is a lifecycle declaration, not a sandbox: a raw
shell can still bypass it, so agents must keep that boundary in their procedure.

Each clone permits four active lanes. The local registry records the owner,
Each clone permits four active lanes by default. A repository can change the limit, or
make reaching it a warning instead of a refusal, with the manifest block
`"governed_worktree_capacity": {"limit": 4, "enforcement": "error"}` (`enforcement`:
`error` or `warn`). The local registry records the owner,
lease, base, branch, target stack, mode, and any recovery ref in Git's common
directory, never in the shared manifest. Use `--into <existing-stack>` when the
destination is already known. Otherwise, after committing, use the existing
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.44.0
0.45.0
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.44.0<!-- 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.0<!-- 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.44.0<!-- syncwheel-version:end --> · MIT · a NestDevLab project</span>
<span>syncwheel v<!-- syncwheel-version:start -->0.45.0<!-- 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
4 changes: 4 additions & 0 deletions docs/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ python3 scripts/syncwheel.py manifest require-integration --apply
`human-gated`. Set it with `syncwheel repo authority set`; it is never
enabled automatically and is not part of coordination state
- `syncwheel_worktree_root` defaults to repo-relative `.syncwheel/wt`
- `governed_worktree_capacity` is optional: `limit` (positive integer, default
`4`) caps active governed lanes per clone, and `enforcement` (`error` by
default, or `warn`) chooses whether reaching it refuses `worktree open` or
only adds the capacity message to the lane warnings. Expired lanes still count
- new manifests set `defaults.integration_membership` to `required`; legacy
manifests without it remain compatible until explicitly migrated
- required membership means every declared stack id must appear in
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.44.0",
"version": "0.45.0",
"provides": [
{
"type": "skills",
Expand Down
39 changes: 36 additions & 3 deletions scripts/syncwheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ def __init__(self, message, observed_tip):
GITHUB_PR_MERGE_ADAPTER_MAX_OUTPUT = 20000
GOVERNED_WORKTREE_REGISTRY_VERSION = 1
GOVERNED_WORKTREE_DEFAULT_CAPACITY = 4
GOVERNED_WORKTREE_CAPACITY_ENFORCEMENTS = ('error', 'warn')
GOVERNED_WORKTREE_DEFAULT_LEASE_SECONDS = 120 * 60
GOVERNED_WORKTREE_LOCK_TIMEOUT_SECONDS = 5
GOVERNED_WORKTREE_LOCK_STALE_SECONDS = 300
Expand Down Expand Up @@ -3794,6 +3795,30 @@ def normalize_syncwheel_worktree_root(value, path='manifest'):
return value.strip()


def normalize_governed_worktree_capacity(value, path='manifest'):
key = f'{path} governed_worktree_capacity'
if value is None:
value = {}
if not isinstance(value, dict):
raise SyncwheelError(f'{key} must be an object')
unknown = sorted(set(value) - {'limit', 'enforcement'})
if unknown:
raise SyncwheelError(f"{key} has unknown field(s): {', '.join(unknown)}")
limit = value.get('limit', GOVERNED_WORKTREE_DEFAULT_CAPACITY)
if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
raise SyncwheelError(f'{key}.limit must be a positive integer')
enforcement = value.get('enforcement', 'error')
if enforcement not in GOVERNED_WORKTREE_CAPACITY_ENFORCEMENTS:
raise SyncwheelError(
f"{key}.enforcement must be one of: {', '.join(GOVERNED_WORKTREE_CAPACITY_ENFORCEMENTS)}"
)
return {'limit': limit, 'enforcement': enforcement}


def governed_worktree_capacity(manifest):
return normalize_governed_worktree_capacity((manifest or {}).get('governed_worktree_capacity'))


def normalize_coordination_gc(value, path='coordination.gc'):
if value is None:
value = {}
Expand Down Expand Up @@ -4710,6 +4735,10 @@ def load_manifest(repo_root, manifest_path=None):
data['syncwheel_worktree_root'] = normalize_syncwheel_worktree_root(
data.get('syncwheel_worktree_root')
)
if 'governed_worktree_capacity' in data:
data['governed_worktree_capacity'] = normalize_governed_worktree_capacity(
data['governed_worktree_capacity']
)

defaults = data.setdefault('defaults', {})
canonical_remote = defaults.setdefault('canonical_remote', 'origin')
Expand Down Expand Up @@ -20631,12 +20660,16 @@ def command_worktree_open(args):
item for item in registry['lanes']
if item['state'] in {'active', 'captured_pending_cleanup'}
]
if len(active) >= GOVERNED_WORKTREE_DEFAULT_CAPACITY:
raise SyncwheelError(
f'governed worktree capacity reached ({GOVERNED_WORKTREE_DEFAULT_CAPACITY}); '
capacity = governed_worktree_capacity(manifest)
if len(active) >= capacity['limit']:
message = (
f"governed worktree capacity reached ({capacity['limit']}); "
'capture or queue an existing lane before opening another'
+ format_remedy_suffix(governed_lane_queue_commands(manifest, active))
)
if capacity['enforcement'] == 'error':
raise SyncwheelError(message)
warnings.append(message)
if any(item['id'] == lane_id for item in registry['lanes']):
raise SyncwheelError(
f'governed worktree lane id was already used: {lane_id}; choose a new lane id'
Expand Down
3 changes: 2 additions & 1 deletion skills/syncwheel/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ The default light lane is for editing and committing only. It receives no
dependency provisioning; use `--full` only for an explicitly necessary
dependency, build, test, or debugging surface. This is not a security sandbox,
so do not defeat the light-lane boundary with raw install/test commands. The
lane is clone-local and bounded to four active entries. After its commits are
lane is clone-local and bounded to four active entries unless the manifest's
`governed_worktree_capacity` sets another `limit` or `enforcement: "warn"`. After its commits are
owned through the existing `stack create`, `stack add`, or
`stack capture-integration` flow, Syncwheel stores a local recovery ref and
reaps only a clean lane. A missing lane with an expired lease or a known-dead
Expand Down
39 changes: 39 additions & 0 deletions tests/test_syncwheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,45 @@ def test_worktree_open_enforces_capacity_without_creating_a_fifth_lane(self):
)
self.assertFalse((self.repo / '.syncwheel' / 'wt' / 'syncwheel-lane-lane-4').exists())

def set_governed_worktree_capacity(self, capacity):
manifest_path = self.repo / '.syncwheel' / 'manifest.json'
data = json.loads(manifest_path.read_text())
data['governed_worktree_capacity'] = capacity
manifest_path.write_text(json.dumps(data, indent=2) + '\n')

def test_worktree_open_honors_configured_capacity_limit(self):
self.set_governed_worktree_capacity({'limit': 2})
self.run_cli('worktree', 'open', 'lane-0', '--json')
self.run_cli('worktree', 'open', 'lane-1', '--json')

result = self.run_cli('worktree', 'open', 'lane-2', '--json', expected=2)

self.assertIn('capacity reached (2)', result.stderr)
self.assertFalse((self.repo / '.syncwheel' / 'wt' / 'syncwheel-lane-lane-2').exists())

def test_worktree_open_warns_over_capacity_when_enforcement_is_warn(self):
self.set_governed_worktree_capacity({'limit': 1, 'enforcement': 'warn'})
self.run_cli('worktree', 'open', 'lane-0', '--json')

opened = json.loads(self.run_cli('worktree', 'open', 'lane-1', '--json').stdout)

self.assertEqual(opened['lane']['id'], 'lane-1')
self.assertTrue(any('capacity reached (1)' in line for line in opened['warnings']))
text_result = self.run_cli('worktree', 'open', 'lane-2')
self.assertIn('warning: governed worktree capacity reached (1)', text_result.stdout)
registry, _ = self.load_syncwheel_module().load_governed_worktree_registry(self.repo)
self.assertEqual(len(registry['lanes']), 3)

def test_worktree_open_rejects_invalid_capacity_policy(self):
for capacity, message in (
({'limit': 0}, 'limit must be a positive integer'),
({'enforcement': 'off'}, 'enforcement must be one of: error, warn'),
({'max': 3}, 'unknown field(s): max'),
):
self.set_governed_worktree_capacity(capacity)
result = self.run_cli('worktree', 'open', 'lane-x', '--json', expected=2)
self.assertIn(message, result.stderr)

def test_worktree_open_warns_and_preserves_unrelated_pending_dirty_lane(self):
dirty = json.loads(self.run_cli('worktree', 'open', 'dirty-owner', '--json').stdout)['lane']
dirty_path = Path(dirty['path'])
Expand Down
Loading