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
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Top-level packages under `src/whygraph/`:
- `analyze/` — LLM-backed analysis. `description.py` / `llm_descriptor.py` produce per-commit diff descriptions; `rationale.py` / `rationale_generator.py` produce the 5-section rationale cards; `backfill.py` runs the lazy on-read backfill. Prompt templates live under `analyze/prompts/`.
- `agents.py` — registry of supported LLM agents (Claude Code, Cursor, VS Code / Copilot, Codex, Claude Desktop) and the per-agent MCP config wiring (`write_snippet` / `render_snippet`). `whygraph init --agent X` reads from here.
- `assets.py` + `assets/claude-code/` — bundled Claude Code assets (agents, commands, skills) copied into a project's `.claude/` by `whygraph init --agent claude`. Loaded at runtime via `importlib.resources.files("whygraph") / "assets" / "claude-code"`; same packaging precedent as `analyze/prompts/`.
- `hooks.py` — the auto-rescan git hooks (`post-commit` / `post-merge` / `post-rewrite` / `post-checkout`): the helper script, the sentinel-guarded dispatcher, and `sync_hooks()`. A top-level module for the same reason as `agents.py` / `assets.py` — an installed-by-`init` concern — and deliberately Click-free (it raises `HooksError`, not `ClickException`).
- `__main__.py` — enables `python -m whygraph`.

Console scripts in `pyproject.toml`: `whygraph` → `cli:main`, `whygraph-mcp` → `mcp.server:main`. Both must keep working — `.mcp.json` files written by `whygraph init` and the `uv tool install` path depend on them.
Expand Down Expand Up @@ -89,7 +90,13 @@ CodeGraph indexing belongs to **`whygraph scan`, not `whygraph init`** — `init

### Auto-rescan git hooks

`whygraph hooks install` (opt-in; `cli/commands/hooks.py`) wires `post-commit` / `post-merge` / `post-rewrite` to keep the DBs current as the developer works — no daemon. Each hook execs a shared helper (`.whygraph/hooks/whygraph-scan`, gitignored) that runs `whygraph scan --skip-analyze --no-remote` (git history + `codegraph sync` only — fast, offline, no token; LLM descriptions stay on lazy backfill). The helper is **detached** (commits return instantly) and **single-flight + coalescing** (portable `mkdir` lock + a `pending` flag, since macOS has no `flock`), so rapid commits neither stack nor drop the latest `HEAD`. Installs are **sentinel-guarded** (`# >>> whygraph managed >>>`) and append to a foreign hook rather than clobber it. The `--no-remote` flag on `scan` exists for this path; `db/engine.py` sets `PRAGMA busy_timeout` so a background rescan and a manual scan don't collide.
`whygraph init` installs them (`hooks.py`, a top-level module beside `agents.py` / `assets.py` — there is **no** `hooks` CLI command — the group was removed). Four hooks — `post-commit` / `post-merge` / `post-rewrite` / `post-checkout` — keep the DBs current as the developer works, no daemon. Each execs a shared helper (`.whygraph/hooks/whygraph-scan`, gitignored) that runs `whygraph scan --skip-analyze --no-remote` (git history + `codegraph sync` only — fast, offline, no token; LLM descriptions stay on lazy backfill). The helper is **detached** (commits return instantly) and **single-flight + coalescing** (portable `mkdir` lock + a `pending` flag, since macOS has no `flock`), so rapid commits neither stack nor drop the latest `HEAD`. Installs are **sentinel-guarded** (`# >>> whygraph managed >>>`) and append to a foreign hook rather than clobber it. The dispatcher forwards `"$@"` because `post-checkout` is the only hook git invokes with arguments; the helper's arg gate skips a file checkout and a same-commit `git switch -c`. The `--no-remote` flag on `scan` exists for this path; `db/engine.py` sets `PRAGMA busy_timeout` so a background rescan and a manual scan don't collide.

`[scan].hooks` is the only switch — a bool *or* a list of hook names — and **`init` is the reconciler**: `sync_hooks()` iterates all of `HOOK_NAMES` every run, installing what is listed and stripping the managed block from what is not, so shrinking the list removes the dropped hooks. It is one function rather than an install/uninstall pair precisely so the removal half cannot be forgotten. It is best-effort: an unwritable hooks dir or an unknown hook name warns and `init` still exits 0.

### Branch membership

`on_default_branch` is **computed, not assumed**. `Repository.default_branch_refs` resolves the default branch (`origin/HEAD` → `<remote>/main` → `<remote>/master`, overridable via `[scan].default_branch`) and unions it with the same-named *local* branch, so unpushed commits on local `main` still count. `GitCrawler` flags new rows against that SHA set and records `first_seen_ref` (a branch name, or `refs/pull/<N>/head` for a `PROriginEnricher` recovery; `NULL` means it was on the default branch) — and a **reconcile pass** recomputes the flag for existing rows on every scan, so the DB self-heals as branches merge or get rewritten. Two guards make a mass-demotion impossible: an unresolvable default branch and a shallow clone both skip the pass entirely. Rows are never deleted — an unreachable commit is still evidence. `first_seen_ref` has exactly one consumer beyond debugging: the rename-alias walks in `mcp/path_history.py` / `mcp/evidence.py` scope to *default branch **or** current branch*, which keeps an in-flight rename visible without letting an abandoned branch pollute path history forever.

Deferred (net-new, not built yet): a project registry for cross-repo orchestration, a persistent/server mode, and per-branch CodeGraph/WhyGraph databases.

Expand Down
79 changes: 65 additions & 14 deletions docs/guide/scanning.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,29 +47,80 @@ whygraph scan --no-remote --skip-analyze

## Keep it fresh

Don't want to re-scan by hand? Install git hooks once, and new commits refresh WhyGraph and CodeGraph
on the fly:
You don't have to re-scan by hand. `whygraph init` installs git hooks that refresh WhyGraph and
CodeGraph in the background as you work - there's no daemon and no separate command to run.

```bash
whygraph hooks install
```
Four hooks are wired, covering every git event that can change the tree or add commits:

| Hook | Fires on |
|---|---|
| `post-commit` | `git commit`, `git commit --amend` |
| `post-merge` | `git pull`, `git merge` |
| `post-rewrite` | `git rebase`, including `git pull --rebase` |
| `post-checkout` | `git switch` / `git checkout` to another branch |

This wires `post-commit`, `post-merge`, and `post-rewrite` to run
`whygraph scan --no-remote --skip-analyze` **in the background**. Git history and a CodeGraph
`sync` only - no LLM, no remote calls - so commits stay instant and the scan is offline and
token-free.
Each runs `whygraph scan --no-remote --skip-analyze` **in the background**. Git history and a
CodeGraph `sync` only - no LLM, no remote calls - so commits stay instant and the scan is offline
and token-free.

The hooks are detached and single-flight: rapid commits coalesce instead of stacking, and the latest
`HEAD` always wins. An existing hook of your own is appended to behind a sentinel guard, never
overwritten.
overwritten. `post-checkout` skips the two cases that can't have changed anything - a file checkout
(`git checkout -- somefile`) and `git switch -c` at the current commit.

Check or remove them any time:
### Choosing which hooks to install

```bash
whygraph hooks status
whygraph hooks uninstall
`[scan].hooks` in `whygraph.toml` governs the set, and **`whygraph init` makes `.git/hooks` match
it exactly**. Edit the value, then re-run `whygraph init` - nothing changes until you do.

```toml
[scan]
hooks = true # all four (the default)
# hooks = false # none
# hooks = ["post-commit", "post-merge"] # only these two
```

The reconcile works in **both directions**. Shrinking the list *removes* the hooks you dropped -
you don't have to undo them by hand - and growing it adds them back. Setting `false` strips all
four and deletes the shared helper, leaving any foreign hook content of your own intact.

Because the setting lives in the committed config, it survives re-runs and applies to everyone who
clones the repo.

!!! note "Hooks stay fast on purpose"
The hooks deliberately skip the remote and LLM phases so they never slow a commit. For PRs,
issues, and fresh descriptions, run a full `whygraph scan` now and then.

## How WhyGraph sees branches

WhyGraph records every commit it walks, but it distinguishes **shipped history** from work in
progress. Each commit row carries `on_default_branch`: `1` when the commit is reachable from the
default branch, `0` when it isn't.

The default branch is resolved from `origin/HEAD`, falling back to `origin/main` then
`origin/master`, and is judged as the union of that remote-tracking ref *and* your local branch of
the same name - so commits you've made on `main` but haven't pushed still count as shipped. For a
repo on `develop` or `trunk` where `origin/HEAD` isn't set, name it explicitly:

```toml
[scan]
default_branch = "develop"
```

The pre-scan panel shows what it resolved. If it says `unresolved`, branch flagging is off and every
commit is treated as on the default branch - the same behaviour as before this existed.

**What this means in practice:** unmerged work on a feature branch is excluded by design from
velocity numbers, area history, and the chat statistics surface. It is still recorded, still
searchable, and still evidence - it just isn't counted as shipped.

Membership is recomputed on **every** scan, so the database self-heals:

- Merge a branch and the next scan promotes its commits to the default branch.
- Squash-merge it and the originals stay off-branch, correctly - a squash creates a *new* commit.
- Force-push a commit away and the next scan demotes it, with a warning naming the count. The row is
kept: a commit that no longer exists on any branch is still valid evidence for why the code looks
the way it does.

Shallow clones (`git clone --depth=1`) skip the recompute entirely - a truncated view of history
would otherwise demote nearly everything.
25 changes: 6 additions & 19 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ own install. There are six commands.
$ whygraph --help
Commands:
analyze Describe a commit's diff with the configured LLM.
hooks Manage opt-in git hooks that auto-rescan on new commits.
init Initialize the WhyGraph database under .whygraph/whygraph.db.
scan Run the source crawlers, then describe each commit with the LLM.
serve Serve the WhyGraph Explorer panel for this repository.
Expand Down Expand Up @@ -35,6 +34,12 @@ summary that masks every secret, asks *"Write these files?"*, then writes both `
defaulted. `--yes` (and any non-TTY invocation) skips the prompts, uses defaults, and never clobbers
an existing `whygraph.toml`.

`init` also installs the auto-rescan git hooks and **reconciles them to `[scan].hooks` in both
directions** - installing what the config lists and stripping the managed block from what it
doesn't. Editing `[scan].hooks` and re-running `whygraph init` is the supported way to change hook
coverage; see [Keep it fresh](../guide/scanning.md#keep-it-fresh). A hooks directory that can't be
written is a warning, never a failed init.

`init` does **not** index CodeGraph. That happens on [`scan`](#whygraph-scan).

With `--agent X`, it also wires the WhyGraph MCP server into that agent's config. All supported
Expand Down Expand Up @@ -104,21 +109,3 @@ whygraph analyze <TARGET> [BASELINE]
Every commit named on the command line must already exist in the WhyGraph database. Run
`whygraph scan` before `whygraph analyze`.

## `whygraph hooks`

Manage opt-in git hooks that auto-rescan on new commits. There's no daemon - the hooks run a fast,
background, offline scan as you commit.

| Subcommand | Description |
|---|---|
| `install` | Install the auto-rescan hooks into the current repository. Idempotent and non-clobbering - it appends to a foreign hook behind a sentinel guard. |
| `status` | Report whether the auto-rescan hooks are installed. |
| `uninstall` | Remove the auto-rescan hooks, leaving any foreign hook content intact. |

```bash
whygraph hooks install
```

The hooks wire `post-commit`, `post-merge`, and `post-rewrite` to run
`whygraph scan --no-remote --skip-analyze` in the background. See
[Keep it fresh](../guide/scanning.md#keep-it-fresh) for the details.
2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ against the running code, so it stays in step with what `whygraph` actually does

---

Every command and flag, straight from `--help`: `init`, `scan`, `analyze`, `hooks`, `version`.
Every command and flag, straight from `--help`: `init`, `scan`, `analyze`, `serve`, `version`.

[:octicons-arrow-right-24: CLI reference](cli.md)

Expand Down
10 changes: 8 additions & 2 deletions src/whygraph/chat/stats_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ def run_stats_query(sql: str, *, db_path: Path | None = None) -> dict:
=== FIVE REQUIRED RULES (each of these silently corrupts results) ===

1. ALWAYS filter `on_default_branch = 1` on the commit table. Rows with 0 are
PR-origin commits recovered from squash merges; counting them double-counts
work that is already on the main walk.
NOT on the default branch — either unmerged local work scanned off a feature
branch, or a PR-origin commit recovered from a squash merge. Neither belongs
in a count of shipped work, and including them double-counts squashed PRs.
`first_seen_ref` tells the two apart if you ever need to.

2. For dates ALWAYS use SQLite's date functions — strftime(), date(),
julianday() — and NEVER substr() on a timestamp. Timestamps are TEXT in
Expand Down Expand Up @@ -187,6 +189,10 @@ def run_stats_query(sql: str, *, db_path: Path | None = None) -> dict:
-- formatter sweep. Observed: 201 commits 0-24,
-- 13 at 25-49, 5 at 50-74. Not a quality measure.
on_default_branch INTEGER 0/1 -- see rule 1
first_seen_ref TEXT NULL -- NULL = was on the default branch when scanned.
-- Otherwise the ref a flag-0 row came from: a branch
-- name (unmerged local work) or refs/pull/<N>/head
-- (a squash-merge recovery).
scanned_at TEXT

commit_file_change — one row per (commit, path AT THAT COMMIT)
Expand Down
2 changes: 0 additions & 2 deletions src/whygraph/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from whygraph.core import configure_logging, get_config

from .commands.analyze import analyze_cmd
from .commands.hooks import hooks_cmd
from .commands.init import init_cmd
from .commands.install import install_cmd
from .commands.scan import scan_cmd
Expand All @@ -34,5 +33,4 @@ def main() -> None:
main.add_command(scan_cmd)
main.add_command(serve_cmd)
main.add_command(analyze_cmd)
main.add_command(hooks_cmd)
main.add_command(install_cmd)
Loading
Loading