collab-runtime 0.8.0__tar.gz → 0.9.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 (38) hide show
  1. {collab_runtime-0.8.0/collab_runtime.egg-info → collab_runtime-0.9.0}/PKG-INFO +1 -1
  2. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/README.md +3 -0
  3. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/live_locks_watcher.py +186 -0
  4. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/lock_client.py +258 -18
  5. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/main.py +3 -3
  6. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/overlap.py +217 -14
  7. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/pr_overlap.py +167 -7
  8. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/safe_subprocess.py +1 -0
  9. {collab_runtime-0.8.0 → collab_runtime-0.9.0/collab_runtime.egg-info}/PKG-INFO +1 -1
  10. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/pyproject.toml +1 -1
  11. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/LICENSE +0 -0
  12. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/__init__.py +0 -0
  13. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/__main__.py +0 -0
  14. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/agent_hooks.py +0 -0
  15. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/agent_identity.py +0 -0
  16. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-charts.js +0 -0
  17. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-filters.js +0 -0
  18. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-format.js +0 -0
  19. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/index.html +0 -0
  20. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard_server.py +0 -0
  21. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/errors.py +0 -0
  22. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/githooks.py +0 -0
  23. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/commit-msg +0 -0
  24. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-checkout +0 -0
  25. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-commit +0 -0
  26. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-merge +0 -0
  27. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/pre-commit +0 -0
  28. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/pre-push +0 -0
  29. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/logging_config.py +0 -0
  30. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/platform_probe.py +0 -0
  31. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/subprocess_bridge.py +0 -0
  32. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/SOURCES.txt +0 -0
  33. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
  34. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/entry_points.txt +0 -0
  35. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/requires.txt +0 -0
  36. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/top_level.txt +0 -0
  37. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/docs/pypi/README.md +0 -0
  38. {collab_runtime-0.8.0 → collab_runtime-0.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -237,6 +237,9 @@ collab daemon-status
237
237
  # Stop the daemon
238
238
  collab daemon-stop
239
239
 
240
+ # Restart the daemon
241
+ collab daemon-restart
242
+
240
243
  # Foreground watcher (internal/diagnostics)
241
244
  collab watch --interval 5 --timeout 0
242
245
 
@@ -261,6 +261,10 @@ _local_owned_locks: set[str] = set()
261
261
  # Guard to prevent _graceful_shutdown from running more than once
262
262
  _shutdown_done: bool = False
263
263
 
264
+ # Track when each lock was acquired so we can enforce a minimum hold time
265
+ # and avoid rapid acquire/release cycles when git status is transient.
266
+ _lock_acquired_at: dict[str, datetime] = {}
267
+
264
268
  # URL of the running dashboard server (set in main after _start_dashboard_server).
265
269
  # Used by interactive conflict menus so users can review all active locks.
266
270
  _dashboard_url: str | None = None
@@ -473,6 +477,15 @@ def _normalize_path(path: str, project_root: str) -> str:
473
477
  return path.replace("\\", "/")
474
478
 
475
479
 
480
+ def _min_auto_lock_hold_seconds() -> int:
481
+ """Minimum seconds an auto-watch lock must be held before auto-release.
482
+
483
+ Configurable via ``COLLAB_MIN_AUTO_LOCK_HOLD_SECONDS`` (default 300 = 5 min).
484
+ Wrapped in a callable so tests can monkeypatch without import-time coupling.
485
+ """
486
+ return int(os.getenv("COLLAB_MIN_AUTO_LOCK_HOLD_SECONDS", "300"))
487
+
488
+
476
489
  def _should_ignore_path(path: str) -> bool:
477
490
  """Return True for paths the watcher should skip."""
478
491
  norm = path.replace("\\", "/")
@@ -741,9 +754,23 @@ def _process_new_files(client, branch: str, new_files: set[str]) -> None:
741
754
  )
742
755
  log_msg = _color(msg, Fore.GREEN) if _HAS_COLORAMA else msg
743
756
  logger.info(log_msg)
757
+ # Cross-branch advisory (#150): warn when renewing a lock
758
+ # held by the same developer on a different branch.
759
+ existing_branch = data[0].get("existing_branch") if data else None
760
+ if existing_branch and existing_branch != br_local:
761
+ warn_msg = (
762
+ f"CROSS-BRANCH: {fp} is also locked by you on "
763
+ f"branch '{existing_branch}' (current: '{br_local}'). "
764
+ "Concurrent edits may cause merge conflicts."
765
+ )
766
+ logger.warning(
767
+ _color(warn_msg, Fore.YELLOW) if _HAS_COLORAMA else warn_msg
768
+ )
744
769
  # Track locks this watcher created so remote scans do not
745
770
  # report them as 'remote added' later.
746
771
  _local_owned_locks.add(fp)
772
+ # Record acquisition time for minimum-hold enforcement.
773
+ _lock_acquired_at[fp] = datetime.now()
747
774
  except Exception:
748
775
  # Log full traceback so errors during acquire are visible in errors.log
