From d5612328519e1fa38fe03c4c6885fd88e8fb67b8 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:35 -0500 Subject: [PATCH 1/5] fix(watcher): add submodule dirty check to git_is_dirty Use git submodule foreach to detect uncommitted changes inside submodules, which git status alone does not report for the parent repo. Runs the submodule check only when the main worktree is clean, avoiding double-detection overhead. Uses the portable foreach approach rather than --recurse-submodules which is not supported by all git distributions (e.g. Apple Git). Co-Authored-By: Claude Sonnet 4.6 --- src/watcher/watcher.c | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 5c730ebf..0c09831d 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -115,7 +115,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), @@ -141,6 +143,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; } From 14d480b946209fc120e7d505dfaa28ebf8615b6a Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:42 -0500 Subject: [PATCH 2/5] feat(mcp): expose touch_project as MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add touch_project to TOOLS[], add handle_touch_project() handler, and add dispatch branch in cbm_mcp_handle_tool(). The tool resets the adaptive poll timer for a named 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. Returns "watcher not running" error gracefully when no watcher thread is attached (e.g. CLI mode). Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/mcp.c | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d126a82f..ec443a3e 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. @@ -319,6 +319,13 @@ static const tool_def_t TOOLS[] = { "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\"}},\"project\":{\"type\":" "\"string\"}},\"required\":[\"traces\"]}"}, + + {"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]); @@ -2553,6 +2560,33 @@ 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); + } + cbm_watcher_touch(srv->watcher, project); + + 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 ────────────────────────────────────────────── */ // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) @@ -2605,6 +2639,9 @@ 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); From b10df9a983a746bb4b880984ce4562d38d6f1666 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:46 -0500 Subject: [PATCH 3/5] feat(cli): add touch_project subcommand to help text Add touch_project to the tools list in print_usage() in main.c and to the tool listing in the CLI help string in cli.c. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/cli.c | 1 + src/main.c | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index e6cd9d97..4613279e 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -388,6 +388,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 1070d36d..9828b663 100644 --- a/src/main.c +++ b/src/main.c @@ -140,7 +140,7 @@ static void print_help(void) { printf("\nTools: index_repository, search_graph, query_graph, trace_call_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 ───────────────────────────────────────────────────────── */ From 54dfb88b69c268296596373e3e99bf039281e053 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:55 -0500 Subject: [PATCH 4/5] test(watcher): add submodule detection and touch_project tests test_watcher.c: - watcher_detects_submodule_dirty: creates parent+submodule repos, modifies a file inside the submodule without committing, and verifies the parent watcher detects it via git submodule foreach. - watcher_touch_resets_interval_dedicated: clean dedicated test that cbm_watcher_touch() bypasses the adaptive backoff interval so the next poll_once() checks for changes immediately. test_mcp.c: - Update mcp_tools_list comment from 14 to 15 tools; add assertion for touch_project. - mcp_touch_project_no_watcher: verifies that touch_project returns "watcher not running" when srv->watcher is NULL (CLI mode). - mcp_touch_project_missing_arg: verifies that touch_project returns "project is required" when the project argument is absent. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_mcp.c | 48 +++++++++-- tests/test_watcher.c | 192 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 213 insertions(+), 27 deletions(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 3c98ff24..31a28275 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(); } @@ -702,6 +703,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 * ══════════════════════════════════════════════════════════════════ */ @@ -1300,11 +1333,10 @@ TEST(mcp_server_run_rapid_messages) { ASSERT_EQ(pipe(fds), 0); /* Write all 3 messages to the write end in one shot */ - const char *msgs = - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," - "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" - "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" - "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; ssize_t written = write(fds[1], msgs, strlen(msgs)); ASSERT_TRUE(written > 0); close(fds[1]); /* EOF signals end of input to the server */ @@ -1411,6 +1443,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 7a3d8a36..fb191558 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"); @@ -1241,6 +1262,131 @@ TEST(watcher_modify_tracked_file) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * SUBMODULE DETECTION + TOUCH RESET + * ══════════════════════════════════════════════════════════════════ */ + +TEST(watcher_detects_submodule_dirty) { + /* Verify that the watcher detects uncommitted changes inside a git + * submodule. Without --recurse-submodules, git status only reports + * the submodule HEAD pointer change, not uncommitted file edits inside + * the submodule. */ + 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 and add submodule */ + 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 add -A && git commit -q -m 'init parent with submod'", + 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 → --recurse-submodules 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(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1295,4 +1441,8 @@ SUITE(watcher) { RUN_TEST(watcher_poll_only_watched_projects); RUN_TEST(watcher_touch_resets_immediate); RUN_TEST(watcher_modify_tracked_file); + + /* Submodule detection + touch reset */ + RUN_TEST(watcher_detects_submodule_dirty); + RUN_TEST(watcher_touch_resets_interval_dedicated); } From 58ae34730cbe196032ccfc65cd8bf74643b36473 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 16:36:41 -0500 Subject: [PATCH 5/5] fix(watcher): address QA round 1 - Q1-01: Test now sets ignore=dirty in .gitmodules so parent git status does not report submodule working-tree changes, isolating the submodule foreach second-stage detection path - Q1-02: Fix stale comment referencing --recurse-submodules - Q1-03: cbm_watcher_touch returns bool; handle_touch_project returns error when project is not in the watch list Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mcp/mcp.c | 8 +++++++- src/watcher/watcher.c | 6 ++++-- src/watcher/watcher.h | 5 +++-- tests/test_watcher.c | 15 +++++++++------ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index ec443a3e..8ab2093a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -2570,7 +2570,13 @@ static char *handle_touch_project(cbm_mcp_server_t *srv, const char *args) { free(project); return cbm_mcp_text_result("watcher not running", true); } - cbm_watcher_touch(srv->watcher, project); + 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); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 0c09831d..bf008b4b 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -286,15 +286,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 25921097..a490be32 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_watcher.c b/tests/test_watcher.c index fb191558..fca594a8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1268,9 +1268,9 @@ TEST(watcher_modify_tracked_file) { TEST(watcher_detects_submodule_dirty) { /* Verify that the watcher detects uncommitted changes inside a git - * submodule. Without --recurse-submodules, git status only reports - * the submodule HEAD pointer change, not uncommitted file edits inside - * the submodule. */ + * 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"); @@ -1291,12 +1291,15 @@ TEST(watcher_detects_submodule_dirty) { SKIP("git not available"); } - /* Init parent repo and add submodule */ + /* 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 add -A && git commit -q -m 'init parent with submod'", + "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); @@ -1317,7 +1320,7 @@ TEST(watcher_detects_submodule_dirty) { snprintf(cmd, sizeof(cmd), "echo 'modified' >> '%s/sub/lib.c'", parent); system(cmd); - /* Touch + poll → --recurse-submodules should detect the dirty submodule */ + /* 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);