/* * test_watcher.c — Tests for the file change watcher module. * * Covers: adaptive interval, watch/unwatch lifecycle, git change detection, * poll_once behavior. */ #include "../src/foundation/compat.h" #include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" #include #include #include #include #include #include #include #include /* Portable git: `git -C "" ` with identity + non-interactive * config injected via -c, so it needs no global config and no POSIX shell * (runs under cmd.exe on Windows). Returns the git exit status. */ static int wt_git(const char *dir, const char *args) { char cmd[1024]; snprintf(cmd, sizeof(cmd), "git -C \"%s\" -c user.name=t -c user.email=t@t.io " "-c init.defaultBranch=master -c commit.gpgsign=false %s", dir, args); return system(cmd); } /* Build "/" into buf (forward slashes work on Windows + git). */ static const char *wt_path(char *buf, size_t n, const char *dir, const char *rel) { snprintf(buf, n, "%s/%s", dir, rel); return buf; } /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL * ══════════════════════════════════════════════════════════════════ */ TEST(poll_interval_base) { /* 0 files → 5s base */ int ms = cbm_watcher_poll_interval_ms(0); ASSERT_EQ(ms, 5000); PASS(); } TEST(poll_interval_scaling) { /* 1000 files → 5000 + 2*1000 = 7000ms */ int ms = cbm_watcher_poll_interval_ms(1000); ASSERT_EQ(ms, 7000); /* 5000 files → 5000 + 10*1000 = 15000ms */ ms = cbm_watcher_poll_interval_ms(5000); ASSERT_EQ(ms, 15000); PASS(); } TEST(poll_interval_cap) { /* 100K files → capped at 60s */ int ms = cbm_watcher_poll_interval_ms(100000); ASSERT_EQ(ms, 60000); PASS(); } TEST(poll_interval_small) { /* 499 files → 5000 + 0*1000 = 5000ms (integer division) */ int ms = cbm_watcher_poll_interval_ms(499); ASSERT_EQ(ms, 5000); /* 500 files → 5000 + 1*1000 = 6000ms */ ms = cbm_watcher_poll_interval_ms(500); ASSERT_EQ(ms, 6000); PASS(); } /* ══════════════════════════════════════════════════════════════════ * LIFECYCLE * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_create_free) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_unwatch) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "project-a", "/tmp/project-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "project-b", "/tmp/project-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 2); cbm_watcher_unwatch(w, "project-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_unwatch(w, "project-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_unwatch_nonexistent) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); /* Should not crash */ cbm_watcher_unwatch(w, "nonexistent"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_replace) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "project-a", "/tmp/old-path"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Replace with new path */ cbm_watcher_watch(w, "project-a", "/tmp/new-path"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* still 1 */ cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_null_safety) { /* All functions should be NULL-safe */ cbm_watcher_free(NULL); cbm_watcher_watch(NULL, "x", "/x"); cbm_watcher_unwatch(NULL, "x"); cbm_watcher_touch(NULL, "x"); ASSERT_EQ(cbm_watcher_watch_count(NULL), 0); ASSERT_EQ(cbm_watcher_poll_once(NULL), 0); PASS(); } /* ══════════════════════════════════════════════════════════════════ * POLL WITH REAL GIT REPO * ══════════════════════════════════════════════════════════════════ */ /* Index callback counter */ static int index_call_count = 0; static int index_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; (void)ud; index_call_count++; return 0; } TEST(watcher_poll_no_projects) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_nonexistent_path) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "ghost", "/tmp/cbm_test_nonexistent_path_12345"); /* First poll → init_baseline (path doesn't exist → skip) */ index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* ══════════════════════════════════════════════════════════════════ * STALE-ROOT PRUNING (#286) * ══════════════════════════════════════════════════════════════════ */ /* Shared fixture for the stale-root pruning tests: a temp project root, a * temp CBM_CACHE_DIR seeded with db/-wal/-shm files for "stale-project", * and saved copies of the env vars the tests override. */ typedef struct { char rootdir[256]; char cachedir[256]; char db_path[512]; char wal_path[512]; char shm_path[512]; char saved_cache_dir[1024]; bool had_cache_dir; char saved_grace[64]; bool had_grace; } prune_fixture_t; /* Returns false (with partial state cleaned up) if setup failed. */ static bool prune_fixture_setup(prune_fixture_t *f, const char *grace_s) { snprintf(f->rootdir, sizeof(f->rootdir), "/tmp/cbm_watcher_stale_root_XXXXXX"); if (!cbm_mkdtemp(f->rootdir)) { return false; } snprintf(f->cachedir, sizeof(f->cachedir), "/tmp/cbm_watcher_stale_cache_XXXXXX"); if (!cbm_mkdtemp(f->cachedir)) { th_rmtree(f->rootdir); return false; } f->had_cache_dir = cbm_safe_getenv("CBM_CACHE_DIR", f->saved_cache_dir, sizeof(f->saved_cache_dir), NULL) != NULL; f->had_grace = cbm_safe_getenv("CBM_WATCHER_PRUNE_GRACE_S", f->saved_grace, sizeof(f->saved_grace), NULL) != NULL; cbm_setenv("CBM_CACHE_DIR", f->cachedir, 1); cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", grace_s, 1); snprintf(f->db_path, sizeof(f->db_path), "%s/stale-project.db", f->cachedir); snprintf(f->wal_path, sizeof(f->wal_path), "%s/stale-project.db-wal", f->cachedir); snprintf(f->shm_path, sizeof(f->shm_path), "%s/stale-project.db-shm", f->cachedir); th_write_file(f->db_path, "db\n"); th_write_file(f->wal_path, "wal\n"); th_write_file(f->shm_path, "shm\n"); return true; } static void prune_fixture_teardown(prune_fixture_t *f) { if (f->had_cache_dir) { cbm_setenv("CBM_CACHE_DIR", f->saved_cache_dir, 1); } else { cbm_unsetenv("CBM_CACHE_DIR"); } if (f->had_grace) { cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", f->saved_grace, 1); } else { cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); } th_rmtree(f->rootdir); th_rmtree(f->cachedir); } TEST(watcher_prunes_sustained_missing_root) { /* Positive prune path. Grace window 0s isolates the streak-threshold * logic; the time gate is guarded by watcher_grace_window_blocks_prune. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "stale-project", f.rootdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Existing root: first poll initializes baseline only. */ cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); th_rmtree(f.rootdir); /* Misses #1 and #2: below the streak threshold — keep project + DB. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); /* Miss #3 with the grace window already satisfied: prune the watch * entry and the cached DB files. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); ASSERT_NEQ(access(f.db_path, F_OK), 0); ASSERT_NEQ(access(f.wal_path, F_OK), 0); ASSERT_NEQ(access(f.shm_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); PASS(); } TEST(watcher_grace_window_blocks_prune) { /* 3+ missing polls but elapsed < grace → NOT pruned. Uses an explicit * 600s window so a fast poll burst can never satisfy the time gate. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "600")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); /* baseline */ th_rmtree(f.rootdir); /* 4 consecutive misses in quick succession: streak threshold reached, * but the sustained-absence window (600s) has not elapsed. */ for (int i = 0; i < 4; i++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); ASSERT_EQ(access(f.wal_path, F_OK), 0); ASSERT_EQ(access(f.shm_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); PASS(); } TEST(watcher_root_missing_errno_classification) { /* Only ENOENT/ENOTDIR may count toward pruning; EACCES-style failures * (permissions, I/O errors, transient mounts, macOS TCC revocation) * must never increment the missing streak. The classifier is unit- * tested with injected errno values because a real EACCES cannot be * simulated portably (tests may run as root on CI; Windows ACLs). */ ASSERT_TRUE(cbm_watcher_root_missing_errno(ENOENT)); ASSERT_TRUE(cbm_watcher_root_missing_errno(ENOTDIR)); ASSERT_FALSE(cbm_watcher_root_missing_errno(0)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EACCES)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EIO)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EINVAL)); ASSERT_FALSE(cbm_watcher_root_missing_errno(ENAMETOOLONG)); PASS(); } TEST(watcher_root_restore_resets_prune_streak) { /* A reappearing root must reset the missing streak AND its first-miss * timestamp — pruning requires a fresh uninterrupted streak. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); /* baseline */ th_rmtree(f.rootdir); /* Misses #1 and #2 — one short of the threshold. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Root comes back (e.g. remount / re-clone): streak resets. */ if (!cbm_mkdir_p(f.rootdir, 0755)) { FAIL("mkdir_p restore failed"); } cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); th_rmtree(f.rootdir); /* Misses #1 and #2 of the NEW streak: must not prune even though the * total number of misses is now four. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); /* Miss #3 of the new streak → prune. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); ASSERT_NEQ(access(f.db_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); PASS(); } TEST(watcher_poll_this_repo) { /* Use this project's own repo as a real git repo test */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); /* Watch our own repo root (we know it's a git repo) */ char cwd[4096]; if (!getcwd(cwd, sizeof(cwd))) { cbm_watcher_free(w); cbm_store_close(store); FAIL("getcwd failed"); } cbm_watcher_watch(w, "self", cwd); /* First poll: init baseline (no reindex expected) */ index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); /* baseline only */ /* Second poll: check for changes. This repo has dirty working tree * (from the tests we just created), so it should detect changes. * But the adaptive interval hasn't elapsed yet, so it won't poll. */ /* Touch to reset interval, then poll */ cbm_watcher_touch(w, "self"); reindexed = cbm_watcher_poll_once(w); /* May or may not reindex depending on whether working tree is dirty. * In CI, working tree might be clean. Just verify it doesn't crash. */ cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_stop_flag) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); /* Set stop flag */ cbm_watcher_stop(w); /* Run should return immediately */ int rc = cbm_watcher_run(w, 1000); ASSERT_EQ(rc, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* ══════════════════════════════════════════════════════════════════ * GIT CHANGE DETECTION (with temp repo) * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_detects_git_commit) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "temp-repo", tmpdir); index_call_count = 0; /* First poll: baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make a change: new commit */ { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m add-world"); /* Touch to bypass interval, then poll */ cbm_watcher_touch(w, "temp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* should detect HEAD change */ /* Poll again without changes → no reindex */ cbm_watcher_touch(w, "temp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* still 1, no new changes */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_detects_dirty_worktree) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "dirty-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); /* Make working tree dirty (uncommitted change) */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } /* Poll → should detect dirty worktree */ cbm_watcher_touch(w, "dirty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_detects_new_file) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "newf-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Add a new untracked file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/newfile.go", tmpdir); th_write_file(_p, "new content\n"); } /* Touch to bypass interval, then poll */ cbm_watcher_touch(w, "newf-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* should detect untracked file */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_no_change_no_reindex) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nochg-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Poll multiple times with no changes — never triggers reindex */ for (int i = 0; i < 5; i++) { cbm_watcher_touch(w, "nochg-repo"); cbm_watcher_poll_once(w); } ASSERT_EQ(index_call_count, 0); /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* #937: a PERSISTENTLY dirty worktree must reindex ONCE per distinct dirty * state, not on every poll. The watcher used to treat "tree is dirty" as * "tree changed", so an idle repo with one uncommitted file re-triggered a * full reindex (and its DB/artifact rewrite) every poll cycle — the reported * 1 TB/day write amplification. A dirty-state signature (porcelain entries + * per-file size/mtime) must gate the trigger: same signature → no reindex; * editing a dirty file again, or reverting the tree to clean, are NEW states * that must each trigger exactly one reindex. */ TEST(watcher_dirty_state_reindexes_once_issue937) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_amp_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "amp-repo", tmpdir); index_call_count = 0; /* Baseline (clean tree) */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Dirty the tree once (uncommitted modification). */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* new dirty state → one reindex */ /* Idle polls on the SAME dirty state must not re-trigger. */ for (int i = 0; i < 3; i++) { cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); } ASSERT_EQ(index_call_count, 1); /* was 4 before the fix: one per poll */ /* Editing the dirty file AGAIN is a new state (size changes). */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified again\n"); } cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Same-state polls stay quiet again. */ cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Reverting to a clean tree changes on-disk content (back to HEAD) — * that is a new state and must reindex exactly once. */ wt_git(tmpdir, "checkout -- file.txt"); cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 3); /* Stable clean tree: quiet. */ cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 3); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* #937 companion: a change whose reindex FAILS (or is skipped busy) must be * retried on the next poll. The watcher used to commit the new HEAD at CHECK * time, so a commit observed while the pipeline was busy/failing was recorded * as seen and never indexed — a silent lost update. Baselines (HEAD and dirty * signature) may only be committed after a SUCCESSFUL reindex. */ static int failing_index_calls = 0; static int failing_index_fail_first_n = 0; static int failing_index_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; (void)ud; failing_index_calls++; if (failing_index_calls <= failing_index_fail_first_n) { return -1; /* simulated pipeline failure */ } return 0; } TEST(watcher_failed_reindex_retries_issue937) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rty_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, failing_index_callback, NULL); cbm_watcher_watch(w, "rty-repo", tmpdir); failing_index_calls = 0; failing_index_fail_first_n = 1; /* first reindex attempt fails */ /* Baseline (clean tree) */ cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 0); /* HEAD moves (new commit). */ { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m add-world"); /* First poll: change detected, reindex attempt FAILS. */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 1); /* The failed change must NOT have been recorded as seen: the next poll * retries and succeeds. Before the fix, the new HEAD was stored at check * time and the commit was silently lost (calls stayed at 1). */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); /* Successful reindex commits the baseline: no further triggers. */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) FAIL("cbm_mkdtemp failed"); if (wt_git(tmpdirA, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); } wt_git(tmpdirA, "add a.txt"); wt_git(tmpdirA, "commit -q -m init"); if (wt_git(tmpdirB, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "projA", tmpdirA); cbm_watcher_watch(w, "projB", tmpdirB); ASSERT_EQ(cbm_watcher_watch_count(w), 2); index_call_count = 0; /* Baseline both */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Modify only A */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/a.txt", tmpdirA); th_append_file(_p, "modified\n"); } /* Poll — only A should trigger */ cbm_watcher_touch(w, "projA"); cbm_watcher_touch(w, "projB"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* only A changed */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdirA); th_rmtree(tmpdirB); PASS(); } /* ══════════════════════════════════════════════════════════════════ * NON-GIT PROJECT * ══════════════════════════════════════════════════════════════════ */ 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); /* Create a file so it's not empty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_write_file(_p, "hello\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nongit", tmpdir); index_call_count = 0; /* Baseline — should detect non-git and set is_git=false */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Modify file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } /* Touch + poll — should NOT trigger (non-git projects are skipped) */ cbm_watcher_touch(w, "nongit"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Even add a new file — still no reindex */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/new.txt", tmpdir); th_write_file(_p, "new\n"); } cbm_watcher_touch(w, "nongit"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL BEHAVIOR * ══════════════════════════════════════════════════════════════════ */ 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "intv-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make repo dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Poll WITHOUT touch — interval should block checking */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* blocked by interval */ /* Now touch to bypass interval */ cbm_watcher_touch(w, "intv-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* now detected */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_poll_interval_full_table) { /* Full table-driven test matching Go TestPollInterval exactly */ struct { int files; int expected_ms; } tests[] = { {0, 5000}, {70, 5000}, {499, 5000}, {500, 6000}, {2000, 9000}, {5000, 15000}, {10000, 25000}, {50000, 60000}, {100000, 60000}, }; int n = (int)(sizeof(tests) / sizeof(tests[0])); for (int i = 0; i < n; i++) { int got = cbm_watcher_poll_interval_ms(tests[i].files); if (got != tests[i].expected_ms) { fprintf(stderr, "FAIL pollInterval(%d) = %d, want %d\n", tests[i].files, got, tests[i].expected_ms); return 1; } } PASS(); } /* ══════════════════════════════════════════════════════════════════ * GIT REMOVAL + CONTINUED DIRTY + BASELINE DIRTY * ══════════════════════════════════════════════════════════════════ */ 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "rmgit-repo", tmpdir); index_call_count = 0; /* Baseline — detects git */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Remove .git — git commands will fail */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/.git", tmpdir); th_rmtree(_p); } /* Poll — should not crash, git_head() and git_is_dirty() fail gracefully */ cbm_watcher_touch(w, "rmgit-repo"); cbm_watcher_poll_once(w); /* No assertion on index_call_count — behavior is implementation-defined. * Main assertion: no crash, no ASan violation. */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_continued_dirty) { /* A tree that STAYS dirty re-triggers only when the dirty state itself * changes (#937): repeat polls over the untouched state are quiet, a * further edit re-triggers, and the cleaning commit triggers once more * (HEAD move + tree back to clean). Historically this test asserted one * reindex per poll while dirty — that WAS the #937 write amplification. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "cont-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* First detection */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* Still dirty but UNCHANGED — must stay quiet (#937) */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* A further edit is a NEW dirty state — detect again */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirtier\n"); } cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Commit to clean up, then poll — should not trigger */ wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m clean"); /* HEAD changed → will trigger one more reindex */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); /* HEAD change from commit → reindex again (count = 3) */ /* Now truly clean — no more reindexes */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); int final_count = index_call_count; /* Touch and poll one more time to verify stability */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, final_count); /* stable */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); /* Make dirty BEFORE baseline */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty from start\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "bld-repo", tmpdir); index_call_count = 0; /* Baseline — captures HEAD but doesn't check for dirty */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ /* First real poll — should detect the pre-existing dirty state */ cbm_watcher_touch(w, "bld-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "prune-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Unwatch — should remove project state immediately */ cbm_watcher_unwatch(w, "prune-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); /* Make dirty + poll — nothing should happen */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* no projects to poll */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); /* Watch → baseline → unwatch */ cbm_watcher_watch(w, "rewatch-repo", tmpdir); cbm_watcher_poll_once(w); /* baseline */ cbm_watcher_unwatch(w, "rewatch-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); /* Make dirty while unwatched */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Re-watch — needs fresh baseline */ cbm_watcher_watch(w, "rewatch-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline again (first poll after re-watch) */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ /* Second poll — detects dirty */ cbm_watcher_touch(w, "rewatch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * FSNOTIFY PORTS (adapted for git-based change detection) * * The Go watcher has fsnotify/dir-mtime strategies alongside git. * The C watcher is git-only. These tests verify the same SEMANTIC * behaviors (file create, delete, subdir, cleanup) through git. * ══════════════════════════════════════════════════════════════════ */ 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "todelete.go"), "todelete\n"); } wt_git(tmpdir, "add -A"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "del-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Delete tracked file → dirty worktree */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/todelete.go", tmpdir); cbm_unlink(_p); } /* Touch + poll → should detect deletion */ cbm_watcher_touch(w, "del-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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, "main.go"), "hello\n"); } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "sub-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Create new subdir and file in it */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/pkg/lib.go", tmpdir); th_write_file(_p, "package pkg\n"); } /* Touch + poll → should detect untracked file in subdir */ cbm_watcher_touch(w, "sub-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_free_idempotent) { /* Port of TestFSNotifyCleanup: * Verify that free() properly cleans up, and free(NULL) is safe. * Tests resource cleanup correctness. */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); /* Watch some projects to create internal state */ cbm_watcher_watch(w, "proj-a", "/tmp/a"); cbm_watcher_watch(w, "proj-b", "/tmp/b"); ASSERT_EQ(cbm_watcher_watch_count(w), 2); /* Free the watcher — should clean up all project state */ cbm_watcher_free(w); /* Free(NULL) should be safe (already tested in null_safety, * but repeated here for parity with Go's close() test) */ cbm_watcher_free(NULL); cbm_store_close(store); PASS(); } TEST(watcher_full_flow_new_file) { /* Port of TestWatcherFSNotifyDetectsNewFile: * 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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, "main.go"), "package main\n"); } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "ffnf-repo", tmpdir); index_call_count = 0; /* Baseline — sets up git strategy, captures HEAD */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Poll again immediately — should be blocked by interval */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Create a new file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/util.go", tmpdir); th_write_file(_p, "package main\n"); } /* Touch to bypass interval, then poll — should detect */ cbm_watcher_touch(w, "ffnf-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_fallback_still_detects) { /* Port of TestFSNotifyFallbackToDirMtime: * 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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, "main.go"), "hello\n"); } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "fb-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Remove .git and re-init (simulates strategy reset) */ { char p[300]; th_rmtree(wt_path(p, sizeof(p), tmpdir, ".git")); } wt_git(tmpdir, "init -q"); wt_git(tmpdir, "add -A"); wt_git(tmpdir, "commit -q -m reinit"); /* Re-watch with fresh state */ cbm_watcher_unwatch(w, "fb-repo"); cbm_watcher_watch(w, "fb-repo", tmpdir); cbm_watcher_poll_once(w); /* new baseline */ /* Add new file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/new.go", tmpdir); th_write_file(_p, "package main\n"); } /* Detect change with fresh git strategy */ cbm_watcher_touch(w, "fb-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } 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"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) FAIL("cbm_mkdtemp failed"); /* Init both repos */ if (wt_git(tmpdirA, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); } wt_git(tmpdirA, "add a.txt"); wt_git(tmpdirA, "commit -q -m init"); if (wt_git(tmpdirB, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); /* Only watch A — B is NOT watched */ cbm_watcher_watch(w, "projA-ow", tmpdirA); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make BOTH repos dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/a.txt", tmpdirA); th_append_file(_p, "dirty\n"); } { char _p[1024]; snprintf(_p, sizeof(_p), "%s/b.txt", tmpdirB); th_append_file(_p, "dirty\n"); } /* Poll — only A should trigger (B is not watched) */ cbm_watcher_touch(w, "projA-ow"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdirA); th_rmtree(tmpdirB); PASS(); } 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "tch-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Without touch: interval blocks poll */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* blocked */ /* With touch: poll proceeds */ cbm_watcher_touch(w, "tch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* detected */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_modify_tracked_file) { /* Port of TestWatcherTriggersOnChange / TestWatcherGitDetectsEdit: * 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"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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, "main.go"), "package main\n"); } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "mod-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* No-change poll */ cbm_watcher_touch(w, "mod-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Overwrite file with new content */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/main.go", tmpdir); th_write_file(_p, "package main\n\nfunc main() {}\n"); } /* Touch + poll → should detect modification */ cbm_watcher_touch(w, "mod-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * RESOURCE MANAGEMENT & AUTO-INDEXING BEHAVIOR * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_null_store_handling) { /* watcher_new with NULL store — verify behavior */ cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); /* Implementation may return NULL or a valid watcher. * Either is acceptable — key is no crash. */ if (w) { ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); } PASS(); } TEST(watcher_free_null_safe) { /* Explicit test: free(NULL) must not crash */ cbm_watcher_free(NULL); cbm_watcher_free(NULL); PASS(); } TEST(watcher_empty_count) { /* Fresh watcher with no projects → count 0 */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_multiple_verify_count) { /* Watch 5 projects, verify count at each step */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); for (int i = 0; i < 5; i++) { char name[32], path[64]; snprintf(name, sizeof(name), "proj-%d", i); snprintf(path, sizeof(path), "/tmp/proj-%d", i); cbm_watcher_watch(w, name, path); ASSERT_EQ(cbm_watcher_watch_count(w), i + 1); } /* Unwatch all */ for (int i = 0; i < 5; i++) { char name[32]; snprintf(name, sizeof(name), "proj-%d", i); cbm_watcher_unwatch(w, name); } ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_same_project_idempotent) { /* Watching the same project twice updates the path, count stays 1 */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "proj", "/tmp/path-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "proj", "/tmp/path-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "proj", "/tmp/path-c"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_unwatch_nonexistent_safe) { /* Unwatch a project that was never watched — no crash */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_unwatch(w, "never-existed"); cbm_watcher_unwatch(w, "also-never-existed"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_touch_nonexistent_project) { /* touch() on a project not in the watch list — no crash */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_touch(w, "nonexistent-project"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_interval_zero_files) { /* 0 files → base interval (5000ms) */ int ms = cbm_watcher_poll_interval_ms(0); ASSERT_EQ(ms, 5000); PASS(); } TEST(watcher_poll_interval_small_files) { /* 100 files → should be close to base (5000ms) */ int ms = cbm_watcher_poll_interval_ms(100); ASSERT_GTE(ms, 5000); /* 100 files / 500 = 0 extra seconds of scaling → 5000ms */ ASSERT_EQ(ms, 5000); PASS(); } TEST(watcher_poll_interval_medium_files) { /* 10000 files → 5000 + 20*1000 = 25000ms */ int ms = cbm_watcher_poll_interval_ms(10000); ASSERT_EQ(ms, 25000); PASS(); } TEST(watcher_poll_interval_capped) { /* 100000 files → capped at 60000ms */ int ms = cbm_watcher_poll_interval_ms(100000); ASSERT_EQ(ms, 60000); /* Even larger → still capped */ ms = cbm_watcher_poll_interval_ms(500000); ASSERT_EQ(ms, 60000); PASS(); } TEST(watcher_poll_interval_negative) { /* Negative file count → should handle gracefully (no crash) */ int ms = cbm_watcher_poll_interval_ms(-1); /* Result should be at least the base interval or 0 — just no crash */ ASSERT_GTE(ms, 0); PASS(); } TEST(watcher_poll_empty_returns_zero) { /* poll_once with empty watch list → 0 reindexed */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_non_git_dir) { /* poll_once with a non-git directory → 0 changes detected */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng2_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); /* Create a regular file so directory is not empty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_write_file(_p, "hello\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nongit2", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); /* Modify file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "world\n"); } /* Poll — non-git directory, should not trigger reindex */ cbm_watcher_touch(w, "nongit2"); int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_stop_prevents_run) { /* Setting stop before run → run returns immediately */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_stop(w); int rc = cbm_watcher_run(w, 60000); ASSERT_EQ(rc, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_unwatch_rapid_cycle) { /* Rapid watch/unwatch cycles — stress lifecycle management */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); for (int i = 0; i < 20; i++) { cbm_watcher_watch(w, "rapid", "/tmp/rapid"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_unwatch(w, "rapid"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); } cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* Callback and state for watcher_callback_data_passed test */ static int g_cbdata_value = 42; static int *g_cbdata_received = NULL; static int capture_data_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; g_cbdata_received = (int *)ud; return 0; } TEST(watcher_callback_data_passed) { /* Verify that user_data pointer is accessible in the callback */ g_cbdata_received = NULL; /* Create a temp git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cbdata_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, capture_data_callback, &g_cbdata_value); cbm_watcher_watch(w, "cbdata-repo", tmpdir); /* Baseline */ cbm_watcher_poll_once(w); /* Make dirty to trigger callback */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } cbm_watcher_touch(w, "cbdata-repo"); cbm_watcher_poll_once(w); /* If callback was invoked, g_cbdata_received should point to g_cbdata_value */ if (g_cbdata_received) { ASSERT_EQ(*g_cbdata_received, 42); } cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_unwatch_drains_pending_free) { /* Unwatch moves project_state to pending_free; the next poll_once * must drain it without crash or leak. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_df_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); 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"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "df-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make dirty + detect change */ { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "dirty\n"); } cbm_watcher_touch(w, "df-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* Unwatch — state moves to pending_free */ cbm_watcher_unwatch(w, "df-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); /* Next poll drains pending_free — no crash, no double-free */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); 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 * ══════════════════════════════════════════════════════════════════ */ SUITE(watcher) { /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling); RUN_TEST(poll_interval_cap); RUN_TEST(poll_interval_small); /* Lifecycle */ RUN_TEST(watcher_create_free); RUN_TEST(watcher_watch_unwatch); RUN_TEST(watcher_unwatch_nonexistent); RUN_TEST(watcher_watch_replace); RUN_TEST(watcher_null_safety); /* Polling */ RUN_TEST(watcher_poll_no_projects); RUN_TEST(watcher_poll_nonexistent_path); RUN_TEST(watcher_prunes_sustained_missing_root); RUN_TEST(watcher_grace_window_blocks_prune); RUN_TEST(watcher_root_missing_errno_classification); RUN_TEST(watcher_root_restore_resets_prune_streak); RUN_TEST(watcher_poll_this_repo); RUN_TEST(watcher_stop_flag); /* Git change detection */ RUN_TEST(watcher_detects_git_commit); RUN_TEST(watcher_detects_dirty_worktree); RUN_TEST(watcher_detects_new_file); RUN_TEST(watcher_no_change_no_reindex); RUN_TEST(watcher_dirty_state_reindexes_once_issue937); RUN_TEST(watcher_failed_reindex_retries_issue937); RUN_TEST(watcher_multiple_projects); /* Non-git project */ RUN_TEST(watcher_non_git_skips); /* Adaptive interval behavior */ RUN_TEST(watcher_interval_blocks_repoll); RUN_TEST(watcher_poll_interval_full_table); /* Git removal + continued dirty + baseline dirty */ RUN_TEST(watcher_git_removed_no_crash); RUN_TEST(watcher_continued_dirty); RUN_TEST(watcher_baseline_dirty_repo); RUN_TEST(watcher_unwatch_prunes_state); RUN_TEST(watcher_watch_after_unwatch); /* FSNotify ports (adapted for git-based detection) */ RUN_TEST(watcher_detects_file_delete); RUN_TEST(watcher_detects_subdir_file); RUN_TEST(watcher_free_idempotent); RUN_TEST(watcher_full_flow_new_file); RUN_TEST(watcher_fallback_still_detects); RUN_TEST(watcher_poll_only_watched_projects); RUN_TEST(watcher_touch_resets_immediate); RUN_TEST(watcher_modify_tracked_file); /* Resource management & auto-indexing behavior */ RUN_TEST(watcher_null_store_handling); RUN_TEST(watcher_free_null_safe); RUN_TEST(watcher_empty_count); RUN_TEST(watcher_watch_multiple_verify_count); RUN_TEST(watcher_watch_same_project_idempotent); RUN_TEST(watcher_unwatch_nonexistent_safe); RUN_TEST(watcher_touch_nonexistent_project); /* Poll interval edge cases */ RUN_TEST(watcher_poll_interval_zero_files); RUN_TEST(watcher_poll_interval_small_files); RUN_TEST(watcher_poll_interval_medium_files); RUN_TEST(watcher_poll_interval_capped); RUN_TEST(watcher_poll_interval_negative); /* Poll edge cases */ RUN_TEST(watcher_poll_empty_returns_zero); RUN_TEST(watcher_poll_non_git_dir); RUN_TEST(watcher_stop_prevents_run); RUN_TEST(watcher_watch_unwatch_rapid_cycle); RUN_TEST(watcher_unwatch_drains_pending_free); RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count); }