collab-runtime 0.9.2__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {collab_runtime-0.9.2/collab_runtime.egg-info → collab_runtime-0.10.0}/PKG-INFO +7 -2
  2. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/README.md +7 -1
  3. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/index.html +15 -6
  4. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard_server.py +4 -2
  5. collab_runtime-0.10.0/collab/env_secrets.py +45 -0
  6. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/githooks.py +171 -25
  7. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/pre-push +12 -1
  8. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/live_locks_watcher.py +305 -54
  9. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/lock_client.py +1137 -43
  10. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/main.py +179 -8
  11. collab_runtime-0.10.0/collab/path_filter.py +174 -0
  12. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/safe_subprocess.py +10 -1
  13. {collab_runtime-0.9.2 → collab_runtime-0.10.0/collab_runtime.egg-info}/PKG-INFO +7 -2
  14. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/SOURCES.txt +2 -0
  15. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/docs/pypi/README.md +6 -1
  16. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/pyproject.toml +1 -1
  17. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/LICENSE +0 -0
  18. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/__init__.py +0 -0
  19. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/__main__.py +0 -0
  20. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/agent_hooks.py +0 -0
  21. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/agent_identity.py +0 -0
  22. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-charts.js +0 -0
  23. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-filters.js +0 -0
  24. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-format.js +0 -0
  25. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/errors.py +0 -0
  26. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/commit-msg +0 -0
  27. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-checkout +0 -0
  28. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-commit +0 -0
  29. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-merge +0 -0
  30. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/pre-commit +0 -0
  31. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/logging_config.py +0 -0
  32. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/overlap.py +0 -0
  33. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/platform_probe.py +0 -0
  34. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/pr_overlap.py +0 -0
  35. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/subprocess_bridge.py +0 -0
  36. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
  37. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/entry_points.txt +0 -0
  38. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/requires.txt +0 -0
  39. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/top_level.txt +0 -0
  40. {collab_runtime-0.9.2 → collab_runtime-0.10.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.9.2
3
+ Version: 0.10.0
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -197,6 +197,11 @@ collab init-hooks --force
197
197
 
198
198
  # Reconcile local and remote lock state
199
199
  collab reconcile
200
+ collab reconcile --prune-orphans
201
+
202
+ # Release orphan locks left by dead worktrees / killed daemons
203
+ collab prune-orphans
204
+ collab prune-orphans --dry-run
200
205
 
201
206
  # Lock history and retention
202
207
  collab history
@@ -215,7 +220,7 @@ collab watch --interval 5 --timeout 0
215
220
  # Web dashboard (opens in browser)
216
221
  collab dashboard
217
222
 
218
- # Cleanup orphaned watcher processes
223
+ # Cleanup orphaned watcher processes (preserves lock rows)
219
224
  collab cleanup
220
225
  ```
221
226
 
@@ -218,13 +218,19 @@ collab release-batch path/to/a.py path/to/b.py
218
218
 
219
219
  # Reconcile local and remote lock state
220
220
  collab reconcile
221
+ collab reconcile --prune-orphans # also release orphan lock rows (#182)
222
+
223
+ # Release orphan locks left by dead worktrees / killed daemons
224
+ collab prune-orphans
225
+ collab prune-orphans --dry-run
226
+ collab prune-orphans --aggressive
221
227
 
222
228
  # Lock history and retention
223
229
  collab history
224
230
  collab history path/to/file.py --limit 50
225
231
  collab history-prune --days 30
226
232
 
227
- # Cleanup orphaned watcher processes
233
+ # Cleanup orphaned watcher processes (preserves lock rows)
228
234
  collab cleanup
229
235
 
230
236
  # Start background watcher
@@ -1986,6 +1986,12 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
1986
1986
  .replace(/'/g, "&#39;");
1987
1987
  }
1988
1988
 
1989
+ function escapeAttr(value) {
1990
+ return String(value == null ? "" : value)
1991
+ .replace(/\\/g, "\\\\")
1992
+ .replace(/'/g, "\\'");
1993
+ }
1994
+
1989
1995
  function isAgentLock(lock) {
1990
1996
  if (lock.origin === "agent") {
1991
1997
  return true;
@@ -2445,7 +2451,7 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
2445
2451
  const canRelease = isMyDeveloper || IS_ADMIN;
2446
2452
  const reason = lock.reason || "No reason";
2447
2453
  const branch = lock.branch_name || "main";
2448
- const safePath = String(lock.file_path || "").replace(/'/g, "\\'");
2454
+ const safePath = escapeAttr(String(lock.file_path || ""));
2449
2455
 
2450
2456
  const action = canRelease
2451
2457
  ? '<button class="btn btn-sm btn-outline-danger" onclick="initRelease(\'' + safePath + '\',' + (isMine ? "true" : "false") + ')">' +
@@ -2477,7 +2483,7 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
2477
2483
  if (stillPresent) {
2478
2484
  var detailId = "detail-" + expandedRowPath.replace(/[^a-zA-Z0-9]/g, "-");
2479
2485
  var row = document.querySelector(
2480
- "tr[data-file='" + expandedRowPath.replace(/'/g, "\\'") + "']"
2486
+ "tr[data-file='" + escapeAttr(expandedRowPath) + "']"
2481
2487
  );
2482
2488
  if (row && !document.getElementById(detailId)) {
2483
2489
  var detailRow = document.createElement("tr");
@@ -2613,7 +2619,7 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
2613
2619
  ? formatDurationMinutes(Math.max(0, Math.round((rel - acq) / 60000)))
2614
2620
  : "-";
2615
2621
  const isMine = lockOwnedByMe(h);
2616
- const safePath = String(h.file_path || "").replace(/'/g, "\\'");
2622
+ const safePath = escapeAttr(String(h.file_path || ""));
2617
2623
 
2618
2624
  return (
2619
2625
  '<tr data-file="' + safePath + '" class="row-expandable">' +
@@ -3362,7 +3368,7 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
3362
3368
  }
3363
3369
 
3364
3370
  // Build a detail panel
3365
- var row = document.querySelector("tr[data-file='" + filePath.replace(/'/g, "\\'") + "']");
3371
+ var row = document.querySelector("tr[data-file='" + escapeAttr(filePath) + "']");
3366
3372
  if (!row) return;
3367
3373
 
3368
3374
  var detailRow = document.createElement("tr");
@@ -3375,11 +3381,14 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
3375
3381
  else if (typeof val === "object") val = JSON.stringify(val);
3376
3382
  return '<div class="detail-label">' + k + '</div><div class="detail-value">' + String(val) + '</div>';
3377
3383
  }).join("");
3378
- var jsonHtml = '<div class="detail-json">' + JSON.stringify(lockJson, null, 2) + '</div>';
3384
+ var jsonText = JSON.stringify(lockJson, null, 2);
3379
3385
  var copyBtn = '<button class="detail-copy-btn" title="Copy JSON" onclick="copyDetailJson(this)"><i class="fas fa-copy"></i></button>';
3380
3386
  detailRow.innerHTML = '<td colspan="6">' +
3381
3387
  copyBtn +
3382
- '<div class="detail-grid">' + gridHtml + '</div>' + jsonHtml + '</td>';
3388
+ '<div class="detail-grid">' + gridHtml + '</div>' +
3389
+ '<div class="detail-json"></div></td>';
3390
+ var jsonDiv = detailRow.querySelector('.detail-json');
3391
+ if (jsonDiv) { jsonDiv.textContent = jsonText; }
3383
3392
  row.parentNode.insertBefore(detailRow, row.nextSibling);
3384
3393
  expandedRowPath = filePath;
3385
3394
  }
@@ -230,9 +230,11 @@ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
230
230
  val = os.getenv(name)
231
231
  return val
232
232
 
233
+ from .env_secrets import effective_anon_key, effective_service_role_key
234
+
233
235
  url = pick("SUPABASE_URL") or ""
234
- anon = pick("SUPABASE_ANON_KEY") or ""
235
- service = pick("SUPABASE_SERVICE_ROLE_KEY") or None
236
+ anon = effective_anon_key(pick("SUPABASE_ANON_KEY")) or ""
237
+ service = effective_service_role_key(pick("SUPABASE_SERVICE_ROLE_KEY"))
236
238
  user = _resolve_developer_name(project_root, file_vals)
237
239
  from . import agent_identity
238
240
 
@@ -0,0 +1,45 @@
1
+ """Resolve effective Supabase credentials from environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Optional
8
+
9
+ # Placeholder patterns copied from scripts/setup.ps1 (Test-IsPlaceholderValue).
10
+ _PLACEHOLDER_ENV_RE = re.compile(
11
+ r"^(?:your[_-]|your[_-]?project|example|CHANGE_ME|change[_-]?me|"
12
+ r"replace[_-]?me|TODO|<team-)",
13
+ re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def is_placeholder_env_value(value: Optional[str]) -> bool:
18
+ """Return True when *value* is unset or still a template placeholder."""
19
+ if value is None:
20
+ return True
21
+ stripped = value.strip()
22
+ if not stripped:
23
+ return True
24
+ return bool(_PLACEHOLDER_ENV_RE.match(stripped))
25
+
26
+
27
+ def effective_env_secret(value: Optional[str]) -> Optional[str]:
28
+ """Return a usable secret or None when *value* is blank/placeholder."""
29
+ if is_placeholder_env_value(value):
30
+ return None
31
+ return value.strip() if value else None
32
+
33
+
34
+ def effective_service_role_key(raw: Optional[str] = None) -> Optional[str]:
35
+ """Service role key for API calls, or None when placeholder/unset."""
36
+ if raw is None:
37
+ raw = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
38
+ return effective_env_secret(raw)
39
+
40
+
41
+ def effective_anon_key(raw: Optional[str] = None) -> Optional[str]:
42
+ """Anon key for API calls, or None when placeholder/unset."""
43
+ if raw is None:
44
+ raw = os.getenv("SUPABASE_ANON_KEY")
45
+ return effective_env_secret(raw)
@@ -18,8 +18,10 @@ repository top level), so the same module works for any consumer repository.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import hashlib
21
22
  import json
22
23
  import os
24
+ import re
23
25
  import sys
24
26
  from importlib import resources
25
27
  from pathlib import Path
@@ -251,6 +253,15 @@ def _release_retaining_pr_claims(client, root: Path) -> int:
251
253
  briefly exist for a branch that did not reach the remote if the push then fails;
252
254
  the next reconcile (branch-gone) or the DB-side expiry releases those.
253
255
  """
256
+ if not client.claims_supported():
257
+ _hook_log(
258
+ "[collab] WARNING: COLLAB_PR_CLAIMS=1 but the Supabase claim migration "
259
+ "is not applied (missing 'is_pr_claim' column). Locks will be RELEASED "
260
+ "on push, not retained as claims. Apply supabase/schema.sql to enable "
261
+ "PR claims, then re-run setup."
262
+ )
263
+ return int(client.release_all())
264
+
254
265
  try:
255
266
  stale = client.reconcile_pr_claims()
256
267
  if stale:
@@ -296,50 +307,172 @@ def _is_collab_hook(text: str) -> bool:
296
307
  return "collab" in text.lower()
297
308
 
298
309
 
310
+ # Marker written into every installed hook so a later ``install_hooks`` run can tell
311
+ # whether the on-disk hook matches the packaged template (content fingerprint) and
312
+ # auto-reinstall stale hooks WITHOUT a manual --force flag (#181).
313
+ _HOOK_MARKER_RE = re.compile(r"# collab-hook v=\S+ fp=([0-9a-f]+)")
314
+
315
+
316
+ def _package_version() -> str:
317
+ """Return the installed collab-runtime version (best-effort)."""
318
+ try:
319
+ from collab import __version__
320
+
321
+ return str(__version__)
322
+ except Exception:
323
+ return "0.0.0"
324
+
325
+
326
+ def _template_fingerprint(raw_template: str) -> str:
327
+ """Return a short content fingerprint (sha256) of a raw hook template."""
328
+ normalized = raw_template.replace("\r\n", "\n")
329
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]
330
+
331
+
332
+ def _stamp_template(raw_template: str, fingerprint: str) -> str:
333
+ """Insert the collab-hook marker line after the shebang (or at the top)."""
334
+ marker = f"# collab-hook v={_package_version()} fp={fingerprint}"
335
+ lines = raw_template.replace("\r\n", "\n").split("\n")
336
+ if lines and lines[0].startswith("#!"):
337
+ lines.insert(1, marker)
338
+ else:
339
+ lines.insert(0, marker)
340
+ return "\n".join(lines)
341
+
342
+
343
+ def _installed_fingerprint(text: str) -> Optional[str]:
344
+ """Return the fingerprint recorded in an installed collab hook, if present."""
345
+ match = _HOOK_MARKER_RE.search(text)
346
+ return match.group(1) if match else None
347
+
348
+
349
+ def _is_precommit_hook(text: str) -> bool:
350
+ """Return True when a hook is managed by the pre-commit framework.
351
+
352
+ Collab hooks may legitimately mention ``pre-commit`` (they invoke it), so callers
353
+ must check :func:`_is_collab_hook` first; this only matches the framework's own
354
+ generated hooks so ``install_hooks`` never clobbers the pre-commit slot.
355
+ """
356
+ lowered = text.lower()
357
+ return "hook-impl" in lowered or "generated by pre-commit" in lowered
358
+
359
+
360
+ def _backup_hook(target: Path) -> Optional[str]:
361
+ """Back up an existing hook to ``<hook>.bak`` before overwriting.
362
+
363
+ Returns name.
364
+ """
365
+ backup = (
366
+ target.with_suffix(target.suffix + ".bak")
367
+ if target.suffix
368
+ else Path(str(target) + ".bak")
369
+ )
370
+ try:
371
+ backup.write_bytes(target.read_bytes())
372
+ return backup.name
373
+ except OSError:
374
+ return None
375
+
376
+
377
+ def _write_hook(target: Path, content: str) -> None:
378
+ """Write hook *content* with LF endings and best-effort exec bit."""
379
+ with open(target, "w", encoding="utf-8", newline="\n") as handle:
380
+ handle.write(content)
381
+ try:
382
+ mode = target.stat().st_mode
383
+ target.chmod(mode | 0o755)
384
+ except OSError:
385
+ pass
386
+
387
+
299
388
  def install_hooks(
300
389
  project_root: Optional[Path] = None,
301
390
  force: bool = False,
302
391
  ) -> dict[str, list[str] | str]:
303
- """Install bundled collab git hooks into the target repository.
304
-
305
- Existing non-collab hooks are preserved unless ``force`` is set, so a developer's
306
- custom hooks are never silently overwritten.
307
-
308
- Returns a summary dict with ``installed``, ``skipped`` and ``hooks_dir``.
392
+ """Install (or update) the bundled collab git hooks into the target repository.
393
+
394
+ Behavior (#181):
395
+
396
+ * **Fresh** hooks are installed and content-stamped with a fingerprint marker.
397
+ * **Collab-managed** hooks are auto-updated when their fingerprint no longer
398
+ matches the packaged template -- so template changes reach existing clones on
399
+ the next ``setup-dev`` / ``collab init-hooks`` run, with no manual flag.
400
+ * **Up-to-date** collab hooks are left untouched.
401
+ * **pre-commit-managed** hooks are never clobbered -- **even with ``force``** --
402
+ because the framework owns that slot and re-claims it on ``pre-commit install``.
403
+ The collab lock lifecycle runs through ``.pre-commit-config.yaml`` instead, so
404
+ these are reported under ``precommit_managed`` (not an error, not actionable).
405
+ * **Custom** (non-collab) hooks are preserved unless ``force`` is set; with
406
+ ``force`` the original is backed up to ``<hook>.bak`` before overwriting, so a
407
+ developer's custom hook is never destroyed silently.
408
+
409
+ Returns a summary dict with ``installed`` (all written: fresh + updated),
410
+ ``updated``, ``up_to_date``, ``skipped`` (custom hooks left in place),
411
+ ``precommit_managed`` (framework-owned slots), ``backed_up`` and ``hooks_dir``.
309
412
  """
310
413
  root = Path(project_root) if project_root is not None else _git_toplevel()
311
414
  hooks_dir = _hooks_dir(root)
312
415
  hooks_dir.mkdir(parents=True, exist_ok=True)
313
416
 
314
417
  installed: list[str] = []
418
+ updated: list[str] = []
419
+ up_to_date: list[str] = []
315
420
  skipped: list[str] = []
421
+ precommit_managed: list[str] = []
422
+ backed_up: list[str] = []
316
423
 
317
424
  for name in HOOK_NAMES:
318
425
  target = hooks_dir / name
319
- if target.exists() and not force:
426
+ raw = _read_template(name).replace("\r\n", "\n")
427
+ fingerprint = _template_fingerprint(raw)
428
+ content = _stamp_template(raw, fingerprint)
429
+
430
+ if target.exists():
320
431
  try:
321
432
  existing = target.read_text(encoding="utf-8", errors="ignore")
322
433
  except OSError:
323
- existing = ""
324
- if not _is_collab_hook(existing):
434
+ # Cannot read -> do not risk clobbering something unknown.
325
435
  skipped.append(name)
326
436
  continue
327
437
 
328
- # Normalize to LF so POSIX `sh` (incl. Git Bash on Windows) can parse
329
- # the shebang and body regardless of how the template was checked out.
330
- content = _read_template(name).replace("\r\n", "\n")
331
- with open(target, "w", encoding="utf-8", newline="\n") as handle:
332
- handle.write(content)
333
- try:
334
- mode = target.stat().st_mode
335
- target.chmod(mode | 0o755)
336
- except OSError:
337
- pass
438
+ if _is_collab_hook(existing):
439
+ # Auto-update only when the fingerprint drifted from the package.
440
+ if _installed_fingerprint(existing) == fingerprint:
441
+ up_to_date.append(name)
442
+ continue
443
+ _write_hook(target, content)
444
+ installed.append(name)
445
+ updated.append(name)
446
+ continue
447
+
448
+ # The pre-commit framework owns this slot: never clobber it (even with
449
+ # --force). The collab lock lifecycle runs via .pre-commit-config.yaml,
450
+ # so this is expected coverage -- reported separately from custom skips.
451
+ if _is_precommit_hook(existing):
452
+ precommit_managed.append(name)
453
+ continue
454
+
455
+ # Custom (non-collab) hook: preserve unless forced; back up on force.
456
+ if not force:
457
+ skipped.append(name)
458
+ continue
459
+ backup_name = _backup_hook(target)
460
+ if backup_name:
461
+ backed_up.append(backup_name)
462
+ _write_hook(target, content)
463
+ installed.append(name)
464
+ continue
465
+
466
+ _write_hook(target, content)
338
467
  installed.append(name)
339
468
 
340
469
  return {
341
470
  "installed": installed,
471
+ "updated": updated,
472
+ "up_to_date": up_to_date,
342
473
  "skipped": skipped,
474
+ "precommit_managed": precommit_managed,
475
+ "backed_up": backed_up,
343
476
  "hooks_dir": str(hooks_dir),
344
477
  }
345
478
 
@@ -367,15 +500,28 @@ def main(argv: Optional[list[str]] = None) -> int:
367
500
  if command == "init":
368
501
  force = "--force" in args[1:]
369
502
  summary = install_hooks(force=force)
370
- installed = summary["installed"]
371
- skipped = summary["skipped"]
503
+ installed = summary.get("installed") or []
504
+ updated = summary.get("updated") or []
505
+ skipped = summary.get("skipped") or []
506
+ precommit_managed = summary.get("precommit_managed") or []
507
+ backed_up = summary.get("backed_up") or []
508
+ fresh = [name for name in installed if name not in updated]
372
509
  print(f"[collab] Installed git hooks into {summary['hooks_dir']}")
373
- if installed:
374
- print(f"[collab] Installed: {', '.join(installed)}")
510
+ if fresh:
511
+ print(f"[collab] Installed: {', '.join(fresh)}")
512
+ if updated:
513
+ print(f"[collab] Updated (template changed): {', '.join(updated)}")
514
+ if backed_up:
515
+ print(f"[collab] Backed up before overwrite: {', '.join(backed_up)}")
516
+ if precommit_managed:
517
+ print(
518
+ "[collab] Managed by pre-commit (.pre-commit-config.yaml), left as-is: "
519
+ f"{', '.join(precommit_managed)}"
520
+ )
375
521
  if skipped:
376
522
  print(
377
- "[collab] Skipped (existing non-collab hooks, use --force): "
378
- f"{', '.join(skipped)}"
523
+ "[collab] Skipped existing custom hooks (rerun with --force to back "
524
+ f"up & replace): {', '.join(skipped)}"
379
525
  )
380
526
  return 0
381
527
 
@@ -66,7 +66,18 @@ elif [ $OVERLAP_EXIT -ne 0 ]; then
66
66
  exit $OVERLAP_EXIT
67
67
  fi
68
68
 
69
- echo "[collab] Releasing all locks after successful pre-push validation..." >&2
69
+ # Message reflects retain-vs-release: when COLLAB_PR_CLAIMS is enabled the pushed
70
+ # branch's files are retained as persistent claims (the rest are released); the
71
+ # Python runtime prints the exact per-file outcome.
72
+ PR_CLAIMS_LOWER="$(printf '%s' "${COLLAB_PR_CLAIMS:-0}" | tr '[:upper:]' '[:lower:]')"
73
+ case "$PR_CLAIMS_LOWER" in
74
+ 1 | true | yes | on)
75
+ echo "[collab] Retaining pushed-branch files as PR claims (COLLAB_PR_CLAIMS enabled); releasing other locks..." >&2
76
+ ;;
77
+ *)
78
+ echo "[collab] Releasing all locks after successful pre-push validation..." >&2
79
+ ;;
80
+ esac
70
81
  "$PYTHON" -m collab.githooks release-all
71
82
 
72
83
  exit 0