From cff720c8a6fb390bc0db50a3e555a9b6df945622 Mon Sep 17 00:00:00 2001 From: RaghavChamadiya Date: Fri, 5 Jun 2026 16:24:10 +0530 Subject: [PATCH] fix(init): record the distill rewrite-hook verdict in every flow The distill command-rewrite hook treats any repo with .repowise/ and no recorded distill.commands.enabled as enabled-with-ask. Workspace-mode init never offered the opt-in nor recorded a verdict, so users with the hook installed from another repo got prompt floods in workspace-indexed repos they were never asked about (--distill-hook/--no-distill-hook was silently ignored there too). - workspace init now runs the opt-in once and records the verdict in every selected repo (interactive prompt or explicit flag; index-only, full, and advanced modes alike; even when indexing failed, since .repowise/ was already created) - offer_distill_rewrite_hook takes a list of repos; the user-level hook installs once, per-repo writes are isolated so one failure cannot abort init - workspace add inherits the primary repo's explicit verdict for newly indexed repos - repowise update backfills the verdict for workspace members indexed for the first time outside init (single-repo and workspace paths) - hook rewrite install gains PATH / --workspace / --no-workspace so a workspace-wide decline can be reversed in one command --- docs/CLI_REFERENCE.md | 9 +- docs/DISTILL.md | 6 +- .../cli/src/repowise/cli/commands/hook_cmd.py | 65 +++++++--- .../cli/commands/init_cmd/_interactive.py | 28 +++- .../repowise/cli/commands/init_cmd/command.py | 10 +- .../cli/commands/init_cmd/workspace.py | 10 +- .../src/repowise/cli/commands/update_cmd.py | 17 +++ .../repowise/cli/commands/workspace_cmd.py | 61 +++++++++ tests/unit/cli/test_workspace_add_defaults.py | 122 ++++++++++++++++++ tests/unit/distill/test_config_and_doctor.py | 27 ++++ tests/unit/distill/test_init_optin.py | 98 +++++++++++++- 11 files changed, 413 insertions(+), 40 deletions(-) diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index f673b989..3b845112 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -68,7 +68,7 @@ In workspace mode, adds: repo scanning, per-repo indexing, cross-repo analysis ( | `--commit-limit` | Max commits to analyze per file (default: 500). | | `--follow-renames` | Track file renames in git history. | | `--no-claude-md` | Don't generate `CLAUDE.md`. | -| `--distill-hook / --no-distill-hook` | Install or skip the Distill command-rewrite hook (Claude Code PreToolUse). Strictly opt-in: interactive runs prompt (default No); `--no-distill-hook` also gates this repo off in config so a globally installed hook stays inert here. See [DISTILL.md](DISTILL.md). | +| `--distill-hook / --no-distill-hook` | Install or skip the Distill command-rewrite hook (Claude Code PreToolUse). Strictly opt-in: interactive runs prompt (default No); `--no-distill-hook` also gates the repo off in config so a globally installed hook stays inert here. In workspace mode the verdict (prompt or flag) applies to every selected repo. See [DISTILL.md](DISTILL.md). | | `--agents / --no-agents` | Generate or skip managed `AGENTS.md` for Codex. Persists the preference. | | `--codex / --no-codex` | Generate or skip project-local Codex MCP/hooks setup. Interactive runs prompt when Codex CLI is installed and logged in; non-interactive runs require `--codex`. | | `--yes / -y` | Skip confirmation prompts. | @@ -516,12 +516,15 @@ approval by default — so the agent sees a compact, errors-first rendering. ```bash repowise hook rewrite install # writes ~/.claude/settings.json (idempotent) +repowise hook rewrite install -w # also re-enable every workspace repo repowise hook rewrite status repowise hook rewrite uninstall # removes only the repowise entry ``` -`install` also re-enables the current repo's `distill.commands` config if a -prior `repowise init` opt-out had gated it off. `uninstall` is global +`install` also re-enables the target's `distill.commands` config if a prior +`repowise init` opt-out had gated it off — the target repo by default, or +every workspace repo with `--workspace`/`-w` (accepts an optional `PATH` and +`--no-workspace`, like `repowise hook install`). `uninstall` is global (settings.json) and leaves per-repo config untouched. Per-repo posture (`permission: ask | allow`, per-family overrides) lives under `distill.commands` in `.repowise/config.yaml` — see diff --git a/docs/DISTILL.md b/docs/DISTILL.md index 02bcf528..ce47db80 100644 --- a/docs/DISTILL.md +++ b/docs/DISTILL.md @@ -91,8 +91,10 @@ The hook is deliberately conservative. It never rewrites: Per-repo behavior is configured under `distill.commands` in `.repowise/config.yaml` — see [Configuration](#configuration). Declining the `repowise init` prompt writes `distill.commands.enabled: false`, so a hook -installed globally from another repo stays inert in this one. The hook answers -in well under 100 ms (stdlib-only hot path, no database). +installed globally from another repo stays inert in this one. A multi-repo +workspace `init` asks once and records the verdict in **every selected +repo**; `repowise hook rewrite install -w` re-enables them all later. The +hook answers in well under 100 ms (stdlib-only hot path, no database). `repowise init` also adds a short "Output Distillation" section to the managed `CLAUDE.md`, teaching the agent to prefer `repowise distill ` voluntarily diff --git a/packages/cli/src/repowise/cli/commands/hook_cmd.py b/packages/cli/src/repowise/cli/commands/hook_cmd.py index c003728f..1c114df0 100644 --- a/packages/cli/src/repowise/cli/commands/hook_cmd.py +++ b/packages/cli/src/repowise/cli/commands/hook_cmd.py @@ -109,28 +109,55 @@ def rewrite_group() -> None: @rewrite_group.command("install") -def rewrite_install() -> None: - """Install the rewrite hook into ~/.claude/settings.json.""" +@click.argument("path", required=False, default=None) +@click.option( + "--workspace", + "-w", + is_flag=True, + default=False, + help="Force workspace mode (re-enable distill rewrites for every repo in the workspace).", +) +@click.option( + "--no-workspace", + is_flag=True, + default=False, + help="Force single-repo mode even when invoked from a workspace.", +) +def rewrite_install(path: str | None, workspace: bool, no_workspace: bool) -> None: + """Install the rewrite hook into ~/.claude/settings.json. + + The hook itself is user-level (one install covers every repo); this + command additionally re-enables ``distill.commands.enabled`` for the + target — every workspace repo in workspace mode, the target repo + otherwise — since a prior ``repowise init`` opt-out may have gated + repos off. + """ from repowise.cli.agent_adapters.claude_code import ClaudeCodeAdapter + from repowise.cli.helpers import save_distill_commands_enabled - path = ClaudeCodeAdapter().install_rewrite_hook() - if path: - console.print(f"Rewrite hook: [green]installed[/green] ({path})") - console.print( - " [dim]Per-repo behavior is configured under `distill.commands` " - "in .repowise/config.yaml (permission: ask | allow).[/dim]" - ) - # A prior init opt-out may have gated this repo off; installing - # explicitly re-enables it here. - from pathlib import Path - - from repowise.cli.helpers import save_distill_commands_enabled - - cwd = Path.cwd() - if (cwd / ".repowise").is_dir(): - save_distill_commands_enabled(cwd, enabled=True) - else: + target = _hook_target(path, workspace, no_workspace) + + hook_path = ClaudeCodeAdapter().install_rewrite_hook() + if not hook_path: console.print("Rewrite hook: [red]install failed[/red]") + return + console.print(f"Rewrite hook: [green]installed[/green] ({hook_path})") + console.print( + " [dim]Per-repo behavior is configured under `distill.commands` " + "in .repowise/config.yaml (permission: ask | allow).[/dim]" + ) + + if target.is_workspace: + assert target.ws_root is not None and target.ws_config is not None + for entry in target.ws_config.repos: + abs_path = (target.ws_root / entry.path).resolve() + if (abs_path / ".repowise").is_dir(): + save_distill_commands_enabled(abs_path, enabled=True) + console.print(f" {entry.alias}: [green]enabled[/green]") + else: + assert target.repo_path is not None + if (target.repo_path / ".repowise").is_dir(): + save_distill_commands_enabled(target.repo_path, enabled=True) @rewrite_group.command("uninstall") diff --git a/packages/cli/src/repowise/cli/commands/init_cmd/_interactive.py b/packages/cli/src/repowise/cli/commands/init_cmd/_interactive.py index eb0f06c0..5a292293 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/_interactive.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/_interactive.py @@ -11,19 +11,28 @@ def offer_distill_rewrite_hook( console_obj: Any, - repo_path: Path, + repo_paths: list[Path], flag: bool | None, ) -> None: """Opt-in install of the distill command-rewrite hook (Claude Code). ``flag`` is the resolved ``--distill-hook/--no-distill-hook`` value: - True installs without prompting, False skips AND gates this repo off in + True installs without prompting, False skips AND gates the repos off in config (so a hook installed globally from another repo stays inert - here), None prompts when interactive and does nothing otherwise — + there), None prompts when interactive and does nothing otherwise — strictly opt-in. + + The hook itself is user-level (one install covers every repo), but the + verdict is recorded per repo as ``distill.commands.enabled`` in each + entry of ``repo_paths`` — one repo for the single-repo init flow, every + selected repo for the workspace flow. Recording the verdict everywhere + matters because the hook treats any repo with ``.repowise/`` and no + config as enabled (with the ``ask`` posture). """ import os + if not repo_paths: + return if os.environ.get("REPOWISE_SKIP_EDITOR_SETUP", "").strip().lower() not in ( "", "0", @@ -46,8 +55,9 @@ def offer_distill_rewrite_hook( "[bold]Distill:[/bold] rewrite noisy agent commands (tests, builds, " "git, searches) to `repowise distill ...` for compact output?" ) + scope = f"Applies to all {len(repo_paths)} selected repos. " if len(repo_paths) > 1 else "" console_obj.print( - " [dim]Each rewrite is shown for approval; raw output stays " + f" [dim]{scope}Each rewrite is shown for approval; raw output stays " "recoverable via `repowise expand`.[/dim]" ) flag = click.confirm(" Install the Claude Code rewrite hook?", default=False) @@ -58,12 +68,18 @@ def offer_distill_rewrite_hook( console_obj.print(f" [green]✓[/green] Rewrite hook installed ({path})") else: console_obj.print(" [yellow]Rewrite hook install failed.[/yellow]") - _save_distill_enabled(repo_path, enabled=True) else: console_obj.print( " [dim]Skipped. Run 'repowise hook rewrite install' later to set up.[/dim]" ) - _save_distill_enabled(repo_path, enabled=False) + + for repo_path in repo_paths: + try: + _save_distill_enabled(repo_path, enabled=bool(flag)) + except Exception as exc: # init must not crash on a config write + console_obj.print( + f" [yellow]Could not record distill verdict for {repo_path.name}: {exc}[/yellow]" + ) def offer_hook_install( diff --git a/packages/cli/src/repowise/cli/commands/init_cmd/command.py b/packages/cli/src/repowise/cli/commands/init_cmd/command.py index c1e90281..b339ae0b 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/command.py @@ -306,7 +306,8 @@ def _run_generation_phase( help=( "Install the Claude Code command-rewrite hook that routes noisy " "commands (tests, builds, git, searches) through `repowise distill` " - "for compact output. Default: ask when interactive; skip otherwise." + "for compact output. Default: ask when interactive; skip otherwise. " + "In workspace mode the verdict applies to every selected repo." ), ) @click.option( @@ -418,6 +419,7 @@ def init_command( no_claude_md=no_claude_md, agents_md=agents_md, codex_setup=codex_setup, + distill_hook=distill_hook, include_submodules=include_submodules, provider_name=provider_name, model=model, @@ -850,6 +852,6 @@ async def _index_with_resume() -> Any: # Offer to install post-commit hook (both index-only and full modes) offer_hook_install(console, [repo_path]) - # Opt-in distill command-rewrite hook for Claude Code (single-repo only; - # workspace repos can enable it later via `repowise hook rewrite install`). - offer_distill_rewrite_hook(console, repo_path, distill_hook) + # Opt-in distill command-rewrite hook for Claude Code. The workspace flow + # runs its own offer across all selected repos inside _workspace_init. + offer_distill_rewrite_hook(console, [repo_path], distill_hook) diff --git a/packages/cli/src/repowise/cli/commands/init_cmd/workspace.py b/packages/cli/src/repowise/cli/commands/init_cmd/workspace.py index f0e15b7d..89c488e6 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/workspace.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/workspace.py @@ -58,7 +58,7 @@ print_banner, ) -from ._interactive import offer_hook_install +from ._interactive import offer_distill_rewrite_hook, offer_hook_install from .generation import ( CostGateDeclined, cost_gate_declined, @@ -391,6 +391,7 @@ def _workspace_init( no_claude_md: bool, agents_md: bool | None, codex_setup: bool | None, + distill_hook: bool | None, include_submodules: bool, # Generation params (passed through from init_command) provider_name: str | None = None, @@ -616,4 +617,11 @@ def _workspace_init( [r.path for r in indexed_repos], aliases=[r.alias for r in indexed_repos], ) + # Opt-in distill command-rewrite hook for Claude Code: one user-level + # install, with the verdict recorded per repo. Applied to *all* selected + # repos (not just successfully indexed ones, and even when every repo + # failed) because ensure_repowise_dir already created `.repowise/` in + # each, and the hook treats any repo with `.repowise/` and no recorded + # verdict as enabled — a decline must gate every one of them off. + offer_distill_rewrite_hook(console, [r.path for r in selected], distill_hook) console.print() diff --git a/packages/cli/src/repowise/cli/commands/update_cmd.py b/packages/cli/src/repowise/cli/commands/update_cmd.py index 1dd532e8..baf1899b 100644 --- a/packages/cli/src/repowise/cli/commands/update_cmd.py +++ b/packages/cli/src/repowise/cli/commands/update_cmd.py @@ -260,6 +260,16 @@ def _on_done(result: RepoUpdateResult) -> None: ) ) + # Backfill the distill rewrite-hook verdict for members that were just + # indexed for the first time (e.g. added with --no-index) — they would + # otherwise default to enabled despite a workspace-wide decline at init. + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + for entry in ws_config.repos: + if repo_alias and entry.alias != repo_alias: + continue + inherit_workspace_distill_verdict((ws_root / entry.path).resolve()) + # Summary updated = sum(1 for r in results if r.updated) errors = sum(1 for r in results if r.error) @@ -977,6 +987,13 @@ def update_command( assert repo_path is not None # single mode always sets repo_path ensure_repowise_dir(repo_path) + # If this repo is a workspace member updated here for the first time, + # inherit the workspace's distill rewrite-hook verdict (best-effort, + # no-op outside a workspace or once a verdict exists). + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + inherit_workspace_distill_verdict(repo_path) + # --- Fast -> full upgrade (--full): a distinct path that reuses the # persisted graph rather than diffing changed files. Dispatched before any # incremental change-detection so the normal `repowise update` flow below diff --git a/packages/cli/src/repowise/cli/commands/workspace_cmd.py b/packages/cli/src/repowise/cli/commands/workspace_cmd.py index c7b3c271..7277271b 100644 --- a/packages/cli/src/repowise/cli/commands/workspace_cmd.py +++ b/packages/cli/src/repowise/cli/commands/workspace_cmd.py @@ -384,6 +384,66 @@ def _resolve_docs_flag( return False, "no provider configured" +def _inherit_distill_verdict(repo_path: Path, primary_cfg: dict) -> None: + """Copy the primary repo's explicit distill rewrite-hook verdict. + + ``repowise init`` records ``distill.commands.enabled`` in every repo it + asks about; a repo added later would otherwise default to enabled (with + the ``ask`` posture) the moment ``.repowise/`` exists — even after a + workspace-wide decline. No explicit verdict on the primary → leave the + new repo's config untouched. + """ + distill = primary_cfg.get("distill") + commands = distill.get("commands") if isinstance(distill, dict) else None + enabled = commands.get("enabled") if isinstance(commands, dict) else None + if not isinstance(enabled, bool): + return + import contextlib + + from repowise.cli.helpers import save_distill_commands_enabled + + # Inheritance is best-effort; never fail an add over it. + with contextlib.suppress(Exception): + save_distill_commands_enabled(repo_path, enabled=enabled) + + +def inherit_workspace_distill_verdict(repo_path: Path) -> None: + """Best-effort backfill of a workspace member's distill verdict. + + Repos that get ``.repowise/`` outside the init flow (``workspace add + --no-index`` followed by an update, or first-time indexing via + ``repowise update``) never recorded a ``distill.commands.enabled`` + verdict, so a globally installed rewrite hook would treat them as + enabled. Copies the primary repo's explicit verdict when the member has + none of its own. No-op when the repo has no ``.repowise/`` yet, sits + outside a workspace, is itself the primary, already holds a verdict, or + the primary never recorded one. + """ + import contextlib + + with contextlib.suppress(Exception): + if not (repo_path / ".repowise").is_dir(): + return + from repowise.cli.helpers import load_config + from repowise.core.workspace.config import WorkspaceConfig + + cfg = load_config(repo_path) + distill = cfg.get("distill") + commands = distill.get("commands") if isinstance(distill, dict) else None + if isinstance(commands, dict) and isinstance(commands.get("enabled"), bool): + return # repo already has its own verdict + ws_root = find_workspace_root(repo_path) + if ws_root is None: + return + primary = WorkspaceConfig.load(ws_root).get_primary() + if primary is None: + return + primary_path = (ws_root / primary.path).resolve() + if primary_path == repo_path.resolve(): + return + _inherit_distill_verdict(repo_path, load_config(primary_path)) + + def _run_index_for_repo( repo_path: Path, alias: str, @@ -453,6 +513,7 @@ def _run_index_for_repo( docs_skip_reason = f"provider failure: {exc}" ensure_repowise_dir(repo_path) + _inherit_distill_verdict(repo_path, primary_cfg) async def _do_index() -> tuple[int, int, int]: result = await run_pipeline( diff --git a/tests/unit/cli/test_workspace_add_defaults.py b/tests/unit/cli/test_workspace_add_defaults.py index b3678b1c..6fc1ae62 100644 --- a/tests/unit/cli/test_workspace_add_defaults.py +++ b/tests/unit/cli/test_workspace_add_defaults.py @@ -109,3 +109,125 @@ def test_no_provider_anywhere_returns_off(ws): ) assert docs is False assert reason == "no provider configured" + + +# --------------------------------------------------------------------------- +# distill verdict inheritance — a repo added to a workspace must not +# silently re-enable rewrites the workspace declined at init. +# --------------------------------------------------------------------------- + + +def _new_repo(tmp_path: Path) -> Path: + repo = tmp_path / "newrepo" + (repo / ".repowise").mkdir(parents=True) + return repo + + +def _distill_enabled(repo: Path) -> object: + import yaml + + cfg_path = repo / ".repowise" / "config.yaml" + if not cfg_path.exists(): + return None + cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + return ((cfg.get("distill") or {}).get("commands") or {}).get("enabled") + + +def test_inherits_primary_distill_decline(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import _inherit_distill_verdict + + repo = _new_repo(tmp_path) + _inherit_distill_verdict(repo, {"distill": {"commands": {"enabled": False}}}) + assert _distill_enabled(repo) is False + + +def test_inherits_primary_distill_optin(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import _inherit_distill_verdict + + repo = _new_repo(tmp_path) + _inherit_distill_verdict(repo, {"distill": {"commands": {"enabled": True}}}) + assert _distill_enabled(repo) is True + + +def test_no_primary_verdict_leaves_config_untouched(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import _inherit_distill_verdict + + repo = _new_repo(tmp_path) + _inherit_distill_verdict(repo, {}) + _inherit_distill_verdict(repo, {"distill": "garbage"}) + _inherit_distill_verdict(repo, {"distill": {"commands": {"permission": "allow"}}}) + assert not (repo / ".repowise" / "config.yaml").exists() + + +# --------------------------------------------------------------------------- +# inherit_workspace_distill_verdict — the post-update backfill for members +# that got `.repowise/` outside the init flow. +# --------------------------------------------------------------------------- + + +def _ws_with_primary_verdict(tmp_path: Path, enabled: bool) -> Path: + """Workspace root with a primary whose distill verdict is *enabled*.""" + root = tmp_path / "wsroot" + (root / "primary" / ".repowise").mkdir(parents=True) + (root / "primary" / ".repowise" / "config.yaml").write_text( + f"distill:\n commands:\n enabled: {str(enabled).lower()}\n", + encoding="utf-8", + ) + (root / "member" / ".repowise").mkdir(parents=True) + cfg = WorkspaceConfig( + version=1, + repos=[ + RepoEntry(path="primary", alias="primary", is_primary=True), + RepoEntry(path="member", alias="member"), + ], + default_repo="primary", + ) + cfg.save(root) + return root + + +def test_backfill_inherits_primary_decline(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + root = _ws_with_primary_verdict(tmp_path, enabled=False) + inherit_workspace_distill_verdict(root / "member") + assert _distill_enabled(root / "member") is False + + +def test_backfill_skips_member_with_own_verdict(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + root = _ws_with_primary_verdict(tmp_path, enabled=False) + (root / "member" / ".repowise" / "config.yaml").write_text( + "distill:\n commands:\n enabled: true\n", encoding="utf-8" + ) + inherit_workspace_distill_verdict(root / "member") + assert _distill_enabled(root / "member") is True + + +def test_backfill_noop_without_repowise_dir(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + root = _ws_with_primary_verdict(tmp_path, enabled=False) + bare = root / "bare" + bare.mkdir() + inherit_workspace_distill_verdict(bare) + assert not (bare / ".repowise").exists() + + +def test_backfill_noop_outside_workspace(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + repo = _new_repo(tmp_path) + inherit_workspace_distill_verdict(repo) + assert not (repo / ".repowise" / "config.yaml").exists() + + +def test_backfill_noop_for_primary_itself(tmp_path: Path): + from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict + + root = _ws_with_primary_verdict(tmp_path, enabled=False) + before = (root / "primary" / ".repowise" / "config.yaml").read_text(encoding="utf-8") + inherit_workspace_distill_verdict(root / "primary") + after = (root / "primary" / ".repowise" / "config.yaml").read_text(encoding="utf-8") + assert before == after diff --git a/tests/unit/distill/test_config_and_doctor.py b/tests/unit/distill/test_config_and_doctor.py index 639ca729..c2d5bd86 100644 --- a/tests/unit/distill/test_config_and_doctor.py +++ b/tests/unit/distill/test_config_and_doctor.py @@ -230,6 +230,33 @@ def test_install_after_init_opt_out_re_enables_repo( assert cfg["distill"]["commands"]["enabled"] is True +def test_install_workspace_mode_re_enables_every_repo( + tmp_path: Path, settings_path: Path, monkeypatch +) -> None: + from repowise.core.repo_config import load_repo_config + from repowise.core.workspace.config import RepoEntry, WorkspaceConfig + + root = tmp_path / "ws" + aliases = ("alpha", "beta") + for alias in aliases: + (root / alias / ".repowise").mkdir(parents=True) + # Simulate a workspace-wide `repowise init` opt-out. + (root / alias / ".repowise" / "config.yaml").write_text( + "distill:\n commands:\n enabled: false\n", encoding="utf-8" + ) + WorkspaceConfig(repos=[RepoEntry(path=a, alias=a) for a in aliases], default_repo="alpha").save( + root + ) + monkeypatch.chdir(root) + + result = CliRunner().invoke(rewrite_install, ["--workspace"]) + assert result.exit_code == 0 + assert "installed" in result.output + for alias in aliases: + cfg = load_repo_config(root / alias) + assert cfg["distill"]["commands"]["enabled"] is True + + def test_uninstall_removes_hook_but_leaves_repo_config( tmp_path: Path, settings_path: Path, monkeypatch ) -> None: diff --git a/tests/unit/distill/test_init_optin.py b/tests/unit/distill/test_init_optin.py index 17a8e61e..bf1bcf90 100644 --- a/tests/unit/distill/test_init_optin.py +++ b/tests/unit/distill/test_init_optin.py @@ -26,6 +26,16 @@ def repo(tmp_path): return tmp_path / "repo" +@pytest.fixture +def workspace_repos(tmp_path): + """Three sibling repos as the workspace flow would have prepared them.""" + repos = [] + for name in ("alpha", "beta", "gamma"): + (tmp_path / "ws" / name / ".repowise").mkdir(parents=True) + repos.append(tmp_path / "ws" / name) + return repos + + def _distill_config(repo) -> dict: cfg = yaml.safe_load((repo / ".repowise" / "config.yaml").read_text(encoding="utf-8")) return cfg.get("distill", {}) @@ -33,7 +43,7 @@ def _distill_config(repo) -> dict: class TestOfferDistillRewriteHook: def test_explicit_optin_installs_and_enables(self, settings_path, repo) -> None: - offer_distill_rewrite_hook(MagicMock(), repo, flag=True) + offer_distill_rewrite_hook(MagicMock(), [repo], flag=True) assert settings_path.exists() data = json.loads(settings_path.read_text(encoding="utf-8")) commands = [h["command"] for e in data["hooks"]["PreToolUse"] for h in e["hooks"]] @@ -41,7 +51,7 @@ def test_explicit_optin_installs_and_enables(self, settings_path, repo) -> None: assert _distill_config(repo)["commands"]["enabled"] is True def test_explicit_optout_gates_repo_off(self, settings_path, repo) -> None: - offer_distill_rewrite_hook(MagicMock(), repo, flag=False) + offer_distill_rewrite_hook(MagicMock(), [repo], flag=False) assert not settings_path.exists() assert _distill_config(repo)["commands"]["enabled"] is False @@ -49,21 +59,99 @@ def test_no_flag_noninteractive_does_nothing(self, settings_path, repo, monkeypa import sys monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - offer_distill_rewrite_hook(MagicMock(), repo, flag=None) + offer_distill_rewrite_hook(MagicMock(), [repo], flag=None) assert not settings_path.exists() assert not (repo / ".repowise" / "config.yaml").exists() def test_skip_editor_setup_env_blocks_install(self, settings_path, repo, monkeypatch) -> None: monkeypatch.setenv("REPOWISE_SKIP_EDITOR_SETUP", "1") - offer_distill_rewrite_hook(MagicMock(), repo, flag=True) + offer_distill_rewrite_hook(MagicMock(), [repo], flag=True) assert not settings_path.exists() + assert not (repo / ".repowise" / "config.yaml").exists() def test_optout_preserves_existing_distill_config(self, settings_path, repo) -> None: (repo / ".repowise" / "config.yaml").write_text( yaml.dump({"distill": {"commands": {"disabled_filters": ["git_diff"]}}}), encoding="utf-8", ) - offer_distill_rewrite_hook(MagicMock(), repo, flag=False) + offer_distill_rewrite_hook(MagicMock(), [repo], flag=False) distill = _distill_config(repo) assert distill["commands"]["enabled"] is False assert distill["commands"]["disabled_filters"] == ["git_diff"] + + def test_empty_repo_list_is_a_noop(self, settings_path) -> None: + offer_distill_rewrite_hook(MagicMock(), [], flag=True) + assert not settings_path.exists() + + +class TestWorkspaceOptIn: + """The workspace flow records one verdict across every selected repo.""" + + def test_optin_installs_once_and_enables_all_repos( + self, settings_path, workspace_repos + ) -> None: + offer_distill_rewrite_hook(MagicMock(), workspace_repos, flag=True) + data = json.loads(settings_path.read_text(encoding="utf-8")) + commands = [h["command"] for e in data["hooks"]["PreToolUse"] for h in e["hooks"]] + # User-level hook installed exactly once, not per repo. + assert commands == ["repowise-rewrite"] + for rp in workspace_repos: + assert _distill_config(rp)["commands"]["enabled"] is True + + def test_optout_gates_every_repo_off(self, settings_path, workspace_repos) -> None: + offer_distill_rewrite_hook(MagicMock(), workspace_repos, flag=False) + assert not settings_path.exists() + for rp in workspace_repos: + assert _distill_config(rp)["commands"]["enabled"] is False + + def test_interactive_decline_gates_every_repo_off( + self, settings_path, workspace_repos, monkeypatch + ) -> None: + import sys + + import click + + from repowise.cli.agent_adapters.claude_code import ClaudeCodeAdapter + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(ClaudeCodeAdapter, "detect", lambda self: True) + monkeypatch.setattr(click, "confirm", lambda *a, **k: False) + offer_distill_rewrite_hook(MagicMock(), workspace_repos, flag=None) + assert not settings_path.exists() + for rp in workspace_repos: + assert _distill_config(rp)["commands"]["enabled"] is False + + def test_interactive_accept_enables_every_repo( + self, settings_path, workspace_repos, monkeypatch + ) -> None: + import sys + + import click + + from repowise.cli.agent_adapters.claude_code import ClaudeCodeAdapter + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(ClaudeCodeAdapter, "detect", lambda self: True) + monkeypatch.setattr(click, "confirm", lambda *a, **k: True) + offer_distill_rewrite_hook(MagicMock(), workspace_repos, flag=None) + assert settings_path.exists() + for rp in workspace_repos: + assert _distill_config(rp)["commands"]["enabled"] is True + + def test_one_bad_repo_does_not_abort_the_rest( + self, settings_path, workspace_repos, monkeypatch + ) -> None: + from repowise.cli import helpers + + original = helpers.save_distill_commands_enabled + bad = workspace_repos[0] + + def flaky(repo_path, *, enabled): + if repo_path == bad: + raise OSError("disk full") + original(repo_path, enabled=enabled) + + monkeypatch.setattr(helpers, "save_distill_commands_enabled", flaky) + offer_distill_rewrite_hook(MagicMock(), workspace_repos, flag=False) + for rp in workspace_repos[1:]: + assert _distill_config(rp)["commands"]["enabled"] is False