refactorai-cli 0.5.8__tar.gz → 0.6.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 (51) hide show
  1. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/PKG-INFO +1 -1
  2. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/pyproject.toml +1 -1
  3. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/cloud_rr.py +174 -8
  5. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/run_cmds.py +8 -1
  6. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/toolchains_cmds.py +42 -0
  7. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/watch_cmds.py +145 -20
  8. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commit_queue.py +4 -1
  9. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/git_scope.py +162 -0
  10. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/refactor_branch_store.py +17 -0
  11. refactorai_cli-0.6.0/refactorai_cli/watch_ledger.py +199 -0
  12. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/watch_state.py +36 -0
  13. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/PKG-INFO +1 -1
  14. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  15. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/README.md +0 -0
  16. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/auth.py +0 -0
  17. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/client.py +0 -0
  18. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/__init__.py +0 -0
  19. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/account_cmds.py +0 -0
  20. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/auth_cmds.py +0 -0
  21. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/branch_cmds.py +0 -0
  22. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
  23. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/engine_cmds.py +0 -0
  24. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/hook_cmds.py +0 -0
  25. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/model_cmds.py +0 -0
  26. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  27. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/request_cmds.py +0 -0
  28. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/rules_cmds.py +0 -0
  29. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
  30. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  31. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/setup_cmds.py +0 -0
  32. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commit_telemetry.py +0 -0
  33. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/control_plane.py +0 -0
  34. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/credentials.py +0 -0
  35. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/dotenv_loader.py +0 -0
  36. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_constitution.py +0 -0
  37. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_engine_runtime.py +0 -0
  38. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_paths.py +0 -0
  39. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/main.py +0 -0
  40. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/model_policy.py +0 -0
  41. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/pre_push_gate.py +0 -0
  42. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/review_runner.py +0 -0
  43. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/runtime_manager.py +0 -0
  44. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/settings.py +0 -0
  45. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/setup_flow.py +0 -0
  46. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/watch_supervisor.py +0 -0
  47. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  48. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
  49. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/requires.txt +0 -0
  50. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/top_level.txt +0 -0
  51. {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.5.8
3
+ Version: 0.6.0
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.5.8"
3
+ version = "0.6.0"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,4 +5,4 @@ the shared `refactor_core` pipeline from a project folder while staying
5
5
  authenticated to the hosted platform via a developer key.
6
6
  """
7
7
 
8
- __version__ = "0.5.8"
8
+ __version__ = "0.6.0"
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  from collections import deque
26
26
  import contextlib
27
27
  import hashlib
28
+ import json
28
29
  import os
29
30
  import re
30
31
  import shlex
@@ -49,12 +50,13 @@ from refactor_core.refactor_requests import (
49
50
  DENIED,
50
51
  FAILED,
51
52
  IN_PROGRESS,
53
+ RefactorAttempt,
52
54
  pending_requests,
53
55
  rr_test_dir,
54
56
  save_request,
55
57
  )
56
58
  from refactor_core.host_toolchain import provision_for_command
57
- from refactor_core.store import create_run
59
+ from refactor_core.store import create_run, resolve_store_dir
58
60
  from refactor_core.testcmd import (
59
61
  _SKIP_DIRS,
60
62
  _configured_command,
@@ -357,6 +359,94 @@ def _managed_toolchain_enabled(config: dict) -> bool:
357
359
  return True
358
360
 
359
361
 
362
+ def _deps_restore_policy(config: dict) -> str:
363
+ """Return dependency-restore policy: ``auto`` (default), ``always``, ``never``."""
364
+ verification = (config or {}).get("verification")
365
+ raw = "auto"
366
+ if isinstance(verification, dict):
367
+ raw = str(verification.get("deps_restore_policy", "auto") or "auto")
368
+ policy = raw.strip().lower()
369
+ return policy if policy in {"auto", "always", "never"} else "auto"
370
+
371
+
372
+ def _sha256_file(path: Path) -> str:
373
+ """Hash a file's bytes; ``""`` when unreadable/missing."""
374
+ if not path.is_file():
375
+ return ""
376
+ digest = hashlib.sha256()
377
+ try:
378
+ with path.open("rb") as fh:
379
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
380
+ digest.update(chunk)
381
+ except OSError:
382
+ return ""
383
+ return digest.hexdigest()
384
+
385
+
386
+ def _read_json(path: Path) -> dict:
387
+ try:
388
+ raw = path.read_text(encoding="utf-8")
389
+ except OSError:
390
+ return {}
391
+ try:
392
+ obj = json.loads(raw)
393
+ except json.JSONDecodeError:
394
+ return {}
395
+ return obj if isinstance(obj, dict) else {}
396
+
397
+
398
+ def _write_json_atomic(path: Path, payload: dict) -> None:
399
+ path.parent.mkdir(parents=True, exist_ok=True)
400
+ tmp = path.with_name(f"{path.name}.tmp")
401
+ tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
402
+ tmp.replace(path)
403
+
404
+
405
+ def _node_lockfile(project_root: Path) -> Path | None:
406
+ for name in ("package-lock.json", "npm-shrinkwrap.json"):
407
+ path = project_root / name
408
+ if path.is_file():
409
+ return path
410
+ return None
411
+
412
+
413
+ def _node_deps_state_path(project_root: Path) -> Path:
414
+ return resolve_store_dir(project_root) / "toolchain-state" / "node-deps.json"
415
+
416
+
417
+ def _node_deps_fingerprint(project_root: Path, env: dict, install_command: str) -> tuple[str, dict]:
418
+ lockfile = _node_lockfile(project_root)
419
+ node_bin = shutil.which("node", path=(env or {}).get("PATH"))
420
+ metadata = {
421
+ "install_command": install_command,
422
+ "package_json_sha256": _sha256_file(project_root / "package.json"),
423
+ "lockfile_name": lockfile.name if lockfile else "",
424
+ "lockfile_sha256": _sha256_file(lockfile) if lockfile else "",
425
+ # A different binary path often means a different runtime version/build.
426
+ "node_binary_path": str(Path(node_bin).resolve()) if node_bin else "",
427
+ }
428
+ digest = hashlib.sha256(
429
+ json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8")
430
+ ).hexdigest()
431
+ return digest, metadata
432
+
433
+
434
+ def _should_restore_node_dependencies(
435
+ project_root: Path, env: dict, install_command: str
436
+ ) -> tuple[bool, str, dict]:
437
+ """Return ``(should_restore, reason, marker_payload)`` for node deps."""
438
+ fingerprint, metadata = _node_deps_fingerprint(project_root, env, install_command)
439
+ payload = {"fingerprint": fingerprint, "metadata": metadata}
440
+ if not (project_root / "node_modules").is_dir():
441
+ return True, "node_modules missing", payload
442
+ state = _read_json(_node_deps_state_path(project_root))
443
+ if not state:
444
+ return True, "cache marker missing", payload
445
+ if str(state.get("fingerprint") or "") != fingerprint:
446
+ return True, "dependency fingerprint changed", payload
447
+ return False, "dependency fingerprint unchanged", payload
448
+
449
+
360
450
  def _detected_is_provisionable(command: str | None) -> bool:
361
451
  """True when every missing binary of ``command`` maps to a managed runtime.
362
452
 
@@ -456,40 +546,72 @@ def _provision_toolchain(
456
546
  continue
457
547
  _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
458
548
  merged_env = _merge_toolchain_env(merged_env, env)
459
- _restore_dependencies(project_root, runtime, merged_env, progress_cb)
549
+ _restore_dependencies(project_root, runtime, merged_env, progress_cb, config=config)
460
550
 
461
551
  return merged_env, " ".join(n for n in notes if n).strip()
462
552
 
463
553
 
464
554
  def _restore_dependencies(
465
- project_root: Path, runtime: str, env: dict, progress_cb: Callable[[dict], None] | None
555
+ project_root: Path,
556
+ runtime: str,
557
+ env: dict,
558
+ progress_cb: Callable[[dict], None] | None,
559
+ *,
560
+ config: dict,
466
561
  ) -> None:
467
562
  """Restore project dependencies with the provisioned toolchain (best effort).
468
563
 
469
564
  Mirrors the sandbox dependency-restore step so the test command has its deps
470
565
  present. Failures are non-fatal: the test run surfaces the real error.
471
566
  """
567
+ policy = _deps_restore_policy(config)
568
+ if policy == "never":
569
+ _emit(
570
+ progress_cb,
571
+ phase="rr",
572
+ event="toolchain_provisioning",
573
+ detail=f"dependency restore skipped for {runtime} (policy=never)",
574
+ )
575
+ return
472
576
  if runtime == "node" and (project_root / "package.json").is_file():
577
+ lockfile = _node_lockfile(project_root)
578
+ install_command = "npm ci" if lockfile is not None else "npm install"
579
+ marker_payload: dict | None = None
580
+ if policy == "auto":
581
+ should_restore, reason, marker_payload = _should_restore_node_dependencies(
582
+ project_root, env, install_command
583
+ )
584
+ if not should_restore:
585
+ _emit(
586
+ progress_cb,
587
+ phase="rr",
588
+ event="toolchain_provisioning",
589
+ detail=f"skipping node dependency restore ({reason})",
590
+ )
591
+ return
473
592
  _emit(
474
593
  progress_cb,
475
594
  phase="rr",
476
595
  event="toolchain_provisioning",
477
- detail="step 1/1: npm install (streaming output below)",
596
+ detail=f"step 1/1: {install_command} (streaming output below)",
478
597
  )
479
598
  ok, detail = _run_command_with_progress(
480
599
  project_root,
481
- "npm install",
600
+ install_command,
482
601
  env=env,
483
602
  progress_cb=progress_cb,
484
603
  label="npm",
485
604
  timeout=1800,
486
605
  )
606
+ if ok and policy == "auto" and marker_payload is not None:
607
+ with contextlib.suppress(OSError):
608
+ _write_json_atomic(_node_deps_state_path(project_root), marker_payload)
487
609
  if not ok:
488
610
  _emit(
489
611
  progress_cb,
490
612
  phase="rr",
491
613
  event="toolchain_provisioning",
492
- detail=f"npm install finished with errors: {detail}",
614
+ detail=f"{install_command} finished with errors: {detail}",
493
615
  )
494
616
  elif runtime == "python":
495
617
  if (project_root / "requirements.txt").is_file():
@@ -1025,8 +1147,16 @@ def run_cloud_refactor_requests(
1025
1147
  diff_text = str(action.get("diff_text", "") or "")
1026
1148
  attempt = int(action.get("attempt", 1) or 1)
1027
1149
  attempts_by_id[current_rr_id] = attempt
1150
+ _PENDING_ATTEMPT[current_rr_id] = RefactorAttempt(
1151
+ proposal_n=attempt,
1152
+ diff_text=diff_text,
1153
+ )
1028
1154
  ok, why = validate_diff(project_root, diff_text)
1029
1155
  if not ok:
1156
+ pending = _PENDING_ATTEMPT.pop(current_rr_id, None)
1157
+ if pending is not None:
1158
+ pending.detail = f"invalid diff: {why}"
1159
+ _persist_attempt(project_root, rr, pending, attempt)
1030
1160
  _emit(progress_cb, phase="rr", event="proposal_invalid", attempt=attempt, reason=why)
1031
1161
  action = session.step(
1032
1162
  _step_payload(token, current_rr_id, "verify_result", by_id, project_root,
@@ -1044,6 +1174,14 @@ def run_cloud_refactor_requests(
1044
1174
  "risk": action.get("risk", "medium"),
1045
1175
  "diff_text": diff_text,
1046
1176
  }))
1177
+ pending = _PENDING_ATTEMPT.get(current_rr_id)
1178
+ if pending is not None:
1179
+ pending.approved = approved
1180
+ if not approved:
1181
+ pending = _PENDING_ATTEMPT.pop(current_rr_id, None)
1182
+ if pending is not None:
1183
+ pending.detail = "denied by developer"
1184
+ _persist_attempt(project_root, rr, pending, attempt)
1047
1185
  _emit(progress_cb, phase="rr", event="approval_decision", rr_id=current_rr_id, approved=approved)
1048
1186
  _PENDING_DIFF[current_rr_id] = diff_text
1049
1187
  action = session.step(
@@ -1058,18 +1196,32 @@ def run_cloud_refactor_requests(
1058
1196
  if verdict == "":
1059
1197
  # Server approved: client applies + verifies, then posts result.
1060
1198
  current_rr_id = action.get("rr_id", "") or current_rr_id
1199
+ rr = by_id.get(current_rr_id)
1061
1200
  diff_text = _PENDING_DIFF.get(current_rr_id, "")
1201
+ attempt_n = attempts_by_id.get(current_rr_id, 1)
1202
+ pending = _PENDING_ATTEMPT.pop(current_rr_id, None)
1203
+ if pending is None:
1204
+ pending = RefactorAttempt(
1205
+ proposal_n=attempt_n,
1206
+ diff_text=diff_text,
1207
+ approved=True,
1208
+ )
1062
1209
  passed, detail, files = _apply_and_verify(
1063
1210
  project_root, run_store.run_dir, diff_text, test_command, env=toolchain_env
1064
1211
  )
1212
+ pending.applied = True
1065
1213
  if passed:
1214
+ pending.test_passed = True
1066
1215
  _emit(progress_cb, phase="rr", event="rr_applied", rr_id=current_rr_id, files=files)
1067
1216
  else:
1217
+ pending.reverted = True
1218
+ pending.detail = f"verification failed: {detail}"
1068
1219
  _emit(progress_cb, phase="rr", event="rr_reverted", rr_id=current_rr_id,
1069
- attempt=attempts_by_id.get(current_rr_id, 1), detail=detail)
1220
+ attempt=attempt_n, detail=detail)
1221
+ _persist_attempt(project_root, rr, pending, attempt_n)
1070
1222
  action = session.step(
1071
1223
  _step_payload(token, current_rr_id, "verify_result", by_id, project_root,
1072
- attempt=attempts_by_id.get(current_rr_id, 1),
1224
+ attempt=attempt_n,
1073
1225
  verify_passed=passed, verify_detail=detail)
1074
1226
  )
1075
1227
  token = action.get("session_token", token)
@@ -1110,6 +1262,20 @@ def run_cloud_refactor_requests(
1110
1262
 
1111
1263
  # Diffs approved but not yet applied, keyed by rr_id (client-local, per run).
1112
1264
  _PENDING_DIFF: dict[str, str] = {}
1265
+ # Attempt snapshots keyed by rr_id; persisted when each proposal reaches a
1266
+ # terminal sub-outcome so telemetry can carry attempts[].diff_text.
1267
+ _PENDING_ATTEMPT: dict[str, RefactorAttempt] = {}
1268
+
1269
+
1270
+ def _persist_attempt(project_root: Path, rr, attempt: RefactorAttempt | None, attempt_n: int) -> None:
1271
+ if rr is None or attempt is None:
1272
+ return
1273
+ rr.attempts.append(attempt)
1274
+ rr.attempt_count = max(
1275
+ int(getattr(rr, "attempt_count", 0) or 0),
1276
+ int(attempt_n or getattr(attempt, "proposal_n", 0) or 0),
1277
+ )
1278
+ save_request(project_root, rr)
1113
1279
 
1114
1280
 
1115
1281
  def _step_payload(token, rr_id, step, by_id, project_root, *, attempt: int = 0, **kw) -> dict:
@@ -2273,6 +2273,9 @@ def _refactor_branch_name(
2273
2273
  template: str = "refactor/{branch}/{shortsha}",
2274
2274
  ) -> str:
2275
2275
  """Compose ``refactor/<sanitized-branch>/<shortsha>`` (detached-safe)."""
2276
+ from refactorai_cli.refactor_branch_store import debase_source_branch
2277
+
2278
+ source_branch = debase_source_branch(source_branch)
2276
2279
  if source_branch and source_branch != "HEAD":
2277
2280
  branch = _sanitize_branch_component(source_branch)
2278
2281
  else:
@@ -2671,7 +2674,11 @@ def _prepare_refactor_branch(
2671
2674
  if not source_sha:
2672
2675
  console.print(f"[yellow]code: could not resolve commit '{commit}' for branch; applying in place.[/yellow]")
2673
2676
  return None
2674
- source_branch = git_scope.current_branch(project_root)
2677
+ # Anchor to the developer's primary branch, never the current HEAD: when a
2678
+ # terminal action runs while HEAD is already on a refactor sub-branch, using
2679
+ # the current branch would nest the name (``refactor/refactor/...``). The
2680
+ # primary branch keeps every sub-branch flat and its source association true.
2681
+ source_branch = git_scope.resolve_primary_branch(project_root)
2675
2682
  branch_name = _refactor_branch_name(source_branch, source_sha, template=template)
2676
2683
 
2677
2684
  # The reviewed commit names the branch + anchors metadata; the checkout base
@@ -16,6 +16,7 @@ from refactor_core import host_toolchain as ht
16
16
 
17
17
  console = Console()
18
18
  app = typer.Typer(help="Inspect and manage managed language toolchains.")
19
+ _SUPPORTED_RUNTIMES = ("node", "python", "go")
19
20
 
20
21
 
21
22
  @app.command("list")
@@ -55,3 +56,44 @@ def prune_toolchains(
55
56
  return
56
57
  for path in removed:
57
58
  console.print(f"[green]removed[/green] {path}")
59
+
60
+
61
+ @app.command("prewarm")
62
+ def prewarm_toolchains(
63
+ runtime: list[str] = typer.Option(
64
+ [],
65
+ "--runtime",
66
+ "-r",
67
+ help="Runtime to prewarm (repeatable): node, python, go. Defaults to all.",
68
+ ),
69
+ ) -> None:
70
+ """Download pinned runtimes into the local toolchain cache."""
71
+ requested = [str(r or "").strip().lower() for r in runtime]
72
+ if not requested:
73
+ targets = list(_SUPPORTED_RUNTIMES)
74
+ else:
75
+ bad = sorted({r for r in requested if r not in _SUPPORTED_RUNTIMES})
76
+ if bad:
77
+ supported = ", ".join(_SUPPORTED_RUNTIMES)
78
+ console.print(
79
+ f"[red]Unsupported runtime(s):[/red] {', '.join(bad)}. "
80
+ f"Supported: {supported}."
81
+ )
82
+ raise typer.Exit(code=1)
83
+ targets = list(dict.fromkeys(requested))
84
+
85
+ failures: list[str] = []
86
+ for idx, name in enumerate(targets, start=1):
87
+ console.print(f"[cyan]prewarm {idx}/{len(targets)}[/cyan]: {name}")
88
+ try:
89
+ ht.ensure_runtime(name, on_progress=lambda msg: console.print(f"[dim] {msg}[/dim]"))
90
+ except Exception as exc:
91
+ failures.append(f"{name}: {exc}")
92
+ continue
93
+ console.print(f"[green]ready[/green] {name}")
94
+
95
+ if failures:
96
+ console.print("[red]Prewarm finished with failures:[/red]")
97
+ for item in failures:
98
+ console.print(f"[red] - {item}[/red]")
99
+ raise typer.Exit(code=1)
@@ -30,6 +30,7 @@ from refactorai_cli import (
30
30
  commit_queue,
31
31
  git_scope,
32
32
  refactor_branch_store,
33
+ watch_ledger,
33
34
  watch_state,
34
35
  watch_supervisor,
35
36
  )
@@ -39,6 +40,7 @@ from refactorai_cli.watch_state import (
39
40
  FinalizeInfo,
40
41
  RRCommit,
41
42
  WatchState,
43
+ hydrate_node_from_ledger,
42
44
  render_graph,
43
45
  )
44
46
 
@@ -268,6 +270,61 @@ def refresh_finalize(project_root: Path, state: WatchState) -> None:
268
270
  node.finalize = resolve_finalize(project_root, node)
269
271
 
270
272
 
273
+ def _ingest_event(project_root: Path, state: WatchState, event: dict) -> None:
274
+ """Add a queued node for ``event``, rehydrating it from the ledger if known.
275
+
276
+ Runs only for brand-new shas (``state.has`` guards re-entry), so a
277
+ deliberately requeued node — e.g. after ``[f]`` fresh review — is never
278
+ silently restored to its old reviewed state. A commit the ledger already has
279
+ is rehydrated (rendered as history) instead of being re-analyzed; its open
280
+ request count is re-derived live from the RR store so the phase stays honest
281
+ even though prior requests may since have been resolved (Phase 2).
282
+ """
283
+ sha = str(event.get("sha") or "")
284
+ if not sha or state.has(sha):
285
+ return
286
+ node = state.upsert_from_event(event)
287
+ record = watch_ledger.get_commit(project_root, sha)
288
+ if not record:
289
+ return
290
+ hydrate_node_from_ledger(node, record)
291
+ if node.run_id:
292
+ pending_count, preview = _pending_for_run(project_root, node.run_id)
293
+ node.rr_count = pending_count
294
+ node.rr_preview = preview
295
+
296
+
297
+ def reconcile_merged(project_root: Path, state: WatchState, *, deep: bool = False) -> None:
298
+ """Detect refactor branches integrated into the primary branch out-of-band.
299
+
300
+ The ``[m]`` merge path already records a ``merged`` outcome; this catches the
301
+ case where the developer merged (or squash/rebase-merged with ``deep``) the
302
+ refactor branch via plain git. When detected, the branch's outcome is
303
+ tombstoned so the node collapses to a DONE row and the commit becomes
304
+ terminal in the ledger — never re-queued or re-analyzed again.
305
+ """
306
+ primary = getattr(state, "primary_branch", "") or ""
307
+ if not primary:
308
+ return
309
+ for node in state.nodes_oldest_first():
310
+ fin = getattr(node, "finalize", None)
311
+ if fin is None or not fin.branch_exists:
312
+ continue
313
+ if fin.outcome in (refactor_branch_store.OUTCOME_MERGED, refactor_branch_store.OUTCOME_DROPPED):
314
+ continue
315
+ try:
316
+ merged = git_scope.branch_merged_into(project_root, fin.refactor_branch, primary, deep=deep)
317
+ except Exception:
318
+ merged = False
319
+ if not merged:
320
+ continue
321
+ refactor_branch_store.set_outcome(
322
+ project_root, fin.refactor_branch, refactor_branch_store.OUTCOME_MERGED
323
+ )
324
+ node.finalize = resolve_finalize(project_root, node)
325
+ watch_ledger.record_commit(project_root, node)
326
+
327
+
271
328
  def _resolve_repo(project_root: Path | None) -> Path:
272
329
  root = git_scope.repo_root(project_root or Path.cwd())
273
330
  if not root:
@@ -302,15 +359,31 @@ def _ensure_auth_in_window(json_mode: bool) -> None:
302
359
  raise typer.Exit(code=1) from exc2
303
360
 
304
361
 
305
- def _load_initial_events(project_root: Path, since: str | None, limit: int) -> list[dict]:
306
- """Queue events to render on start, optionally sliced by ``--since`` / ``--limit``."""
362
+ def _load_initial_events(
363
+ project_root: Path,
364
+ since: str | None,
365
+ limit: int,
366
+ *,
367
+ watermark: str | None = None,
368
+ ) -> list[dict]:
369
+ """Queue events to render on start, sliced by ``--since`` / watermark / ``--limit``.
370
+
371
+ An explicit ``--since`` wins. Otherwise the persisted review watermark (the
372
+ newest commit already reviewed on the primary branch) bounds the scan so each
373
+ launch is O(new commits) rather than re-loading the whole append-only queue
374
+ (Phase 2). ``--limit`` still caps the render for a very large first run.
375
+ """
307
376
  events = commit_queue.read_events(project_root)
308
- if since:
309
- since_sha = git_scope.resolve_sha(project_root, since)
310
- if since_sha:
377
+ anchor = since or watermark
378
+ anchor_is_watermark = not since and bool(watermark)
379
+ if anchor:
380
+ anchor_sha = git_scope.resolve_sha(project_root, anchor)
381
+ if anchor_sha:
311
382
  for idx, ev in enumerate(events):
312
- if ev.get("sha") == since_sha:
313
- events = events[idx:]
383
+ if ev.get("sha") == anchor_sha:
384
+ # The watermark commit is already reviewed -> start *after* it;
385
+ # an explicit --since is inclusive of the named commit.
386
+ events = events[idx + 1:] if anchor_is_watermark else events[idx:]
314
387
  break
315
388
  if limit and limit > 0:
316
389
  events = events[-limit:]
@@ -366,16 +439,20 @@ def process_pending(
366
439
  except Exception:
367
440
  pass
368
441
  for event in commit_queue.read_events(project_root):
369
- sha = str(event.get("sha") or "")
370
- if sha and not state.has(sha):
371
- state.upsert_from_event(event)
442
+ _ingest_event(project_root, state, event)
372
443
  if on_update:
373
444
  on_update(state)
374
445
 
375
446
  reviewed = 0
447
+ newest_reviewed = ""
376
448
  for node in state.nodes_oldest_first():
377
449
  if node.state != watch_state.QUEUED:
378
450
  continue
451
+ # A commit finalized in a previous session (merged/dropped) is terminal:
452
+ # never re-analyze it, even if it is still sitting in the append-only
453
+ # queue (Phase 2 core fix).
454
+ if watch_ledger.is_terminal(watch_ledger.get_status(project_root, node.sha)):
455
+ continue
379
456
  state.mark_reviewing(node.sha)
380
457
  if on_update:
381
458
  on_update(state)
@@ -390,12 +467,21 @@ def process_pending(
390
467
  outcome.rr_preview = _preview_for_run(project_root, outcome.run_id)
391
468
  state.apply_outcome(node.sha, outcome)
392
469
  reviewed += 1
470
+ newest_reviewed = node.sha
393
471
  if on_update:
394
472
  on_update(state)
395
473
  _report_node(project_root, node)
474
+ # Persist the outcome so a relaunched terminal renders it as history
475
+ # instead of re-running the model on this commit.
476
+ watch_ledger.record_commit(project_root, state.get(node.sha))
396
477
  # docs/43 Phase 1: fold current refactor-branch data into reviewed nodes so
397
478
  # their phase (review/finalize/done) reflects any branch built by `[r]`.
398
479
  refresh_finalize(project_root, state)
480
+ # Detect out-of-band merges (deep scan only when we actually did work this
481
+ # pass, so idle polls stay cheap) and advance the review watermark.
482
+ reconcile_merged(project_root, state, deep=bool(reviewed))
483
+ if reviewed and newest_reviewed:
484
+ watch_ledger.set_watermark(project_root, state.primary_branch or state.current_branch, newest_reviewed)
399
485
  if on_update:
400
486
  on_update(state)
401
487
  if reviewed:
@@ -544,6 +630,28 @@ def _sync_current_branch(project_root: Path, state: WatchState) -> None:
544
630
  pass
545
631
 
546
632
 
633
+ def _return_to_primary(project_root: Path, state: WatchState) -> None:
634
+ """Restore HEAD to the developer's primary branch after a terminal action.
635
+
636
+ Resolving (``[r]``) leaves HEAD on the refactor sub-branch; if the next commit
637
+ is authored there the sub-branch name nests (``refactor/refactor/...``).
638
+ Checking back out onto the primary branch keeps every future commit — and
639
+ thus every future sub-branch — anchored to the user's real branch. Fail-soft:
640
+ a dirty tree or missing branch simply leaves HEAD where it is (Phase 1, A4).
641
+ """
642
+ primary = getattr(state, "primary_branch", "") or ""
643
+ if not primary or primary == "HEAD":
644
+ _sync_current_branch(project_root, state)
645
+ return
646
+ try:
647
+ current = git_scope.current_branch(project_root)
648
+ if current != primary and git_scope.branch_exists(project_root, primary):
649
+ git_scope.checkout(project_root, primary)
650
+ except Exception:
651
+ pass
652
+ _sync_current_branch(project_root, state)
653
+
654
+
547
655
  def _run_action(
548
656
  live,
549
657
  project_root: Path,
@@ -582,16 +690,31 @@ def _run_action(
582
690
  pending_count, preview = _pending_for_run(project_root, node_after.run_id)
583
691
  node_after.rr_count = pending_count
584
692
  node_after.rr_preview = preview
585
- _sync_current_branch(project_root, state)
693
+ # `[r]` leaves HEAD on the sub-branch; return to primary so the next
694
+ # commit (and its sub-branch) never nests (Phase 1, A4).
695
+ _return_to_primary(project_root, state)
586
696
  refresh_finalize(project_root, state)
697
+ refreshed = state.get(node.sha)
698
+ if refreshed is not None:
699
+ watch_ledger.record_commit(project_root, refreshed)
587
700
  elif action in ("merge", "drop", "revert"):
588
701
  # Finalize actions (docs/43). `merge_branch` records the merged outcome
589
702
  # itself; a completed drop leaves no branch, so tombstone the dropped
590
703
  # outcome here so the node collapses to a DONE (dropped) row.
591
704
  if action == "drop" and branch and not git_scope.branch_exists(project_root, branch):
592
705
  refactor_branch_store.set_outcome(project_root, branch, refactor_branch_store.OUTCOME_DROPPED)
593
- _sync_current_branch(project_root, state)
706
+ # After merge/drop end on the primary branch; revert stays in place.
707
+ if action in ("merge", "drop"):
708
+ _return_to_primary(project_root, state)
709
+ else:
710
+ _sync_current_branch(project_root, state)
594
711
  refresh_finalize(project_root, state)
712
+ # Persist the terminal outcome so the ledger short-circuits this commit
713
+ # on the next launch (no re-review, no re-queued requests).
714
+ if action in ("merge", "drop"):
715
+ refreshed = state.get(node.sha)
716
+ if refreshed is not None:
717
+ watch_ledger.record_commit(project_root, refreshed)
595
718
  # `[r]`/`[m]`/`[d]` can change RR status; mirror the local store to the Inbox.
596
719
  if action in ("code", "merge", "drop"):
597
720
  _report_refactor_requests(project_root)
@@ -862,12 +985,13 @@ def watch(
862
985
  state = WatchState(repo_name=project_root.name)
863
986
  try:
864
987
  state.current_branch = git_scope.current_branch(project_root) or ""
988
+ state.primary_branch = git_scope.resolve_primary_branch(project_root) or ""
865
989
  except Exception:
866
990
  state.current_branch = ""
867
- for event in _load_initial_events(project_root, since, limit):
868
- sha = str(event.get("sha") or "")
869
- if sha and not state.has(sha):
870
- state.upsert_from_event(event)
991
+ watch_ledger.set_primary_branch(project_root, state.primary_branch)
992
+ watermark = watch_ledger.get_watermark(project_root, state.primary_branch or state.current_branch)
993
+ for event in _load_initial_events(project_root, since, limit, watermark=watermark):
994
+ _ingest_event(project_root, state, event)
871
995
 
872
996
  runner: ReviewRunner = SubprocessReviewRunner(project_root)
873
997
 
@@ -970,12 +1094,13 @@ def ensure(
970
1094
  state = WatchState(repo_name=project_root.name)
971
1095
  try:
972
1096
  state.current_branch = git_scope.current_branch(project_root) or ""
1097
+ state.primary_branch = git_scope.resolve_primary_branch(project_root) or ""
973
1098
  except Exception:
974
1099
  state.current_branch = ""
975
- for event in _load_initial_events(project_root, None, 20):
976
- sha = str(event.get("sha") or "")
977
- if sha and not state.has(sha):
978
- state.upsert_from_event(event)
1100
+ watch_ledger.set_primary_branch(project_root, state.primary_branch)
1101
+ watermark = watch_ledger.get_watermark(project_root, state.primary_branch or state.current_branch)
1102
+ for event in _load_initial_events(project_root, None, 20, watermark=watermark):
1103
+ _ingest_event(project_root, state, event)
979
1104
  runner: ReviewRunner = SubprocessReviewRunner(project_root)
980
1105
  _open_watch_window(project_root, state, runner)
981
1106
  return
@@ -65,7 +65,10 @@ def build_commit_event(project_root: Path | str, sha: str) -> dict | None:
65
65
  subject, message = git_scope.commit_message(project_root, resolved)
66
66
  return {
67
67
  "sha": resolved,
68
- "branch": git_scope.current_branch(project_root),
68
+ # Record the developer's primary branch, not a refactor sub-branch: a
69
+ # stray commit authored on a ``refactor/*`` ref would otherwise seed the
70
+ # nested-name problem for the next sub-branch (Phase 1, A5).
71
+ "branch": git_scope.resolve_primary_branch(project_root),
69
72
  "subject": subject,
70
73
  "message": message,
71
74
  "changed_files": git_scope.commit_changed_files(project_root, resolved),
@@ -140,6 +140,79 @@ def current_branch(project_root: Path | str) -> str:
140
140
  return proc.stdout.strip()
141
141
 
142
142
 
143
+ def is_refactor_ref(name: str) -> bool:
144
+ """True when ``name`` is a refactor sub-branch (``refactor/...``).
145
+
146
+ The refactor flow must never treat one of its own sub-branches as the
147
+ developer's base, otherwise sub-branch names nest (``refactor/refactor/...``).
148
+ """
149
+ return str(name or "").strip().startswith("refactor/")
150
+
151
+
152
+ def default_branch(project_root: Path | str) -> str:
153
+ """Best-effort default branch (``origin/HEAD`` -> ``main`` -> ``master``).
154
+
155
+ Used as the last-resort primary branch when HEAD is detached or the current
156
+ ref is a refactor sub-branch with no recorded source. Returns ``""`` when
157
+ nothing sensible can be resolved.
158
+ """
159
+ proc = _git(project_root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
160
+ if proc and proc.returncode == 0:
161
+ ref = proc.stdout.strip()
162
+ if ref:
163
+ name = ref.rsplit("/", 1)[-1].strip()
164
+ if name and not is_refactor_ref(name):
165
+ return name
166
+ for candidate in ("main", "master"):
167
+ if branch_exists(project_root, candidate):
168
+ return candidate
169
+ return ""
170
+
171
+
172
+ def resolve_primary_branch(project_root: Path | str) -> str:
173
+ """Resolve the developer's primary (working) branch, never a ``refactor/*``.
174
+
175
+ This is the single source of truth for "which branch does the user own": the
176
+ refactor sub-branch flow anchors its base and naming to this so a sub-branch
177
+ is always ``refactor/<primary>/<sha>`` (flat), even when the terminal action
178
+ is invoked while HEAD is standing on a previously-created sub-branch.
179
+
180
+ Resolution order (docs Phase 1, A1):
181
+ 1. the current branch, if it is not itself a refactor ref;
182
+ 2. otherwise walk the recorded ``source_branch`` of the current refactor
183
+ sub-branch until a non-refactor base is found;
184
+ 3. otherwise fall back to the repo default branch.
185
+ """
186
+ current = current_branch(project_root)
187
+ if current and current != "HEAD" and not is_refactor_ref(current):
188
+ return current
189
+
190
+ # HEAD is detached or sitting on a refactor sub-branch: walk the source chain.
191
+ try:
192
+ from refactorai_cli import refactor_branch_store
193
+ except Exception:
194
+ refactor_branch_store = None
195
+
196
+ branch = current
197
+ seen: set[str] = set()
198
+ while (
199
+ refactor_branch_store is not None
200
+ and branch
201
+ and is_refactor_ref(branch)
202
+ and branch not in seen
203
+ ):
204
+ seen.add(branch)
205
+ meta = refactor_branch_store.load_branch(project_root, branch)
206
+ source = str(getattr(meta, "source_branch", "") or "") if meta else ""
207
+ if not source:
208
+ break
209
+ branch = source
210
+
211
+ if branch and branch != "HEAD" and not is_refactor_ref(branch):
212
+ return branch
213
+ return default_branch(project_root)
214
+
215
+
143
216
  def commit_parent_count(project_root: Path | str, sha: str) -> int:
144
217
  """Number of parents of ``sha`` (a merge commit has >= 2)."""
145
218
  sha = (sha or "").strip()
@@ -405,6 +478,95 @@ def commit_sha_for_rr(project_root: Path | str, rr_id: str) -> str | None:
405
478
  return sha[0].strip() if sha and sha[0].strip() else None
406
479
 
407
480
 
481
+ def is_ancestor(project_root: Path | str, maybe_ancestor: str, descendant: str) -> bool:
482
+ """True when ``maybe_ancestor`` is reachable from ``descendant`` (merged in).
483
+
484
+ Uses ``git merge-base --is-ancestor`` (exit 0 == ancestor, 1 == not). Any
485
+ other outcome (bad ref, git error) fails soft to ``False``.
486
+ """
487
+ a = (maybe_ancestor or "").strip()
488
+ b = (descendant or "").strip()
489
+ if not a or not b:
490
+ return False
491
+ proc = _git(project_root, "merge-base", "--is-ancestor", a, b)
492
+ return bool(proc) and proc.returncode == 0
493
+
494
+
495
+ def rev_list(project_root: Path | str, ref: str, *, limit: int = 50) -> list[str]:
496
+ """Up to ``limit`` commit SHAs reachable from ``ref`` (newest first)."""
497
+ ref = (ref or "").strip()
498
+ if not ref:
499
+ return []
500
+ args = ["rev-list", ref]
501
+ if limit and limit > 0:
502
+ args.append(f"--max-count={int(limit)}")
503
+ proc = _git(project_root, *args)
504
+ if not proc or proc.returncode != 0:
505
+ return []
506
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
507
+
508
+
509
+ def patch_id(project_root: Path | str, ref: str) -> str:
510
+ """Stable patch-id of a commit's diff, or ``""`` on failure.
511
+
512
+ Survives rebase/cherry-pick/squash (content-addressed rather than SHA-based),
513
+ so it can detect that a refactor branch was integrated even when the merge
514
+ rewrote history. Pipes ``git show`` into ``git patch-id --stable``.
515
+ """
516
+ ref = (ref or "").strip()
517
+ if not ref:
518
+ return ""
519
+ try:
520
+ show = subprocess.run(
521
+ ["git", "-C", str(project_root), "show", "--format=%H", ref],
522
+ capture_output=True,
523
+ text=True,
524
+ timeout=_TIMEOUT,
525
+ check=False,
526
+ )
527
+ if show.returncode != 0 or not show.stdout:
528
+ return ""
529
+ pid = subprocess.run(
530
+ ["git", "-C", str(project_root), "patch-id", "--stable"],
531
+ input=show.stdout,
532
+ capture_output=True,
533
+ text=True,
534
+ timeout=_TIMEOUT,
535
+ check=False,
536
+ )
537
+ except (OSError, subprocess.SubprocessError):
538
+ return ""
539
+ if pid.returncode != 0 or not pid.stdout.strip():
540
+ return ""
541
+ # Output is "<patch-id> <commit-sha>"; keep the patch-id half.
542
+ return pid.stdout.strip().split()[0]
543
+
544
+
545
+ def branch_merged_into(
546
+ project_root: Path | str, branch: str, target: str, *, deep: bool = False, window: int = 50
547
+ ) -> bool:
548
+ """True when ``branch`` has been integrated into ``target``.
549
+
550
+ Cheap path (always): ``branch`` tip is an ancestor of ``target`` (covers
551
+ fast-forward and ``--no-ff`` merges, including a manual ``git merge`` done
552
+ outside the terminal). ``deep`` additionally compares the branch tip's
553
+ patch-id against ``target``'s recent history to catch squash/rebase merges;
554
+ it is opt-in because it runs one ``patch-id`` per scanned commit.
555
+ """
556
+ branch = (branch or "").strip()
557
+ target = (target or "").strip()
558
+ if not branch or not target:
559
+ return False
560
+ if is_ancestor(project_root, branch, target):
561
+ return True
562
+ if not deep:
563
+ return False
564
+ bpid = patch_id(project_root, branch)
565
+ if not bpid:
566
+ return False
567
+ return any(patch_id(project_root, sha) == bpid for sha in rev_list(project_root, target, limit=window))
568
+
569
+
408
570
  def commit_message(project_root: Path | str, sha: str) -> tuple[str, str]:
409
571
  """Return ``(subject, full_message)`` for commit ``sha`` (empty on failure)."""
410
572
  sha = (sha or "").strip()
@@ -48,6 +48,22 @@ def sanitize_branch_component(value: str) -> str:
48
48
  return (cleaned or "work")[:60]
49
49
 
50
50
 
51
+ def debase_source_branch(source_branch: str) -> str:
52
+ """Strip any leading ``refactor/<base>/...`` so a name can never nest.
53
+
54
+ A refactor sub-branch is always ``refactor/<component>/<shortsha>`` (the
55
+ ``<component>`` never contains a slash — ``sanitize_branch_component`` folds
56
+ them to ``-``). If a caller mistakenly passes a refactor ref as the source
57
+ branch we peel it back to its ``<component>`` so the composed name stays flat
58
+ (``refactor/refactor/...`` can never be produced). Phase 1 backstop.
59
+ """
60
+ b = str(source_branch or "").strip()
61
+ while b.startswith("refactor/"):
62
+ parts = b.split("/")
63
+ b = parts[1] if len(parts) > 1 else ""
64
+ return b
65
+
66
+
51
67
  def refactor_branch_name(
52
68
  source_branch: str,
53
69
  source_sha: str,
@@ -55,6 +71,7 @@ def refactor_branch_name(
55
71
  template: str = "refactor/{branch}/{shortsha}",
56
72
  ) -> str:
57
73
  """Compose ``refactor/<sanitized-branch>/<shortsha>`` (detached-safe)."""
74
+ source_branch = debase_source_branch(source_branch)
58
75
  if source_branch and source_branch != "HEAD":
59
76
  branch = sanitize_branch_component(source_branch)
60
77
  else:
@@ -0,0 +1,199 @@
1
+ """Durable per-repo review ledger for the Refactor Terminal (Phase 2).
2
+
3
+ The in-memory ``WatchState`` is rebuilt from scratch every time ``refactor watch``
4
+ launches, and the commit queue is append-only, so without a durable record the
5
+ terminal re-runs the model on every commit it already analyzed (and re-surfaces
6
+ requests that were already resolved/merged). This module persists, per repo, the
7
+ outcome of each commit review plus a per-branch watermark so a relaunched window
8
+ *renders history* instead of re-reviewing.
9
+
10
+ Storage lives beside the commit queue (``<git-dir>/refactor-runs/
11
+ review_ledger.json``) so it is per-repo, never tracked, and excluded from
12
+ indexing. Everything is best-effort and fail-soft: a missing/corrupt ledger
13
+ reads as empty and a failed write is swallowed, so the terminal never crashes or
14
+ stalls because of ledger I/O.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ from refactorai_cli import commit_queue
24
+
25
+ LEDGER_FILENAME = "review_ledger.json"
26
+ LEDGER_VERSION = 1
27
+
28
+ # Persisted per-commit review statuses (a superset view of the in-memory
29
+ # lifecycle in ``watch_state``): a commit is analyzed once and then tracked as it
30
+ # moves toward a terminal ``merged``/``dropped`` outcome.
31
+ QUEUED = "queued"
32
+ REVIEWING = "reviewing"
33
+ REVIEWED = "reviewed"
34
+ FINALIZING = "finalizing"
35
+ MERGED = "merged"
36
+ DROPPED = "dropped"
37
+ ERROR = "error"
38
+
39
+ # Terminal states are never re-queued or re-analyzed.
40
+ _TERMINAL = {MERGED, DROPPED}
41
+ # States that mean "already analyzed" -> the node rehydrates as REVIEWED.
42
+ _REVIEWED_LIKE = {REVIEWED, FINALIZING, MERGED, DROPPED}
43
+
44
+
45
+ def _now_iso() -> str:
46
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
47
+
48
+
49
+ def _empty() -> dict:
50
+ return {"version": LEDGER_VERSION, "primary_branch": "", "watermark": {}, "commits": {}}
51
+
52
+
53
+ def ledger_path(project_root: Path | str) -> Path | None:
54
+ """Path to the per-repo review ledger, or ``None`` outside a git dir."""
55
+ directory = commit_queue.runs_dir(project_root)
56
+ return directory / LEDGER_FILENAME if directory else None
57
+
58
+
59
+ def load_ledger(project_root: Path | str) -> dict:
60
+ """Read the ledger (normalized shape); an empty ledger on any failure."""
61
+ path = ledger_path(project_root)
62
+ if not path or not path.is_file():
63
+ return _empty()
64
+ try:
65
+ data = json.loads(path.read_text(encoding="utf-8"))
66
+ except (json.JSONDecodeError, OSError):
67
+ return _empty()
68
+ if not isinstance(data, dict):
69
+ return _empty()
70
+ data.setdefault("version", LEDGER_VERSION)
71
+ data.setdefault("primary_branch", "")
72
+ if not isinstance(data.get("commits"), dict):
73
+ data["commits"] = {}
74
+ if not isinstance(data.get("watermark"), dict):
75
+ data["watermark"] = {}
76
+ return data
77
+
78
+
79
+ def _write(project_root: Path | str, data: dict) -> None:
80
+ path = ledger_path(project_root)
81
+ if not path:
82
+ return
83
+ try:
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ data["updated_at"] = _now_iso()
86
+ path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
87
+ except OSError:
88
+ pass
89
+
90
+
91
+ def is_terminal(status: str) -> bool:
92
+ """True when a commit's review is finalized (``merged``/``dropped``)."""
93
+ return str(status or "") in _TERMINAL
94
+
95
+
96
+ def is_reviewed_like(status: str) -> bool:
97
+ """True when the status means the commit was already analyzed."""
98
+ return str(status or "") in _REVIEWED_LIKE
99
+
100
+
101
+ def get_commit(project_root: Path | str, sha: str) -> dict | None:
102
+ """The stored record for ``sha``, or ``None`` if never reviewed."""
103
+ sha = str(sha or "")
104
+ if not sha:
105
+ return None
106
+ return load_ledger(project_root)["commits"].get(sha) or None
107
+
108
+
109
+ def get_status(project_root: Path | str, sha: str) -> str:
110
+ """The stored review status for ``sha`` (``""`` when unknown)."""
111
+ rec = get_commit(project_root, sha)
112
+ return str(rec.get("status", "")) if rec else ""
113
+
114
+
115
+ def _status_for_node(node) -> str:
116
+ """Derive the persisted status from a live ``CommitNode``."""
117
+ fin = getattr(node, "finalize", None)
118
+ outcome = str(getattr(fin, "outcome", "") or "") if fin else ""
119
+ if outcome in _TERMINAL:
120
+ return outcome
121
+ state = str(getattr(node, "state", "") or "")
122
+ if state == "error":
123
+ return ERROR
124
+ if state == "reviewing":
125
+ return REVIEWING
126
+ if state == "reviewed":
127
+ # A resolved commit sitting on a live refactor branch is mid-finalize.
128
+ if fin is not None and getattr(fin, "branch_exists", False) and int(getattr(node, "rr_count", 0) or 0) == 0:
129
+ return FINALIZING
130
+ return REVIEWED
131
+ return QUEUED
132
+
133
+
134
+ def record_commit(project_root: Path | str, node) -> None:
135
+ """Persist (or refresh) the review record for a commit node."""
136
+ if node is None:
137
+ return
138
+ sha = str(getattr(node, "sha", "") or "")
139
+ if not sha:
140
+ return
141
+ data = load_ledger(project_root)
142
+ existing = data["commits"].get(sha, {})
143
+ fin = getattr(node, "finalize", None)
144
+ status = _status_for_node(node)
145
+ reviewed_at = existing.get("reviewed_at") or ""
146
+ if not reviewed_at and is_reviewed_like(status):
147
+ reviewed_at = _now_iso()
148
+ data["commits"][sha] = {
149
+ "sha": sha,
150
+ "branch": str(getattr(node, "branch", "") or ""),
151
+ "subject": str(getattr(node, "subject", "") or ""),
152
+ "status": status,
153
+ "run_id": str(getattr(node, "run_id", "") or ""),
154
+ "gate_status": str(getattr(node, "gate_status", "") or ""),
155
+ "high_findings": int(getattr(node, "high_findings", 0) or 0),
156
+ "rr_count": int(getattr(node, "rr_count", 0) or 0),
157
+ "scope_mode": str(getattr(node, "scope_mode", "commit") or "commit"),
158
+ "changed": int(getattr(node, "changed", 0) or 0),
159
+ "associated": int(getattr(node, "associated", 0) or 0),
160
+ "intent_summary": str(getattr(node, "intent_summary", "") or ""),
161
+ "refactor_branch": str(getattr(fin, "refactor_branch", "") or "") if fin else "",
162
+ "outcome": str(getattr(fin, "outcome", "") or "") if fin else "",
163
+ "reviewed_at": reviewed_at,
164
+ "updated_at": _now_iso(),
165
+ }
166
+ _write(project_root, data)
167
+
168
+
169
+ def mark_merged(project_root: Path | str, sha: str) -> None:
170
+ """Force a commit's record to the terminal ``merged`` state (reconciler)."""
171
+ sha = str(sha or "")
172
+ if not sha:
173
+ return
174
+ data = load_ledger(project_root)
175
+ rec = data["commits"].get(sha)
176
+ if not rec:
177
+ return
178
+ rec["status"] = MERGED
179
+ rec["outcome"] = MERGED
180
+ rec["updated_at"] = _now_iso()
181
+ _write(project_root, data)
182
+
183
+
184
+ def set_primary_branch(project_root: Path | str, branch: str) -> None:
185
+ data = load_ledger(project_root)
186
+ data["primary_branch"] = str(branch or "")
187
+ _write(project_root, data)
188
+
189
+
190
+ def set_watermark(project_root: Path | str, branch: str, sha: str) -> None:
191
+ """Record the newest reviewed commit for ``branch`` (scan lower bound)."""
192
+ data = load_ledger(project_root)
193
+ data["watermark"][str(branch or "")] = str(sha or "")
194
+ _write(project_root, data)
195
+
196
+
197
+ def get_watermark(project_root: Path | str, branch: str) -> str:
198
+ """The last reviewed commit recorded for ``branch`` (``""`` when none)."""
199
+ return str(load_ledger(project_root)["watermark"].get(str(branch or ""), ""))
@@ -198,6 +198,10 @@ class WatchState:
198
198
  model: str = "-"
199
199
  selected_sha: str = ""
200
200
  current_branch: str = ""
201
+ # The developer's primary (working) branch — never a ``refactor/*`` sub-branch.
202
+ # Resolved once at startup; terminal actions restore HEAD here afterwards so
203
+ # commits are never authored on a sub-branch (which would nest branch names).
204
+ primary_branch: str = ""
201
205
  # Shas the user pressed `[b] back` on: render the review view for them even
202
206
  # though their phase is ``finalize`` (docs/43 Part D). Cleared on requeue.
203
207
  forced_review: set[str] = field(default_factory=set)
@@ -340,6 +344,38 @@ class WatchState:
340
344
  node.run_id = outcome.run_id
341
345
 
342
346
 
347
+ def hydrate_node_from_ledger(node: "CommitNode", record: dict) -> None:
348
+ """Rebuild a node's review outcome from a persisted ledger record (Phase 2).
349
+
350
+ Lets a relaunched terminal render a previously-analyzed commit without
351
+ re-running the model. A ``reviewed``/``finalizing``/terminal record restores
352
+ the node to ``REVIEWED``; anything else (``queued``/``reviewing``/``error``)
353
+ stays ``QUEUED`` so it is (re)analyzed — errors are intentionally retryable.
354
+ Open-request counts are intentionally *not* trusted from the ledger (they go
355
+ stale as requests are resolved); the caller re-derives them live from the RR
356
+ store when a ``run_id`` is present.
357
+ """
358
+ status = str(record.get("status", "") or "")
359
+ if status in (REVIEWED, "finalizing", "merged", "dropped"):
360
+ node.state = REVIEWED
361
+ else:
362
+ node.state = QUEUED
363
+ node.run_id = str(record.get("run_id", "") or "") or node.run_id
364
+ node.gate_status = str(record.get("gate_status", "") or "")
365
+ node.high_findings = int(record.get("high_findings", 0) or 0)
366
+ node.rr_count = int(record.get("rr_count", 0) or 0)
367
+ scope_mode = str(record.get("scope_mode", "") or "")
368
+ if scope_mode:
369
+ node.scope_mode = scope_mode
370
+ changed = int(record.get("changed", 0) or 0)
371
+ if changed:
372
+ node.changed = changed
373
+ node.associated = int(record.get("associated", 0) or 0)
374
+ intent = str(record.get("intent_summary", "") or "")
375
+ if intent:
376
+ node.intent_summary = intent
377
+
378
+
343
379
  class ReviewOutcomeLike: # typing helper only; real type lives in review_runner
344
380
  error: str
345
381
  changed: int
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.5.8
3
+ Version: 0.6.0
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -21,6 +21,7 @@ refactorai_cli/review_runner.py
21
21
  refactorai_cli/runtime_manager.py
22
22
  refactorai_cli/settings.py
23
23
  refactorai_cli/setup_flow.py
24
+ refactorai_cli/watch_ledger.py
24
25
  refactorai_cli/watch_state.py
25
26
  refactorai_cli/watch_supervisor.py
26
27
  refactorai_cli.egg-info/PKG-INFO
File without changes
File without changes