diff --git a/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md b/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md new file mode 100644 index 0000000..2645802 --- /dev/null +++ b/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md @@ -0,0 +1,145 @@ +--- +plan: 21-01 +phase: 21 +title: "Validate tool-routing behavior and improve CLAUDE.md guidance" +status: ready +date: 2026-03-24 +commit_scope: docs +--- + +# Plan 21-01: Validate Tool-Routing Graceful Degradation + Improve CLAUDE.md + +## Goal + +Validate the Scout research finding that a `list_supported_languages` tool is NOT +needed. CMM already handles misrouted calls gracefully (empty results, no errors). +After hands-on validation, improve CLAUDE.md documentation to close the minor gaps +identified in the research. + +## Background (from research) + +- All structural queries (`search_graph`, `get_code_snippet`, `trace_call_path`) + return `{"total":0, "results":[]}` for text-only or non-indexed file types — no + errors, no crashes. +- JSON, YAML, and Markdown ARE indexed by CMM (Tree-Sitter parsers extract Module + and Variable nodes), so `search_code` works on them. Current CLAUDE.md guidance + says to use Read for these, missing the `search_code` opportunity. +- The cost of a `list_supported_languages` tool (~10ms per session + complexity) + exceeds the cost of try-and-fallback (~1ms empty query). +- User-configured extensions (Phase 5) create a minor knowledge gap, but graceful + fallback handles it. + +## File Ownership (no overlapping edits between tasks) + +| Task | Files Modified | +|------|---------------| +| T1 | None (validation only — reads + CMM tool calls) | +| T2 | `.claude/rules/global-claude-md.md` | + +T1 and T2 can run in parallel — T1 produces validation evidence, T2 edits docs. +T2 does not depend on T1 because the research already provides sufficient evidence +for the doc changes. If T1 discovers unexpected issues, a pivot task would be added. + +--- + +## Tasks + +### T1 — Hands-on validation of graceful degradation + +**Wave:** 1 (no deps) +**Files:** None modified (read-only validation) + +**Steps:** + +1. Call `search_graph` with a `file_pattern` targeting a non-indexed extension + (e.g., `*.txt` or `*.xyz`) on the current CMM project. Verify the response is + `{"total":0, "results":[]}` or equivalent empty result — not an error. + +2. Call `search_graph` with `label_filter="Function"` on a YAML or JSON file in + the project. Verify it returns empty results (these file types have no + Function nodes). + +3. Call `search_code` with a known string pattern scoped to `*.yaml` or `*.json` + files. Verify it returns matches (proving text search works on indexed + text-only formats). + +4. Call `get_code_snippet` with a qualified name that does not exist (e.g., + `nonexistent_module::fake_function`). Verify graceful empty/error response. + +5. Document results: write a brief validation summary as a comment in the + research file or as a standalone validation note. + +**Acceptance criteria:** +- All four CMM calls return graceful responses (empty results or clear error + messages, never crashes or unhandled exceptions) +- Validation results confirm the research finding: no tool needed +- If ANY call produces an unexpected error or crash, escalate immediately — + this would trigger a pivot to tool implementation + +**Commit:** `docs(phase-21): add tool-routing validation results` + +--- + +### T2 — Improve CLAUDE.md guidance for text-only file types and search_code + +**Wave:** 1 (no deps) +**Files:** `.claude/rules/global-claude-md.md` + +**Changes:** + +1. Update the "When Read is Correct" section to clarify that text-only formats + (JSON, YAML, TOML, Markdown) are indexed and searchable via `search_code`, + while `Read` remains correct for full-file context: + +```markdown +### When Read is Correct + +Use `Read` directly when: +- Non-code files (JSON, YAML, TOML, config, HTML templates, Markdown, .env) + **Note**: After indexing, these formats are searchable via `search_code` for + pattern matching (e.g., finding all occurrences of a config key across YAML + files). Use `Read` when you need full-file context; use `search_code` when + searching for specific strings across many config/data files. +- Full file context needed (imports, globals, module-level initialization flow) +- Very small files (under 50 lines) +- Files not yet indexed (new files before `index_repository`) +- Editing 6+ functions in the same file (batch context is more efficient) +- Jupyter notebooks, READMEs, documentation files +``` + +2. Add a `search_code` usage note to the Tool Reference section, after the + existing `search_code` bullet: + +```markdown +- **`search_code`**: Use for text search in source files — string literals, error + messages, TODOs, config values, import statements. Scoped to indexed project with + pagination. Case-insensitive by default. Also works on indexed non-code files + (JSON, YAML, TOML, Markdown) — prefer over `Read` when searching for patterns + across multiple config or data files. +``` + +**Acceptance criteria:** +- "When Read is Correct" section includes the clarifying note about `search_code` + for indexed text-only formats +- `search_code` tool reference mentions non-code file applicability +- No other sections are modified +- The guidance does NOT recommend building a `list_supported_languages` tool + +**Commit:** `docs(claude-md): clarify search_code works on indexed text-only formats` + +--- + +## Wave Summary + +| Wave | Tasks | Parallelizable? | +|------|-------|----------------| +| 1 | T1, T2 | Yes — T1 is read-only, T2 edits docs only | + +## Definition of Done + +- [ ] Hands-on validation confirms all CMM tools degrade gracefully for + text-only and non-indexed file types (T1) +- [ ] `.claude/rules/global-claude-md.md` updated with clarified guidance (T2) +- [ ] No `list_supported_languages` tool implementation (research conclusion + validated) +- [ ] Both commits follow conventional format diff --git a/src/cli/cli.c b/src/cli/cli.c index 72739a8..7a4cec4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -405,6 +405,7 @@ static const char skill_reference_content[] = "- `delete_project` — remove a project\n" "- `manage_adr` — architecture decision records\n" "- `ingest_traces` — import runtime traces\n" + "- `touch_project` — reset poll timer for on-demand reindex\n" "\n" "## Edge Types\n" "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" diff --git a/src/main.c b/src/main.c index 979ee2f..dbc9487 100644 --- a/src/main.c +++ b/src/main.c @@ -149,7 +149,7 @@ static void print_help(void) { printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n"); printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n"); printf(" list_projects, delete_project, index_status, detect_changes,\n"); - printf(" manage_adr, ingest_traces\n"); + printf(" manage_adr, ingest_traces, touch_project\n"); } /* ── Main ───────────────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index dbc1ef4..68491ad 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1,5 +1,5 @@ /* - * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools. + * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 15 graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. @@ -329,6 +329,13 @@ static const tool_def_t TOOLS[] = { "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\"}},\"project\":{\"type\":" "\"string\"}},\"required\":[\"traces\",\"project\"]}"}, + + {"touch_project", + "Reset the adaptive poll timer for a project so the next watcher cycle " + "runs check_changes() immediately. Useful from git hooks or editor " + "save hooks to trigger reindex without waiting for the poll interval.", + "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"," + "\"description\":\"Project name to touch\"}},\"required\":[\"project\"]}"}, }; static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]); @@ -2842,6 +2849,39 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { return result; } +/* touch_project: reset adaptive backoff so next poll cycle is immediate. */ +static char *handle_touch_project(cbm_mcp_server_t *srv, const char *args) { + char *project = cbm_mcp_get_string_arg(args, "project"); + if (!project) { + return cbm_mcp_text_result("project is required", true); + } + if (!srv->watcher) { + free(project); + return cbm_mcp_text_result("watcher not running", true); + } + bool found = cbm_watcher_touch(srv->watcher, project); + if (!found) { + char msg[256]; + snprintf(msg, sizeof(msg), "project '%s' not found in watch list", project); + free(project); + return cbm_mcp_text_result(msg, true); + } + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "project", project); + yyjson_mut_obj_add_str(doc, root, "status", "touched"); + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + free(project); + + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; +} + /* ── Tool dispatch ────────────────────────────────────────────── */ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { @@ -2893,6 +2933,10 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch if (strcmp(tool_name, "ingest_traces") == 0) { return handle_ingest_traces(srv, args_json); } + if (strcmp(tool_name, "touch_project") == 0) { + return handle_touch_project(srv, args_json); + } + char msg[256]; snprintf(msg, sizeof(msg), "unknown tool: %s", tool_name); return cbm_mcp_text_result(msg, true); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index a50672d..1402e7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -111,7 +111,9 @@ static int git_head(const char *root_path, char *out, size_t out_size) { return -1; } -/* Returns true if working tree has changes (modified, untracked, etc.) */ +/* Returns true if working tree has changes (modified, untracked, etc.). + * Also checks submodules via `git submodule foreach` to detect uncommitted + * changes inside submodules that `git status` alone would not report. */ static bool git_is_dirty(const char *root_path) { char cmd[1024]; snprintf(cmd, sizeof(cmd), @@ -136,6 +138,34 @@ static bool git_is_dirty(const char *root_path) { } } cbm_pclose(fp); + + if (dirty) { + return true; + } + + /* Check submodules: uncommitted changes inside a submodule are invisible + * to the parent's `git status` unless --recurse-submodules is supported. + * Use `git submodule foreach` as a portable fallback. */ + snprintf(cmd, sizeof(cmd), + "git --no-optional-locks -C '%s' submodule foreach --quiet --recursive " + "'git status --porcelain --untracked-files=normal 2>/dev/null' " + "2>/dev/null", + root_path); + // NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c) + fp = cbm_popen(cmd, "r"); + if (!fp) { + return false; + } + if (fgets(line, sizeof(line), fp)) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; + } + if (len > 0) { + dirty = true; + } + } + cbm_pclose(fp); return dirty; } @@ -248,15 +278,17 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { } } -void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name) { +bool cbm_watcher_touch(cbm_watcher_t *w, const char *project_name) { if (!w || !project_name) { - return; + return false; } project_state_t *s = cbm_ht_get(w->projects, project_name); if (s) { /* Reset backoff — poll immediately on next cycle */ s->next_poll_ns = 0; + return true; } + return false; } int cbm_watcher_watch_count(const cbm_watcher_t *w) { diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 2592109..a490be3 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -45,8 +45,9 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r /* Remove a project from the watch list. */ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name); -/* Refresh a project's timestamp (resets adaptive backoff). */ -void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name); +/* Refresh a project's timestamp (resets adaptive backoff). + * Returns true if the project was found, false otherwise. */ +bool cbm_watcher_touch(cbm_watcher_t *w, const char *project_name); /* ── Polling ────────────────────────────────────────────────────── */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 72729f1..a004b4d 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -129,7 +129,7 @@ TEST(mcp_initialize_response) { TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); - /* Should contain all 14 tools */ + /* Should contain all 15 tools */ ASSERT_NOT_NULL(strstr(json, "index_repository")); ASSERT_NOT_NULL(strstr(json, "search_graph")); ASSERT_NOT_NULL(strstr(json, "query_graph")); @@ -144,6 +144,7 @@ TEST(mcp_tools_list) { ASSERT_NOT_NULL(strstr(json, "detect_changes")); ASSERT_NOT_NULL(strstr(json, "manage_adr")); ASSERT_NOT_NULL(strstr(json, "ingest_traces")); + ASSERT_NOT_NULL(strstr(json, "touch_project")); free(json); PASS(); } @@ -707,6 +708,38 @@ TEST(tool_ingest_traces_empty) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * TOUCH PROJECT + * ══════════════════════════════════════════════════════════════════ */ + +TEST(mcp_touch_project_no_watcher) { + /* When watcher is NULL (CLI mode), touch_project returns "watcher not running" + * error rather than crashing. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + /* srv->watcher is NULL by default */ + + char *result = cbm_mcp_handle_tool(srv, "touch_project", "{\"project\":\"x\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "watcher not running")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(mcp_touch_project_missing_arg) { + /* touch_project without project arg returns "project is required" error. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *result = cbm_mcp_handle_tool(srv, "touch_project", "{}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "project is required")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * IDLE STORE EVICTION * ══════════════════════════════════════════════════════════════════ */ @@ -1717,6 +1750,10 @@ SUITE(mcp) { RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty); + /* touch_project */ + RUN_TEST(mcp_touch_project_no_watcher); + RUN_TEST(mcp_touch_project_missing_arg); + /* Idle store eviction */ RUN_TEST(store_idle_eviction); RUN_TEST(store_idle_no_eviction_within_timeout); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index f2b8a66..6c990da 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -229,7 +229,8 @@ TEST(watcher_stop_flag) { TEST(watcher_detects_git_commit) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -282,7 +283,8 @@ TEST(watcher_detects_git_commit) { TEST(watcher_detects_dirty_worktree) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -326,7 +328,8 @@ TEST(watcher_detects_dirty_worktree) { TEST(watcher_detects_new_file) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -371,7 +374,8 @@ TEST(watcher_detects_new_file) { TEST(watcher_no_change_no_reindex) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -414,8 +418,10 @@ TEST(watcher_no_change_no_reindex) { TEST(watcher_multiple_projects) { /* Create two temporary git repos */ - char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_mA_XXXXXX"); - char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_mB_XXXXXX"); + char tmpdirA[256]; + snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_mA_XXXXXX"); + char tmpdirB[256]; + snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_mB_XXXXXX"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) SKIP("cbm_mkdtemp failed"); @@ -475,7 +481,8 @@ TEST(watcher_multiple_projects) { TEST(watcher_non_git_skips) { /* Non-git dir → baseline sets is_git=false → poll never reindexes. * Port of TestProbeStrategyNonGit behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nongit_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nongit_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -524,7 +531,8 @@ TEST(watcher_interval_blocks_repoll) { /* After baseline, the adaptive interval (5s minimum) should block * immediate re-polling. Without touch(), the next poll is a no-op. * Port of TestWatcherGitNoChanges' interval behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_intv_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_intv_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -598,7 +606,8 @@ TEST(watcher_git_removed_no_crash) { /* Init git repo, baseline, remove .git, poll → should not crash. * Port of TestStrategyDowngradeGitToDirMtime behavior (C version * doesn't downgrade — just git commands fail silently). */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rmgit_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rmgit_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -643,7 +652,8 @@ TEST(watcher_git_removed_no_crash) { TEST(watcher_continued_dirty) { /* If working tree stays dirty, each poll should re-trigger reindex. * Port of repeated git sentinel detection behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -711,7 +721,8 @@ TEST(watcher_continued_dirty) { TEST(watcher_baseline_dirty_repo) { /* Baseline on a repo that already has uncommitted changes. * Port of TestGitSentinelDetectsEdit (dirty from the start). */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -755,7 +766,8 @@ TEST(watcher_baseline_dirty_repo) { TEST(watcher_unwatch_prunes_state) { /* Watch, baseline, unwatch → project state removed. * Port of TestPollAllPrunesUnwatched + TestWatcherPrunesDeletedProjects. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_prune_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_prune_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -801,7 +813,8 @@ TEST(watcher_unwatch_prunes_state) { TEST(watcher_watch_after_unwatch) { /* Re-watching after unwatch should start fresh (new baseline). * Tests lifecycle correctness. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rewatch_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rewatch_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -862,7 +875,8 @@ TEST(watcher_watch_after_unwatch) { TEST(watcher_detects_file_delete) { /* Port of TestFSNotifyDetectsFileDelete: * Delete a tracked file → git status shows change → reindex triggered. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_del_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_del_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -907,7 +921,8 @@ TEST(watcher_detects_file_delete) { TEST(watcher_detects_subdir_file) { /* Port of TestFSNotifyWatchesNewSubdir: * Create new subdir + file in it → git detects untracked → reindex. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sub_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sub_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -978,7 +993,8 @@ TEST(watcher_full_flow_new_file) { * Full lifecycle: watch → baseline → add file → detect change. * This is a more thorough version of watcher_detects_new_file * that mirrors the Go test's structure exactly. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ffnf_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ffnf_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1028,7 +1044,8 @@ TEST(watcher_fallback_still_detects) { * Even when the "primary" strategy has issues, the watcher * still detects changes. In C, we test that after removing .git * and re-creating it, changes are still detected on re-watch. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_fb_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_fb_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1086,8 +1103,10 @@ TEST(watcher_poll_only_watched_projects) { /* Port of TestPollAllOnlyWatched: * Two repos exist, only one is watched → only the watched one * gets polled and can trigger reindex. */ - char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_owA_XXXXXX"); - char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_owB_XXXXXX"); + char tmpdirA[256]; + snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_owA_XXXXXX"); + char tmpdirB[256]; + snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_owB_XXXXXX"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) SKIP("cbm_mkdtemp failed"); @@ -1145,7 +1164,8 @@ TEST(watcher_touch_resets_immediate) { /* Port of TestTouchProjectUpdatesTimestamp: * Verify that touch() resets the adaptive backoff so the next * poll actually checks for changes immediately. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tch_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tch_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1195,7 +1215,8 @@ TEST(watcher_modify_tracked_file) { * Modify tracked file content (not just create/delete) → detected. * Similar to watcher_detects_dirty_worktree but modifies specific * tracked file content rather than appending. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mod_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mod_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1539,6 +1560,147 @@ TEST(watcher_null_watch_count) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * SUBMODULE DETECTION + TOUCH RESET + * ══════════════════════════════════════════════════════════════════ */ + +TEST(watcher_detects_submodule_dirty) { + /* Verify that the watcher detects uncommitted changes inside a git + * submodule even when the parent's git status ignores submodule dirty + * state (ignore=dirty in .gitmodules). The submodule foreach second + * stage in git_is_dirty() is the only detection mechanism here. */ + char parent[256], submod[256]; + snprintf(submod, sizeof(submod), "/tmp/cbm_watcher_submod_sub_XXXXXX"); + snprintf(parent, sizeof(parent), "/tmp/cbm_watcher_submod_par_XXXXXX"); + if (!cbm_mkdtemp(submod) || !cbm_mkdtemp(parent)) + SKIP("cbm_mkdtemp failed"); + + char cmd[1024]; + + /* Init the submodule repo */ + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && echo 'hello' > lib.c && " + "git add lib.c && git commit -q -m 'init submod'", + submod); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", submod, parent); + system(cmd); + SKIP("git not available"); + } + + /* Init parent repo, add submodule, and set ignore=dirty so that the + * parent's git status --porcelain does NOT report submodule working-tree + * changes. This isolates the submodule foreach second stage. */ + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && " + "git -c protocol.file.allow=always submodule add '%s' sub 2>/dev/null && " + "git config -f .gitmodules submodule.sub.ignore dirty && " + "git add -A && git commit -q -m 'init parent with submod (ignore=dirty)'", + parent, submod); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", submod, parent); + system(cmd); + SKIP("git submodule add not available"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "par-repo", parent); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make an uncommitted change inside the submodule (not committed to submod) */ + snprintf(cmd, sizeof(cmd), "echo 'modified' >> '%s/sub/lib.c'", parent); + system(cmd); + + /* Touch + poll → submodule foreach should detect the dirty submodule */ + cbm_watcher_touch(w, "par-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Cleanup */ + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", parent, submod); + system(cmd); + PASS(); +} + +TEST(watcher_touch_resets_interval_dedicated) { + /* Dedicated test: cbm_watcher_touch() resets next_poll_ns=0 so the + * next poll_once() immediately checks for changes instead of waiting + * for the adaptive interval to expire. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tchd_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + char cmd[512]; + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && echo 'hello' > file.txt && " + "git add file.txt && git commit -q -m 'init'", + tmpdir); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + SKIP("git not available"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "tchd-repo", tmpdir); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make repo dirty */ + snprintf(cmd, sizeof(cmd), "echo 'dirty' >> '%s/file.txt'", tmpdir); + system(cmd); + + /* Poll without touch — interval blocks (adaptive backoff not yet elapsed) */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); /* blocked by interval */ + + /* Touch resets timer — next poll proceeds immediately */ + cbm_watcher_touch(w, "tchd-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* detected */ + + /* Make more changes, touch again — still works */ + snprintf(cmd, sizeof(cmd), "echo 'more dirt' >> '%s/file.txt'", tmpdir); + system(cmd); + cbm_watcher_touch(w, "tchd-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); + + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + PASS(); +} + +TEST(watcher_null_poll_once) { + /* poll_once(NULL) → 0 */ + int reindexed = cbm_watcher_poll_once(NULL); + ASSERT_EQ(reindexed, 0); + PASS(); +} + +TEST(watcher_null_watch_count) { + /* watch_count(NULL) → 0 */ + int count = cbm_watcher_watch_count(NULL); + ASSERT_EQ(count, 0); + PASS(); +} /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1616,4 +1778,7 @@ SUITE(watcher) { RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count); + /* Submodule detection + touch reset */ + RUN_TEST(watcher_detects_submodule_dirty); + RUN_TEST(watcher_touch_resets_interval_dedicated); }