Skip to content
Open
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
62 changes: 41 additions & 21 deletions packages/cli/src/repowise/cli/commands/init_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,36 +603,54 @@ def init_command(
repo_paths=[r.path for r in scan.repos],
seed_base=seed_base,
include_submodules=include_submodules,
dry_run=dry_run,
)
if seeded:
if dry_run:
console.print(
"[dim]Dry run: Would seed worktree index and delegate to update.[/dim]"
)
return

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,
)
from repowise.core.persistence.database import DB_ENV_VARS

# Strip db-pin env vars to enforce worktree DB resolution (note: potential process-global race condition).
old_db_urls = {}
for env_key in DB_ENV_VARS:
if env_key in os.environ:
old_db_urls[env_key] = os.environ.pop(env_key)

try:
# 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,
)
finally:
os.environ.update(old_db_urls)
return
if len(scan.repos) > 1 and not no_workspace:
_workspace_init(
Expand Down Expand Up @@ -1083,6 +1101,8 @@ async def _index_with_resume() -> Any:
# ---- Post-run: config, state, MCP, editor project files ----
if commit_limit is not None:
save_config_partial(repo_path, commit_limit=resolved_commit_limit)
if coverage_pct is not None:
save_config_partial(repo_path, coverage_pct=coverage_pct)

write_editor_project_files(
console,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,10 @@ def run_update(
# coverage. Without reading these back, every update would silently drop the
# deterministic tail (and any tier-1 cap) to their defaults.
tail_dirs_cfg = cfg.get("tier2_tail_dirs")
kwargs = {}
if cfg.get("coverage_pct") is not None:
kwargs["coverage_pct"] = float(cfg["coverage_pct"])

config = GenerationConfig(
max_concurrency=concurrency,
language=language,
Expand All @@ -791,6 +795,7 @@ def run_update(
tier2_tail_enabled=bool(cfg.get("tier2_tail_enabled", True)),
tier2_tail_cap=cfg.get("tier2_tail_cap"),
tier2_tail_dirs=tuple(tail_dirs_cfg) if tail_dirs_cfg else None,
**kwargs,
)

provider = resolve_provider(provider_name, model, repo_path=repo_path)
Expand Down
108 changes: 80 additions & 28 deletions packages/cli/src/repowise/cli/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import shutil
import subprocess
import tempfile
import time
import uuid
from pathlib import Path

from repowise.cli.helpers import console

_SEED_TEMPDIR_STALENESS_SECS = 3600


def _git_output(args: list[str], cwd: Path) -> str:
try:
Expand Down Expand Up @@ -59,11 +62,21 @@ def base_is_seedable(base: Path) -> bool:


def sweep_stale_seed_backups(paths: list[Path]) -> None:
"""Remove ``.repowise.bak.*`` leftovers from previously disrupted seeds."""
"""Remove leftovers from previously disrupted seeds."""
now = time.time()
for root in paths:
for p in root.glob(".repowise.bak.*"):
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
for glob_pattern in (".repowise.bak.*", ".repowise-seed-*"):
for p in root.glob(glob_pattern):
if p.is_dir():
try:
if ".repowise.bak." in p.name:
shutil.rmtree(p, ignore_errors=True)
else:
mtime = p.stat().st_mtime
if now - mtime > _SEED_TEMPDIR_STALENESS_SECS:
shutil.rmtree(p, ignore_errors=True)
except OSError:
pass


def _get_initial_commit(p: Path) -> str:
Expand Down Expand Up @@ -116,6 +129,7 @@ def seed_index_from_base(
repo_paths: list[Path],
seed_base: Path,
include_submodules: bool | None = None,
dry_run: bool = False,
) -> bool:
"""Copy ``.repowise/`` from a base checkout into each target repo.

Expand Down Expand Up @@ -188,40 +202,78 @@ def seed_index_from_base(
f"from last synced commit {last_sync_commit[:8]}.[/dim]"
)

temp_dir = Path(tempfile.mkdtemp(prefix=".repowise-seed-", dir=r_path))
temp_dirs.append((r_path, temp_dir))
if dry_run:
continue

try:
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)
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"))
# 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"))

if include_submodules is not None:
state_include = st_data.get("include_submodules", False)
if include_submodules != state_include:
console.print(
f"[yellow]Warning: --include-submodules={include_submodules} "
f"conflicts with copied state ({state_include}). Seeded state "
f"will take precedence.[/yellow]"
)
if include_submodules is not None:
state_include = st_data.get("include_submodules", False)
if include_submodules != state_include:
console.print(
f"[yellow]Warning: --include-submodules={include_submodules} "
f"conflicts with copied state ({state_include}). Seeded state "
f"will take precedence.[/yellow]"
)

(temp_dir / "state.json").write_text(json.dumps(st_data, indent=2), encoding="utf-8")
(temp_dir / "state.json").write_text(json.dumps(st_data, indent=2), encoding="utf-8")

_adopt_repository_identity(temp_dir, src_repo=src_repo, dest_repo=r_path)
_adopt_repository_identity(temp_dir, src_repo=src_repo, dest_repo=r_path)
except Exception as e:
console.print(
f"[yellow]Error staging seed for {r_path}: {e}. Falling back to full init.[/yellow]"
)
success = False
break

if not success:
for _, temp_dir in temp_dirs:
shutil.rmtree(temp_dir, ignore_errors=True)
return False

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:
if dry_run:
return True

backups_created: list[tuple[Path, Path]] = []
renamed_targets: list[Path] = []

try:
# Pass 1: backup existing
for r_path, _ in temp_dirs:
target = r_path / ".repowise"
if target.exists():
backup = r_path / f".repowise.bak.{uuid.uuid4().hex[:8]}"
target.rename(backup)
backups_created.append((target, backup))

# Pass 2: rename temp to target
for r_path, temp_dir in temp_dirs:
target = r_path / ".repowise"
temp_dir.rename(target)
renamed_targets.append(target)

# Pass 3: clean backups
for _, backup in backups_created:
shutil.rmtree(backup, ignore_errors=True)
except Exception:
# Rollback target renames
for target in renamed_targets:
shutil.rmtree(target, ignore_errors=True)
# Rollback backups
for target, backup in backups_created:
if backup.exists():
backup.rename(target)
# Clean temp dirs
for _, temp_dir in temp_dirs:
shutil.rmtree(temp_dir, ignore_errors=True)
raise

return True
36 changes: 29 additions & 7 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,29 @@ def test_creates_db_and_state(self, runner, work_repo):
assert (work_repo / ".repowise" / "state.json").exists()
assert "init complete" in result.output

def test_coverage_persisted_after_init(self, runner, git_work_repo):
from repowise.cli.helpers import load_config

r_init = runner.invoke(
cli,
["init", str(git_work_repo), "--provider", "mock", "--coverage", "0.5", "--yes"],
catch_exceptions=False,
)
assert r_init.exit_code == 0, r_init.output

cfg = load_config(git_work_repo)
assert cfg.get("coverage_pct") == 0.5

r_update = runner.invoke(
cli,
["update", str(git_work_repo)],
catch_exceptions=False,
)
assert r_update.exit_code == 0, r_update.output

cfg_after = load_config(git_work_repo)
assert cfg_after.get("coverage_pct") == 0.5


class TestInitIndexOnly:
def test_index_only_creates_db_and_state_no_pages(self, runner, work_repo):
Expand Down Expand Up @@ -659,7 +682,8 @@ def test_seeds_from_base_branch_with_provider(self, git_work_repo, monkeypatch):

from click.testing import CliRunner

monkeypatch.delenv("REPOWISE_DB_URL", raising=False)
# Leave REPOWISE_DB_URL pinned (as git_work_repo sets it) to verify
# that the delegated update does not leak worktree pages into the base DB.

# Full doc coverage so the new file is not tier-gated out of a page;
# the copied config carries the setting into the delegated update.
Expand All @@ -671,7 +695,7 @@ def test_seeds_from_base_branch_with_provider(self, git_work_repo, monkeypatch):
assert r0.exit_code == 0, r0.output

_git(["checkout", "-b", "feature"], git_work_repo)
(git_work_repo / "new_file.py").write_text("print('hello')\n", encoding="utf-8")
(git_work_repo / "new_file.py").write_text("def my_func(): pass\n", encoding="utf-8")
_git(["add", "new_file.py"], git_work_repo)
_git(["commit", "-m", "feature commit"], git_work_repo)

Expand Down Expand Up @@ -706,11 +730,9 @@ def test_seeds_from_base_branch_with_provider(self, git_work_repo, monkeypatch):
"SELECT target_path FROM wiki_pages",
)
assert len(paths) > 0, "Seeded pages should have survived"
# Note: no assertion that new_file.py gets its own file page. The
# update-time selection budget is computed over the affected-file
# subset (not the whole repo), so a small update batch rarely
# selects a brand-new file for a page. Tracked as #746;
# independent of seeding.

# Since the file has a symbol and we have 1.0 coverage, it must be selected
assert "new_file.py" in paths, f"Expected 'new_file.py' in {paths}"
state = json.loads(
(worktree_dir / ".repowise" / "state.json").read_text(encoding="utf-8")
)
Expand Down
Loading