Skip to content

feat(init): install the post-commit hook by default, with the guards that make it safe - #2143

Open
RaghavChamadiya wants to merge 10 commits into
mainfrom
feat/index-freshness-hook
Open

feat(init): install the post-commit hook by default, with the guards that make it safe#2143
RaghavChamadiya wants to merge 10 commits into
mainfrom
feat/index-freshness-hook

Conversation

@RaghavChamadiya

@RaghavChamadiya RaghavChamadiya commented Sep 5, 2026

Copy link
Copy Markdown
Member

Why

Telemetry puts 40 to 53 percent of MCP tool calls on a stale index. The root cause is that init --yes and non-interactive runs, the paths the docs recommend, skipped the post-commit hook offer with no message and no opt-out flag. Turning the hook on by default is safe only once the things that made it risky are fixed, so this PR does those first.

What changes

The sidecar stores open under a contended WAL switch. The distill omissions store, the precedent store, the staged-decision store and the CLI hook ledger issued PRAGMA journal_mode=WAL bare, before any busy timeout. The switch needs a short exclusive lock, so a reader holding an open transaction on the same file made every constructor raise database is locked. The ledger swallowed that and cached None, turning itself off for the rest of the process; the other three took their command down. One stdlib-only helper, repowise.core.sqlite_pragmas.apply_sqlite_pragmas, applies busy_timeout first, tolerates a failed switch, then sets synchronous, in the order the async engine listener already uses. It lives beside fts_query rather than in persistence/database because importing the engine module costs over a second on the distill and expand paths (measured: 0.41 s to 1.69 s for the import chain). The ledger keeps the same shape inline; it may not import repowise.core at all. Each store is tested by opening it while another connection holds the file.

The MCP server exits once, with the path, instead of respawning. An MCP host restarts a stdio server that dies at startup. The lifespan created .repowise and opened the database unguarded, so a read-only directory or a .repowise that is a file escaped as a traceback on every respawn. ensure_full_surface imported every tool module unconditionally, so one missing optional dependency took the whole surface down the same way. The lifespan now raises StoreUnavailableError naming the directory and the fix, repowise mcp turns that into one line on stderr and exit 1, and a tool module that cannot import is logged with the missing distribution and skipped while the rest serves.

