bingo-light 2.1.2 → 2.1.3

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.
@@ -8,6 +8,7 @@ import json
8
8
  import os
9
9
  import re
10
10
  import shlex
11
+ import shutil
11
12
  import subprocess
12
13
  import tempfile
13
14
  from datetime import datetime, timezone
@@ -33,6 +34,7 @@ from bingo_core.models import ConflictInfo
33
34
  from bingo_core.git import Git
34
35
  from bingo_core.config import Config
35
36
  from bingo_core.state import State
37
+ from bingo_core.team import TeamState
36
38
 
37
39
 
38
40
  class Repo:
@@ -43,6 +45,7 @@ class Repo:
43
45
  self.git = Git(self.path)
44
46
  self.config = Config(self.path)
45
47
  self.state = State(self.path)
48
+ self.team = TeamState(self.path, git=self.git)
46
49
 
47
50
  # -- Internal helpers --
48
51
 
@@ -212,6 +215,200 @@ class Repo:
212
215
  pass
213
216
  return ""
214
217
 
218
+ _PATCH_SUBJECT_RE = re.compile(
219
+ r"^\[bl\]\s+([a-zA-Z0-9][a-zA-Z0-9_-]*)\s*:\s*(.*)$"
220
+ )
221
+ _MESSAGE_MAX = 2048
222
+
223
+ def _build_patch_intent(self) -> dict:
224
+ """Assemble patch-intent context for a rebase in progress.
225
+
226
+ Returns a dict with: name, subject, message, message_truncated,
227
+ original_sha, original_diff, diff_truncated, meta, stack_position.
228
+
229
+ All fields fall back to empty/None rather than raising. This is
230
+ defensive context-gathering for AI consumption, not a validator.
231
+ """
232
+ result = {
233
+ "name": "",
234
+ "subject": "",
235
+ "message": "",
236
+ "message_truncated": False,
237
+ "original_sha": None,
238
+ "original_diff": None,
239
+ "diff_truncated": False,
240
+ "meta": None,
241
+ "stack_position": None,
242
+ }
243
+
244
+ msg_file = os.path.join(self.path, ".git", "rebase-merge", "message")
245
+ sha_file = os.path.join(self.path, ".git", "rebase-merge", "stopped-sha")
246
+
247
+ try:
248
+ with open(msg_file) as f:
249
+ raw_msg = f.read()
250
+ except (IOError, OSError):
251
+ return result
252
+
253
+ if len(raw_msg) > self._MESSAGE_MAX:
254
+ result["message"] = raw_msg[: self._MESSAGE_MAX]
255
+ result["message_truncated"] = True
256
+ else:
257
+ result["message"] = raw_msg
258
+
259
+ first_line = raw_msg.split("\n", 1)[0]
260
+ m = self._PATCH_SUBJECT_RE.match(first_line)
261
+ if m:
262
+ result["name"] = m.group(1)
263
+ result["subject"] = m.group(2).strip()
264
+
265
+ try:
266
+ with open(sha_file) as f:
267
+ sha = f.read().strip()
268
+ if sha:
269
+ result["original_sha"] = sha
270
+ try:
271
+ diff = self.git.run("show", "--format=", sha)
272
+ except GitError:
273
+ diff = ""
274
+ if diff:
275
+ if len(diff) > MAX_DIFF_SIZE:
276
+ result["original_diff"] = diff[:MAX_DIFF_SIZE]
277
+ result["diff_truncated"] = True
278
+ else:
279
+ result["original_diff"] = diff
280
+ except (IOError, OSError):
281
+ pass
282
+
283
+ if result["name"]:
284
+ try:
285
+ result["meta"] = self.state.patch_meta_get(result["name"])
286
+ except Exception:
287
+ result["meta"] = None
288
+
289
+ try:
290
+ c = self._load()
291
+ base = self._patches_base(c)
292
+ if base:
293
+ try:
294
+ log_output = self.git.run(
295
+ "rev-list", "--reverse",
296
+ f"{base}..{c['patches_branch']}"
297
+ )
298
+ except GitError:
299
+ log_output = ""
300
+ shas = log_output.splitlines()
301
+ subjects = []
302
+ for s in shas:
303
+ try:
304
+ subj = self.git.run(
305
+ "log", "-1", "--format=%s", s
306
+ ).strip()
307
+ except GitError:
308
+ subj = ""
309
+ subjects.append(subj)
310
+ for idx, subj in enumerate(subjects, start=1):
311
+ m2 = self._PATCH_SUBJECT_RE.match(subj)
312
+ if m2 and m2.group(1) == result["name"]:
313
+ result["stack_position"] = {
314
+ "index": idx,
315
+ "total": len(subjects),
316
+ }
317
+ break
318
+ except Exception:
319
+ pass
320
+
321
+ return result
322
+
323
+ def _auto_dep_apply(self) -> Optional[dict]:
324
+ """Auto-apply dependency patches after a successful sync.
325
+
326
+ Returns dep apply result dict, or None if no dep patches configured.
327
+ """
328
+ dep_dir = os.path.join(self.path, ".bingo-deps")
329
+ if not os.path.isdir(dep_dir):
330
+ return None
331
+ try:
332
+ from bingo_core.dep import DepManager
333
+ dm = DepManager(self.path)
334
+ return dm.apply()
335
+ except Exception as e:
336
+ import sys as _sys
337
+ print(f"warning: auto dep-apply failed: {e}", file=_sys.stderr)
338
+ return {"ok": False, "warning": f"dep apply failed: {e}"}
339
+
340
+ # Lock file basenames that should be auto-resolved during sync
341
+ _LOCK_FILES = {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"}
342
+
343
+ # Lock file -> package manager command
344
+ _LOCK_MANAGERS = {
345
+ "package-lock.json": ["npm", "install", "--package-lock-only"],
346
+ "yarn.lock": ["yarn", "install", "--mode", "update-lockfile"],
347
+ "pnpm-lock.yaml": ["pnpm", "install", "--lockfile-only"],
348
+ }
349
+
350
+ # Verification command templates by file extension.
351
+ # Each value is (template, kind). {path} is replaced with shlex.quote(file).
352
+ # Templates pass the path as argv[1] so nested quoting is not a concern.
353
+ _VERIFY_HINTS_BY_EXT = {
354
+ ".py": ("python3 -m py_compile {path}", "syntax"),
355
+ ".json": ("python3 -c 'import json,sys; json.load(open(sys.argv[1]))' {path}", "parse"),
356
+ ".yml": ("python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' {path}", "parse"),
357
+ ".yaml": ("python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' {path}", "parse"),
358
+ ".toml": ("python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],\"rb\"))' {path}", "parse"),
359
+ ".sh": ("bash -n {path}", "syntax"),
360
+ }
361
+
362
+ def _verify_hints_for(self, files: List[str]) -> List[dict]:
363
+ """Generate per-file verification commands by extension.
364
+
365
+ Returns a list of dicts: {"file": str, "command": str, "kind": str}.
366
+ Files with unknown extensions are silently skipped. Paths are passed
367
+ through shlex.quote to stay shell-safe.
368
+ """
369
+ hints: List[dict] = []
370
+ for f in files:
371
+ _, ext = os.path.splitext(f)
372
+ entry = self._VERIFY_HINTS_BY_EXT.get(ext.lower())
373
+ if entry is None:
374
+ continue
375
+ template, kind = entry
376
+ command = template.format(path=shlex.quote(f))
377
+ hints.append({"file": f, "command": command, "kind": kind})
378
+ return hints
379
+
380
+ def _resolve_lock_files(self, unresolved: List[str]) -> List[str]:
381
+ """Auto-resolve lock file conflicts by accepting theirs + regenerating.
382
+
383
+ Returns the list of still-unresolved files (lock files removed).
384
+ """
385
+ lock_files = [f for f in unresolved if os.path.basename(f) in self._LOCK_FILES]
386
+ if not lock_files:
387
+ return unresolved
388
+
389
+ for lf in lock_files:
390
+ # Accept upstream version
391
+ self.git.run_ok("checkout", "--theirs", "--", lf)
392
+ self.git.run_ok("add", lf)
393
+
394
+ # Try to regenerate via package manager
395
+ basename = os.path.basename(lf)
396
+ mgr_cmd = self._LOCK_MANAGERS.get(basename)
397
+ lock_dir = os.path.dirname(os.path.join(self.path, lf)) or self.path
398
+ if (mgr_cmd and shutil.which(mgr_cmd[0])
399
+ and os.path.isfile(os.path.join(lock_dir, "package.json"))):
400
+ try:
401
+ subprocess.run(
402
+ mgr_cmd, cwd=lock_dir,
403
+ capture_output=True, text=True, timeout=120,
404
+ )
405
+ # Re-add after regeneration
406
+ self.git.run_ok("add", lf)
407
+ except (subprocess.TimeoutExpired, OSError):
408
+ pass # Keep the theirs version
409
+
410
+ return [f for f in unresolved if f not in lock_files]
411
+
215
412
  def _build_conflict_result(
216
413
  self,
217
414
  conflicted_files: List[str],
@@ -607,9 +804,12 @@ class Repo:
607
804
  "reason": reason,
608
805
  }
609
806
 
610
- def doctor(self) -> dict:
807
+ def doctor(self, report: bool = False) -> dict:
611
808
  """Run health checks on the repository.
612
809
 
810
+ Args:
811
+ report: If True, include extended checks (team locks, expiry, deps).
812
+
613
813
  Returns {"ok": True/False, "issues": N, "checks": [...]}
614
814
  """
615
815
  c = self._load()
@@ -735,6 +935,66 @@ class Repo:
735
935
  else:
736
936
  _check("config", "fail", "missing")
737
937
 
938
+ # Extended checks (--report mode)
939
+ if report:
940
+ # Stale locks
941
+ locks = self.team.list_locks()
942
+ if locks:
943
+ now = datetime.now(timezone.utc)
944
+ for lock in locks:
945
+ locked_at = lock.get("locked_at", "")
946
+ if locked_at:
947
+ try:
948
+ lock_dt = datetime.strptime(
949
+ locked_at, "%Y-%m-%dT%H:%M:%SZ"
950
+ ).replace(tzinfo=timezone.utc)
951
+ days = (now - lock_dt).days
952
+ if days > 7:
953
+ _check(
954
+ f"stale_lock:{lock['patch']}",
955
+ "warn",
956
+ f"locked by {lock['owner']} for {days}d",
957
+ )
958
+ except ValueError:
959
+ pass
960
+ if not any(c_item["name"].startswith("stale_lock:") for c_item in checks):
961
+ _check("team_locks", "pass", f"{len(locks)} active lock(s)")
962
+ else:
963
+ _check("team_locks", "pass", "no locks")
964
+
965
+ # Expired patches
966
+ try:
967
+ expire_result = self.patch_expire()
968
+ n_expired = len(expire_result.get("expired", []))
969
+ n_expiring = len(expire_result.get("expiring_soon", []))
970
+ if n_expired > 0:
971
+ _check("expired_patches", "warn", f"{n_expired} expired patch(es)")
972
+ elif n_expiring > 0:
973
+ _check("expiring_patches", "warn", f"{n_expiring} expiring soon")
974
+ else:
975
+ _check("patch_expiry", "pass", "none expired")
976
+ except Exception:
977
+ pass
978
+
979
+ # Dependency patches health
980
+ dep_dir = os.path.join(self.path, ".bingo-deps")
981
+ if os.path.isdir(dep_dir):
982
+ try:
983
+ from bingo_core.dep import DepManager
984
+ dm = DepManager(self.path)
985
+ dep_status = dm.status()
986
+ if dep_status.get("ok"):
987
+ total = dep_status.get("total_patches", 0)
988
+ healthy = dep_status.get("healthy", 0)
989
+ if healthy == total:
990
+ _check("dep_patches", "pass", f"{total} patch(es) healthy")
991
+ else:
992
+ _check("dep_patches", "warn", f"{total - healthy}/{total} need attention")
993
+ else:
994
+ _check("dep_patches", "warn", dep_status.get("error", "unknown"))
995
+ except Exception:
996
+ pass
997
+
738
998
  return {"ok": issues == 0, "issues": issues, "checks": checks}
739
999
 
740
1000
  def diff(self) -> dict:
@@ -857,7 +1117,9 @@ class Repo:
857
1117
  def conflict_analyze(self) -> dict:
858
1118
  """Analyze current rebase conflicts.
859
1119
 
860
- Returns structured info about each conflicted file.
1120
+ Returns structured info about each conflicted file plus
1121
+ patch-intent context and per-file verification hints when
1122
+ a rebase is in progress.
861
1123
  """
862
1124
  self._ensure_git_repo()
863
1125
 
@@ -871,11 +1133,19 @@ class Repo:
871
1133
  current_patch = self._current_rebase_patch()
872
1134
  conflicts = [self._extract_conflict(f) for f in conflicted]
873
1135
 
1136
+ patch_intent = self._build_patch_intent()
1137
+ verify = {
1138
+ "test_command": self.config.get("test.command") or None,
1139
+ "file_hints": self._verify_hints_for(conflicted),
1140
+ }
1141
+
874
1142
  return {
875
1143
  "ok": True,
876
1144
  "in_rebase": True,
877
1145
  "current_patch": current_patch,
878
1146
  "conflicts": [c.to_dict() for c in conflicts],
1147
+ "patch_intent": patch_intent,
1148
+ "verify": verify,
879
1149
  "resolution_steps": [
880
1150
  "1. Read ours (upstream) and theirs (your patch) for each conflict",
881
1151
  "2. Write the merged file content (include both changes where possible)",
@@ -886,7 +1156,9 @@ class Repo:
886
1156
  ],
887
1157
  }
888
1158
 
889
- def conflict_resolve(self, file_path: str, content: str = "") -> dict:
1159
+ def conflict_resolve(
1160
+ self, file_path: str, content: str = "", verify: bool = False
1161
+ ) -> dict:
890
1162
  """Resolve a single conflicted file and continue rebase if possible.
891
1163
 
892
1164
  Args:
@@ -983,12 +1255,35 @@ class Repo:
983
1255
  "sync_complete": False,
984
1256
  }
985
1257
  # Rebase fully complete
986
- return {
1258
+ result_dict = {
987
1259
  "ok": True,
988
1260
  "resolved": rel_path,
989
1261
  "rebase_continued": True,
990
1262
  "sync_complete": True,
991
1263
  }
1264
+ if verify:
1265
+ test_cmd = self.config.get("test.command")
1266
+ if not test_cmd:
1267
+ result_dict["verify_result"] = {
1268
+ "skipped": True,
1269
+ "reason": "no test.command configured",
1270
+ }
1271
+ else:
1272
+ try:
1273
+ t = self.test()
1274
+ vr = {
1275
+ "test": t.get("test", "fail"),
1276
+ "command": t.get("command", test_cmd),
1277
+ }
1278
+ if t.get("output"):
1279
+ vr["output"] = t["output"]
1280
+ result_dict["verify_result"] = vr
1281
+ except BingoError as e:
1282
+ result_dict["verify_result"] = {
1283
+ "skipped": True,
1284
+ "reason": str(e),
1285
+ }
1286
+ return result_dict
992
1287
 
993
1288
  # rebase --continue failed -- check why
994
1289
  new_unmerged = self.git.ls_files_unmerged()
@@ -1187,15 +1482,22 @@ class Repo:
1187
1482
  "test_error": str(e),
1188
1483
  }
1189
1484
 
1190
- return {
1485
+ result = {
1191
1486
  "ok": True,
1192
1487
  "synced": True,
1193
1488
  "behind_before": behind,
1194
1489
  "patches_rebased": patch_count,
1195
1490
  }
1491
+ dep = self._auto_dep_apply()
1492
+ if dep is not None:
1493
+ result["dep_apply"] = dep
1494
+ return result
1196
1495
 
1197
1496
  # Rebase failed -- check if rerere auto-resolved
1198
1497
  unresolved = self.git.ls_files_unmerged()
1498
+ # Auto-resolve lock file conflicts (package-lock.json, yarn.lock, etc.)
1499
+ if unresolved:
1500
+ unresolved = self._resolve_lock_files(unresolved)
1199
1501
  if not unresolved:
1200
1502
  # rerere resolved everything -- try to continue
1201
1503
  rerere_ok = True
@@ -1233,13 +1535,17 @@ class Repo:
1233
1535
  "rerere_resolved": True,
1234
1536
  },
1235
1537
  )
1236
- return {
1538
+ result = {
1237
1539
  "ok": True,
1238
1540
  "synced": True,
1239
1541
  "behind_before": behind,
1240
1542
  "patches_rebased": patch_count,
1241
1543
  "rerere_resolved": True,
1242
1544
  }
1545
+ dep = self._auto_dep_apply()
1546
+ if dep is not None:
1547
+ result["dep_apply"] = dep
1548
+ return result
1243
1549
 
1244
1550
  # Rollback tracking branch
1245
1551
  self.git.run_ok("branch", "-f", c["tracking_branch"], saved_tracking)
@@ -1349,13 +1655,17 @@ class Repo:
1349
1655
  # Clean rebase
1350
1656
  self.state.clear_circuit_breaker()
1351
1657
  self._record_sync(c, behind, saved_tracking)
1352
- return {
1658
+ result = {
1353
1659
  "ok": True,
1354
1660
  "action": "synced",
1355
1661
  "behind_before": behind,
1356
1662
  "patches_rebased": patch_count,
1357
1663
  "conflicts_resolved": 0,
1358
1664
  }
1665
+ dep = self._auto_dep_apply()
1666
+ if dep is not None:
1667
+ result["dep_apply"] = dep
1668
+ return result
1359
1669
 
1360
1670
  # Enter conflict resolution loop
1361
1671
  conflicts_resolved = 0
@@ -1391,6 +1701,21 @@ class Repo:
1391
1701
  if not unresolved:
1392
1702
  continue
1393
1703
 
1704
+ # Auto-resolve lock file conflicts before reporting
1705
+ unresolved = self._resolve_lock_files(unresolved)
1706
+ if not unresolved:
1707
+ # Lock files were the only conflicts — try to continue
1708
+ env = os.environ.copy()
1709
+ env["GIT_EDITOR"] = "true"
1710
+ cont_result = subprocess.run(
1711
+ ["git", "rebase", "--continue"],
1712
+ cwd=self.path,
1713
+ capture_output=True, text=True, env=env,
1714
+ )
1715
+ if cont_result.returncode == 0:
1716
+ conflicts_resolved += 1
1717
+ continue
1718
+
1394
1719
  # Real unresolved conflicts -- report and stop
1395
1720
  self.git.run_ok("branch", "-f", c["tracking_branch"], saved_tracking)
1396
1721
 
@@ -1415,13 +1740,17 @@ class Repo:
1415
1740
  # If we get here, all conflicts were auto-resolved by rerere
1416
1741
  self.state.clear_circuit_breaker()
1417
1742
  self._record_sync(c, behind, saved_tracking)
1418
- return {
1743
+ result = {
1419
1744
  "ok": True,
1420
1745
  "action": "synced_with_rerere",
1421
1746
  "behind_before": behind,
1422
1747
  "patches_rebased": patch_count,
1423
1748
  "conflicts_auto_resolved": conflicts_resolved,
1424
1749
  }
1750
+ dep = self._auto_dep_apply()
1751
+ if dep is not None:
1752
+ result["dep_apply"] = dep
1753
+ return result
1425
1754
 
1426
1755
  def undo(self) -> dict:
1427
1756
  """Undo the last sync operation.
@@ -1664,6 +1993,15 @@ class Repo:
1664
1993
  if m:
1665
1994
  pname = m.group(1)
1666
1995
 
1996
+ # Lock enforcement — check by parsed name or by target
1997
+ lock_name = pname or target
1998
+ if lock_name and self.team.is_locked_by_other(lock_name):
1999
+ lock = self.team.get_lock(lock_name)
2000
+ raise BingoError(
2001
+ f"Patch '{lock_name}' is locked by {lock['owner']}. "
2002
+ "They must unlock it first."
2003
+ )
2004
+
1667
2005
  if self.git.current_branch() != c["patches_branch"]:
1668
2006
  self.git.run("checkout", c["patches_branch"])
1669
2007
 
@@ -1695,6 +2033,17 @@ class Repo:
1695
2033
  )
