From 52692eac59a50db737623ff38db87bd070078ae3 Mon Sep 17 00:00:00 2001 From: paarthsikka Date: Fri, 3 Jul 2026 00:10:06 +0530 Subject: [PATCH 1/3] feat(cli): allow seeding worktree index from base branch --- .../repowise/cli/commands/init_cmd/command.py | 164 +++++++++++++- .../cli/commands/update_cmd/command.py | 48 ++++- .../src/repowise/core/workspace/scanner.py | 12 +- tests/integration/test_cli.py | 204 ++++++++++++++++-- tests/unit/workspace/test_scanner.py | 15 +- 5 files changed, 416 insertions(+), 27 deletions(-) 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 035776979..53627985b 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/command.py @@ -8,9 +8,14 @@ from __future__ import annotations +import json import os +import shutil +import subprocess import sys +import tempfile import time +import uuid from pathlib import Path from typing import Any @@ -398,6 +403,11 @@ def _run_generation_phase( "an interactive full run you'll be prompted; otherwise comprehensive." ), ) +@click.option( + "--seed-from", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=str), + help="Seed the index from an existing base-branch checkout to skip indexing unmodified files.", +) def init_command( path: str | None, provider_name: str | None, @@ -418,6 +428,7 @@ def init_command( commit_limit: int | None, follow_renames: bool, no_claude_md: bool, + seed_from: str | None, agents_md: bool | None, codex_setup: bool | None, distill_hook: bool | None, @@ -454,6 +465,151 @@ def init_command( from repowise.core.workspace import scan_for_repos scan = scan_for_repos(repo_path, include_submodules=include_submodules) + + # Startup Cleanup: sweep and remove any stale .repowise.bak.* directories. + for r in scan.repos: + for p in r.path.glob(".repowise.bak.*"): + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + for p in repo_path.glob(".repowise.bak.*"): + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + + if seed_from: + seed_base = Path(seed_from).resolve() + if seed_base == repo_path.resolve(): + raise click.ClickException("--seed-from cannot be the same as the target directory.") + + temp_dirs = [] + success = True + + def _get_initial_commit(p: Path) -> str: + try: + return subprocess.check_output( + ["git", "rev-list", "--max-parents=0", "HEAD"], + cwd=p, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except subprocess.CalledProcessError: + return "" + + for r in scan.repos: + r_rel = ( + r.path.relative_to(scan.root) + if getattr(scan, "root", None) + else r.path.relative_to(repo_path) + ) + src_repo = seed_base / r_rel + + if ( + not (src_repo / ".repowise" / "state.json").exists() + or not (src_repo / ".repowise" / "wiki.db").exists() + ): + console.print( + f"[yellow]Seed source {src_repo} is missing .repowise state/db. Falling back to full init.[/yellow]" + ) + success = False + break + + if _get_initial_commit(src_repo) != _get_initial_commit( + r.path + ) or not _get_initial_commit(src_repo): + console.print( + f"[yellow]Seed source {src_repo} does not share the same initial commit as worktree. Falling back to full init.[/yellow]" + ) + success = False + break + + state_data = json.loads( + (src_repo / ".repowise" / "state.json").read_text(encoding="utf-8") + ) + last_sync_commit = state_data.get("last_sync_commit") + if not last_sync_commit: + console.print( + f"[yellow]Seed source {src_repo} has no last_sync_commit. Falling back to full init.[/yellow]" + ) + success = False + break + + try: + subprocess.check_call( + ["git", "merge-base", "--is-ancestor", last_sync_commit, "HEAD"], + cwd=r.path, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + console.print( + f"[yellow]Seed source {src_repo} last_sync_commit {last_sync_commit[:8]} is not an ancestor of worktree HEAD. Falling back to full init.[/yellow]" + ) + success = False + break + + try: + source_head = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=src_repo, text=True, stderr=subprocess.DEVNULL + ).strip() + if source_head and last_sync_commit != source_head: + console.print( + f"[dim]Note: Seed source {src_repo} is behind its HEAD, seeding from last synced commit {last_sync_commit[:8]}.[/dim]" + ) + except subprocess.CalledProcessError: + pass + + temp_dir = Path(tempfile.mkdtemp(prefix=".repowise-seed-", dir=r.path)) + temp_dirs.append((r.path, temp_dir)) + + shutil.copy2(src_repo / ".repowise" / "wiki.db", temp_dir / "wiki.db") + shutil.copy2(src_repo / ".repowise" / "state.json", temp_dir / "state.json") + + st_data = json.loads((temp_dir / "state.json").read_text(encoding="utf-8")) + st_data.pop("config_fingerprint", None) + (temp_dir / "state.json").write_text(json.dumps(st_data, indent=2), encoding="utf-8") + + if not success: + for _, temp_dir in temp_dirs: + shutil.rmtree(temp_dir, ignore_errors=True) + else: + # Stage 2: Rename + for r_path, temp_dir in temp_dirs: + target = r_path / ".repowise" + backup = None + if target.exists(): + backup = r_path / f".repowise.bak.{uuid.uuid4().hex[:8]}" + target.rename(backup) + temp_dir.rename(target) + if backup: + shutil.rmtree(backup, ignore_errors=True) + + console.print( + "[green]Worktree index seeded successfully. Delegating to update...[/green]" + ) + from repowise.cli.commands.update_cmd.command import run_update + + is_workspace = len(scan.repos) > 1 and not no_workspace + + # Delegate to update + run_update( + path=str(repo_path), + provider_name=provider_name, + model=model, + since=None, + reasoning=reasoning, + cascade_budget=None, + dry_run=dry_run, + workspace=is_workspace, + no_workspace=no_workspace, + repo_alias=None, + index_only=index_only, + docs_flag=None, + full=force, + agents_md=agents_md, + concurrency=concurrency, + no_cost_tracking=False, + verbose=False, + progress="rich", + ) + return if len(scan.repos) > 1 and not no_workspace: _workspace_init( scan=scan, @@ -510,9 +666,7 @@ def init_command( # ---- Interactive mode (TTY, no explicit flags) ---- # --yes forces non-interactive even on a TTY (mirrors the workspace path), # so a scripted `init -y` never blocks on the mode-selection menu. - is_interactive = ( - sys.stdin.isatty() and provider_name is None and not index_only and not yes - ) + is_interactive = sys.stdin.isatty() and provider_name is None and not index_only and not yes # Tiered doc generation cap (set in advanced mode); None = every selected # file page is a full-LLM tier-1 page (unchanged behaviour). @@ -602,9 +756,7 @@ def init_command( if ( run_mode != "fast" and should_offer_fast_mode(scan_info) - and interactive_fast_mode_offer( - console, scan_info, default_fast=not generate_docs - ) + and interactive_fast_mode_offer(console, scan_info, default_fast=not generate_docs) ): run_mode = "fast" index_only = True diff --git a/packages/cli/src/repowise/cli/commands/update_cmd/command.py b/packages/cli/src/repowise/cli/commands/update_cmd/command.py index a3251707c..a45445b9d 100644 --- a/packages/cli/src/repowise/cli/commands/update_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/update_cmd/command.py @@ -234,6 +234,52 @@ def update_command( workspace exists upstream of the working directory. Use --no-workspace to force single-repo mode and --workspace to force workspace mode. """ + return run_update( + path=path, + provider_name=provider_name, + model=model, + since=since, + reasoning=reasoning, + cascade_budget=cascade_budget, + dry_run=dry_run, + workspace=workspace, + no_workspace=no_workspace, + repo_alias=repo_alias, + index_only=index_only, + docs_flag=docs_flag, + full=full, + agents_md=agents_md, + concurrency=concurrency, + no_cost_tracking=no_cost_tracking, + verbose=verbose, + progress=progress, + ) + + +def run_update( + path: str | None, + provider_name: str | None, + model: str | None, + since: str | None, + reasoning: str | None, + cascade_budget: int | None, + dry_run: bool, + workspace: bool, + no_workspace: bool, + repo_alias: str | None, + index_only: bool = False, + docs_flag: bool | None = None, + full: bool = False, + agents_md: bool | None = None, + concurrency: int = 10, + no_cost_tracking: bool = False, + verbose: bool = False, + progress: str = "rich", +) -> None: + """Incrementally update wiki pages for files changed since last sync. + + If `since` is None, the base commit is read from state.json's last_sync_commit. + """ start = time.monotonic() # --- Machine-readable progress (--progress json) -------------------- @@ -827,7 +873,7 @@ def _on_page_done(_page_id: str) -> None: else: gen_progress.update(gen_task, advance=1, cost=cost_tracker.session_cost) - with (make_generation_progress() if emitter is None else nullcontext()) as gen_progress: + with make_generation_progress() if emitter is None else nullcontext() as gen_progress: gen_task = ( gen_progress.add_task("Generating pages...", total=None, cost=0.0) if gen_progress is not None diff --git a/packages/core/src/repowise/core/workspace/scanner.py b/packages/core/src/repowise/core/workspace/scanner.py index 5f2049203..2cb6bd8b9 100644 --- a/packages/core/src/repowise/core/workspace/scanner.py +++ b/packages/core/src/repowise/core/workspace/scanner.py @@ -166,7 +166,17 @@ def _is_git_repo(path: Path) -> bool: def _is_submodule(path: Path) -> bool: """A submodule has ``.git`` as a file pointing to the parent's modules dir.""" - return (path / ".git").is_file() + git_file = path / ".git" + if not git_file.is_file(): + return False + + try: + # Distinguish submodules from git worktrees (which also use a .git file) + # by checking if the gitdir points to a worktrees directory. + content = git_file.read_text(encoding="utf-8") + return "/worktrees/" not in content + except OSError: + return True def _prune_dirs( diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 35f1d519e..f031cc968 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -310,9 +310,7 @@ def _state(self, repo): return json.loads((repo / ".repowise" / "state.json").read_text(encoding="utf-8")) - def test_init_stores_fingerprint_and_update_detects_config_change( - self, runner, git_work_repo - ): + def test_init_stores_fingerprint_and_update_detects_config_change(self, runner, git_work_repo): """init records a config_fingerprint; an update with no file changes skips rescore when config is unchanged but triggers one when health-rules.json changes (#296, issue 3).""" @@ -342,11 +340,8 @@ def test_init_stores_fingerprint_and_update_detects_config_change( def test_dry_run_does_not_rescore_or_advance_fingerprint(self, runner, git_work_repo): """`update --dry-run` after a config change must not mutate state/DB.""" - import json - runner.invoke( - cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False - ) + runner.invoke(cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False) fp_before = self._state(git_work_repo)["config_fingerprint"] (git_work_repo / ".repowise" / "health-rules.json").write_text( @@ -366,9 +361,7 @@ def test_dry_run_does_not_rescore_or_advance_fingerprint(self, runner, git_work_ def test_config_change_with_source_diffs_runs_full_rescore(self, runner, git_work_repo): """A config change must take the full re-score path even when there are also source-file commits (not the partial update).""" - runner.invoke( - cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False - ) + runner.invoke(cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False) # New source commit AND a config change in the same update window. (git_work_repo / "new_module.py").write_text("def f():\n return 1\n", encoding="utf-8") @@ -392,9 +385,7 @@ def test_single_file_update_preserves_unchanged_files(self, runner, git_work_rep unchanged files keep their findings (regression guard for #295).""" import sqlite3 - runner.invoke( - cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False - ) + runner.invoke(cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False) db = git_work_repo / ".repowise" / "wiki.db" @@ -475,3 +466,190 @@ def test_already_up_to_date(self, runner, git_work_repo): ) assert r1.exit_code == 0, r1.output assert "Already up to date" in r1.output + + +class TestInitSeedFrom: + def test_seeds_from_base_branch(self, git_work_repo): + """ + Tests the 'slightly stale but valid base' case. + The base repo is initialized at commit A. + The worktree branch advances to commit B. + Seeding the worktree from the base repo works because A is an ancestor of B. + """ + from click.testing import CliRunner + + # 1. Initialize the base "main" branch + runner1 = CliRunner() + r0 = runner1.invoke( + cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False + ) + assert r0.exit_code == 0 + + # 2. Simulate branching and making a commit + import subprocess + + subprocess.check_call( + ["git", "checkout", "-b", "feature"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + (git_work_repo / "new_file.py").write_text("print('hello')\n", encoding="utf-8") + subprocess.check_call( + ["git", "add", "new_file.py"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + subprocess.check_call( + ["git", "commit", "-m", "feature commit"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + + # 3. Create a worktree for the feature branch + worktree_dir = git_work_repo.parent / "feature-worktree" + subprocess.check_call( + ["git", "worktree", "add", "-b", "feature2", str(worktree_dir), "feature"], + cwd=git_work_repo, + stdout=subprocess.DEVNULL, + ) + + try: + # 4. Initialize the worktree by seeding from the base repo + runner2 = CliRunner() + r1 = runner2.invoke( + cli, + ["init", str(worktree_dir), "--seed-from", str(git_work_repo), "--index-only"], + catch_exceptions=False, + ) + assert r1.exit_code == 0, r1.output + assert "Worktree index seeded successfully" in r1.output + assert "Delegating to update..." in r1.output + + # Verify the worktree state is initialized correctly + assert (worktree_dir / ".repowise" / "state.json").exists() + assert (worktree_dir / ".repowise" / "wiki.db").exists() + + import json + + state = json.loads( + (worktree_dir / ".repowise" / "state.json").read_text(encoding="utf-8") + ) + # update_cmd would have advanced last_sync_commit to the feature commit + feature_head = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=worktree_dir, text=True + ).strip() + assert state["last_sync_commit"] == feature_head + + finally: + import shutil + + shutil.rmtree(worktree_dir, ignore_errors=True) + subprocess.check_call( + ["git", "worktree", "remove", "--force", str(worktree_dir)], + cwd=git_work_repo, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + + def test_unrelated_repo_fallback(self, git_work_repo, tmp_path): + import subprocess + + from click.testing import CliRunner + + # 1. Init first repo + runner1 = CliRunner() + r0 = runner1.invoke( + cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False + ) + assert r0.exit_code == 0 + + # 2. Create unrelated repo + unrelated_repo = tmp_path / "unrelated" + unrelated_repo.mkdir() + subprocess.check_call(["git", "init"], cwd=unrelated_repo, stdout=subprocess.DEVNULL) + (unrelated_repo / "main.py").write_text("x = 1\n", encoding="utf-8") + subprocess.check_call(["git", "add", "."], cwd=unrelated_repo, stdout=subprocess.DEVNULL) + subprocess.check_call( + ["git", "commit", "-m", "init"], cwd=unrelated_repo, stdout=subprocess.DEVNULL + ) + + # 3. Seed unrelated repo from git_work_repo (should fallback to full init) + runner2 = CliRunner() + r1 = runner2.invoke( + cli, + ["init", str(unrelated_repo), "--seed-from", str(git_work_repo), "--index-only"], + catch_exceptions=False, + ) + assert r1.exit_code == 0 + assert "does not share the same initial commit" in r1.output + assert "Falling back to full init" in r1.output + + def test_unreachable_commit_fallback(self, git_work_repo): + import subprocess + + from click.testing import CliRunner + + # 1. Create diverging branches feature1 and feature2 + subprocess.check_call( + ["git", "checkout", "-b", "feature1"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + (git_work_repo / "f1.py").write_text("x = 1\n", encoding="utf-8") + subprocess.check_call(["git", "add", "f1.py"], cwd=git_work_repo, stdout=subprocess.DEVNULL) + subprocess.check_call( + ["git", "commit", "-m", "f1"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + + subprocess.check_call( + ["git", "checkout", "main"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + subprocess.check_call( + ["git", "checkout", "-b", "feature2"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + (git_work_repo / "f2.py").write_text("y = 2\n", encoding="utf-8") + subprocess.check_call(["git", "add", "f2.py"], cwd=git_work_repo, stdout=subprocess.DEVNULL) + subprocess.check_call( + ["git", "commit", "-m", "f2"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + + # 2. Init feature1 (seed source) + subprocess.check_call( + ["git", "checkout", "feature1"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + runner1 = CliRunner() + r0 = runner1.invoke( + cli, ["init", str(git_work_repo), "--index-only"], catch_exceptions=False + ) + assert r0.exit_code == 0 + + # 3. Create worktree for feature2 (target) + worktree_dir = git_work_repo.parent / "f2-worktree" + subprocess.check_call( + ["git", "worktree", "add", "-b", "feature2-wt", str(worktree_dir), "feature2"], + cwd=git_work_repo, + stdout=subprocess.DEVNULL, + ) + + try: + # 4. Seed feature2 from feature1. Ancestry check should fail since feature1 is not an ancestor of feature2. + runner2 = CliRunner() + r1 = runner2.invoke( + cli, + ["init", str(worktree_dir), "--seed-from", str(git_work_repo), "--index-only"], + catch_exceptions=False, + ) + assert r1.exit_code == 0 + assert "is not an ancestor of worktree HEAD" in r1.output + assert "Falling back to full init" in r1.output + finally: + import shutil + + shutil.rmtree(worktree_dir, ignore_errors=True) + subprocess.check_call( + ["git", "worktree", "remove", "--force", str(worktree_dir)], + cwd=git_work_repo, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + + def test_seed_from_self_fails(self, git_work_repo): + from click.testing import CliRunner + + runner = CliRunner() + # Should raise an error if attempting to seed from the same directory + r = runner.invoke(cli, ["init", str(git_work_repo), "--seed-from", str(git_work_repo)]) + assert r.exit_code != 0 + assert "--seed-from cannot be the same as the target directory" in r.output diff --git a/tests/unit/workspace/test_scanner.py b/tests/unit/workspace/test_scanner.py index f7824a3b0..b69099920 100644 --- a/tests/unit/workspace/test_scanner.py +++ b/tests/unit/workspace/test_scanner.py @@ -4,18 +4,13 @@ from pathlib import Path -import pytest - from repowise.core.workspace.scanner import ( - DiscoveredRepo, - ScanResult, _generate_aliases, _is_git_repo, _is_submodule, scan_for_repos, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -90,7 +85,11 @@ def test_root_is_repo_with_sub_repos(self, tmp_path: Path) -> None: result = scan_for_repos(tmp_path) aliases = {r.alias for r in result.repos} assert len(result.repos) == 3 - assert tmp_path.name in aliases or "." in aliases or tmp_path.resolve() in {r.path for r in result.repos} + assert ( + tmp_path.name in aliases + or "." in aliases + or tmp_path.resolve() in {r.path for r in result.repos} + ) assert "backend" in aliases assert "frontend" in aliases @@ -283,6 +282,10 @@ def test_is_not_submodule(self, tmp_path: Path) -> None: (tmp_path / ".git").mkdir() assert _is_submodule(tmp_path) is False + def test_is_worktree_not_submodule(self, tmp_path: Path) -> None: + (tmp_path / ".git").write_text("gitdir: /path/to/repo/.git/worktrees/feature-branch") + assert _is_submodule(tmp_path) is False + def test_generate_aliases_no_collision(self) -> None: root = Path("/ws") pairs = [(Path("/ws/a"), root), (Path("/ws/b"), root)] From 1c0b9d5716655d4991e89a3c1713df825544bcf9 Mon Sep 17 00:00:00 2001 From: paarthsikka Date: Fri, 3 Jul 2026 17:59:18 +0530 Subject: [PATCH 2/3] feat: refine --seed-from worktree seeding logic --- docs/CHANGELOG.md | 1 + docs/CLI_REFERENCE.md | 1 + docs/WORKSPACES.md | 4 + .../repowise/cli/commands/init_cmd/command.py | 52 +++++++++--- .../repowise/core/upgrade/_data/CHANGELOG.md | 1 + tests/integration/test_cli.py | 79 +++++++++++++++++++ 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f5d607bef..edce302d3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.25.0] — 2026-06-27 ### Added +- **Seed worktree index.** `repowise init --seed-from ` lets you seed the index from an existing base-branch checkout. It copies the vector store, config, and state, delegating to `update` to only embed files changed in the worktree. - **Split File refactoring.** Code Health now detects files that should be decomposed into smaller modules and proposes a concrete split. A new detector identifies low-cohesion modules and groups their members into coherent target files (#607), with richer cohesion signals driving the grouping (#614). Each plan is browsable in the web Refactoring tab and can be turned into real code via the deterministic code-gen path (#608). - **Extract Method refactoring.** Long, complex functions get an Extract Method suggestion computed over a real dataflow layer: an intra-procedural control-flow graph for flagged functions (#612), def/use chains and reaching definitions over that CFG (#613), and the Extract Method planner built on top (#615). The refactoring is available for Python, Go, and TypeScript/JavaScript (#616). - **Coverage report ingestion.** Indexing can now ingest test-coverage reports, folding coverage into the code-health picture during a run. (#604) diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index a8d3e8ecd..51c706817 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -84,6 +84,7 @@ All three reach the indexing knobs; the LLM-only knobs appear only when docs are | `--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`. | +| `--seed-from` | Seed the index from an existing base-branch checkout to skip indexing unmodified files. | | `--yes / -y` | Skip confirmation prompts. | **Examples:** diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md index c43b10e11..cb1bd5935 100644 --- a/docs/WORKSPACES.md +++ b/docs/WORKSPACES.md @@ -557,3 +557,7 @@ Currently, cross-repo analysis runs automatically during `repowise init .` and ` ### Does the MCP server handle multiple repos? Yes. A single MCP server instance serves all workspace repos. It uses lazy-loading with LRU eviction (max 5 repos loaded simultaneously) to manage memory. The default repo is always kept in memory. + +### Can I use `repowise` with git worktrees? + +Yes. If you use `git worktree` to check out branches into separate directories, you don't need to re-index the entire project from scratch. You can seed the new worktree's index from your base checkout using `repowise init --seed-from `. This copies the vector store, config, and state, then incrementally updates only the files that differ in the worktree. 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 53627985b..7a3e76664 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/command.py @@ -408,6 +408,25 @@ def _run_generation_phase( type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=str), help="Seed the index from an existing base-branch checkout to skip indexing unmodified files.", ) +@click.option( + "--no-cost-tracking", + is_flag=True, + default=False, + help="Skip DB-backed LLM cost tracking for this run.", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + default=False, + help="Show the full changed-file list and per-phase internals.", +) +@click.option( + "--progress", + type=click.Choice(["rich", "json"]), + default="rich", + help="Progress output style.", +) def init_command( path: str | None, provider_name: str | None, @@ -440,6 +459,9 @@ def init_command( coverage_report: tuple[str, ...], harvest_decisions: bool, wiki_style: str | None, + no_cost_tracking: bool, + verbose: bool, + progress: str, ) -> None: """Generate wiki documentation for a codebase. @@ -466,16 +488,16 @@ def init_command( scan = scan_for_repos(repo_path, include_submodules=include_submodules) - # Startup Cleanup: sweep and remove any stale .repowise.bak.* directories. - for r in scan.repos: - for p in r.path.glob(".repowise.bak.*"): + if seed_from: + # Startup Cleanup: sweep and remove any stale .repowise.bak.* directories from previous disrupted setups. + for r in scan.repos: + for p in r.path.glob(".repowise.bak.*"): + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + for p in repo_path.glob(".repowise.bak.*"): if p.is_dir(): shutil.rmtree(p, ignore_errors=True) - for p in repo_path.glob(".repowise.bak.*"): - if p.is_dir(): - shutil.rmtree(p, ignore_errors=True) - if seed_from: seed_base = Path(seed_from).resolve() if seed_base == repo_path.resolve(): raise click.ClickException("--seed-from cannot be the same as the target directory.") @@ -559,11 +581,15 @@ def _get_initial_commit(p: Path) -> str: temp_dir = Path(tempfile.mkdtemp(prefix=".repowise-seed-", dir=r.path)) temp_dirs.append((r.path, temp_dir)) - shutil.copy2(src_repo / ".repowise" / "wiki.db", temp_dir / "wiki.db") - shutil.copy2(src_repo / ".repowise" / "state.json", temp_dir / "state.json") + shutil.copytree(src_repo / ".repowise", temp_dir, dirs_exist_ok=True) + # Since config.yaml is copied atomically alongside state.json, the config_fingerprint remains valid. st_data = json.loads((temp_dir / "state.json").read_text(encoding="utf-8")) - st_data.pop("config_fingerprint", None) + + state_include = st_data.get("include_submodules", False) + if include_submodules != state_include: + console.print(f"[yellow]Warning: --include-submodules={include_submodules} conflicts with copied state ({state_include}). Seeded state will take precedence.[/yellow]") + (temp_dir / "state.json").write_text(json.dumps(st_data, indent=2), encoding="utf-8") if not success: @@ -605,9 +631,9 @@ def _get_initial_commit(p: Path) -> str: full=force, agents_md=agents_md, concurrency=concurrency, - no_cost_tracking=False, - verbose=False, - progress="rich", + no_cost_tracking=no_cost_tracking, + verbose=verbose, + progress=progress, ) return if len(scan.repos) > 1 and not no_workspace: diff --git a/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md b/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md index f5d607bef..edce302d3 100644 --- a/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md +++ b/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.25.0] — 2026-06-27 ### Added +- **Seed worktree index.** `repowise init --seed-from ` lets you seed the index from an existing base-branch checkout. It copies the vector store, config, and state, delegating to `update` to only embed files changed in the worktree. - **Split File refactoring.** Code Health now detects files that should be decomposed into smaller modules and proposes a concrete split. A new detector identifies low-cohesion modules and groups their members into coherent target files (#607), with richer cohesion signals driving the grouping (#614). Each plan is browsable in the web Refactoring tab and can be turned into real code via the deterministic code-gen path (#608). - **Extract Method refactoring.** Long, complex functions get an Extract Method suggestion computed over a real dataflow layer: an intra-procedural control-flow graph for flagged functions (#612), def/use chains and reaching definitions over that CFG (#613), and the Extract Method planner built on top (#615). The refactoring is available for Python, Go, and TypeScript/JavaScript (#616). - **Coverage report ingestion.** Indexing can now ingest test-coverage reports, folding coverage into the code-health picture during a run. (#604) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index f031cc968..acd5d8a36 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -524,6 +524,7 @@ def test_seeds_from_base_branch(self, git_work_repo): assert (worktree_dir / ".repowise" / "wiki.db").exists() import json + import sqlite3 state = json.loads( (worktree_dir / ".repowise" / "state.json").read_text(encoding="utf-8") @@ -533,6 +534,11 @@ def test_seeds_from_base_branch(self, git_work_repo): ["git", "rev-parse", "HEAD"], cwd=worktree_dir, text=True ).strip() assert state["last_sync_commit"] == feature_head + with sqlite3.connect(worktree_dir / ".repowise" / "wiki.db") as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM wiki_pages WHERE target_path != 'new_file.py'") + count = cursor.fetchone()[0] + assert count > 0, "Unchanged files should have survived the seed" finally: import shutil @@ -653,3 +659,76 @@ def test_seed_from_self_fails(self, git_work_repo): r = runner.invoke(cli, ["init", str(git_work_repo), "--seed-from", str(git_work_repo)]) assert r.exit_code != 0 assert "--seed-from cannot be the same as the target directory" in r.output + + def test_seeds_from_base_branch_with_provider(self, git_work_repo, tmp_path): + import subprocess + import json + from click.testing import CliRunner + + # 1. Initialize the base "main" branch WITH a provider (mock) + runner1 = CliRunner() + r0 = runner1.invoke( + cli, + ["init", str(git_work_repo), "--provider", "mock", "--yes"], + catch_exceptions=False, + ) + assert r0.exit_code == 0 + + # Check config has mock provider + config_path = git_work_repo / ".repowise" / "config.yaml" + assert config_path.exists() + assert "provider: mock" in config_path.read_text() + + # 2. Simulate branching and making a commit + subprocess.check_call( + ["git", "checkout", "-b", "feature"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + (git_work_repo / "new_file.py").write_text("print('hello')\n", encoding="utf-8") + subprocess.check_call( + ["git", "add", "new_file.py"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + subprocess.check_call( + ["git", "commit", "-m", "feature commit"], cwd=git_work_repo, stdout=subprocess.DEVNULL + ) + + # 3. Create a worktree for the feature branch + worktree_dir = git_work_repo.parent / "feature-worktree-provider" + subprocess.check_call( + ["git", "worktree", "add", "-b", "feature2", str(worktree_dir), "feature"], + cwd=git_work_repo, + stdout=subprocess.DEVNULL, + ) + + try: + # 4. Initialize the worktree by seeding from the base repo (no --index-only this time) + runner2 = CliRunner() + r1 = runner2.invoke( + cli, + ["init", str(worktree_dir), "--seed-from", str(git_work_repo)], + catch_exceptions=False, + ) + assert r1.exit_code == 0 + assert "Delegating to update" in r1.output + + # Verify the worktree state is initialized correctly + # It should have copied config.yaml and the vector db directory (lancedb) + assert (worktree_dir / ".repowise" / "config.yaml").exists() + assert (worktree_dir / ".repowise" / "lancedb").exists() + + # verify we can read the db. + import sqlite3 + with sqlite3.connect(worktree_dir / ".repowise" / "wiki.db") as conn: + cursor = conn.cursor() + cursor.execute("SELECT target_path FROM wiki_pages") + paths = [row[0] for row in cursor.fetchall()] + assert any("new_file.py" in p for p in paths), f"The new file should have generated a page via delegated update. Paths found: {paths}" + + finally: + import shutil + shutil.rmtree(worktree_dir, ignore_errors=True) + subprocess.check_call( + ["git", "worktree", "remove", "--force", str(worktree_dir)], + cwd=git_work_repo, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) From 9415da9596ee849c9008afce81744e72bb2e1d88 Mon Sep 17 00:00:00 2001 From: paarthsikka Date: Sun, 5 Jul 2026 16:26:07 +0530 Subject: [PATCH 3/3] fix test_update_command_uses_editor_refresh_abstraction after refactor --- tests/unit/cli/test_editor_setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/test_editor_setup.py b/tests/unit/cli/test_editor_setup.py index fea689ba9..33d781fcd 100644 --- a/tests/unit/cli/test_editor_setup.py +++ b/tests/unit/cli/test_editor_setup.py @@ -10,6 +10,7 @@ from repowise.cli import mcp_config from repowise.cli.commands import init_cmd, update_cmd +from repowise.cli.commands.update_cmd.command import run_update from repowise.cli.editor_integrations import claude as claude_integration from repowise.cli.editor_integrations import claude_config from repowise.cli.editor_integrations import codex as codex_integration @@ -512,7 +513,7 @@ async def fake_write_claude_md(repo_path: Path) -> None: def test_update_command_uses_editor_refresh_abstraction() -> None: - source = inspect.getsource(update_cmd.update_command.callback) + source = inspect.getsource(run_update) assert "refresh_editor_project_files" in source assert "ClaudeMdGenerator" not in source