refactorai-cli 0.5.9__tar.gz → 0.7.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.9 → refactorai_cli-0.7.0}/PKG-INFO +1 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/pyproject.toml +1 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/cloud_rr.py +413 -32
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/run_cmds.py +8 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/toolchains_cmds.py +42 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/watch_cmds.py +145 -20
- refactorai_cli-0.7.0/refactorai_cli/commands/workspace_cmds.py +79 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commit_queue.py +4 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/git_scope.py +162 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/main.py +4 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/refactor_branch_store.py +17 -0
- refactorai_cli-0.7.0/refactorai_cli/watch_ledger.py +199 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/watch_state.py +36 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/PKG-INFO +1 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/SOURCES.txt +3 -1
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/README.md +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/auth.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/client.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/hook_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/pre_push_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commit_telemetry.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/pre_push_gate.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/watch_supervisor.py +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/requires.txt +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.5.9 → refactorai_cli-0.7.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
|
|
@@ -55,7 +56,7 @@ from refactor_core.refactor_requests import (
|
|
|
55
56
|
save_request,
|
|
56
57
|
)
|
|
57
58
|
from refactor_core.host_toolchain import provision_for_command
|
|
58
|
-
from refactor_core.store import create_run
|
|
59
|
+
from refactor_core.store import create_run, resolve_store_dir
|
|
59
60
|
from refactor_core.testcmd import (
|
|
60
61
|
_SKIP_DIRS,
|
|
61
62
|
_configured_command,
|
|
@@ -67,6 +68,13 @@ from refactorai_cli.auth import ensure_authenticated
|
|
|
67
68
|
from refactorai_cli.settings import platform_url
|
|
68
69
|
|
|
69
70
|
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
71
|
+
_WORKSPACE_DIRNAME = "workspace"
|
|
72
|
+
_WORKSPACE_NAME = "host"
|
|
73
|
+
_WORKSPACE_CACHE_DIRNAME = "cache"
|
|
74
|
+
# Directory names never mirrored into (or pruned from) the managed workspace:
|
|
75
|
+
# git metadata, the central store, and heavy dependency dirs that are restored
|
|
76
|
+
# in-workspace and cached across runs.
|
|
77
|
+
_WORKSPACE_EXCLUDE = {".git", ".refactor", "node_modules"}
|
|
70
78
|
|
|
71
79
|
# Manifest/config files that reveal how a project runs its tests. Sent (truncated)
|
|
72
80
|
# to the server for agentic test-command discovery when deterministic detection
|
|
@@ -104,7 +112,7 @@ def _read_source(project_root: Path, rel: str) -> str:
|
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def _run_test(
|
|
107
|
-
|
|
115
|
+
execution_root: Path, test_command: str | None, *, env: dict | None = None
|
|
108
116
|
) -> tuple[bool, str]:
|
|
109
117
|
"""Run the configured test command on the host; (passed, detail).
|
|
110
118
|
|
|
@@ -126,7 +134,7 @@ def _run_test(
|
|
|
126
134
|
proc = subprocess.run(
|
|
127
135
|
command,
|
|
128
136
|
shell=True,
|
|
129
|
-
cwd=str(
|
|
137
|
+
cwd=str(execution_root),
|
|
130
138
|
capture_output=True,
|
|
131
139
|
text=True,
|
|
132
140
|
timeout=600,
|
|
@@ -140,7 +148,7 @@ def _run_test(
|
|
|
140
148
|
|
|
141
149
|
|
|
142
150
|
def _run_command_with_progress(
|
|
143
|
-
|
|
151
|
+
execution_root: Path,
|
|
144
152
|
command: str,
|
|
145
153
|
*,
|
|
146
154
|
env: dict | None = None,
|
|
@@ -157,7 +165,7 @@ def _run_command_with_progress(
|
|
|
157
165
|
proc = subprocess.Popen(
|
|
158
166
|
command,
|
|
159
167
|
shell=True,
|
|
160
|
-
cwd=str(
|
|
168
|
+
cwd=str(execution_root),
|
|
161
169
|
stdout=subprocess.PIPE,
|
|
162
170
|
stderr=subprocess.STDOUT,
|
|
163
171
|
text=True,
|
|
@@ -358,6 +366,105 @@ def _managed_toolchain_enabled(config: dict) -> bool:
|
|
|
358
366
|
return True
|
|
359
367
|
|
|
360
368
|
|
|
369
|
+
def _deps_restore_policy(config: dict) -> str:
|
|
370
|
+
"""Return dependency-restore policy: ``auto`` (default), ``always``, ``never``."""
|
|
371
|
+
verification = (config or {}).get("verification")
|
|
372
|
+
raw = "auto"
|
|
373
|
+
if isinstance(verification, dict):
|
|
374
|
+
raw = str(verification.get("deps_restore_policy", "auto") or "auto")
|
|
375
|
+
policy = raw.strip().lower()
|
|
376
|
+
return policy if policy in {"auto", "always", "never"} else "auto"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _sha256_file(path: Path) -> str:
|
|
380
|
+
"""Hash a file's bytes; ``""`` when unreadable/missing."""
|
|
381
|
+
if not path.is_file():
|
|
382
|
+
return ""
|
|
383
|
+
digest = hashlib.sha256()
|
|
384
|
+
try:
|
|
385
|
+
with path.open("rb") as fh:
|
|
386
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
387
|
+
digest.update(chunk)
|
|
388
|
+
except OSError:
|
|
389
|
+
return ""
|
|
390
|
+
return digest.hexdigest()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _read_json(path: Path) -> dict:
|
|
394
|
+
try:
|
|
395
|
+
raw = path.read_text(encoding="utf-8")
|
|
396
|
+
except OSError:
|
|
397
|
+
return {}
|
|
398
|
+
try:
|
|
399
|
+
obj = json.loads(raw)
|
|
400
|
+
except json.JSONDecodeError:
|
|
401
|
+
return {}
|
|
402
|
+
return obj if isinstance(obj, dict) else {}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _write_json_atomic(path: Path, payload: dict) -> None:
|
|
406
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
408
|
+
tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
409
|
+
tmp.replace(path)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _node_lockfile(project_root: Path) -> Path | None:
|
|
413
|
+
for name in ("pnpm-lock.yaml", "yarn.lock", "package-lock.json", "npm-shrinkwrap.json"):
|
|
414
|
+
path = project_root / name
|
|
415
|
+
if path.is_file():
|
|
416
|
+
return path
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _node_install_command(project_root: Path) -> tuple[str, str]:
|
|
421
|
+
lockfile = _node_lockfile(project_root)
|
|
422
|
+
if lockfile is None:
|
|
423
|
+
return "npm", "npm install"
|
|
424
|
+
if lockfile.name == "pnpm-lock.yaml":
|
|
425
|
+
return "corepack", "corepack pnpm install --frozen-lockfile"
|
|
426
|
+
if lockfile.name == "yarn.lock":
|
|
427
|
+
return "corepack", "corepack yarn install --immutable"
|
|
428
|
+
return "npm", "npm ci"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _node_deps_state_path(project_root: Path) -> Path:
|
|
432
|
+
return resolve_store_dir(project_root) / "toolchain-state" / "node-deps.json"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _node_deps_fingerprint(project_root: Path, env: dict, install_command: str) -> tuple[str, dict]:
|
|
436
|
+
lockfile = _node_lockfile(project_root)
|
|
437
|
+
node_bin = shutil.which("node", path=(env or {}).get("PATH"))
|
|
438
|
+
metadata = {
|
|
439
|
+
"install_command": install_command,
|
|
440
|
+
"package_json_sha256": _sha256_file(project_root / "package.json"),
|
|
441
|
+
"lockfile_name": lockfile.name if lockfile else "",
|
|
442
|
+
"lockfile_sha256": _sha256_file(lockfile) if lockfile else "",
|
|
443
|
+
# A different binary path often means a different runtime version/build.
|
|
444
|
+
"node_binary_path": str(Path(node_bin).resolve()) if node_bin else "",
|
|
445
|
+
}
|
|
446
|
+
digest = hashlib.sha256(
|
|
447
|
+
json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
448
|
+
).hexdigest()
|
|
449
|
+
return digest, metadata
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _should_restore_node_dependencies(
|
|
453
|
+
project_root: Path, env: dict, install_command: str
|
|
454
|
+
) -> tuple[bool, str, dict]:
|
|
455
|
+
"""Return ``(should_restore, reason, marker_payload)`` for node deps."""
|
|
456
|
+
fingerprint, metadata = _node_deps_fingerprint(project_root, env, install_command)
|
|
457
|
+
payload = {"fingerprint": fingerprint, "metadata": metadata}
|
|
458
|
+
if not (project_root / "node_modules").is_dir():
|
|
459
|
+
return True, "node_modules missing", payload
|
|
460
|
+
state = _read_json(_node_deps_state_path(project_root))
|
|
461
|
+
if not state:
|
|
462
|
+
return True, "cache marker missing", payload
|
|
463
|
+
if str(state.get("fingerprint") or "") != fingerprint:
|
|
464
|
+
return True, "dependency fingerprint changed", payload
|
|
465
|
+
return False, "dependency fingerprint unchanged", payload
|
|
466
|
+
|
|
467
|
+
|
|
361
468
|
def _detected_is_provisionable(command: str | None) -> bool:
|
|
362
469
|
"""True when every missing binary of ``command`` maps to a managed runtime.
|
|
363
470
|
|
|
@@ -403,7 +510,7 @@ def _merge_toolchain_env(base: dict | None, new: dict) -> dict:
|
|
|
403
510
|
|
|
404
511
|
|
|
405
512
|
def _provision_toolchain(
|
|
406
|
-
|
|
513
|
+
execution_root: Path,
|
|
407
514
|
test_command: str | None,
|
|
408
515
|
config: dict,
|
|
409
516
|
*,
|
|
@@ -428,6 +535,10 @@ def _provision_toolchain(
|
|
|
428
535
|
from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
|
|
429
536
|
|
|
430
537
|
def _on_progress(msg: str) -> None:
|
|
538
|
+
# Warm-path reuse is expected on every run; suppress it to keep cloud RR
|
|
539
|
+
# progress noise low while still surfacing real downloads/extracts.
|
|
540
|
+
if str(msg).startswith("reusing "):
|
|
541
|
+
return
|
|
431
542
|
_emit(progress_cb, phase="rr", event="toolchain_provisioning", detail=msg)
|
|
432
543
|
|
|
433
544
|
merged_env: dict | None = None
|
|
@@ -457,40 +568,82 @@ def _provision_toolchain(
|
|
|
457
568
|
continue
|
|
458
569
|
_emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
|
|
459
570
|
merged_env = _merge_toolchain_env(merged_env, env)
|
|
460
|
-
_restore_dependencies(
|
|
571
|
+
_restore_dependencies(execution_root, runtime, merged_env, progress_cb, config=config)
|
|
461
572
|
|
|
462
573
|
return merged_env, " ".join(n for n in notes if n).strip()
|
|
463
574
|
|
|
464
575
|
|
|
465
576
|
def _restore_dependencies(
|
|
466
|
-
project_root: Path,
|
|
577
|
+
project_root: Path,
|
|
578
|
+
runtime: str,
|
|
579
|
+
env: dict,
|
|
580
|
+
progress_cb: Callable[[dict], None] | None,
|
|
581
|
+
*,
|
|
582
|
+
config: dict,
|
|
467
583
|
) -> None:
|
|
468
584
|
"""Restore project dependencies with the provisioned toolchain (best effort).
|
|
469
585
|
|
|
470
586
|
Mirrors the sandbox dependency-restore step so the test command has its deps
|
|
471
587
|
present. Failures are non-fatal: the test run surfaces the real error.
|
|
472
588
|
"""
|
|
589
|
+
policy = _deps_restore_policy(config)
|
|
590
|
+
if policy == "never":
|
|
591
|
+
_emit(
|
|
592
|
+
progress_cb,
|
|
593
|
+
phase="rr",
|
|
594
|
+
event="toolchain_provisioning",
|
|
595
|
+
detail=f"dependency restore skipped for {runtime} (policy=never)",
|
|
596
|
+
)
|
|
597
|
+
return
|
|
473
598
|
if runtime == "node" and (project_root / "package.json").is_file():
|
|
599
|
+
install_binary, install_command = _node_install_command(project_root)
|
|
600
|
+
marker_payload: dict | None = None
|
|
601
|
+
if policy == "auto":
|
|
602
|
+
should_restore, reason, marker_payload = _should_restore_node_dependencies(
|
|
603
|
+
project_root, env, install_command
|
|
604
|
+
)
|
|
605
|
+
if not should_restore:
|
|
606
|
+
_emit(
|
|
607
|
+
progress_cb,
|
|
608
|
+
phase="rr",
|
|
609
|
+
event="toolchain_provisioning",
|
|
610
|
+
detail=f"skipping node dependency restore ({reason})",
|
|
611
|
+
)
|
|
612
|
+
return
|
|
474
613
|
_emit(
|
|
475
614
|
progress_cb,
|
|
476
615
|
phase="rr",
|
|
477
616
|
event="toolchain_provisioning",
|
|
478
|
-
detail="step 1/1:
|
|
617
|
+
detail=f"step 1/1: {install_command} (streaming output below)",
|
|
479
618
|
)
|
|
619
|
+
if not _binary_on_path(install_binary, env=env):
|
|
620
|
+
_emit(
|
|
621
|
+
progress_cb,
|
|
622
|
+
phase="rr",
|
|
623
|
+
event="toolchain_provisioning",
|
|
624
|
+
detail=(
|
|
625
|
+
f"skipping node dependency restore: '{install_binary}' is not available "
|
|
626
|
+
f"for {install_command}"
|
|
627
|
+
),
|
|
628
|
+
)
|
|
629
|
+
return
|
|
480
630
|
ok, detail = _run_command_with_progress(
|
|
481
631
|
project_root,
|
|
482
|
-
|
|
632
|
+
install_command,
|
|
483
633
|
env=env,
|
|
484
634
|
progress_cb=progress_cb,
|
|
485
635
|
label="npm",
|
|
486
636
|
timeout=1800,
|
|
487
637
|
)
|
|
638
|
+
if ok and policy == "auto" and marker_payload is not None:
|
|
639
|
+
with contextlib.suppress(OSError):
|
|
640
|
+
_write_json_atomic(_node_deps_state_path(project_root), marker_payload)
|
|
488
641
|
if not ok:
|
|
489
642
|
_emit(
|
|
490
643
|
progress_cb,
|
|
491
644
|
phase="rr",
|
|
492
645
|
event="toolchain_provisioning",
|
|
493
|
-
detail=f"
|
|
646
|
+
detail=f"{install_command} finished with errors: {detail}",
|
|
494
647
|
)
|
|
495
648
|
elif runtime == "python":
|
|
496
649
|
if (project_root / "requirements.txt").is_file():
|
|
@@ -579,6 +732,184 @@ def _collect_test_manifests(
|
|
|
579
732
|
return docs
|
|
580
733
|
|
|
581
734
|
|
|
735
|
+
def _workspace_root(project_root: Path) -> Path:
|
|
736
|
+
return resolve_store_dir(project_root) / _WORKSPACE_DIRNAME / _WORKSPACE_NAME
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _workspace_cache_root(project_root: Path) -> Path:
|
|
740
|
+
return resolve_store_dir(project_root) / _WORKSPACE_CACHE_DIRNAME
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _workspace_env(project_root: Path, base_env: dict | None) -> dict | None:
|
|
744
|
+
env = dict(base_env) if base_env is not None else None
|
|
745
|
+
cache_root = _workspace_cache_root(project_root)
|
|
746
|
+
npm_cache = cache_root / "npm"
|
|
747
|
+
yarn_cache = cache_root / "yarn"
|
|
748
|
+
pnpm_store = cache_root / "pnpm-store"
|
|
749
|
+
for directory in (npm_cache, yarn_cache, pnpm_store):
|
|
750
|
+
with contextlib.suppress(OSError):
|
|
751
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
752
|
+
if env is None:
|
|
753
|
+
return None
|
|
754
|
+
env.setdefault("npm_config_cache", str(npm_cache))
|
|
755
|
+
env.setdefault("YARN_CACHE_FOLDER", str(yarn_cache))
|
|
756
|
+
env.setdefault("PNPM_STORE_DIR", str(pnpm_store))
|
|
757
|
+
return env
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _has_excluded_component(rel: Path, exclude: set[str]) -> bool:
|
|
761
|
+
return any(part in exclude for part in rel.parts)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _needs_copy(src: Path, dst: Path) -> bool:
|
|
765
|
+
"""True when ``dst`` is missing or differs from ``src`` (size/mtime)."""
|
|
766
|
+
try:
|
|
767
|
+
s = src.stat()
|
|
768
|
+
except OSError:
|
|
769
|
+
return False
|
|
770
|
+
try:
|
|
771
|
+
d = dst.stat()
|
|
772
|
+
except OSError:
|
|
773
|
+
return True
|
|
774
|
+
return int(s.st_mtime) != int(d.st_mtime) or s.st_size != d.st_size
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _sync_working_tree(
|
|
778
|
+
src: Path, dst: Path, *, exclude: set[str], skip_abs: set[Path]
|
|
779
|
+
) -> None:
|
|
780
|
+
"""Incrementally mirror the working tree ``src`` into ``dst``.
|
|
781
|
+
|
|
782
|
+
Copies tracked *and* untracked files (so gitignored inputs a test needs — a
|
|
783
|
+
local ``.env``, fixtures — are present) while skipping heavy/managed dirs
|
|
784
|
+
(``exclude`` by name, ``skip_abs`` by absolute path). Unchanged files are
|
|
785
|
+
left alone (size+mtime check) so re-runs are cheap, and files deleted in the
|
|
786
|
+
source are removed from the mirror. Excluded dirs (e.g. the workspace's own
|
|
787
|
+
``node_modules``) are never copied *or* deleted, so dependency caches persist.
|
|
788
|
+
"""
|
|
789
|
+
src = Path(src)
|
|
790
|
+
dst = Path(dst)
|
|
791
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
792
|
+
|
|
793
|
+
seen_files: set[str] = set()
|
|
794
|
+
seen_dirs: set[str] = set()
|
|
795
|
+
for root, dirs, files in os.walk(src):
|
|
796
|
+
root_path = Path(root)
|
|
797
|
+
# Prune excluded/managed dirs in place so os.walk never descends them.
|
|
798
|
+
dirs[:] = [
|
|
799
|
+
d
|
|
800
|
+
for d in dirs
|
|
801
|
+
if d not in exclude and (root_path / d).resolve() not in skip_abs
|
|
802
|
+
]
|
|
803
|
+
rel_root = root_path.relative_to(src)
|
|
804
|
+
for d in dirs:
|
|
805
|
+
rel = rel_root / d
|
|
806
|
+
seen_dirs.add(rel.as_posix())
|
|
807
|
+
with contextlib.suppress(OSError):
|
|
808
|
+
(dst / rel).mkdir(parents=True, exist_ok=True)
|
|
809
|
+
for f in files:
|
|
810
|
+
rel = rel_root / f
|
|
811
|
+
seen_files.add(rel.as_posix())
|
|
812
|
+
source_file = src / rel
|
|
813
|
+
target_file = dst / rel
|
|
814
|
+
if _needs_copy(source_file, target_file):
|
|
815
|
+
with contextlib.suppress(OSError):
|
|
816
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
817
|
+
shutil.copy2(source_file, target_file)
|
|
818
|
+
|
|
819
|
+
# Deletion phase: drop mirror entries whose source no longer exists, never
|
|
820
|
+
# touching anything under an excluded component (node_modules, caches, .git).
|
|
821
|
+
for root, dirs, files in os.walk(dst, topdown=False):
|
|
822
|
+
rel_root = Path(root).relative_to(dst)
|
|
823
|
+
if _has_excluded_component(rel_root, exclude):
|
|
824
|
+
continue
|
|
825
|
+
for f in files:
|
|
826
|
+
rel = rel_root / f
|
|
827
|
+
if _has_excluded_component(rel, exclude):
|
|
828
|
+
continue
|
|
829
|
+
if rel.as_posix() not in seen_files:
|
|
830
|
+
with contextlib.suppress(OSError):
|
|
831
|
+
(Path(root) / f).unlink()
|
|
832
|
+
for d in dirs:
|
|
833
|
+
if d in exclude:
|
|
834
|
+
continue
|
|
835
|
+
rel = rel_root / d
|
|
836
|
+
if rel.as_posix() not in seen_dirs:
|
|
837
|
+
with contextlib.suppress(OSError):
|
|
838
|
+
(Path(root) / d).rmdir()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _prepare_managed_workspace(project_root: Path, *, progress_cb: Callable[[dict], None] | None) -> Path:
|
|
842
|
+
"""Mirror the project's working tree into the managed workspace and return it.
|
|
843
|
+
|
|
844
|
+
The mirror (not a HEAD worktree) reflects exactly what the developer has on
|
|
845
|
+
disk, so behavior verification matches reality even with uncommitted or
|
|
846
|
+
gitignored files. Installs/tests run here; the real repo is never touched.
|
|
847
|
+
"""
|
|
848
|
+
workspace = _workspace_root(project_root)
|
|
849
|
+
with contextlib.suppress(OSError):
|
|
850
|
+
workspace.parent.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
skip_abs: set[Path] = set()
|
|
852
|
+
with contextlib.suppress(Exception):
|
|
853
|
+
skip_abs.add(resolve_store_dir(project_root).resolve())
|
|
854
|
+
with contextlib.suppress(Exception):
|
|
855
|
+
skip_abs.add(workspace.resolve())
|
|
856
|
+
try:
|
|
857
|
+
_sync_working_tree(
|
|
858
|
+
project_root, workspace, exclude=set(_WORKSPACE_EXCLUDE), skip_abs=skip_abs
|
|
859
|
+
)
|
|
860
|
+
except OSError as exc:
|
|
861
|
+
raise CloudRRError(f"failed to prepare managed workspace: {exc}") from exc
|
|
862
|
+
_emit(
|
|
863
|
+
progress_cb,
|
|
864
|
+
phase="rr",
|
|
865
|
+
event="workspace_ready",
|
|
866
|
+
detail=f"managed workspace ready at {workspace}",
|
|
867
|
+
)
|
|
868
|
+
return workspace
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def prune_workspace(project_root: Path, *, include_cache: bool = False) -> list[str]:
|
|
872
|
+
"""Remove the managed workspace mirror (and optionally its dep caches).
|
|
873
|
+
|
|
874
|
+
Returns the list of removed absolute paths. Safe to call when nothing has
|
|
875
|
+
been provisioned yet (returns an empty list).
|
|
876
|
+
"""
|
|
877
|
+
removed: list[str] = []
|
|
878
|
+
base = resolve_store_dir(project_root) / _WORKSPACE_DIRNAME
|
|
879
|
+
if base.exists():
|
|
880
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
881
|
+
removed.append(str(base))
|
|
882
|
+
if include_cache:
|
|
883
|
+
cache = _workspace_cache_root(project_root)
|
|
884
|
+
if cache.exists():
|
|
885
|
+
shutil.rmtree(cache, ignore_errors=True)
|
|
886
|
+
removed.append(str(cache))
|
|
887
|
+
return removed
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def workspace_paths(project_root: Path) -> dict:
|
|
891
|
+
"""Return the managed workspace + cache locations for ``project_root``."""
|
|
892
|
+
return {
|
|
893
|
+
"workspace": _workspace_root(project_root),
|
|
894
|
+
"cache": _workspace_cache_root(project_root),
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _git_status_porcelain(project_root: Path) -> str:
|
|
899
|
+
try:
|
|
900
|
+
proc = subprocess.run(
|
|
901
|
+
["git", "-C", str(project_root), "status", "--porcelain"],
|
|
902
|
+
capture_output=True,
|
|
903
|
+
text=True,
|
|
904
|
+
timeout=30,
|
|
905
|
+
)
|
|
906
|
+
except (subprocess.SubprocessError, OSError):
|
|
907
|
+
return ""
|
|
908
|
+
if proc.returncode != 0:
|
|
909
|
+
return ""
|
|
910
|
+
return (proc.stdout or "").strip()
|
|
911
|
+
|
|
912
|
+
|
|
582
913
|
def _discover_test_command_via_cloud(
|
|
583
914
|
*,
|
|
584
915
|
project_root: Path,
|
|
@@ -846,6 +1177,18 @@ def run_cloud_refactor_requests(
|
|
|
846
1177
|
if requests is None:
|
|
847
1178
|
requests = pending_requests(project_root)
|
|
848
1179
|
|
|
1180
|
+
run_store = create_run(project_root)
|
|
1181
|
+
run_id = run_store.run_id
|
|
1182
|
+
resolved_model = model_hint or str(
|
|
1183
|
+
constitution.get_setting("model_id", "managed-default") or "managed-default"
|
|
1184
|
+
)
|
|
1185
|
+
outcomes: list[dict] = []
|
|
1186
|
+
counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
|
|
1187
|
+
notes: list[str] = []
|
|
1188
|
+
stopped = False
|
|
1189
|
+
stop_reason = ""
|
|
1190
|
+
model_id = resolved_model
|
|
1191
|
+
|
|
849
1192
|
_emit(
|
|
850
1193
|
progress_cb,
|
|
851
1194
|
phase="rr",
|
|
@@ -854,18 +1197,51 @@ def run_cloud_refactor_requests(
|
|
|
854
1197
|
test_command=test_command or "",
|
|
855
1198
|
execution_env="cloud",
|
|
856
1199
|
)
|
|
1200
|
+
execution_root = _prepare_managed_workspace(project_root, progress_cb=progress_cb)
|
|
1201
|
+
status_before = _git_status_porcelain(project_root)
|
|
857
1202
|
|
|
858
1203
|
# Managed host toolchain: when the resolved command's runtime is missing from
|
|
859
1204
|
# the host PATH, download a pinned runtime into the central store and run
|
|
860
1205
|
# tests with it (no container, no system changes). ``toolchain_env`` is None
|
|
861
1206
|
# when nothing was provisioned (already runnable / no managed runtime).
|
|
862
1207
|
toolchain_env, toolchain_note = _provision_toolchain(
|
|
863
|
-
|
|
1208
|
+
execution_root,
|
|
864
1209
|
test_command,
|
|
865
1210
|
config,
|
|
866
1211
|
consent_cb=toolchain_consent_cb,
|
|
867
1212
|
progress_cb=progress_cb,
|
|
868
1213
|
)
|
|
1214
|
+
toolchain_env = _workspace_env(project_root, toolchain_env)
|
|
1215
|
+
status_after_toolchain = _git_status_porcelain(project_root)
|
|
1216
|
+
if status_after_toolchain != status_before:
|
|
1217
|
+
reason = (
|
|
1218
|
+
"managed workspace invariant violated: toolchain/dependency setup changed the project "
|
|
1219
|
+
"working tree before any RR patch was applied; aborting run."
|
|
1220
|
+
)
|
|
1221
|
+
_emit(
|
|
1222
|
+
progress_cb,
|
|
1223
|
+
phase="rr",
|
|
1224
|
+
event="workspace_dirty_abort",
|
|
1225
|
+
reason=reason,
|
|
1226
|
+
)
|
|
1227
|
+
notes = []
|
|
1228
|
+
if testcmd_note:
|
|
1229
|
+
notes.append(testcmd_note)
|
|
1230
|
+
if toolchain_note:
|
|
1231
|
+
notes.append(toolchain_note)
|
|
1232
|
+
notes.append(reason)
|
|
1233
|
+
return _build_artifact(
|
|
1234
|
+
run_store,
|
|
1235
|
+
run_id,
|
|
1236
|
+
constitution,
|
|
1237
|
+
model_id,
|
|
1238
|
+
outcomes,
|
|
1239
|
+
counts,
|
|
1240
|
+
notes,
|
|
1241
|
+
stopped=True,
|
|
1242
|
+
stop_reason=reason,
|
|
1243
|
+
test_command=test_command,
|
|
1244
|
+
)
|
|
869
1245
|
|
|
870
1246
|
# python3-only hosts: rewrite a bare `python` test command to `python3` when
|
|
871
1247
|
# we did NOT provision a managed runtime (managed provisioning already
|
|
@@ -882,23 +1258,10 @@ def run_cloud_refactor_requests(
|
|
|
882
1258
|
previous=test_command,
|
|
883
1259
|
)
|
|
884
1260
|
test_command = normalized
|
|
885
|
-
|
|
886
|
-
run_store = create_run(project_root)
|
|
887
|
-
run_id = run_store.run_id
|
|
888
|
-
resolved_model = model_hint or str(
|
|
889
|
-
constitution.get_setting("model_id", "managed-default") or "managed-default"
|
|
890
|
-
)
|
|
891
|
-
|
|
892
|
-
outcomes: list[dict] = []
|
|
893
|
-
counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
|
|
894
|
-
notes: list[str] = []
|
|
895
1261
|
if testcmd_note:
|
|
896
1262
|
notes.append(testcmd_note)
|
|
897
1263
|
if toolchain_note:
|
|
898
1264
|
notes.append(toolchain_note)
|
|
899
|
-
stopped = False
|
|
900
|
-
stop_reason = ""
|
|
901
|
-
model_id = resolved_model
|
|
902
1265
|
|
|
903
1266
|
if not requests:
|
|
904
1267
|
return _build_artifact(
|
|
@@ -1009,7 +1372,7 @@ def run_cloud_refactor_requests(
|
|
|
1009
1372
|
current_rr_id = action.get("rr_id", "") or current_rr_id
|
|
1010
1373
|
_emit(progress_cb, phase="rr", event="test_generating", rr_id=current_rr_id)
|
|
1011
1374
|
_write_generated_test(project_root, current_rr_id, action, by_id)
|
|
1012
|
-
passed, detail = _run_test(
|
|
1375
|
+
passed, detail = _run_test(execution_root, test_command, env=toolchain_env)
|
|
1013
1376
|
_emit(progress_cb, phase="rr", event="baseline_test", rr_id=current_rr_id,
|
|
1014
1377
|
passed=passed, detail=detail,
|
|
1015
1378
|
execution_env="host+toolchain" if toolchain_env else "host")
|
|
@@ -1086,7 +1449,12 @@ def run_cloud_refactor_requests(
|
|
|
1086
1449
|
approved=True,
|
|
1087
1450
|
)
|
|
1088
1451
|
passed, detail, files = _apply_and_verify(
|
|
1089
|
-
project_root,
|
|
1452
|
+
project_root,
|
|
1453
|
+
execution_root,
|
|
1454
|
+
run_store.run_dir,
|
|
1455
|
+
diff_text,
|
|
1456
|
+
test_command,
|
|
1457
|
+
env=toolchain_env,
|
|
1090
1458
|
)
|
|
1091
1459
|
pending.applied = True
|
|
1092
1460
|
if passed:
|
|
@@ -1186,7 +1554,13 @@ def _write_generated_test(project_root: Path, rr_id: str, action: dict, by_id: d
|
|
|
1186
1554
|
|
|
1187
1555
|
|
|
1188
1556
|
def _apply_and_verify(
|
|
1189
|
-
project_root: Path,
|
|
1557
|
+
project_root: Path,
|
|
1558
|
+
execution_root: Path,
|
|
1559
|
+
run_dir: Path,
|
|
1560
|
+
diff_text: str,
|
|
1561
|
+
test_command,
|
|
1562
|
+
*,
|
|
1563
|
+
env: dict | None = None,
|
|
1190
1564
|
) -> tuple[bool, str, list[str]]:
|
|
1191
1565
|
"""Apply the diff (with backup), run the test; revert on red.
|
|
1192
1566
|
|
|
@@ -1204,17 +1578,24 @@ def _apply_and_verify(
|
|
|
1204
1578
|
if not changes:
|
|
1205
1579
|
return False, "diff produced no file changes", []
|
|
1206
1580
|
changed_files = list(changes.keys())
|
|
1581
|
+
verify_backup_dir = run_dir / "workspace-backup"
|
|
1582
|
+
verify_backup_dir.mkdir(parents=True, exist_ok=True)
|
|
1207
1583
|
try:
|
|
1208
|
-
apply_changes(
|
|
1584
|
+
apply_changes(execution_root, verify_backup_dir, changes)
|
|
1209
1585
|
except Exception as exc:
|
|
1210
1586
|
return False, f"apply failed: {exc}", changed_files
|
|
1211
|
-
passed, detail = _run_test(
|
|
1587
|
+
passed, detail = _run_test(execution_root, test_command, env=env)
|
|
1212
1588
|
if not passed:
|
|
1213
1589
|
try:
|
|
1214
|
-
revert_from_backup(
|
|
1590
|
+
revert_from_backup(execution_root, verify_backup_dir)
|
|
1215
1591
|
except Exception as exc: # keep going; report both
|
|
1216
1592
|
detail = f"{detail}; revert error: {exc}"
|
|
1217
|
-
|
|
1593
|
+
return passed, detail, changed_files
|
|
1594
|
+
try:
|
|
1595
|
+
apply_changes(project_root, run_dir, changes)
|
|
1596
|
+
except Exception as exc:
|
|
1597
|
+
return False, f"apply to project failed after successful verify: {exc}", changed_files
|
|
1598
|
+
return True, detail, changed_files
|
|
1218
1599
|
|
|
1219
1600
|
|
|
1220
1601
|
def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
|
|
@@ -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
|