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
5 changes: 5 additions & 0 deletions .changeset/wsl-watch-git-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/codemap": patch
---

Add WSL watch policy (auto-disable on `/mnt/*` mounts) and opt-in git hooks for background incremental index when the watcher is off.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ codemap agents init --force
codemap agents init --interactive # -i; IDE wiring + symlink vs copy
```

**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).
**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).

**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.

Expand Down
6 changes: 6 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ This repo also has [`.agents/`](../.agents/) for Codemap development (CLI from s
codemap agents init
codemap agents init --force
codemap agents init --interactive # or -i; requires a TTY
codemap agents init --git-hooks # opt-in background index on git events
codemap agents init --no-git-hooks # remove codemap hook blocks
```

- **`--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).
Expand Down Expand Up @@ -54,6 +56,10 @@ All integrations reuse the **same** bundled content under **`.agents/`**. Symlin
| **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. |
| **Gemini** | **`GEMINI.md`** | For integrations that load **`GEMINI.md`**. |

## Git hooks (opt-in freshness)

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.

## Pointer files

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):
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store

**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.

**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.
**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.

**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).

Expand Down
20 changes: 20 additions & 0 deletions src/agents-init-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {

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

export interface RunAgentsInitInteractiveOptions {
projectRoot: string;
force: boolean;
gitHooks?: "install" | "uninstall";
}

const INTEGRATION_OPTIONS: {
Expand Down Expand Up @@ -148,11 +150,29 @@ export async function runAgentsInitInteractive(
return false;
}

let gitHooks = opts.gitHooks;
if (
gitHooks === undefined &&
watchDisabledReason(opts.projectRoot) !== null
) {
const offerHooks = await confirm({
message:
"File watcher is unreliable here — install git hooks for background codemap sync after commit/merge/checkout?",
initialValue: true,
});
if (isCancel(offerHooks)) {
cancel("Cancelled.");
return false;
}
if (offerHooks) gitHooks = "install";
}

const success = runAgentsInit({
projectRoot: opts.projectRoot,
force: opts.force,
targets,
linkMode,
gitHooks,
});

if (success) {
Expand Down
24 changes: 24 additions & 0 deletions src/agents-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";

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

/**
Expand Down Expand Up @@ -288,6 +289,8 @@ export interface AgentsInitOptions {
* Default \`symlink\`.
*/
linkMode?: AgentsInitLinkMode;
/** Install or remove opt-in git hooks for background incremental index. */
gitHooks?: "install" | "uninstall";
}

/**
Expand Down Expand Up @@ -516,6 +519,12 @@ function applyCursorIntegration(
* @returns `false` when `.agents/` exists and `--force` was not used.
*/
export function runAgentsInit(options: AgentsInitOptions): boolean {
if (options.gitHooks === "uninstall") {
uninstallGitHooks(options.projectRoot);
console.log(" Removed codemap blocks from git hooks");
return true;
}

const templateRoot = resolveAgentsTemplateDir();
if (!existsSync(templateRoot)) {
throw new Error(
Expand All @@ -539,6 +548,13 @@ export function runAgentsInit(options: AgentsInitOptions): boolean {
);
}
if (!options.force) {
if (options.gitHooks === "install") {
installGitHooks(options.projectRoot);
console.log(
" Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync",
);
return true;
}
console.error(
` .agents/ already exists at ${destRoot}. Re-run with --force to refresh bundled template files under rules/ and skills/, or remove the directory.`,
);
Expand Down Expand Up @@ -571,5 +587,13 @@ export function runAgentsInit(options: AgentsInitOptions): boolean {
}

ensureGitignoreCodemapPattern(options.projectRoot);

if (options.gitHooks === "install") {
installGitHooks(options.projectRoot);
console.log(
" Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync",
);
}

return true;
}
98 changes: 98 additions & 0 deletions src/application/git-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "bun:test";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";

import {
buildHookBlock,
CODEMAP_HOOK_BEGIN,
CODEMAP_HOOK_END,
installGitHooks,
isCodemapHookInstalled,
stripHookBlock,
uninstallGitHooks,
upsertHookBlock,
} from "./git-hooks";

function makeGitRepo(): string {
const scratch = join(process.cwd(), "fixtures", "tmp");
mkdirSync(scratch, { recursive: true });
const dir = mkdtempSync(join(scratch, "git-hooks-"));
mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
return dir;
}

describe("git-hooks", () => {
it("upsertHookBlock is idempotent", () => {
const once = upsertHookBlock("");
const twice = upsertHookBlock(once);
expect(twice).toBe(once);
expect(twice).toContain(CODEMAP_HOOK_BEGIN);
expect(twice).toContain("( codemap >/dev/null 2>&1 & )");
});

it("stripHookBlock removes only the codemap block", () => {
const merged = upsertHookBlock("#!/bin/sh\necho before\n");
const stripped = stripHookBlock(merged);
expect(stripped).toContain("echo before");
expect(stripped).not.toContain(CODEMAP_HOOK_BEGIN);
});

it("installGitHooks writes executable hook with background codemap", () => {
const dir = makeGitRepo();
try {
installGitHooks(dir, ["post-commit"]);
const hookPath = join(dir, ".git", "hooks", "post-commit");
expect(isCodemapHookInstalled(hookPath)).toBe(true);
const body = readFileSync(hookPath, "utf8");
expect(body).toContain("( codemap >/dev/null 2>&1 & )");
try {
const mode = statSync(hookPath).mode & 0o777;
expect(mode & 0o111).not.toBe(0);
} catch {
chmodSync(hookPath, 0o755);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("uninstallGitHooks removes codemap block but preserves foreign lines", () => {
const dir = makeGitRepo();
try {
const hookPath = join(dir, ".git", "hooks", "post-commit");
writeFileSync(hookPath, "#!/bin/sh\necho keep\n", "utf8");
installGitHooks(dir, ["post-commit"]);
uninstallGitHooks(dir, ["post-commit"]);
const body = readFileSync(hookPath, "utf8");
expect(body).toContain("echo keep");
expect(body).not.toContain(CODEMAP_HOOK_BEGIN);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("installGitHooks throws when .git is missing", () => {
const scratch = join(process.cwd(), "fixtures", "tmp");
mkdirSync(scratch, { recursive: true });
const dir = mkdtempSync(join(scratch, "no-git-"));
try {
expect(() => installGitHooks(dir)).toThrow(/not a git repository/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("buildHookBlock matches plan hook body shape", () => {
expect(buildHookBlock()).toBe(
`${CODEMAP_HOOK_BEGIN}\n( codemap >/dev/null 2>&1 & )\n${CODEMAP_HOOK_END}\n`,
);
});
});
Loading
Loading