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.
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/PKG-INFO +1 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/pyproject.toml +1 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/cloud_rr.py +174 -8
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/run_cmds.py +8 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/toolchains_cmds.py +42 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/watch_cmds.py +145 -20
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commit_queue.py +4 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/git_scope.py +162 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/refactor_branch_store.py +17 -0
- refactorai_cli-0.6.0/refactorai_cli/watch_ledger.py +199 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/watch_state.py +36 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/PKG-INFO +1 -1
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/SOURCES.txt +1 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/README.md +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/hook_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/pre_push_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/commit_telemetry.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/main.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/pre_push_gate.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli/watch_supervisor.py +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/requires.txt +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.5.8 → refactorai_cli-0.6.0}/setup.cfg +0 -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,
|
|
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:
|
|
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
|
-
|
|
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"
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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(
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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") ==
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|