refactorai-cli 0.4.4__tar.gz → 0.4.7__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 (46) hide show
  1. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/cloud_rr.py +14 -2
  5. refactorai_cli-0.4.7/refactorai_cli/commands/branch_cmds.py +340 -0
  6. refactorai_cli-0.4.7/refactorai_cli/commands/request_cmds.py +129 -0
  7. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/run_cmds.py +711 -11
  8. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/watch_cmds.py +78 -12
  9. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/git_scope.py +213 -0
  10. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/main.py +6 -0
  11. refactorai_cli-0.4.7/refactorai_cli/refactor_branch_store.py +116 -0
  12. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/review_runner.py +2 -1
  13. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/watch_state.py +34 -14
  14. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/PKG-INFO +2 -2
  15. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/SOURCES.txt +3 -0
  16. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/requires.txt +1 -1
  17. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/README.md +0 -0
  18. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/auth.py +0 -0
  19. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/client.py +0 -0
  20. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/__init__.py +0 -0
  21. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/account_cmds.py +0 -0
  22. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/auth_cmds.py +0 -0
  23. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/cloud_cmds.py +0 -0
  24. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/engine_cmds.py +0 -0
  25. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/hook_cmds.py +0 -0
  26. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/model_cmds.py +0 -0
  27. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/rules_cmds.py +0 -0
  28. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/runtime_cmds.py +0 -0
  29. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  30. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/setup_cmds.py +0 -0
  31. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  32. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/commit_queue.py +0 -0
  33. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/control_plane.py +0 -0
  34. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/credentials.py +0 -0
  35. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/dotenv_loader.py +0 -0
  36. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/local_constitution.py +0 -0
  37. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/local_engine_runtime.py +0 -0
  38. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/local_paths.py +0 -0
  39. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/model_policy.py +0 -0
  40. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/runtime_manager.py +0 -0
  41. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/settings.py +0 -0
  42. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli/setup_flow.py +0 -0
  43. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  44. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/entry_points.txt +0 -0
  45. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/refactorai_cli.egg-info/top_level.txt +0 -0
  46. {refactorai_cli-0.4.4 → refactorai_cli-0.4.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.4
3
+ Version: 0.4.7
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: rich>=13.7.0
10
10
  Requires-Dist: PyYAML>=6.0.1
11
- Requires-Dist: refactorai-core>=3.2.3
11
+ Requires-Dist: refactorai-core>=3.2.5
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.4"
3
+ version = "0.4.7"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "httpx>=0.27.0",
13
13
  "rich>=13.7.0",
14
14
  "PyYAML>=6.0.1",
15
- "refactorai-core>=3.2.3",
15
+ "refactorai-core>=3.2.5",
16
16
  ]
17
17
 
18
18
  [project.scripts]
@@ -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.4.4"
8
+ __version__ = "0.4.7"
@@ -650,6 +650,7 @@ def run_cloud_refactor_requests(
650
650
  approval_cb: Callable[[dict], bool] | None = None,
651
651
  apply_byok_payload: Callable[[dict, object], None] | None = None,
652
652
  toolchain_consent_cb: Callable[[dict], bool] | None = None,
653
+ commit_cb: Callable[[object], None] | None = None,
653
654
  ) -> RunArtifact:
654
655
  """Drive pending RRs through the cloud server-run loop and return a summary.
655
656
 
@@ -659,6 +660,11 @@ def run_cloud_refactor_requests(
659
660
  shared BYOK resolver injected by the caller to avoid a circular import.
660
661
  ``toolchain_consent_cb`` gates first-run managed-toolchain provisioning; when
661
662
  None, provisioning proceeds without prompting (e.g. ``--auto-approve``).
663
+
664
+ ``commit_cb`` (R29) mirrors the local executor's post-apply hook: it is fired
665
+ once per RR that reaches ``APPLIED`` so the CLI can record one git commit per
666
+ resolved request on an isolated refactor branch. Best-effort; git failures
667
+ never break the cloud RR loop.
662
668
  """
663
669
  project_root = Path(project_root)
664
670
  progress_cb = progress_cb or (lambda e: None)
@@ -867,7 +873,7 @@ def run_cloud_refactor_requests(
867
873
  _record_outcome(
868
874
  project_root, by_id, completed_id, verdict, action.get("message", ""),
869
875
  attempts_by_id.get(completed_id, 0 if verdict == "deferred" else 1),
870
- outcomes, counts, progress_cb,
876
+ outcomes, counts, progress_cb, commit_cb=commit_cb,
871
877
  )
872
878
  if verdict == "failed":
873
879
  stopped = True
@@ -956,7 +962,7 @@ def _apply_and_verify(
956
962
 
957
963
 
958
964
  def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
959
- outcomes: list, counts: dict, progress_cb) -> None:
965
+ outcomes: list, counts: dict, progress_cb, *, commit_cb=None) -> None:
960
966
  status_map = {
961
967
  "applied": APPLIED,
962
968
  "denied": DENIED,
@@ -968,6 +974,12 @@ def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
968
974
  rr.status = status_map.get(verdict, rr.status)
969
975
  rr.attempt_count = max(int(getattr(rr, "attempt_count", 0) or 0), int(attempts or 0))
970
976
  save_request(project_root, rr)
977
+ # R29: one git commit per applied RR (client-side git policy), best-effort.
978
+ if commit_cb is not None and rr.status == APPLIED:
979
+ try:
980
+ commit_cb(rr)
981
+ except Exception:
982
+ pass
971
983
  outcomes.append({
972
984
  "location": getattr(rr, "location", rr_id) if rr is not None else rr_id,
973
985
  "operation_type": getattr(rr, "operation_type", "content_patch") if rr is not None else "content_patch",
@@ -0,0 +1,340 @@
1
+ """`refactor branch` commands: manage the local refactor branch (docs/42 `R29`).
2
+
3
+ Phase 3 of the local refactor-branch flow. After ``refactor code --commit``
4
+ records one commit per resolved request on an isolated ``refactor/<branch>/<sha>``
5
+ branch, these commands let the developer inspect it (``status``), merge it into
6
+ their working branch **locally** (``merge`` — never pushes), or discard it
7
+ (``drop``).
8
+
9
+ Everything here is local git + the central RR/branch stores; there is no runtime
10
+ or network dependency. ``refactor_core`` imports are lazy so a stale installed
11
+ core can never break loading the CLI.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from refactorai_cli import git_scope, refactor_branch_store
24
+
25
+ console = Console()
26
+ app = typer.Typer(
27
+ name="branch",
28
+ help="Manage the local refactor branch and its per-request commits (docs/42).",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+
33
+
34
+ def _project_repo() -> Path:
35
+ """Resolve the project root and require a git repo (local-only commands)."""
36
+ from refactorai_cli.local_constitution import find_config
37
+
38
+ cfg = find_config(Path.cwd())
39
+ if cfg is None:
40
+ console.print("[yellow]branch:[/yellow] missing refactor.config. Run `refactor init` first.")
41
+ raise typer.Exit(code=1)
42
+ root = cfg.parent
43
+ if not git_scope.is_git_repo(root):
44
+ console.print("[red]branch:[/red] not inside a git repository.")
45
+ raise typer.Exit(code=1)
46
+ return root
47
+
48
+
49
+ def _committed_by_branch(project_root: Path) -> dict[str, list]:
50
+ """Map refactor branch name -> RRs that have a per-request commit on it."""
51
+ from refactor_core.refactor_requests import list_requests
52
+
53
+ out: dict[str, list] = {}
54
+ for rr in list_requests(project_root):
55
+ branch = str(getattr(rr, "refactor_branch", "") or "")
56
+ if branch and getattr(rr, "commit_sha", ""):
57
+ out.setdefault(branch, []).append(rr)
58
+ return out
59
+
60
+
61
+ def _squash_message(project_root: Path, target: str, source: str) -> str:
62
+ rr_count = len(_committed_by_branch(project_root).get(target, []))
63
+ return (
64
+ f"refactor: squash {target} ({rr_count} request(s)) [skip-refactor]\n\n"
65
+ f"Squashed refactor commits from {target} into {source}.\n"
66
+ "Refactor-Trailer: tool=refactorai; kind=squash-merge"
67
+ )
68
+
69
+
70
+ def source_moved_sha(project_root: Path, target: str, source: str) -> str | None:
71
+ """Return the source tip sha if it advanced past the branch's recorded base.
72
+
73
+ ``None`` when the source is unchanged (or unknown). Used to warn before a
74
+ merge that git will bridge a gap the refactor branch was not built against.
75
+ """
76
+ meta = refactor_branch_store.load_branch(project_root, target)
77
+ recorded = meta.source_sha if meta else ""
78
+ if not recorded:
79
+ return None
80
+ tip = git_scope.resolve_sha(project_root, source)
81
+ return tip if (tip and tip != recorded) else None
82
+
83
+
84
+ def rebase_refactor_branch(project_root: Path, target: str, source: str) -> tuple[bool, str]:
85
+ """Rebase ``target`` onto ``source`` so it sits atop the current source tip.
86
+
87
+ Conflict-safe (auto-aborts). Updates the recorded base sha on success so a
88
+ subsequent ``source_moved_sha`` check is clean.
89
+ """
90
+ ok, output = git_scope.rebase(project_root, source, target)
91
+ if not ok:
92
+ return False, output
93
+ tip = git_scope.resolve_sha(project_root, source)
94
+ if tip:
95
+ meta = refactor_branch_store.load_branch(project_root, target)
96
+ refactor_branch_store.record_branch(
97
+ project_root,
98
+ target,
99
+ source_branch=source,
100
+ source_sha=tip,
101
+ )
102
+ return True, f"rebased {target} onto {source}"
103
+
104
+
105
+ def merge_branch(
106
+ project_root: Path,
107
+ target: str,
108
+ *,
109
+ source: str,
110
+ squash: bool = False,
111
+ ) -> tuple[bool, str]:
112
+ """Merge ``target`` into ``source`` locally (never pushes). Returns (ok, msg).
113
+
114
+ Pure of prompting/printing so it is reused by both the ``merge`` command and
115
+ the ``code`` finalize step. Ensures we are on ``source`` first; a dirty tree
116
+ or a conflict returns ``(False, msg)`` and leaves both branches intact.
117
+ """
118
+ if not git_scope.branch_exists(project_root, target):
119
+ return False, f"refactor branch '{target}' does not exist"
120
+ if not source or not git_scope.branch_exists(project_root, source):
121
+ return False, f"source branch '{source}' does not exist"
122
+ current = git_scope.current_branch(project_root)
123
+ if current != source:
124
+ if git_scope.is_worktree_dirty(project_root):
125
+ return False, "working tree is dirty; commit or stash before merging"
126
+ if not git_scope.checkout(project_root, source):
127
+ return False, f"could not check out source branch '{source}'"
128
+ ok, output = git_scope.merge(project_root, target, squash=squash)
129
+ if not ok:
130
+ return False, f"merge failed / conflicted: {output}"
131
+ if squash:
132
+ sha = git_scope.commit(project_root, _squash_message(project_root, target, source))
133
+ if not sha:
134
+ return True, f"nothing to squash-merge from {target}"
135
+ return True, f"squash-merged {target} into {source} ({sha[:9]})"
136
+ return True, f"merged {target} into {source}"
137
+
138
+
139
+ def drop_branch(
140
+ project_root: Path,
141
+ target: str,
142
+ *,
143
+ source: str | None = None,
144
+ ) -> tuple[bool, str]:
145
+ """Delete ``target`` and clear its metadata (discards its commits if unmerged).
146
+
147
+ Switches off the branch first (to ``source``) when it is checked out. Pure of
148
+ prompting/printing so both ``drop`` and finalize can reuse it.
149
+ """
150
+ if not git_scope.branch_exists(project_root, target):
151
+ refactor_branch_store.remove_branch(project_root, target)
152
+ return True, f"'{target}' did not exist; cleared metadata"
153
+ current = git_scope.current_branch(project_root)
154
+ if current == target:
155
+ dest = source
156
+ if not dest:
157
+ meta = refactor_branch_store.load_branch(project_root, target)
158
+ dest = meta.source_branch if meta else ""
159
+ if not dest or not git_scope.branch_exists(project_root, dest):
160
+ return False, "on the refactor branch; check out another branch first"
161
+ if git_scope.is_worktree_dirty(project_root):
162
+ return False, "working tree is dirty; commit or stash before dropping"
163
+ if not git_scope.checkout(project_root, dest):
164
+ return False, f"could not switch off '{target}'"
165
+ if git_scope.delete_branch(project_root, target, force=True):
166
+ refactor_branch_store.remove_branch(project_root, target)
167
+ return True, f"dropped {target}"
168
+ return False, f"could not delete '{target}'"
169
+
170
+
171
+ def _resolve_target_branch(project_root: Path, branch: str | None) -> str:
172
+ """Pick the refactor branch to act on (explicit, else the single candidate)."""
173
+ if branch:
174
+ return branch
175
+ metas = refactor_branch_store.list_branches(project_root)
176
+ existing = [m.branch for m in metas if git_scope.branch_exists(project_root, m.branch)]
177
+ if len(existing) == 1:
178
+ return existing[0]
179
+ if len(existing) > 1:
180
+ console.print("[yellow]branch:[/yellow] multiple refactor branches exist; pass --branch:")
181
+ for name in existing:
182
+ console.print(f" - {name}")
183
+ raise typer.Exit(code=2)
184
+ # Fallbacks: a recorded (but now deleted) branch, RR records, or being on one.
185
+ committed = _committed_by_branch(project_root)
186
+ if len(committed) == 1:
187
+ return next(iter(committed))
188
+ current = git_scope.current_branch(project_root)
189
+ if current.startswith("refactor/"):
190
+ return current
191
+ console.print("[red]branch:[/red] no refactor branch found. Run `refactor code --commit <sha>` first.")
192
+ raise typer.Exit(code=1)
193
+
194
+
195
+ @app.command("status")
196
+ def status() -> None:
197
+ """Show refactor branches, their source, and per-request commits."""
198
+ root = _project_repo()
199
+ current = git_scope.current_branch(root)
200
+ console.print(f"Repository: [bold]{root}[/bold]")
201
+ console.print(f"Current branch: [cyan]{current or '(unknown)'}[/cyan]")
202
+
203
+ committed = _committed_by_branch(root)
204
+ metas = {m.branch: m for m in refactor_branch_store.list_branches(root)}
205
+ names: list[str] = []
206
+ for name in list(metas.keys()) + list(committed.keys()):
207
+ if name not in names:
208
+ names.append(name)
209
+
210
+ if not names:
211
+ console.print("[dim]No refactor branches yet. Use `refactor code --commit <sha>`.[/dim]")
212
+ return
213
+
214
+ for name in names:
215
+ exists = git_scope.branch_exists(root, name)
216
+ meta = metas.get(name)
217
+ source = meta.source_branch if meta else ""
218
+ rrs = committed.get(name, [])
219
+ applied = [rr for rr in rrs if not getattr(rr, "reverted", False)]
220
+ reverted = [rr for rr in rrs if getattr(rr, "reverted", False)]
221
+ header = f"\n[cyan]{name}[/cyan]"
222
+ if not exists:
223
+ header += " [dim](deleted)[/dim]"
224
+ console.print(
225
+ f"{header} [dim]source={source or '?'} "
226
+ f"commits={len(applied)} reverted={len(reverted)}[/dim]"
227
+ )
228
+ if not rrs:
229
+ continue
230
+ table = Table(show_lines=False)
231
+ table.add_column("rr_id", no_wrap=True)
232
+ table.add_column("status", no_wrap=True)
233
+ table.add_column("commit", no_wrap=True)
234
+ table.add_column("location")
235
+ for rr in rrs:
236
+ table.add_row(
237
+ rr.rr_id,
238
+ str(getattr(rr, "status", "")),
239
+ str(getattr(rr, "commit_sha", "") or "")[:9],
240
+ getattr(rr, "location", ""),
241
+ )
242
+ console.print(table)
243
+
244
+
245
+ @app.command("merge")
246
+ def merge(
247
+ branch: str | None = typer.Option(None, "--branch", help="Refactor branch to merge (default: the single existing one)."),
248
+ squash: bool = typer.Option(False, "--squash", help="Squash the refactor commits into one on the source branch."),
249
+ rebase: bool = typer.Option(False, "--rebase", help="If the source branch advanced, rebase the refactor branch onto it before merging."),
250
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
251
+ ) -> None:
252
+ """Merge the refactor branch into its source branch locally (never pushes)."""
253
+ root = _project_repo()
254
+ target = _resolve_target_branch(root, branch)
255
+ if not git_scope.branch_exists(root, target):
256
+ console.print(f"[red]branch:[/red] refactor branch '{target}' does not exist.")
257
+ raise typer.Exit(code=1)
258
+
259
+ meta = refactor_branch_store.load_branch(root, target)
260
+ source = meta.source_branch if meta else ""
261
+ current = git_scope.current_branch(root)
262
+ if not source or not git_scope.branch_exists(root, source):
263
+ # Fall back to the current branch when it is not the refactor branch.
264
+ if current and current != target:
265
+ source = current
266
+ else:
267
+ console.print(
268
+ "[red]branch:[/red] could not determine the source branch. "
269
+ "Check out your working branch, then re-run `refactor branch merge`."
270
+ )
271
+ raise typer.Exit(code=2)
272
+
273
+ # docs/42 Part O: warn when the source branch advanced past the pinned base.
274
+ moved = source_moved_sha(root, target, source)
275
+ if moved:
276
+ recorded = meta.source_sha if meta else ""
277
+ console.print(
278
+ f"[yellow]branch:[/yellow] source '{source}' advanced since {target} was "
279
+ f"created ([dim]{(recorded or '?')[:9]} -> {moved[:9]}[/dim])."
280
+ )
281
+ if rebase:
282
+ console.print(f"[dim]branch: rebasing {target} onto {source}...[/dim]")
283
+ ok, msg = rebase_refactor_branch(root, target, source)
284
+ if not ok:
285
+ console.print(
286
+ f"[red]branch: rebase failed / conflicted.[/red]\n[dim]{msg}[/dim]\n"
287
+ "[dim]Resolve the refactor branch manually, then re-run merge.[/dim]"
288
+ )
289
+ raise typer.Exit(code=1)
290
+ console.print(f"[green]{msg}.[/green]")
291
+ else:
292
+ console.print(
293
+ "[dim]Pass --rebase to replay the refactor commits on the new tip "
294
+ "first, or continue to let git bridge the gap.[/dim]"
295
+ )
296
+
297
+ if not yes and sys.stdin.isatty():
298
+ how = "squash-merge" if squash else "merge"
299
+ if not typer.confirm(f"{how} {target} into {source} (local, no push)?", default=True):
300
+ console.print("[dim]branch: merge aborted.[/dim]")
301
+ raise typer.Exit(code=1)
302
+
303
+ ok, message = merge_branch(root, target, source=source, squash=squash)
304
+ if not ok:
305
+ console.print(f"[red]branch: {message}[/red]")
306
+ console.print(
307
+ "[dim]Resolve conflicts and commit, or run `git merge --abort` to back out. "
308
+ "Both branches are intact.[/dim]"
309
+ )
310
+ raise typer.Exit(code=1)
311
+
312
+ console.print(f"[green]{message}[/green] [dim](local only)[/dim]")
313
+ console.print(
314
+ f"[dim]The refactor commits are now on {source}. Push when you're ready; "
315
+ f"drop the branch with `refactor branch drop --branch {target}`.[/dim]"
316
+ )
317
+
318
+
319
+ @app.command("drop")
320
+ def drop(
321
+ branch: str | None = typer.Option(None, "--branch", help="Refactor branch to drop (default: the single existing one)."),
322
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
323
+ ) -> None:
324
+ """Delete the refactor branch and forget its metadata (discards its commits)."""
325
+ root = _project_repo()
326
+ target = _resolve_target_branch(root, branch)
327
+
328
+ if not yes and sys.stdin.isatty() and git_scope.branch_exists(root, target):
329
+ if not typer.confirm(
330
+ f"drop {target}? This discards its refactor commits (unless already merged).",
331
+ default=False,
332
+ ):
333
+ console.print("[dim]branch: drop aborted.[/dim]")
334
+ raise typer.Exit(code=1)
335
+
336
+ ok, message = drop_branch(root, target)
337
+ if not ok:
338
+ console.print(f"[red]branch: {message}.[/red]")
339
+ raise typer.Exit(code=2 if "check out" in message or "dirty" in message else 1)
340
+ console.print(f"[green]{message}.[/green]")
@@ -0,0 +1,129 @@
1
+ """`refactor request` commands: per-request listing + revert (docs/42 `R29`).
2
+
3
+ Phase 3 of the local refactor-branch flow. ``list`` surfaces each Refactor
4
+ Request with its ``rr_id`` and per-request commit; ``revert --id`` reverses a
5
+ single applied request via ``git revert`` (history-preserving) and marks it
6
+ ``reverted``.
7
+
8
+ Local git + the central RR store only; ``refactor_core`` imports are lazy so a
9
+ stale installed core can never break loading the CLI.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from refactorai_cli import git_scope
22
+
23
+ console = Console()
24
+ app = typer.Typer(
25
+ name="request",
26
+ help="Inspect and revert individual Refactor Requests (docs/42).",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ )
30
+
31
+
32
+ def _project_repo(*, require_git: bool) -> Path:
33
+ from refactorai_cli.local_constitution import find_config
34
+
35
+ cfg = find_config(Path.cwd())
36
+ if cfg is None:
37
+ console.print("[yellow]request:[/yellow] missing refactor.config. Run `refactor init` first.")
38
+ raise typer.Exit(code=1)
39
+ root = cfg.parent
40
+ if require_git and not git_scope.is_git_repo(root):
41
+ console.print("[red]request:[/red] not inside a git repository.")
42
+ raise typer.Exit(code=1)
43
+ return root
44
+
45
+
46
+ @app.command("list")
47
+ def list_requests_cmd() -> None:
48
+ """List Refactor Requests with rr_id, status, and per-request commit."""
49
+ root = _project_repo(require_git=False)
50
+ from refactor_core.refactor_requests import list_requests
51
+
52
+ rrs = list_requests(root)
53
+ if not rrs:
54
+ console.print("[dim]No refactor requests. Run `refactor review` to create them.[/dim]")
55
+ return
56
+
57
+ table = Table(title="Refactor requests", show_lines=False)
58
+ table.add_column("rr_id", no_wrap=True)
59
+ table.add_column("status", no_wrap=True)
60
+ table.add_column("commit", no_wrap=True)
61
+ table.add_column("branch", no_wrap=True)
62
+ table.add_column("location")
63
+ color = {
64
+ "pending": "yellow",
65
+ "in_progress": "yellow",
66
+ "applied": "green",
67
+ "reverted": "magenta",
68
+ "denied": "red",
69
+ "failed": "red",
70
+ "deferred": "cyan",
71
+ }
72
+ for rr in rrs:
73
+ status = str(getattr(rr, "status", ""))
74
+ style = color.get(status, "white")
75
+ table.add_row(
76
+ rr.rr_id,
77
+ f"[{style}]{status}[/{style}]",
78
+ str(getattr(rr, "commit_sha", "") or "")[:9] or "[dim]-[/dim]",
79
+ str(getattr(rr, "refactor_branch", "") or "") or "[dim]-[/dim]",
80
+ getattr(rr, "location", ""),
81
+ )
82
+ console.print(table)
83
+
84
+
85
+ @app.command("revert")
86
+ def revert(
87
+ id: str = typer.Option(..., "--id", help="rr_id of the request to revert."),
88
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
89
+ ) -> None:
90
+ """Revert a single request's commit via `git revert` and mark it reverted."""
91
+ root = _project_repo(require_git=True)
92
+ from refactor_core.refactor_requests import REVERTED, load_request, save_request
93
+
94
+ rr = load_request(root, id)
95
+ if rr is None:
96
+ console.print(f"[red]request:[/red] no request with rr_id '{id}'.")
97
+ raise typer.Exit(code=1)
98
+
99
+ sha = str(getattr(rr, "commit_sha", "") or "") or (git_scope.commit_sha_for_rr(root, id) or "")
100
+ if not sha:
101
+ console.print(
102
+ f"[red]request:[/red] no per-request commit recorded for '{id}'. "
103
+ "Only requests resolved on a refactor branch can be reverted this way."
104
+ )
105
+ raise typer.Exit(code=1)
106
+
107
+ if getattr(rr, "reverted", False):
108
+ console.print(f"[yellow]request:[/yellow] '{id}' is already reverted.")
109
+ raise typer.Exit(code=0)
110
+
111
+ if not yes and sys.stdin.isatty():
112
+ if not typer.confirm(
113
+ f"revert request {id} (commit {sha[:9]}) via git revert?", default=False
114
+ ):
115
+ console.print("[dim]request: revert aborted.[/dim]")
116
+ raise typer.Exit(code=1)
117
+
118
+ ok, output = git_scope.revert_commit(root, sha)
119
+ if not ok:
120
+ console.print(f"[red]request: git revert failed / conflicted.[/red]\n[dim]{output}[/dim]")
121
+ console.print(
122
+ "[dim]Resolve conflicts and commit, or run `git revert --abort` to back out.[/dim]"
123
+ )
124
+ raise typer.Exit(code=1)
125
+
126
+ rr.reverted = True
127
+ rr.status = REVERTED
128
+ save_request(root, rr)
129
+ console.print(f"[green]Reverted[/green] request {id} (revert of {sha[:9]}).")