mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-03 08:03:38 +08:00
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 <ATHundt@gmail.com>
This commit is contained in:
+10
-4
@@ -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';
|
||||
|
||||
+31
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user