From 50987109f596dc0247bdd1b47c97b6dd473af6e8 Mon Sep 17 00:00:00 2001 From: paarthsikka Date: Tue, 14 Jul 2026 16:50:20 +0530 Subject: [PATCH 1/2] fix(cli): resolve DB identity on delegated worktree updates (#746) - Strip REPOWISE_DB_URL from os.environ during delegated run_update so it honors the copied DB - Add atomic try/except rollbacks to seed staging and rename phases - Guard sweep_stale_seed_backups with an mtime staleness check - Pass full=False instead of full=force to prevent re-embedding everything on update - Skip updating entirely when dry_run=True to prevent bogus output - Persist --coverage setting explicitly provided in init to config.yaml --- .../repowise/cli/commands/init_cmd/command.py | 60 ++++++---- packages/cli/src/repowise/cli/worktree.py | 103 +++++++++++++----- tests/integration/test_cli.py | 36 ++++-- 3 files changed, 143 insertions(+), 56 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 c9751110c..14c027ada 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/command.py @@ -603,8 +603,15 @@ 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]" ) @@ -612,27 +619,36 @@ def init_command( 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, - ) + # Strip db-pin env vars to enforce worktree DB resolution (note: potential process-global race condition). + old_db_urls = {} + for env_key in ("REPOWISE_DB_URL", "REPOWISE_DATABASE_URL"): + 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=False, + 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( @@ -1083,6 +1099,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=coverage_pct) write_editor_project_files( console, diff --git a/packages/cli/src/repowise/cli/worktree.py b/packages/cli/src/repowise/cli/worktree.py index df5e90877..f7fbcd725 100644 --- a/packages/cli/src/repowise/cli/worktree.py +++ b/packages/cli/src/repowise/cli/worktree.py @@ -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: @@ -59,11 +62,19 @@ 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: + # Windows directory mtimes may not update on file creation; 1-hour threshold mitigates this. + 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: @@ -116,6 +127,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. @@ -188,40 +200,75 @@ 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: + 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 diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index f256026b4..f01e4ec51 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -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") == 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") == 0.5 + class TestInitIndexOnly: def test_index_only_creates_db_and_state_no_pages(self, runner, work_repo): @@ -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. @@ -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) @@ -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") ) From 8a59b192794d2f427b219e65869383c22729303b Mon Sep 17 00:00:00 2001 From: paarthsikka <280866374+paarthhsikka@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:34:38 +0530 Subject: [PATCH 2/2] fix(cli): address review feedback on worktree seeding and config --- .../repowise/cli/commands/init_cmd/command.py | 8 +++++--- .../cli/commands/update_cmd/command.py | 5 +++++ packages/cli/src/repowise/cli/worktree.py | 19 ++++++++++++------- tests/integration/test_cli.py | 6 +++--- 4 files changed, 25 insertions(+), 13 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 14c027ada..70a6bd1ce 100644 --- a/packages/cli/src/repowise/cli/commands/init_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/init_cmd/command.py @@ -619,9 +619,11 @@ def init_command( is_workspace = len(scan.repos) > 1 and not no_workspace + 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 ("REPOWISE_DB_URL", "REPOWISE_DATABASE_URL"): + for env_key in DB_ENV_VARS: if env_key in os.environ: old_db_urls[env_key] = os.environ.pop(env_key) @@ -640,7 +642,7 @@ def init_command( repo_alias=None, index_only=index_only, docs_flag=None, - full=False, + full=force, agents_md=agents_md, concurrency=concurrency, no_cost_tracking=no_cost_tracking, @@ -1100,7 +1102,7 @@ async def _index_with_resume() -> Any: 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=coverage_pct) + save_config_partial(repo_path, coverage_pct=coverage_pct) write_editor_project_files( console, 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 050fac1bf..fa033427c 100644 --- a/packages/cli/src/repowise/cli/commands/update_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/update_cmd/command.py @@ -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, @@ -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) diff --git a/packages/cli/src/repowise/cli/worktree.py b/packages/cli/src/repowise/cli/worktree.py index f7fbcd725..29ae9e11c 100644 --- a/packages/cli/src/repowise/cli/worktree.py +++ b/packages/cli/src/repowise/cli/worktree.py @@ -69,10 +69,12 @@ def sweep_stale_seed_backups(paths: list[Path]) -> None: for p in root.glob(glob_pattern): if p.is_dir(): try: - # Windows directory mtimes may not update on file creation; 1-hour threshold mitigates this. - mtime = p.stat().st_mtime - if now - mtime > _SEED_TEMPDIR_STALENESS_SECS: + 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 @@ -225,7 +227,10 @@ def seed_index_from_base( (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) - except Exception: + except Exception as e: + console.print( + f"[yellow]Error staging seed for {r_path}: {e}. Falling back to full init.[/yellow]" + ) success = False break @@ -239,7 +244,7 @@ def seed_index_from_base( backups_created: list[tuple[Path, Path]] = [] renamed_targets: list[Path] = [] - + try: # Pass 1: backup existing for r_path, _ in temp_dirs: @@ -248,13 +253,13 @@ def seed_index_from_base( 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) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index f01e4ec51..7ada925bb 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -179,7 +179,7 @@ def test_coverage_persisted_after_init(self, runner, git_work_repo): assert r_init.exit_code == 0, r_init.output cfg = load_config(git_work_repo) - assert cfg.get("coverage") == 0.5 + assert cfg.get("coverage_pct") == 0.5 r_update = runner.invoke( cli, @@ -189,7 +189,7 @@ def test_coverage_persisted_after_init(self, runner, git_work_repo): assert r_update.exit_code == 0, r_update.output cfg_after = load_config(git_work_repo) - assert cfg_after.get("coverage") == 0.5 + assert cfg_after.get("coverage_pct") == 0.5 class TestInitIndexOnly: @@ -730,7 +730,7 @@ 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" - + # 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(