749
776
  logger.exception("Failed to acquire lock for %s", fp)
@@ -754,8 +781,26 @@ def _process_releases(client, released: set[str]) -> None:
754
781
 
755
782
  Extracted so tests can simulate exceptions when removing locks from the local-owned
756
783
  set without running the entire main loop.
784
+
785
+ Enforces a minimum hold time to avoid rapid acquire/release cycles when git status
786
+ is transient.
757
787
  """
758
788
  for fp in released:
789
+ # Enforce minimum lock hold time: skip files whose lock was acquired
790
+ # too recently to avoid thrashing when git status fluctuates.
791
+ acquired = _lock_acquired_at.get(fp)
792
+ if acquired is not None:
793
+ age = (datetime.now() - acquired).total_seconds()
794
+ if age < _min_auto_lock_hold_seconds():
795
+ logger.debug(
796
+ "⏳ [KEPT] %s — lock is only %ds old "
797
+ "(< %ds minimum); deferring auto-release",
798
+ fp,
799
+ int(age),
800
+ _min_auto_lock_hold_seconds(),
801
+ )
802
+ continue
803
+
759
804
  # Was this file in conflict?
760
805
  if fp in _active_conflicts:
761
806
  _active_conflicts.discard(fp)
@@ -916,6 +961,74 @@ def _resolve_lock_diff_base_ref() -> str | None:
916
961
  return None
917
962
 
918
963
 
964
+ def _get_sibling_worktree_dirty_files() -> set[str]:
965
+ """Return dirty files from sibling git worktrees.
966
+
967
+ Uses ``git worktree list --porcelain`` to discover sibling worktrees, then runs
968
+ ``git status --porcelain`` in each. Paths are normalised to project- relative form.
969
+ Used by :func:`_get_modified_and_unpushed_files` so that concurrent edits across
970
+ worktrees are not released as stale (#150).
971
+ """
972
+ sibling_files: set[str] = set()
973
+
974
+ captured = safe_subprocess.capture(
975
+ ["git", "worktree", "list", "--porcelain"],
976
+ policy="git",
977
+ cwd=_PROJECT_ROOT,
978
+ timeout=10.0,
979
+ )
980
+ if not captured.ok or captured.timed_out:
981
+ return sibling_files
982
+
983
+ out = safe_subprocess.decode_output(captured.stdout)
984
+ worktrees: list[tuple[str, str]] = [] # (path, branch)
985
+ current_path = ""
986
+ for line in out.splitlines():
987
+ line = line.strip()
988
+ if line.startswith("worktree "):
989
+ current_path = line[len("worktree ") :]
990
+ elif line.startswith("branch ") and current_path:
991
+ branch_ref = line[len("branch ") :]
992
+ branch = branch_ref.replace("refs/heads/", "", 1)
993
+ worktrees.append((current_path, branch))
994
+ current_path = ""
995
+
996
+ our_root = os.path.abspath(_PROJECT_ROOT).rstrip("\\/").lower()
997
+
998
+ for wt_path, wt_branch in worktrees:
999
+ wt_abs = os.path.abspath(wt_path).rstrip("\\/").lower()
1000
+ if wt_abs == our_root:
1001
+ continue
1002
+ if not os.path.isdir(wt_path):
1003
+ continue
1004
+
1005
+ try:
1006
+ wt_captured = safe_subprocess.capture(
1007
+ ["git", "status", "--porcelain"],
1008
+ policy="git",
1009
+ cwd=wt_path,
1010
+ timeout=10.0,
1011
+ )
1012
+ if not wt_captured.ok or wt_captured.timed_out:
1013
+ continue
1014
+ wt_out = safe_subprocess.decode_output(wt_captured.stdout).strip("\r\n")
1015
+ for line in wt_out.splitlines():
1016
+ if len(line) > 3:
1017
+ p = _normalize_path(_parse_git_status_path(line), _PROJECT_ROOT)
1018
+ if p and not _should_ignore_path(p):
1019
+ sibling_files.add(p)
1020
+ logger.debug(
1021
+ "👥 [WORKTREE] %s — dirty in sibling '%s' (%s)",
1022
+ p,
1023
+ wt_branch,
1024
+ wt_path,
1025
+ )
1026
+ except Exception as exc:
1027
+ logger.debug("Failed to scan sibling worktree %s: %s", wt_path, exc)
1028
+
1029
+ return sibling_files
1030
+
1031
+
919
1032
  def _get_modified_and_unpushed_files() -> set[str]:
920
1033
  """Return the set of files that are 'in progress' for this developer.
921
1034
 
@@ -957,6 +1070,15 @@ def _get_modified_and_unpushed_files() -> set[str]:
957
1070
  # just won't lock committed-but-unpushed files, which beats crashing.
958
1071
  pass
959
1072
 
1073
+ # Part 3: sibling worktree dirty files (#150) — prevent stale-lock release
1074
+ # when another worktree is actively editing the same files.
1075
+ try:
1076
+ sibling_dirty = _get_sibling_worktree_dirty_files()
1077
+ if sibling_dirty:
1078
+ result.update(sibling_dirty)
1079
+ except Exception as exc:
1080
+ logger.debug("Sibling worktree scan failed: %s", exc)
1081
+
960
1082
  return result
961
1083
 
962
1084
 
@@ -1003,6 +1125,18 @@ def _reconcile_on_startup(client) -> None:
1003
1125
  fp = lock.get("file_path", "")
1004
1126
  if fp:
1005
1127
  lock_map[fp] = lock
1128
+ # Populate _lock_acquired_at from existing locks so the minimum-hold
1129
+ # check works correctly across watcher restarts.
1130
+ acquired_str = lock.get("acquired_at")
1131
+ if acquired_str and fp not in _lock_acquired_at:
1132
+ try:
1133
+ _lock_acquired_at[fp] = (
1134
+ datetime.fromisoformat(acquired_str)
1135
+ .astimezone()
1136
+ .replace(tzinfo=None)
1137
+ )
1138
+ except (ValueError, TypeError):
1139
+ pass
1006
1140
 
1007
1141
  locked_paths = set(lock_map.keys())
1008
1142
  branch = _get_current_branch()
@@ -1071,6 +1205,26 @@ def _reconcile_on_startup(client) -> None:
1071
1205
  msg = f"[RESUMED] {fp} - lock re-adopted from this machine"
1072
1206
  logger.info(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
1073
1207
  else:
1208
+ # File is clean — but skip if the lock was acquired too recently.
1209
+ # This prevents the watcher from releasing locks it just acquired
1210
+ # during the same reconciliation pass (e.g. when git status is
1211
+ # transient).
1212
+ acquired = _lock_acquired_at.get(fp)
1213
+ if acquired is not None:
1214
+ age = (datetime.now() - acquired).total_seconds()
1215
+ if age < _min_auto_lock_hold_seconds():
1216
+ logger.debug(
1217
+ "⏳ [KEPT] %s — lock is only %ds old "
1218
+ "(< %ds minimum); skipping auto-release",
1219
+ fp,
1220
+ int(age),
1221
+ _min_auto_lock_hold_seconds(),
1222
+ )
1223
+ # Keep the lock in _local_owned_locks so the main loop
1224
+ # continues to track it for eventual release.
1225
+ _local_owned_locks.add(fp)
1226
+ continue
1227
+
1074
1228
  # File is clean - stale lock, release it
1075
1229
  try:
1076
1230
  _scoped_owned_query(
@@ -1114,6 +1268,7 @@ def _reconcile_on_startup(client) -> None:
1114
1268
  _handle_post_restart_conflict(client, fp, data[0])
1115
1269
  else:
1116
1270
  _local_owned_locks.add(fp)
1271
+ _lock_acquired_at[fp] = datetime.now()
1117
1272
  n_newly_locked += 1
1118
1273
  msg = f"[LOCKED] {fp} - acquired lock for dirty file at startup"
1119
1274
  logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
@@ -1367,6 +1522,22 @@ def _graceful_shutdown() -> None:
1367
1522
  msg = f"[KEPT] {fp} - still has local edits, lock preserved"
1368
1523
  logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
1369
1524
  else:
1525
+ # Enforce minimum hold time even during shutdown:
1526
+ # keep locks that were acquired too recently.
1527
+ acquired = _lock_acquired_at.get(fp)
1528
+ if acquired is not None:
1529
+ age = (datetime.now() - acquired).total_seconds()
1530
+ if age < _min_auto_lock_hold_seconds():
1531
+ n_kept += 1
1532
+ logger.debug(
1533
+ "⏳ [KEPT] %s — lock is only %ds old "
1534
+ "(< %ds minimum); preserving",
1535
+ fp,
1536
+ int(age),
1537
+ _min_auto_lock_hold_seconds(),
1538
+ )
1539
+ continue
1540
+
1370
1541
  try:
1371
1542
  _scope_agent(
1372
1543
  client.table("file_locks")
@@ -1400,6 +1571,21 @@ def _graceful_shutdown() -> None:
1400
1571
  ]
1401
1572
  for fp in db_locks:
1402
1573
  if fp and fp not in still_dirty:
1574
+ # Enforce minimum hold time
1575
+ acquired = _lock_acquired_at.get(fp)
1576
+ if acquired is not None:
1577
+ age = (datetime.now() - acquired).total_seconds()
1578
+ if age < _min_auto_lock_hold_seconds():
1579
+ n_kept += 1
1580
+ logger.debug(
1581
+ "⏳ [KEPT] %s — lock is only %ds old "
1582
+ "(< %ds minimum); preserving",
1583
+ fp,
1584
+ int(age),
1585
+ _min_auto_lock_hold_seconds(),
1586
+ )
1587
+ continue
1588
+
1403
1589
  _scope_agent(
1404
1590
  client.table("file_locks")
1405
1591
  .delete()