Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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 <base>` 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)
Expand Down
1 change: 1 addition & 0 deletions docs/CLI_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ All three reach the indexing knobs; the LLM-only knobs appear only when docs are
| `--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`. |
| `--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 applies to every selected repo. See [DISTILL.md](DISTILL.md). |
| `--seed-from` | Seed the index from an existing base-branch checkout to skip indexing unmodified files. |
| `--yes` / `-y` | Skip confirmation prompts |
| `--dry-run` | Show generation plan and cost estimate without running |
| `--test-run` | Generate docs for only the top 10 files (by PageRank) |
Expand Down
4 changes: 4 additions & 0 deletions docs/WORKSPACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,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 <path-to-base>`. This copies the vector store, config, and state, then incrementally updates only the files that differ in the worktree.
190 changes: 184 additions & 6 deletions packages/cli/src/repowise/cli/commands/init_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -398,6 +403,30 @@ 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.",
)
@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,
Expand All @@ -418,6 +447,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,
Expand All @@ -429,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.

Expand All @@ -454,6 +487,155 @@ def init_command(
from repowise.core.workspace import scan_for_repos

scan = scan_for_repos(repo_path, include_submodules=include_submodules)

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)

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.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"))

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:
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=no_cost_tracking,
verbose=verbose,
progress=progress,
)
return
if len(scan.repos) > 1 and not no_workspace:
_workspace_init(
scan=scan,
Expand Down Expand Up @@ -510,9 +692,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).
Expand Down Expand Up @@ -602,9 +782,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
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/repowise/cli/commands/update_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,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) --------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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 <base>` 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)
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/repowise/core/workspace/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading