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.
Files changed (52) hide show
  1. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/PKG-INFO +1 -1
  2. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/pyproject.toml +1 -1
  3. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/cloud_rr.py +413 -32
  5. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/run_cmds.py +8 -1
  6. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/toolchains_cmds.py +42 -0
  7. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/watch_cmds.py +145 -20
  8. refactorai_cli-0.7.0/refactorai_cli/commands/workspace_cmds.py +79 -0
  9. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commit_queue.py +4 -1
  10. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/git_scope.py +162 -0
  11. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/main.py +4 -0
  12. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/refactor_branch_store.py +17 -0
  13. refactorai_cli-0.7.0/refactorai_cli/watch_ledger.py +199 -0
  14. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/watch_state.py +36 -0
  15. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/PKG-INFO +1 -1
  16. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/SOURCES.txt +3 -1
  17. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/README.md +0 -0
  18. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/auth.py +0 -0
  19. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/client.py +0 -0
  20. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/__init__.py +0 -0
  21. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/account_cmds.py +0 -0
  22. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/auth_cmds.py +0 -0
  23. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/branch_cmds.py +0 -0
  24. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
  25. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/engine_cmds.py +0 -0
  26. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/hook_cmds.py +0 -0
  27. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/model_cmds.py +0 -0
  28. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  29. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/request_cmds.py +0 -0
  30. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/rules_cmds.py +0 -0
  31. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
  32. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  33. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commands/setup_cmds.py +0 -0
  34. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/commit_telemetry.py +0 -0
  35. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/control_plane.py +0 -0
  36. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/credentials.py +0 -0
  37. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/dotenv_loader.py +0 -0
  38. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_constitution.py +0 -0
  39. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_engine_runtime.py +0 -0
  40. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/local_paths.py +0 -0
  41. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/model_policy.py +0 -0
  42. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/pre_push_gate.py +0 -0
  43. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/review_runner.py +0 -0
  44. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/runtime_manager.py +0 -0
  45. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/settings.py +0 -0
  46. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/setup_flow.py +0 -0
  47. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli/watch_supervisor.py +0 -0
  48. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  49. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
  50. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/requires.txt +0 -0
  51. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/top_level.txt +0 -0
  52. {refactorai_cli-0.5.9 → refactorai_cli-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.5.9
3
+ Version: 0.7.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.9"
3
+ version = "0.7.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.9"
8
+ __version__ = "0.7.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
- project_root: Path, test_command: str | None, *, env: dict | None = None
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(project_root),
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
- project_root: Path,
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(project_root),
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
- project_root: Path,
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(project_root, runtime, merged_env, progress_cb)
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, runtime: str, env: dict, progress_cb: Callable[[dict], None] | None
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: npm install (streaming output below)",
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
- "npm install",
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"npm install finished with errors: {detail}",
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
- project_root,
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(project_root, test_command, env=toolchain_env)
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, run_store.run_dir, diff_text, test_command, env=toolchain_env
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, run_dir: Path, diff_text: str, test_command, *, env: dict | None = None
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(project_root, run_dir, 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(project_root, test_command, env=env)
1587
+ passed, detail = _run_test(execution_root, test_command, env=env)
1212
1588
  if not passed:
1213
1589
  try:
1214
- revert_from_backup(project_root, run_dir)
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
- return passed, detail, changed_files
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
- 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