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 ───────────────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d126a82f..8ab2093a 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,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 ────────────────────────────────────────────── */ // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) @@ -2605,6 +2645,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); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 5c730ebf..bf008b4b 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; } @@ -256,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_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..fca594a8 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,134 @@ 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 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(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1295,4 +1444,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); }