1696
2034
  hash_val = self._resolve_patch(c, target)
1697
2035
 
2036
+ # Lock enforcement — check by parsed name or by target
2037
+ subject_chk = self.git.run("log", "-1", "--format=%s", hash_val)
2038
+ m_chk = re.match(r"^\[bl\] ([^:]+):", subject_chk)
2039
+ lock_name_chk = m_chk.group(1) if m_chk else target
2040
+ if lock_name_chk and self.team.is_locked_by_other(lock_name_chk):
2041
+ lock = self.team.get_lock(lock_name_chk)
2042
+ raise BingoError(
2043
+ f"Patch '{lock_name_chk}' is locked by {lock['owner']}. "
2044
+ "They must unlock it first."
2045
+ )
2046
+
1698
2047
  has_staged = not self.git.run_ok("diff", "--cached", "--quiet")
1699
2048
  if not has_staged:
1700
2049
  raise BingoError(
@@ -2048,6 +2397,444 @@ class Repo:
2048
2397
  self.state.patch_meta_set(target, key, value)
2049
2398
  return {"ok": True, "patch": target, "set": key, "value": value}
2050
2399
 
2400
+ # -- Team / Locking --
2401
+
2402
+ def patch_lock(self, name: str, reason: str = "") -> dict:
2403
+ """Lock a patch for exclusive editing.
2404
+
2405
+ Returns {"ok": True, "patch": ..., "owner": ..., "locked_at": ...}
2406
+ """
2407
+ c = self._load()
2408
+ # Verify patch exists
2409
+ self._resolve_patch(c, name)
2410
+ return self.team.lock(name, reason=reason)
2411
+
2412
+ def patch_unlock(self, name: str, force: bool = False) -> dict:
2413
+ """Unlock a patch.
2414
+
2415
+ Returns {"ok": True, "patch": ..., "owner": ...}
2416
+ """
2417
+ c = self._load()
2418
+ self._resolve_patch(c, name)
2419
+ return self.team.unlock(name, force=force)
2420
+
2421
+ # -- Smart Patch Management --
2422
+
2423
+ def patch_check(self, name: str = "") -> dict:
2424
+ """Check if patches are still needed (obsolescence detection).
2425
+
2426
+ For each patch, checks whether upstream now contains equivalent changes.
2427
+ Heuristic: apply patch diff to current upstream — if it produces no change,
2428
+ the patch is obsolete.
2429
+
2430
+ Returns {"ok": True, "patches": [{"name", "status", "reason"}]}
2431
+ """
2432
+ c = self._load()
2433
+ base = self._patches_base(c)
2434
+ if not base:
2435
+ return {"ok": True, "patches": [], "count": 0}
2436
+
2437
+ patches = self.git.log_patches(base, c["patches_branch"])
2438
+ if not patches:
2439
+ return {"ok": True, "patches": [], "count": 0}
2440
+
2441
+ # If a specific name given, filter
2442
+ if name:
2443
+ patches = [p for p in patches if p.name == name]
2444
+ if not patches:
2445
+ raise BingoError(f"Patch '{name}' not found.")
2446
+
2447
+ # Get current upstream tip
2448
+ tracking = c.get("tracking_branch", DEFAULT_TRACKING)
2449
+ upstream_head = self.git.rev_parse(tracking)
2450
+ if not upstream_head:
2451
+ return {
2452
+ "ok": True,
2453
+ "patches": [
2454
+ {"name": p.name, "status": "unknown", "reason": "No upstream tracking branch"}
2455
+ for p in patches
2456
+ ],
2457
+ "count": len(patches),
2458
+ }
2459
+
2460
+ results = []
2461
+ for p in patches:
2462
+ try:
2463
+ # Get the files this patch touches
2464
+ diff_output = self.git.run(
2465
+ "diff", "--name-only", f"{p.hash}^", p.hash, check=False
2466
+ )
2467
+ patch_files = [f for f in diff_output.splitlines() if f.strip()]
2468
+
2469
+ if not patch_files:
2470
+ results.append({"name": p.name, "status": "active", "reason": "No files changed"})
2471
+ continue
2472
+
2473
+ # Check if upstream already contains equivalent changes
2474
+ # If it applies but produces no diff, the changes are already upstream
2475
+ try:
2476
+ # Check if the patch's changes already exist at upstream
2477
+ all_match = True
2478
+ for pf in patch_files:
2479
+ # Get file content at patch commit
2480
+ try:
2481
+ content_at_patch = self.git.run(
2482
+ "show", f"{p.hash}:{pf}", check=False
2483
+ )
2484
+ except GitError:
2485
+ content_at_patch = ""
2486
+
2487
+ # Get file content at upstream
2488
+ try:
2489
+ content_at_upstream = self.git.run(
2490
+ "show", f"{tracking}:{pf}", check=False
2491
+ )
2492
+ except GitError:
2493
+ content_at_upstream = ""
2494
+
2495
+ # If upstream already has the same content as post-patch,
2496
+ # this patch is obsolete for this file
2497
+ if content_at_upstream != content_at_patch:
2498
+ all_match = False
2499
+ break
2500
+
2501
+ if all_match:
2502
+ results.append({
2503
+ "name": p.name,
2504
+ "status": "obsolete",
2505
+ "reason": "Upstream contains equivalent changes",
2506
+ })
2507
+ else:
2508
+ # Check if upstream changed same files (potential conflict)
2509
+ upstream_changed = self.git.run(
2510
+ "diff", "--name-only", base, tracking, "--", *patch_files,
2511
+ check=False,
2512
+ )
2513
+ if upstream_changed.strip():
2514
+ results.append({
2515
+ "name": p.name,
2516
+ "status": "active",
2517
+ "reason": "Upstream also modified these files — review recommended",
2518
+ })
2519
+ else:
2520
+ results.append({
2521
+ "name": p.name,
2522
+ "status": "active",
2523
+ "reason": "Patch still applies unique changes",
2524
+ })
2525
+ except GitError:
2526
+ results.append({"name": p.name, "status": "active", "reason": "Could not compare"})
2527
+
2528
+ except GitError:
2529
+ results.append({"name": p.name, "status": "unknown", "reason": "Error analyzing patch"})
2530
+
2531
+ return {"ok": True, "patches": results, "count": len(results)}
2532
+
2533
+ def patch_upstream(self, name: str) -> dict:
2534
+ """Export a patch as a clean PR-ready diff for upstream submission.
2535
+
2536
+ Strips [bl] prefix and git metadata — produces a clean diff + description.
2537
+
2538
+ Returns {"ok": True, "patch": ..., "diff": ..., "description": ..., "files": [...]}
2539
+ """
2540
+ c = self._load()
2541
+ hash_val = self._resolve_patch(c, name)
2542
+
2543
+ # Get commit subject and strip [bl] prefix
2544
+ subject = self.git.run("log", "-1", "--format=%s", hash_val)
2545
+ description = subject
2546
+ m = re.match(r"^\[bl\] [^:]+:\s*(.*)", subject)
2547
+ if m:
2548
+ description = m.group(1)
2549
+
2550
+ # Get commit body (if any)
2551
+ body = self.git.run("log", "-1", "--format=%b", hash_val, check=False).strip()
2552
+ if body:
2553
+ description = f"{description}\n\n{body}"
2554
+
2555
+ # Generate clean diff
2556
+ diff = self.git.run("diff", f"{hash_val}^", hash_val, check=False)
2557
+
2558
+ # Get file list
2559
+ files_output = self.git.run(
2560
+ "diff", "--name-only", f"{hash_val}^", hash_val, check=False
2561
+ )
2562
+ files = [f for f in files_output.splitlines() if f.strip()]
2563
+
2564
+ # Get stats
2565
+ stat = self.git.run(
2566
+ "diff", "--stat", f"{hash_val}^", hash_val, check=False
2567
+ ).strip()
2568
+
2569
+ return {
2570
+ "ok": True,
2571
+ "patch": name,
2572
+ "diff": diff,
2573
+ "description": description,
2574
+ "files": files,
2575
+ "stats": stat,
2576
+ }
2577
+
2578
+ def patch_expire(self) -> dict:
2579
+ """List patches that have passed or are approaching their expiry date.
2580
+
2581
+ Returns {"ok": True, "expired": [...], "expiring_soon": [...], "active": [...]}
2582
+ """
2583
+ c = self._load()
2584
+ base = self._patches_base(c)
2585
+ if not base:
2586
+ return {"ok": True, "expired": [], "expiring_soon": [], "active": [], "count": 0}
2587
+
2588
+ patches = self.git.log_patches(base, c["patches_branch"])
2589
+ now = datetime.now(timezone.utc)
2590
+ expired = []
2591
+ expiring_soon = []
2592
+ active = []
2593
+
2594
+ for p in patches:
2595
+ meta = self.state.patch_meta_get(p.name)
2596
+ expires_str = meta.get("expires")
2597
+ if not expires_str:
2598
+ active.append({"name": p.name, "expires": None, "status": "no_expiry"})
2599
+ continue
2600
+
2601
+ try:
2602
+ expires_dt = datetime.strptime(expires_str, "%Y-%m-%d").replace(
2603
+ tzinfo=timezone.utc
2604
+ )
2605
+ except ValueError:
2606
+ try:
2607
+ expires_dt = datetime.strptime(
2608
+ expires_str, "%Y-%m-%dT%H:%M:%SZ"
2609
+ ).replace(tzinfo=timezone.utc)
2610
+ except ValueError:
2611
+ active.append({"name": p.name, "expires": expires_str, "status": "invalid_date"})
2612
+ continue
2613
+
2614
+ days_left = (expires_dt - now).days
2615
+ entry = {"name": p.name, "expires": expires_str, "days_left": days_left}
2616
+
2617
+ if days_left < 0:
2618
+ entry["status"] = "expired"
2619
+ expired.append(entry)
2620
+ elif days_left <= 7:
2621
+ entry["status"] = "expiring_soon"
2622
+ expiring_soon.append(entry)
2623
+ else:
2624
+ entry["status"] = "active"
2625
+ active.append(entry)
2626
+
2627
+ return {
2628
+ "ok": True,
2629
+ "expired": expired,
2630
+ "expiring_soon": expiring_soon,
2631
+ "active": active,
2632
+ "count": len(expired) + len(expiring_soon),
2633
+ }
2634
+
2635
+ def patch_stats(self) -> dict:
2636
+ """Get health metrics for all patches.
2637
+
2638
+ Returns {"ok": True, "patches": [{"name", "age_days", "files", "insertions",
2639
+ "deletions", "sync_conflicts"}]}
2640
+ """
2641
+ c = self._load()
2642
+ base = self._patches_base(c)
2643
+ if not base:
2644
+ return {"ok": True, "patches": [], "count": 0}
2645
+
2646
+ patches = self.git.log_patches(base, c["patches_branch"])
2647
+ now = datetime.now(timezone.utc)
2648
+
2649
+ # Load sync history for conflict frequency analysis
2650
+ sync_history = self.state.get_sync_history()
2651
+ syncs = sync_history.get("syncs", [])
2652
+
2653
+ results = []
2654
+ for p in patches:
2655
+ meta = self.state.patch_meta_get(p.name)
2656
+
2657
+ # Compute age
2658
+ created_str = meta.get("created", "")
2659
+ age_days = -1
2660
+ if created_str:
2661
+ try:
2662
+ created_dt = datetime.strptime(
2663
+ created_str, "%Y-%m-%dT%H:%M:%SZ"
2664
+ ).replace(tzinfo=timezone.utc)
2665
+ age_days = (now - created_dt).days
2666
+ except ValueError:
2667
+ pass
2668
+
2669
+ # Count sync conflicts — look for syncs where this patch name
2670
+ # appeared and the sync had issues
2671
+ sync_count = 0
2672
+ for sync in syncs:
2673
+ for sp in sync.get("patches", []):
2674
+ if sp.get("name") == p.name:
2675
+ sync_count += 1
2676
+
2677
+ # Lock info
2678
+ lock = self.team.get_lock(p.name)
2679
+
2680
+ entry = {
2681
+ "name": p.name,
2682
+ "age_days": age_days,
2683
+ "files": p.files,
2684
+ "insertions": p.insertions,
2685
+ "deletions": p.deletions,
2686
+ "status": meta.get("status", "permanent"),
2687
+ "owner": meta.get("owner", ""),
2688
+ "locked_by": lock["owner"] if lock else "",
2689
+ "syncs_survived": sync_count,
2690
+ }
2691
+ results.append(entry)
2692
+
2693
+ return {"ok": True, "patches": results, "count": len(results)}
2694
+
2695
+ # -- Report --
2696
+
2697
+ def report(self) -> dict:
2698
+ """Generate a comprehensive markdown health report.
2699
+
2700
+ Aggregates status, patches, stats, expiry, locks, history, and deps.
2701
+
2702
+ Returns {"ok": True, "report": "<markdown>", "summary": {...}}
2703
+ """
2704
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
2705
+ lines = ["# Fork Health Report", f"Generated: {now}", ""]
2706
+
2707
+ alerts = []
2708
+ patch_count = 0
2709
+ behind = 0
2710
+
2711
+ # Overview
2712
+ try:
2713
+ st = self.status()
2714
+ behind = st.get("behind", 0)
2715
+ patch_count = st.get("patch_count", 0)
2716
+ upstream = st.get("upstream_url", "?")
2717
+ branch = st.get("upstream_branch", "?")
2718
+ action = st.get("recommended_action", "?")
2719
+ lines.append("## Overview")
2720
+ lines.append(f"- Upstream: {upstream} ({branch})")
2721
+ lines.append(f"- Behind: {behind} commit(s)")
2722
+ lines.append(f"- Patches: {patch_count}")
2723
+ lines.append(f"- Recommended action: {action}")
2724
+ last_sync = st.get("last_sync", "")
2725
+ if last_sync:
2726
+ lines.append(f"- Last sync: {last_sync}")
2727
+ lines.append("")
2728
+ except Exception as e:
2729
+ lines.append(f"## Overview\n- Error: {e}\n")
2730
+
2731
+ # Patch Stack
2732
+ try:
2733
+ stats = self.patch_stats()
2734
+ stat_patches = stats.get("patches", [])
2735
+ if stat_patches:
2736
+ lines.append("## Patch Stack")
2737
+ lines.append("| # | Name | Age | Size | Syncs | Status | Owner |")
2738
+ lines.append("|---|------|-----|------|-------|--------|-------|")
2739
+ for i, p in enumerate(stat_patches, 1):
2740
+ age = f"{p['age_days']}d" if p.get("age_days", -1) >= 0 else "?"
2741
+ size = f"+{p.get('insertions', 0)}/-{p.get('deletions', 0)}"
2742
+ syncs = str(p.get("syncs_survived", 0))
2743
+ status = p.get("status", "")
2744
+ owner = p.get("owner", "") or p.get("locked_by", "") or "-"
2745
+ lines.append(f"| {i} | {p['name']} | {age} | {size} | {syncs} | {status} | {owner} |")
2746
+ lines.append("")
2747
+ except Exception:
2748
+ pass
2749
+
2750
+ # Expiry
2751
+ try:
2752
+ expire = self.patch_expire()
2753
+ expired = expire.get("expired", [])
2754
+ expiring = expire.get("expiring_soon", [])
2755
+ for e in expired:
2756
+ alerts.append(f"[EXPIRED] patch \"{e['name']}\" expired {e['expires']}")
2757
+ for e in expiring:
2758
+ alerts.append(f"[EXPIRING] patch \"{e['name']}\" expires {e['expires']} ({e['days_left']}d left)")
2759
+ except Exception:
2760
+ pass
2761
+
2762
+ # Team locks
2763
+ try:
2764
+ locks = self.team.list_locks()
2765
+ if locks:
2766
+ now_dt = datetime.now(timezone.utc)
2767
+ for lock in locks:
2768
+ locked_at = lock.get("locked_at", "")
2769
+ if locked_at:
2770
+ try:
2771
+ lock_dt = datetime.strptime(
2772
+ locked_at, "%Y-%m-%dT%H:%M:%SZ"
2773
+ ).replace(tzinfo=timezone.utc)
2774
+ days = (now_dt - lock_dt).days
2775
+ if days > 7:
2776
+ alerts.append(
2777
+ f"[STALE LOCK] patch \"{lock['patch']}\" "
2778
+ f"locked by {lock['owner']} for {days}d"
2779
+ )
2780
+ except ValueError:
2781
+ pass
2782
+ except Exception:
2783
+ pass
2784
+
2785
+ # Alerts
2786
+ if alerts:
2787
+ lines.append("## Alerts")
2788
+ for a in alerts:
2789
+ lines.append(f"- {a}")
2790
+ lines.append("")
2791
+
2792
+ # Sync History (last 5)
2793
+ try:
2794
+ history = self.state.get_sync_history()
2795
+ syncs = history.get("syncs", [])
2796
+ if syncs:
2797
+ lines.append("## Sync History (last 5)")
2798
+ for sync in syncs[-5:]:
2799
+ ts = sync.get("timestamp", "?")
2800
+ n = sync.get("upstream_commits_integrated", 0)
2801
+ p_count = len(sync.get("patches", []))
2802
+ lines.append(f"- {ts}: {n} upstream commit(s), {p_count} patch(es)")
2803
+ lines.append("")
2804
+ except Exception:
2805
+ pass
2806
+
2807
+ # Dependencies
2808
+ try:
2809
+ dep_dir = os.path.join(self.path, ".bingo-deps")
2810
+ if os.path.isdir(dep_dir):
2811
+ from bingo_core.dep import DepManager
2812
+ dm = DepManager(self.path)
2813
+ dep_list = dm.list_patches()
2814
+ dep_pkgs = dep_list.get("packages", [])
2815
+ if dep_pkgs:
2816
+ lines.append("## Dependencies")
2817
+ for pkg in dep_pkgs:
2818
+ pname = pkg.get("name", "?")
2819
+ ver = pkg.get("version", "?")
2820
+ n_patches = len(pkg.get("patches", []))
2821
+ lines.append(f"- {pname}@{ver}: {n_patches} patch(es)")
2822
+ lines.append("")
2823
+ except Exception:
2824
+ pass
2825
+
2826
+ report_text = "\n".join(lines)
2827
+
2828
+ return {
2829
+ "ok": True,
2830
+ "report": report_text,
2831
+ "summary": {
2832
+ "patches": patch_count,
2833
+ "behind": behind,
2834
+ "alerts": len(alerts),
2835
+ },
2836
+ }
2837
+
2051
2838
  # -- Config --
2052
2839
 
2053
2840
  def config_get(self, key: str) -> dict: