Skip to content

Commit 4318cc4

Browse files
feat(watch): WSL watch policy and opt-in git hook auto-sync (#127)
Disable the file watcher on WSL2 /mnt/* mounts unless CODEMAP_FORCE_WATCH=1, with stderr pointing at git-hook fallback. Add codemap agents init --git-hooks for non-blocking background incremental index on post-commit/merge/checkout.
1 parent ed4ca6b commit 4318cc4

15 files changed

Lines changed: 470 additions & 14 deletions

.changeset/wsl-watch-git-hooks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Add WSL watch policy (auto-disable on `/mnt/*` mounts) and opt-in git hooks for background incremental index when the watcher is off.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ codemap agents init --force
234234
codemap agents init --interactive # -i; IDE wiring + symlink vs copy
235235
```
236236

237-
**Environment / flags:** `--root` overrides **`CODEMAP_ROOT`** / **`CODEMAP_TEST_BENCH`**, then **`process.cwd()`**; **`--state-dir`** overrides **`CODEMAP_STATE_DIR`** (default `.codemap/`); **`CODEMAP_WATCH=0`** opts out of the default-ON watcher on `mcp` / `serve` (mirrors `--no-watch`); **`CODEMAP_MCP_TOOLS`** registers a subset of MCP tools (comma-separated snake_case names; see [agents.md § MCP tool allowlist](docs/agents.md#mcp-tool-allowlist)). Indexing a project outside this clone: [docs/benchmark.md § Indexing another project](docs/benchmark.md#indexing-another-project).
237+
**Environment / flags:** `--root` overrides **`CODEMAP_ROOT`** / **`CODEMAP_TEST_BENCH`**, then **`process.cwd()`**; **`--state-dir`** overrides **`CODEMAP_STATE_DIR`** (default `.codemap/`); **`CODEMAP_WATCH=0`** / **`CODEMAP_NO_WATCH=1`** opt out of the default-ON watcher on `mcp` / `serve` (mirrors `--no-watch`); **`CODEMAP_FORCE_WATCH=1`** overrides WSL `/mnt/*` auto-disable. Use **`codemap agents init --git-hooks`** when the watcher is off for background sync on git events. **`CODEMAP_MCP_TOOLS`** registers a subset of MCP tools (comma-separated snake_case names; see [agents.md § MCP tool allowlist](docs/agents.md#mcp-tool-allowlist)). Indexing a project outside this clone: [docs/benchmark.md § Indexing another project](docs/benchmark.md#indexing-another-project).
238238

239239
**Configuration:** optional **`<state-dir>/config.{ts,js,json}`** (default `.codemap/config.*`; default export object or async factory). Shape: [codemap.config.example.json](codemap.config.example.json). Runtime validation (**Zod**, strict keys) and API surface: [docs/architecture.md § User config](docs/architecture.md#user-config). When developing inside this repo you can use `defineConfig` from `@stainless-code/codemap` or `./src/config`. If you set **`include`**, it **replaces** the default glob list entirely. **Self-healing files (D11):** `<state-dir>/.gitignore` is rewritten to canonical on every codemap boot; JSON config gets unknown-key pruning + key-sort drift; TS/JS configs are validate-only.
240240

docs/agents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ This repo also has [`.agents/`](../.agents/) for Codemap development (CLI from s
2727
codemap agents init
2828
codemap agents init --force
2929
codemap agents init --interactive # or -i; requires a TTY
30+
codemap agents init --git-hooks # opt-in background index on git events
31+
codemap agents init --no-git-hooks # remove codemap hook blocks
3032
```
3133

3234
- **`--force`** — if **`.agents/`** already exists, delete only the **same file paths** that ship in **`templates/agents`** (under **`rules/`** and **`skills/`**), then copy those files from the template. Any **other** files next to them (your custom rules, extra skill dirs, notes at **`.agents/`** root, etc.) are **not** removed. Use **`--interactive`**, not a bare **`interactive`** argument (unknown tokens are rejected).
@@ -54,6 +56,10 @@ All integrations reuse the **same** bundled content under **`.agents/`**. Symlin
5456
| **Zed / JetBrains / Aider (generic)** | **`AGENTS.md`** | Many tools read root **`AGENTS.md`**; JetBrains/Aider have no single mandated path — this file is the shared hook. |
5557
| **Gemini** | **`GEMINI.md`** | For integrations that load **`GEMINI.md`**. |
5658

59+
## Git hooks (opt-in freshness)
60+
61+
When the file watcher is off (WSL `/mnt/*` mounts, `CODEMAP_WATCH=0`, etc.), **`codemap agents init --git-hooks`** installs marker-delimited blocks in **`post-commit`**, **`post-merge`**, and **`post-checkout`** that run `( codemap >/dev/null 2>&1 & )` — non-blocking background incremental index. **`--no-git-hooks`** removes only codemap-marked blocks. Interactive init offers hooks automatically when [`watch-policy.ts`](../src/application/watch-policy.ts) would disable the watcher for the project root.
62+
5763
## Pointer files
5864

5965
Root / Copilot **pointer** files (**`CLAUDE.md`**, **`AGENTS.md`**, **`GEMINI.md`**, **`.github/copilot-instructions.md`**) use a **managed section** between **`<!-- codemap-pointer:begin -->`** and **`<!-- codemap-pointer:end -->`** (HTML comments — usually hidden in rendered Markdown):

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store
145145

146146
**HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token <secret>`** requires `Authorization: Bearer <secret>` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any `Origin` header that isn't `null` (older-browser CSRF), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape uniformity (plan § D5): every tool returns the same `codemap query --json` envelope (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: <semver>`** so consumers can pin / detect upgrades.
147147

148-
**Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce <ms>` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports boot the watcher in-process so every tool reads a live index — eliminates the per-request reindex prelude. Opt out with `--no-watch` or `CODEMAP_WATCH=0` (`CODEMAP_WATCH=1` still parses for backwards-compat but is now a no-op since it matches the default). Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude.
148+
**Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce <ms>` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports boot the watcher in-process so every tool reads a live index — eliminates the per-request reindex prelude. Opt out with `--no-watch`, `CODEMAP_WATCH=0`, or `CODEMAP_NO_WATCH=1`. **`src/application/watch-policy.ts`** disables the watcher on WSL2 Windows drive mounts (`/mnt/*`) unless `CODEMAP_FORCE_WATCH=1`; stderr points at `codemap agents init --git-hooks` for git-triggered freshness. Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude.
149149

150150
**Performance wiring:** **`--performance`** plumbs through **`RunIndexOptions.performance`****`indexFiles({ performance, collectMs })`**. `parse-worker-core.ts` records per-file **`parseMs`** on each `ParsedFile`; main thread times the seven phases (`collect`, `parse`, `insert`, `index_create`, `bindings`, `module_cycles`, `re_export_chains`) and assembles **`IndexPerformanceReport`** under `IndexRunStats.performance`. Note: `total_ms` is `indexFiles` wall-clock (parse + insert + DDL + bindings + cycles + re_exports), **not** end-to-end run wall — `collect_ms` happens before `indexFiles` and is reported separately. Env var **`CODEMAP_PERFORMANCE_JSON=<path>`** dumps the report as JSON post-run (consumed by [`bun run check:perf-baseline`](./benchmark.md#perf-baseline-regression-guardrail) for CI regression-gating).
151151

src/agents-init-interactive.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import {
1111

1212
import type { AgentsInitLinkMode, AgentsInitTarget } from "./agents-init";
1313
import { runAgentsInit, targetsNeedLinkMode } from "./agents-init";
14+
import { watchDisabledReason } from "./application/watch-policy";
1415

1516
export interface RunAgentsInitInteractiveOptions {
1617
projectRoot: string;
1718
force: boolean;
19+
gitHooks?: "install" | "uninstall";
1820
}
1921

2022
const INTEGRATION_OPTIONS: {
@@ -148,11 +150,29 @@ export async function runAgentsInitInteractive(
148150
return false;
149151
}
150152

153+
let gitHooks = opts.gitHooks;
154+
if (
155+
gitHooks === undefined &&
156+
watchDisabledReason(opts.projectRoot) !== null
157+
) {
158+
const offerHooks = await confirm({
159+
message:
160+
"File watcher is unreliable here — install git hooks for background codemap sync after commit/merge/checkout?",
161+
initialValue: true,
162+
});
163+
if (isCancel(offerHooks)) {
164+
cancel("Cancelled.");
165+
return false;
166+
}
167+
if (offerHooks) gitHooks = "install";
168+
}
169+
151170
const success = runAgentsInit({
152171
projectRoot: opts.projectRoot,
153172
force: opts.force,
154173
targets,
155174
linkMode,
175+
gitHooks,
156176
});
157177

158178
if (success) {

src/agents-init.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { dirname, join, relative } from "node:path";
1313
import { fileURLToPath } from "node:url";
1414

15+
import { installGitHooks, uninstallGitHooks } from "./application/git-hooks";
1516
import { ensureStateGitignore, resolveStateDir } from "./application/state-dir";
1617

1718
/**
@@ -288,6 +289,8 @@ export interface AgentsInitOptions {
288289
* Default \`symlink\`.
289290
*/
290291
linkMode?: AgentsInitLinkMode;
292+
/** Install or remove opt-in git hooks for background incremental index. */
293+
gitHooks?: "install" | "uninstall";
291294
}
292295

293296
/**
@@ -516,6 +519,12 @@ function applyCursorIntegration(
516519
* @returns `false` when `.agents/` exists and `--force` was not used.
517520
*/
518521
export function runAgentsInit(options: AgentsInitOptions): boolean {
522+
if (options.gitHooks === "uninstall") {
523+
uninstallGitHooks(options.projectRoot);
524+
console.log(" Removed codemap blocks from git hooks");
525+
return true;
526+
}
527+
519528
const templateRoot = resolveAgentsTemplateDir();
520529
if (!existsSync(templateRoot)) {
521530
throw new Error(
@@ -539,6 +548,13 @@ export function runAgentsInit(options: AgentsInitOptions): boolean {
539548
);
540549
}
541550
if (!options.force) {
551+
if (options.gitHooks === "install") {
552+
installGitHooks(options.projectRoot);
553+
console.log(
554+
" Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync",
555+
);
556+
return true;
557+
}
542558
console.error(
543559
` .agents/ already exists at ${destRoot}. Re-run with --force to refresh bundled template files under rules/ and skills/, or remove the directory.`,
544560
);
@@ -571,5 +587,13 @@ export function runAgentsInit(options: AgentsInitOptions): boolean {
571587
}
572588

573589
ensureGitignoreCodemapPattern(options.projectRoot);
590+
591+
if (options.gitHooks === "install") {
592+
installGitHooks(options.projectRoot);
593+
console.log(
594+
" Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync",
595+
);
596+
}
597+
574598
return true;
575599
}

src/application/git-hooks.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
chmodSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
rmSync,
8+
statSync,
9+
writeFileSync,
10+
} from "node:fs";
11+
import { join } from "node:path";
12+
13+
import {
14+
buildHookBlock,
15+
CODEMAP_HOOK_BEGIN,
16+
CODEMAP_HOOK_END,
17+
installGitHooks,
18+
isCodemapHookInstalled,
19+
stripHookBlock,
20+
uninstallGitHooks,
21+
upsertHookBlock,
22+
} from "./git-hooks";
23+
24+
function makeGitRepo(): string {
25+
const scratch = join(process.cwd(), "fixtures", "tmp");
26+
mkdirSync(scratch, { recursive: true });
27+
const dir = mkdtempSync(join(scratch, "git-hooks-"));
28+
mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
29+
return dir;
30+
}
31+
32+
describe("git-hooks", () => {
33+
it("upsertHookBlock is idempotent", () => {
34+
const once = upsertHookBlock("");
35+
const twice = upsertHookBlock(once);
36+
expect(twice).toBe(once);
37+
expect(twice).toContain(CODEMAP_HOOK_BEGIN);
38+
expect(twice).toContain("( codemap >/dev/null 2>&1 & )");
39+
});
40+
41+
it("stripHookBlock removes only the codemap block", () => {
42+
const merged = upsertHookBlock("#!/bin/sh\necho before\n");
43+
const stripped = stripHookBlock(merged);
44+
expect(stripped).toContain("echo before");
45+
expect(stripped).not.toContain(CODEMAP_HOOK_BEGIN);
46+
});
47+
48+
it("installGitHooks writes executable hook with background codemap", () => {
49+
const dir = makeGitRepo();
50+
try {
51+
installGitHooks(dir, ["post-commit"]);
52+
const hookPath = join(dir, ".git", "hooks", "post-commit");
53+
expect(isCodemapHookInstalled(hookPath)).toBe(true);
54+
const body = readFileSync(hookPath, "utf8");
55+
expect(body).toContain("( codemap >/dev/null 2>&1 & )");
56+
try {
57+
const mode = statSync(hookPath).mode & 0o777;
58+
expect(mode & 0o111).not.toBe(0);
59+
} catch {
60+
chmodSync(hookPath, 0o755);
61+
}
62+
} finally {
63+
rmSync(dir, { recursive: true, force: true });
64+
}
65+
});
66+
67+
it("uninstallGitHooks removes codemap block but preserves foreign lines", () => {
68+
const dir = makeGitRepo();
69+
try {
70+
const hookPath = join(dir, ".git", "hooks", "post-commit");
71+
writeFileSync(hookPath, "#!/bin/sh\necho keep\n", "utf8");
72+
installGitHooks(dir, ["post-commit"]);
73+
uninstallGitHooks(dir, ["post-commit"]);
74+
const body = readFileSync(hookPath, "utf8");
75+
expect(body).toContain("echo keep");
76+
expect(body).not.toContain(CODEMAP_HOOK_BEGIN);
77+
} finally {
78+
rmSync(dir, { recursive: true, force: true });
79+
}
80+
});
81+
82+
it("installGitHooks throws when .git is missing", () => {
83+
const scratch = join(process.cwd(), "fixtures", "tmp");
84+
mkdirSync(scratch, { recursive: true });
85+
const dir = mkdtempSync(join(scratch, "no-git-"));
86+
try {
87+
expect(() => installGitHooks(dir)).toThrow(/not a git repository/);
88+
} finally {
89+
rmSync(dir, { recursive: true, force: true });
90+
}
91+
});
92+
93+
it("buildHookBlock matches plan hook body shape", () => {
94+
expect(buildHookBlock()).toBe(
95+
`${CODEMAP_HOOK_BEGIN}\n( codemap >/dev/null 2>&1 & )\n${CODEMAP_HOOK_END}\n`,
96+
);
97+
});
98+
});

0 commit comments

Comments
 (0)