diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 74f2e6a1e..d006eea29 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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 ` 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 25215c094..6bd9acdeb 100644
--- a/docs/CLI_REFERENCE.md
+++ b/docs/CLI_REFERENCE.md
@@ -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) |
diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md
index da4d8ecb9..d36448d4a 100644
--- a/docs/WORKSPACES.md
+++ b/docs/WORKSPACES.md
@@ -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 `. 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 035776979..7a3e76664 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,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,
@@ -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,
@@ -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.
@@ -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,
@@ -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).
@@ -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
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 80922dcb8..af27b7039 100644
--- a/packages/cli/src/repowise/cli/commands/update_cmd/command.py
+++ b/packages/cli/src/repowise/cli/commands/update_cmd/command.py
@@ -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) --------------------
diff --git a/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md b/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md
index 74f2e6a1e..d006eea29 100644
--- a/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md
+++ b/packages/core/src/repowise/core/upgrade/_data/CHANGELOG.md
@@ -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 ` 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/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..acd5d8a36 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,269 @@ 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
+ import sqlite3
+
+ 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
+ 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
+
+ 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
+
+ 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,
+ )
diff --git a/tests/unit/cli/test_editor_setup.py b/tests/unit/cli/test_editor_setup.py
index 7a5115bee..505d18f99 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
@@ -514,7 +515,7 @@ async def fake_write_claude_md(repo_path: Path) -> None:
def test_update_command_uses_editor_refresh_abstraction() -> None:
from repowise.cli.commands.update_cmd.command import _refresh_editor_stamp
- command_source = inspect.getsource(update_cmd.update_command.callback)
+ command_source = inspect.getsource(run_update)
stamp_source = inspect.getsource(_refresh_editor_stamp)
# The command routes every editor-file write through the shared stamp
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)]