init installs the post-commit hook by default, with --hook/--no-hook. An interactive run still asks. --yes and non-interactive runs install it and print one line naming repowise hook uninstall, which is the consent pattern for a default that is on. --no-hook skips it. --no-editor-setup and REPOWISE_SKIP_EDITOR_SETUP keep it off the way they keep the distill rewrite hook off, because a git hook is a write outside .repowise/. Workspace init applies the same rule to every indexed repo. A failed install is reported and never fails a finished init (the second point on #1609).

The hook itself is made boring before it goes on by default. The script fell back to uv run repowise update when repowise was not on PATH, which resolves whatever project uv finds above the repo, on every commit, in repos that are not Python projects; it now looks only in the repo's own .venv. It fired whenever .repowise existed, so a dry-run directory or a deleted store failed on every commit forever; it now needs state.json, the precondition update itself checks. And install appended shell to any existing post-commit hook, breaking one written for node or python; it now refuses those and the offer reports the refusal instead of printing the consent line.

doctor reports the hook and probes that .repowise takes a write. Doctor had no row for the post-commit hook, so a hook that failed to install, or was deleted, was invisible on the one command people run to ask whether their setup is healthy. The new row reads hooks.status, which asks git for the real hooks directory, so worktrees and core.hooksPath report correctly; absence is informational because --no-hook is a choice. The .repowise row tested existence only, which read OK for a read-only directory, the one state that stops the MCP server from starting; it now writes and removes a probe file. The workspace summary told users to run repowise hook install to register an MCP client; that installs the git hook. It now names repowise init.

Release currency on the two long-lived processes. The stdio MCP server never checked its own version, and serve checked once at startup. The lifespan now polls the TTL-cached PyPI check on a worker thread in both single-repo and workspace mode, and build_meta names a newer version in the first response after the poller sees it (_meta.newer_release), then stays quiet for that version. serve re-runs its advisory on a daemon thread once per interval. The disk cache is shared with the CLI advisory, so neither adds network beyond one fetch a day.

Interactions

Verification

  • pytest tests/unit/persistence -q: 797 passed.
  • pytest tests/unit/server/mcp -q: 1733 passed, 4 failed; the four are test_risk absolute /tmp path normalization on Windows and two test_why_workspace_ranking cases that pass when run alone, none touching files in this diff.
  • pytest tests/unit/cli -q: 2449 passed, 1 failed; the failure is test_openai_compatible::test_persist_setup_saves_endpoint_and_key_with_consent, a chmod 0o600 assertion that cannot hold on Windows and is untouched by this diff.
  • ruff check packages/ tests/: clean.
  • Scratch clone with an isolated home: init --yes --no-prose printed ✓ clone: post-commit hook installed and Auto-sync is on: repowise update runs after each commit. Run 'repowise hook uninstall' to turn it off.; .git/hooks/post-commit carried the marker; repowise hook uninstall printed removed and deleted the file.
  • repowise mcp . against a .repowise that is a file: exit 1, nothing on stdout, one Error: line on stderr naming the path and repowise init.

The distill omissions, precedent and staged-decision stores and the CLI
hook ledger issued PRAGMA journal_mode=WAL bare, before any busy timeout.
The switch needs a brief exclusive lock, so a reader holding an open
transaction on the same file made every constructor raise database is
locked. The ledger swallowed that and cached None, turning itself off for
the rest of the process; the other three took their command down.

One stdlib-only helper applies busy_timeout first, tolerates a failed
switch, then sets synchronous, in the order the async engine listener
already uses. It lives beside fts_query rather than in persistence/database
because importing the engine module costs over a second on the distill and
expand paths. The ledger keeps the same shape inline; it may not import
repowise.core at all.

Each store is tested by opening it while another connection holds the
file.
…rving when a tool cannot import

An MCP host restarts a stdio server that dies at startup, so two startup
failures became crash loops. The lifespan created .repowise and opened the
database unguarded, and a read-only directory or a .repowise that is a
file escaped as a traceback on every respawn. ensure_full_surface imported
every tool module unconditionally, so one missing optional dependency took
the whole surface down the same way.

The lifespan now raises StoreUnavailableError naming the directory and the
fix, the mcp command turns that into one line on stderr and exit 1, and a
tool module that cannot import is logged with the missing distribution and
skipped while the rest of the surface serves.
…-hook

init --yes and non-interactive runs skipped the hook offer with no message
and no opt-out, so the paths the product recommends left the index stale
after the first commit. Telemetry put 40 to 53 percent of tool calls on a
stale index for that reason.

The offer now installs on those paths and prints one line naming
repowise hook uninstall, which is the consent pattern for a default that
is on. An interactive run still asks. --no-hook skips it, and
--no-editor-setup or REPOWISE_SKIP_EDITOR_SETUP keeps it off the way they
keep the distill rewrite hook off, because a git hook is a write outside
.repowise/. Workspace init applies the same rule to every indexed repo. A
failed install is reported and never fails a finished init.
…kes a write

Doctor had no row for the post-commit hook, so a hook that failed to
install, or was deleted, was invisible on the one command people run to
ask whether their setup is healthy. It now reads hooks.status, which asks
git for the real hooks directory, so worktrees and core.hooksPath report
correctly. Absence is informational: --no-hook is a choice.

The .repowise row tested existence only, which read OK for a read-only
directory, the one state that stops the MCP server from starting. It now
writes and removes a probe file, since os.access is unreliable on Windows.

The workspace summary told users to run repowise hook install to register
an MCP client; that command installs the git hook. It now names
repowise init.
The stdio MCP server is the longest-lived process the product runs and the
one path that never checked its own currency, so a client that spawned it
weeks ago kept talking to that version with no signal. serve checked once
at startup and never again.

The lifespan now polls the TTL-cached PyPI check on a worker thread, in
both single-repo and workspace mode, and build_meta names a newer version
in the first response after the poller sees it, then stays quiet for that
version. serve re-runs its advisory on a daemon thread once per interval,
announcing each release once. The disk cache is shared with the CLI
advisory, so neither adds network beyond one fetch a day.
…on every startup failure

Three things a hook that fires on every commit for everyone must not do.
The script fell back to uv run repowise update when repowise was not on
PATH, which resolves whatever project uv finds above the repo, on every
commit, in repos that are not Python projects; it now looks only in the
repo's own .venv. It fired whenever .repowise existed, so a dry-run
directory or a deleted store failed on every commit forever; it now needs
state.json, the precondition update itself checks. And install appended
shell to any existing post-commit hook, breaking one written for node or
python; it now refuses those and the init offer reports the refusal.

The MCP lifespan gave a configured database the directory remedy; a
REPOWISE_DB_URL failure now says to check the URL and the server. The
workspace branch gets the same store protection as single-repo mode and
cancels its background tasks when the registry fails to load.

Docs that still called the hook an offer or a prompt say default now, and
the --no-editor-setup entry names the hook among what it skips.
The package proxies every _state global as a module attribute; the two new
release fields were not in its name set, so a write through the proxy
landed on the package instead of the state.
@repowise-bot

repowise-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔍 2 things to check

  1. offer_hook_install in .../init_cmd/_interactive.py signature changed. Review 1 caller outside this PR: .../cli/test_commands.py::TestInitYesFlag::test_yes_skips_offer_hook_install
  2. Run .../mcp/test_instrument.py, tests/integration/test_mcp.py, tests/integration/test_generation_pipeline.py (+36 more) first: they import the changed files

✅ Health of changed files: 3.6 → 3.9 (+0.3)
🚨 Change risk: high, riskier than 82% of this repo's commits.


📊 See the full report for this PR
Your repo map with this PR's blast radius lit up, every caller of the contracts it changes, and health before and after. No sign-in. · ⭐ Star Repowise · 📥 Install bot · Silence on a single PR with [skip repowise] in the title · Per-repo toggle on repowise.dev/settings?tab=bot · Updated 2026-09-05 07:36 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant