From ea218e17e9e5054e2fd4de15dfaa7c22bc01604b Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Jul 2026 00:41:57 -0400 Subject: [PATCH] fix(watcher): baseline dirty worktrees Record the git dirty hash during watcher baseline so an already-dirty worktree registered after explicit MCP indexing is not treated as a new change on the next watcher poll. This prevents watcher-driven full reindex from mutating the project DB after index_repository has returned. Also preserve adaptive backoff after normal baseline snapshots while still honoring a real touch during an in-flight poll by recording the snapshot's original next_poll_ns. Validation: CBM_ONLY_SUITE=watcher make -f Makefile.cbm test -> 63 passed (/private/tmp/cbm-pan1-20260703T0538Z-watcher-baseline-suite.log); make -f Makefile.cbm lint-source-safety -> rc 0 (/private/tmp/cbm-pan1-20260703T0544Z-lint-source-safety.log); make -f Makefile.cbm cbm -> rc 0 (/private/tmp/cbm-pan1-20260703T0546Z-watcher-cbm-build.log); targeted MCP self-dogfood one_source_file -> rc 0 and canonical graph equal (/private/tmp/cbm-pan1-20260703T0549Z-watcher-baseline-one-source-mcp.json). Signed-off-by: Andrew Hundt --- src/watcher/watcher.c | 14 ++++++++++---- tests/test_watcher.c | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 5d85b3a48..025d2d90f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -8,7 +8,7 @@ * Per-project state tracks: * - Last git HEAD hash (detects commits, checkout, pull) * - Last dirty-tree hash (djb2 of git status --porcelain; prevents reindex - * loop when tree is permanently dirty — only reindexes when content changes) + * loops when the worktree remains in the same dirty status) * - Last poll time + adaptive interval * - Whether the project is a git repo * @@ -69,6 +69,7 @@ typedef struct { int file_count; int interval_ms; int64_t next_poll_ns; + int64_t observed_next_poll_ns; } project_snapshot_t; /* ── Watcher struct ─────────────────────────────────────────────── */ @@ -158,6 +159,7 @@ static bool snapshot_from_state(project_snapshot_t *dst, const project_state_t * dst->file_count = src->file_count; dst->interval_ms = src->interval_ms; dst->next_poll_ns = src->next_poll_ns; + dst->observed_next_poll_ns = src->next_poll_ns; return true; } @@ -171,7 +173,8 @@ static void snapshot_free(project_snapshot_t *s) { } static void state_apply_snapshot(project_state_t *dst, const project_snapshot_t *src) { - bool touched_during_poll = dst->next_poll_ns == 0 && src->next_poll_ns != 0; + bool touched_during_poll = + src->observed_next_poll_ns != 0 && dst->next_poll_ns == 0 && src->next_poll_ns != 0; memcpy(dst->last_head, src->last_head, sizeof(dst->last_head)); memcpy(dst->last_dirty_hash, src->last_dirty_hash, sizeof(dst->last_dirty_hash)); dst->is_git = src->is_git; @@ -321,7 +324,9 @@ static void init_baseline(project_snapshot_t *s, const cbm_watcher_t *w) { } cbm_git_snapshot_t snap = {0}; - if (cbm_git_snapshot_read(s->root_path, CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_FILE_COUNT, + if (cbm_git_snapshot_read(s->root_path, + CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_DIRTY | + CBM_GIT_SNAPSHOT_FILE_COUNT, &snap) != 0) { s->baseline_done = true; s->is_git = false; @@ -334,6 +339,7 @@ static void init_baseline(project_snapshot_t *s, const cbm_watcher_t *w) { if (s->is_git) { memcpy(s->last_head, snap.head, sizeof(s->last_head)); + memcpy(s->last_dirty_hash, snap.dirty_hash, sizeof(s->last_dirty_hash)); s->file_count = snap.file_count; s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count, w->poll_base_ms, w->poll_max_ms); cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files", @@ -367,7 +373,7 @@ static bool check_changes(project_snapshot_t *s) { memcpy(s->last_head, snap.head, sizeof(s->last_head)); } - /* Check working tree: only reindex if content changed since last poll. */ + /* Check working tree: only reindex if git porcelain status changed since last poll. */ if (snap.dirty_bytes <= 0) { /* Clean tree: clear hash so future dirt is always caught. */ s->last_dirty_hash[0] = '\0'; diff --git a/tests/test_watcher.c b/tests/test_watcher.c index d63fc49f3..a84a88df6 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -900,8 +900,9 @@ 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). */ + /* Baseline on a repo that already has uncommitted changes. Watcher + * registration means "current state was just observed", so the same dirty + * state must not trigger a redundant reindex on the next poll. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) @@ -909,7 +910,8 @@ TEST(watcher_baseline_dirty_repo) { if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } - wt_git(tmpdir, "add file.txt"); + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); } + wt_git(tmpdir, "add file.txt file2.txt"); wt_git(tmpdir, "commit -q -m init"); /* Make dirty BEFORE baseline */ @@ -924,11 +926,22 @@ TEST(watcher_baseline_dirty_repo) { cbm_watcher_watch(w, "bld-repo", tmpdir); index_call_count = 0; - /* Baseline — captures HEAD but doesn't check for dirty */ + /* Baseline captures both HEAD and the current dirty hash. */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ - /* First real poll — should detect the pre-existing dirty state */ + /* Same dirty state — should not reindex. */ + cbm_watcher_touch(w, "bld-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* New dirty status — should reindex once. The watcher hashes porcelain + * status, so appending again to file.txt would keep the same status line. */ + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir); + th_append_file(_p, "dirty after baseline\n"); + } cbm_watcher_touch(w, "bld-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); @@ -991,7 +1004,8 @@ TEST(watcher_watch_after_unwatch) { if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } - wt_git(tmpdir, "add file.txt"); + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); } + wt_git(tmpdir, "add file.txt file2.txt"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); @@ -1019,7 +1033,17 @@ TEST(watcher_watch_after_unwatch) { cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ - /* Second poll — detects dirty */ + /* Same dirty state captured by the re-watch baseline should not reindex. */ + cbm_watcher_touch(w, "rewatch-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* A later dirty-status change still triggers. */ + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir); + th_append_file(_p, "dirty after rewatch baseline\n"); + } cbm_watcher_touch(w, "rewatch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1);