From 40fe44f2cda9ad46f9a369228b1f5116373023f8 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 25 May 2026 11:33:06 +0300 Subject: [PATCH] feat(watch): WSL watch policy and opt-in git hook auto-sync 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. --- .changeset/wsl-watch-git-hooks.md | 5 ++ README.md | 2 +- docs/agents.md | 6 ++ docs/architecture.md | 2 +- src/agents-init-interactive.ts | 20 +++++ src/agents-init.ts | 24 ++++++ src/application/git-hooks.test.ts | 98 ++++++++++++++++++++++++ src/application/git-hooks.ts | 107 +++++++++++++++++++++++++++ src/application/watch-policy.test.ts | 62 ++++++++++++++++ src/application/watch-policy.ts | 90 ++++++++++++++++++++++ src/cli/cmd-agents.ts | 7 +- src/cli/cmd-mcp.ts | 16 ++-- src/cli/cmd-serve.ts | 16 ++-- src/cli/cmd-watch.ts | 11 +++ src/cli/main.ts | 18 ++++- 15 files changed, 470 insertions(+), 14 deletions(-) create mode 100644 .changeset/wsl-watch-git-hooks.md create mode 100644 src/application/git-hooks.test.ts create mode 100644 src/application/git-hooks.ts create mode 100644 src/application/watch-policy.test.ts create mode 100644 src/application/watch-policy.ts diff --git a/.changeset/wsl-watch-git-hooks.md b/.changeset/wsl-watch-git-hooks.md new file mode 100644 index 0000000..820a5cf --- /dev/null +++ b/.changeset/wsl-watch-git-hooks.md @@ -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. diff --git a/README.md b/README.md index 46798e6..98fb0bc 100644 --- a/README.md +++ b/README.md @@ -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`). 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. Indexing a project outside this clone: [docs/benchmark.md § Indexing another project](docs/benchmark.md#indexing-another-project). **Configuration:** optional **`/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):** `/.gitignore` is rewritten to canonical on every codemap boot; JSON config gets unknown-key pruning + key-sort drift; TS/JS configs are validate-only. diff --git a/docs/agents.md b/docs/agents.md index ab99f73..56d5742 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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). @@ -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 **``** and **``** (HTML comments — usually hidden in rendered Markdown): diff --git a/docs/architecture.md b/docs/architecture.md index eef4d7d..73ba7d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `** requires `Authorization: Bearer ` 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: `** so consumers can pin / detect upgrades. -**Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce ` / `--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 ` / `--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=`** dumps the report as JSON post-run (consumed by [`bun run check:perf-baseline`](./benchmark.md#perf-baseline-regression-guardrail) for CI regression-gating). diff --git a/src/agents-init-interactive.ts b/src/agents-init-interactive.ts index 37ebd12..c37b8ee 100644 --- a/src/agents-init-interactive.ts +++ b/src/agents-init-interactive.ts @@ -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: { @@ -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) { diff --git a/src/agents-init.ts b/src/agents-init.ts index 71d9dc1..45d54d2 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -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"; /** @@ -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"; } /** @@ -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( @@ -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.`, ); @@ -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; } diff --git a/src/application/git-hooks.test.ts b/src/application/git-hooks.test.ts new file mode 100644 index 0000000..9ee0944 --- /dev/null +++ b/src/application/git-hooks.test.ts @@ -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`, + ); + }); +}); diff --git a/src/application/git-hooks.ts b/src/application/git-hooks.ts new file mode 100644 index 0000000..b5d271e --- /dev/null +++ b/src/application/git-hooks.ts @@ -0,0 +1,107 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +/** Shell-comment markers — hook files are executable scripts, not Markdown. */ +export const CODEMAP_HOOK_BEGIN = "# CODEMAP_HOOK_BEGIN"; +export const CODEMAP_HOOK_END = "# CODEMAP_HOOK_END"; + +export type GitHookName = "post-commit" | "post-merge" | "post-checkout"; + +export const DEFAULT_GIT_HOOKS: readonly GitHookName[] = [ + "post-commit", + "post-merge", + "post-checkout", +] as const; + +const HOOK_BODY = "( codemap >/dev/null 2>&1 & )\n"; + +export function buildHookBlock(): string { + return `${CODEMAP_HOOK_BEGIN}\n${HOOK_BODY}${CODEMAP_HOOK_END}\n`; +} + +export function upsertHookBlock(existing: string): string { + const block = buildHookBlock(); + const beginIdx = existing.indexOf(CODEMAP_HOOK_BEGIN); + const endIdx = existing.indexOf(CODEMAP_HOOK_END); + if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { + const tail = existing.slice(endIdx + CODEMAP_HOOK_END.length); + const prefix = existing.slice(0, beginIdx); + return `${prefix}${block}${tail.replace(/^\n?/, "")}`; + } + if (existing.length === 0) return block; + const sep = existing.endsWith("\n") ? "" : "\n"; + return `${existing}${sep}${block}`; +} + +export function stripHookBlock(content: string): string { + const beginIdx = content.indexOf(CODEMAP_HOOK_BEGIN); + const endIdx = content.indexOf(CODEMAP_HOOK_END); + if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { + return content; + } + const before = content.slice(0, beginIdx).replace(/\n$/, ""); + const after = content + .slice(endIdx + CODEMAP_HOOK_END.length) + .replace(/^\n/, ""); + if (before.length === 0) return after; + if (after.length === 0) return before.endsWith("\n") ? before : `${before}\n`; + return `${before}\n${after}`; +} + +export function isCodemapHookInstalled(hookPath: string): boolean { + if (!existsSync(hookPath)) return false; + return readFileSync(hookPath, "utf8").includes(CODEMAP_HOOK_BEGIN); +} + +function resolveGitHooksDir(projectRoot: string): string { + const gitDir = join(projectRoot, ".git"); + if (!existsSync(gitDir)) { + throw new Error( + `codemap: ${projectRoot} is not a git repository — git hooks require .git/`, + ); + } + const hooksDir = join(gitDir, "hooks"); + mkdirSync(hooksDir, { recursive: true }); + return hooksDir; +} + +export function installGitHooks( + projectRoot: string, + hooks: readonly GitHookName[] = DEFAULT_GIT_HOOKS, +): void { + const hooksDir = resolveGitHooksDir(projectRoot); + for (const name of hooks) { + const hookPath = join(hooksDir, name); + const prev = existsSync(hookPath) ? readFileSync(hookPath, "utf8") : ""; + const next = upsertHookBlock(prev); + writeFileSync(hookPath, next, "utf8"); + try { + chmodSync(hookPath, 0o755); + } catch { + // Windows / sandbox may reject chmod; hook content still written. + } + } +} + +export function uninstallGitHooks( + projectRoot: string, + hooks: readonly GitHookName[] = DEFAULT_GIT_HOOKS, +): void { + const hooksDir = resolveGitHooksDir(projectRoot); + for (const name of hooks) { + const hookPath = join(hooksDir, name); + if (!existsSync(hookPath)) continue; + const prev = readFileSync(hookPath, "utf8"); + const next = stripHookBlock(prev); + if (next.trim().length === 0) { + continue; + } + writeFileSync(hookPath, next, "utf8"); + } +} diff --git a/src/application/watch-policy.test.ts b/src/application/watch-policy.test.ts new file mode 100644 index 0000000..646d063 --- /dev/null +++ b/src/application/watch-policy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; + +import { + applyWatchPolicy, + detectWsl, + envWatchDefaultOn, + isWindowsDriveMount, + watchDisabledReason, +} from "./watch-policy"; + +describe("watch-policy", () => { + it("envWatchDefaultOn respects CODEMAP_NO_WATCH and CODEMAP_WATCH=0", () => { + expect(envWatchDefaultOn({})).toBe(true); + expect(envWatchDefaultOn({ CODEMAP_NO_WATCH: "1" })).toBe(false); + expect(envWatchDefaultOn({ CODEMAP_WATCH: "0" })).toBe(false); + expect(envWatchDefaultOn({ CODEMAP_WATCH: "false" })).toBe(false); + }); + + it("isWindowsDriveMount detects /mnt/ paths", () => { + expect(isWindowsDriveMount("/mnt/c/Users/foo")).toBe(true); + expect(isWindowsDriveMount("/mnt/z")).toBe(true); + expect(isWindowsDriveMount("/home/user/proj")).toBe(false); + expect(isWindowsDriveMount("/Users/me/proj")).toBe(false); + }); + + it("watchDisabledReason disables WSL Windows mounts by default", () => { + const reason = watchDisabledReason( + "/mnt/c/Users/proj", + {}, + { + isWsl: () => true, + }, + ); + expect(reason).toContain("/mnt/*"); + }); + + it("CODEMAP_FORCE_WATCH=1 overrides WSL mount detection", () => { + expect( + watchDisabledReason( + "/mnt/c/Users/proj", + { CODEMAP_FORCE_WATCH: "1" }, + { isWsl: () => true }, + ), + ).toBeNull(); + }); + + it("applyWatchPolicy logs path disables watch but keeps transport alive", () => { + const { watch } = applyWatchPolicy({ + root: "/mnt/c/repo", + requestedWatch: true, + label: "codemap mcp", + env: {}, + probe: { isWsl: () => true }, + }); + expect(watch).toBe(false); + }); + + it("detectWsl uses injected probe", () => { + expect(detectWsl({ isWsl: () => true })).toBe(true); + expect(detectWsl({ isWsl: () => false })).toBe(false); + }); +}); diff --git a/src/application/watch-policy.ts b/src/application/watch-policy.ts new file mode 100644 index 0000000..c04f85d --- /dev/null +++ b/src/application/watch-policy.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface WatchPolicyProbe { + isWsl?: () => boolean; + readProcVersion?: () => string | undefined; +} + +/** Default-ON watcher unless env opts out (mirrors pre-policy CLI behavior). */ +export function envWatchDefaultOn( + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env["CODEMAP_NO_WATCH"] === "1") return false; + const watch = env["CODEMAP_WATCH"]; + if (watch === "0" || watch === "false") return false; + return true; +} + +export function detectWsl(probe?: WatchPolicyProbe): boolean { + if (probe?.isWsl !== undefined) return probe.isWsl(); + if (process.env["WSL_DISTRO_NAME"] !== undefined) return true; + try { + const version = + probe?.readProcVersion?.() ?? + readFileSync("/proc/version", "utf8").toString(); + return /microsoft|WSL/i.test(version); + } catch { + return false; + } +} + +/** True when `root` lives on a WSL2 Windows drive mount (`/mnt//...`). */ +export function isWindowsDriveMount(root: string): boolean { + const normalized = resolve(root).replace(/\\/g, "/"); + return /^\/mnt\/[a-zA-Z](?:\/|$)/.test(normalized); +} + +/** + * When non-null, the file watcher should not start for `root`. + * Precedence: `CODEMAP_FORCE_WATCH=1` → allow; explicit off env → disable; + * WSL `/mnt/*` → disable unless forced. + */ +export function watchDisabledReason( + root: string, + env: NodeJS.ProcessEnv = process.env, + probe?: WatchPolicyProbe, +): string | null { + if (env["CODEMAP_FORCE_WATCH"] === "1") return null; + if (env["CODEMAP_WATCH"] === "1" || env["CODEMAP_WATCH"] === "true") { + return null; + } + if (env["CODEMAP_NO_WATCH"] === "1") { + return "CODEMAP_NO_WATCH=1"; + } + if (env["CODEMAP_WATCH"] === "0" || env["CODEMAP_WATCH"] === "false") { + return "CODEMAP_WATCH=0"; + } + if (detectWsl(probe) && isWindowsDriveMount(root)) { + return "WSL2 Windows drive mount (/mnt/*): file watcher unreliable on this path"; + } + return null; +} + +export function logWatchDisabled(label: string, reason: string): void { + // eslint-disable-next-line no-console -- intentional bootstrap log on stderr + console.error(`${label}: watcher disabled — ${reason}.`); + // eslint-disable-next-line no-console -- intentional bootstrap log on stderr + console.error( + `${label}: set CODEMAP_FORCE_WATCH=1 to override, or run \`codemap agents init --git-hooks\` for background sync on git events.`, + ); +} + +/** Apply policy after CLI/env resolved the requested watch flag. Logs once when disabled. */ +export function applyWatchPolicy(opts: { + root: string; + requestedWatch: boolean; + label: string; + env?: NodeJS.ProcessEnv; + probe?: WatchPolicyProbe; +}): { watch: boolean } { + if (!opts.requestedWatch) return { watch: false }; + const reason = watchDisabledReason( + opts.root, + opts.env ?? process.env, + opts.probe, + ); + if (reason === null) return { watch: true }; + logWatchDisabled(opts.label, reason); + return { watch: false }; +} diff --git a/src/cli/cmd-agents.ts b/src/cli/cmd-agents.ts index 61a6849..cb10fea 100644 --- a/src/cli/cmd-agents.ts +++ b/src/cli/cmd-agents.ts @@ -4,6 +4,7 @@ export async function runAgentsInitCmd(opts: { projectRoot: string; force: boolean; interactive: boolean; + gitHooks?: "install" | "uninstall"; }): Promise { if (opts.interactive) { if (!process.stdin.isTTY || !process.stdout.isTTY) { @@ -16,5 +17,9 @@ export async function runAgentsInitCmd(opts: { await import("../agents-init-interactive.js"); return runAgentsInitInteractive(opts); } - return runAgentsInit(opts); + return runAgentsInit({ + projectRoot: opts.projectRoot, + force: opts.force, + gitHooks: opts.gitHooks, + }); } diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index 339d523..7d4c6e8 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -1,4 +1,8 @@ import { runMcpServer } from "../application/mcp-server"; +import { + applyWatchPolicy, + envWatchDefaultOn, +} from "../application/watch-policy"; import { DEFAULT_DEBOUNCE_MS } from "../application/watcher"; import { CODEMAP_VERSION } from "../version"; @@ -28,10 +32,7 @@ export function parseMcpRest(rest: string[]): // out (mirrors --no-watch) for IDE / CI launches that can't easily // edit the agent host's tool spawn command. CODEMAP_WATCH=1 / "true" // is redundant after the default flip but kept for backwards-compat. - const envWatchOff = - process.env["CODEMAP_WATCH"] === "0" || - process.env["CODEMAP_WATCH"] === "false"; - let watch = !envWatchOff; + let watch = envWatchDefaultOn(process.env); let debounceMs = DEFAULT_DEBOUNCE_MS; for (let i = 1; i < rest.length; i++) { @@ -158,12 +159,17 @@ export async function runMcpCmd(opts: { watch: boolean; debounceMs: number; }): Promise { + const { watch } = applyWatchPolicy({ + root: opts.root, + requestedWatch: opts.watch, + label: "codemap mcp", + }); await runMcpServer({ version: CODEMAP_VERSION, root: opts.root, configFile: opts.configFile, stateDir: opts.stateDir, - watch: opts.watch, + watch, debounceMs: opts.debounceMs, }); } diff --git a/src/cli/cmd-serve.ts b/src/cli/cmd-serve.ts index 2f43962..a9fd185 100644 --- a/src/cli/cmd-serve.ts +++ b/src/cli/cmd-serve.ts @@ -1,4 +1,8 @@ import { runHttpServer } from "../application/http-server"; +import { + applyWatchPolicy, + envWatchDefaultOn, +} from "../application/watch-policy"; import { DEFAULT_DEBOUNCE_MS } from "../application/watcher"; import { CODEMAP_VERSION } from "../version"; @@ -49,10 +53,7 @@ export function parseServeRest(rest: string[]): // CODEMAP_WATCH=0 / "false" is the env shortcut to opt out (mirrors // --no-watch). CODEMAP_WATCH=1 / "true" is redundant after the default // flip but kept for backwards-compat. - const envWatchOff = - process.env["CODEMAP_WATCH"] === "0" || - process.env["CODEMAP_WATCH"] === "false"; - let watch = !envWatchOff; + let watch = envWatchDefaultOn(process.env); let debounceMs = DEFAULT_DEBOUNCE_MS; for (let i = 1; i < rest.length; i++) { @@ -242,6 +243,11 @@ export async function runServeCmd(opts: { watch: boolean; debounceMs: number; }): Promise { + const { watch } = applyWatchPolicy({ + root: opts.root, + requestedWatch: opts.watch, + label: "codemap serve", + }); await runHttpServer({ version: CODEMAP_VERSION, root: opts.root, @@ -250,7 +256,7 @@ export async function runServeCmd(opts: { host: opts.host, port: opts.port, token: opts.token, - watch: opts.watch, + watch, debounceMs: opts.debounceMs, }); } diff --git a/src/cli/cmd-watch.ts b/src/cli/cmd-watch.ts index 9b60a1b..3f73d90 100644 --- a/src/cli/cmd-watch.ts +++ b/src/cli/cmd-watch.ts @@ -1,3 +1,4 @@ +import { applyWatchPolicy } from "../application/watch-policy"; import { createPrimeIndex, createReindexOnChange, @@ -125,6 +126,16 @@ export async function runWatchCmd(opts: WatchOpts): Promise { await bootstrapCodemap(opts); const root = getProjectRoot(); + const { watch } = applyWatchPolicy({ + root, + requestedWatch: true, + label: "codemap watch", + }); + if (!watch) { + process.exitCode = 1; + return; + } + if (!opts.quiet) { // eslint-disable-next-line no-console -- intentional bootstrap log on stderr console.error( diff --git a/src/cli/main.ts b/src/cli/main.ts index 247364a..40cdee5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -59,11 +59,13 @@ export async function main(): Promise { if (rest[0] === "agents" && rest[1] === "init") { if (rest.includes("--help") || rest.includes("-h")) { - console.log(`Usage: codemap agents init [--force] [--interactive|-i] + console.log(`Usage: codemap agents init [--force] [--interactive|-i] [--git-hooks] [--no-git-hooks] Copies bundled agent templates into .agents/ under the project root. --force Refresh only files that ship in templates/agents (merge into rules/ & skills/) --interactive Pick IDEs (Cursor, Copilot, Windsurf, …) and symlink vs copy + --git-hooks Install background incremental index hooks (post-commit, post-merge, post-checkout) + --no-git-hooks Remove codemap blocks from git hooks `); return; } @@ -72,6 +74,8 @@ Copies bundled agent templates into .agents/ under the project root. "--force", "--interactive", "-i", + "--git-hooks", + "--no-git-hooks", "--help", "-h", ]); @@ -88,10 +92,22 @@ Copies bundled agent templates into .agents/ under the project root. process.exit(1); } const { runAgentsInitCmd } = await import("./cmd-agents.js"); + const gitHooks = rest.includes("--no-git-hooks") + ? "uninstall" + : rest.includes("--git-hooks") + ? "install" + : undefined; + if (gitHooks !== undefined && rest.includes("--interactive")) { + console.error( + "codemap: --git-hooks / --no-git-hooks cannot be combined with --interactive.", + ); + process.exit(1); + } const ok = await runAgentsInitCmd({ projectRoot: root, force: rest.includes("--force"), interactive: rest.includes("--interactive") || rest.includes("-i"), + gitHooks, }); if (!ok) process.exit(1); return;