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.
- {collab_runtime-0.9.2/collab_runtime.egg-info → collab_runtime-0.10.0}/PKG-INFO +7 -2
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/README.md +7 -1
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/index.html +15 -6
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard_server.py +4 -2
- collab_runtime-0.10.0/collab/env_secrets.py +45 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/githooks.py +171 -25
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/pre-push +12 -1
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/live_locks_watcher.py +305 -54
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/lock_client.py +1137 -43
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/main.py +179 -8
- collab_runtime-0.10.0/collab/path_filter.py +174 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/safe_subprocess.py +10 -1
- {collab_runtime-0.9.2 → collab_runtime-0.10.0/collab_runtime.egg-info}/PKG-INFO +7 -2
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/SOURCES.txt +2 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/docs/pypi/README.md +6 -1
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/pyproject.toml +1 -1
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/LICENSE +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/__init__.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/__main__.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/agent_hooks.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/agent_identity.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-charts.js +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-filters.js +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/dashboard/dashboard-format.js +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/errors.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/commit-msg +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-checkout +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-commit +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/post-merge +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/hook_templates/pre-commit +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/logging_config.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/overlap.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/platform_probe.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/pr_overlap.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab/subprocess_bridge.py +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/entry_points.txt +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/requires.txt +0 -0
- {collab_runtime-0.9.2 → collab_runtime-0.10.0}/collab_runtime.egg-info/top_level.txt +0 -0
- {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.
|
|
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, "'");
|
|
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 || "")
|
|
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
|
|
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 || "")
|
|
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
|
|
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
|
|
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>' +
|
|
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")
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
371
|
-
|
|
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
|
|
374
|
-
print(f"[collab] Installed: {', '.join(
|
|
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
|
|
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
|
-
|
|
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
|