77 *
88 * Per-project state tracks:
99 * - Last git HEAD hash (detects commits, checkout, pull)
10+ * - Dirty-state signature (#937): porcelain entries + per-file size/mtime,
11+ * so a persistently dirty tree reindexes once per distinct state instead
12+ * of on every poll (write amplification)
1013 * - Last poll time + adaptive interval
1114 * - Whether the project is a git repo
1215 *
16+ * Baselines are committed only after a successful reindex; busy-skips and
17+ * failed runs leave them untouched so the change is retried, never lost.
18+ *
1319 * Adaptive interval: 5s base + 1s per 500 files, capped at 60s.
1420 * Matches the Go watcher's `pollInterval()` logic.
1521 */
3844typedef struct {
3945 char * project_name ;
4046 char * root_path ;
41- char last_head [CBM_SZ_64 ]; /* git HEAD hash */
47+ char last_head [CBM_SZ_64 ]; /* git HEAD hash (committed baseline) */
4248 bool is_git ; /* false → skip polling */
4349 bool baseline_done ; /* true after first poll */
4450 int missing_root_count ; /* consecutive polls where root was missing (ENOENT/ENOTDIR) */
4551 uint64_t first_missing_ms ; /* cbm_now_ms() of the streak's first miss (0 = no streak) */
4652 int file_count ; /* approximate, for interval calc */
4753 int interval_ms ; /* adaptive poll interval */
4854 int64_t next_poll_ns ; /* next poll time (monotonic ns) */
55+ /* Dirty-state signature (#937): a persistently dirty worktree must
56+ * reindex once per DISTINCT dirty state, not on every poll. Baselines
57+ * are committed only after a SUCCESSFUL reindex (busy-skips and failed
58+ * runs retry); check_changes stages its observations in the pending_*
59+ * fields. 0 = clean tree. */
60+ uint64_t last_dirty_sig ; /* committed dirty-state signature */
61+ uint64_t pending_dirty_sig ; /* observed at check time */
62+ char pending_head [CBM_SZ_64 ]; /* HEAD observed at check time */
4963} project_state_t ;
5064
5165/* ── Watcher struct ─────────────────────────────────────────────── */
@@ -150,64 +164,144 @@ static int git_head(const char *root_path, char *out, size_t out_size) {
150164 return CBM_NOT_FOUND ;
151165}
152166
153- /* Returns true if working tree has changes (modified, untracked, etc.).
154- * Also checks submodules via `git submodule foreach` to detect uncommitted
155- * changes inside submodules that `git status` alone would not report. */
156- static bool git_is_dirty (const char * root_path ) {
167+ /* ── Dirty-state signature (#937) ───────────────────────────────── */
168+
169+ #define SIG_FNV_OFFSET 1469598103934665603ULL
170+ #define SIG_FNV_PRIME 1099511628211ULL
171+
172+ static uint64_t sig_fold (uint64_t h , const void * data , size_t len ) {
173+ const unsigned char * p = data ;
174+ for (size_t i = 0 ; i < len ; i ++ ) {
175+ h ^= p [i ];
176+ h *= SIG_FNV_PRIME ;
177+ }
178+ return h ;
179+ }
180+
181+ /* Platform-portable mtime_ns (mirrors pipeline_incremental.c). */
182+ static int64_t sig_stat_mtime_ns (const struct stat * st ) {
183+ #ifdef __APPLE__
184+ return ((int64_t )st -> st_mtimespec .tv_sec * NS_PER_SEC ) + (int64_t )st -> st_mtimespec .tv_nsec ;
185+ #elif defined(_WIN32 )
186+ return (int64_t )st -> st_mtime * NS_PER_SEC ;
187+ #else
188+ return ((int64_t )st -> st_mtim .tv_sec * NS_PER_SEC ) + (int64_t )st -> st_mtim .tv_nsec ;
189+ #endif
190+ }
191+
192+ /* Fold a listed path's (size, mtime) into the signature so an in-place edit
193+ * of an already-dirty file still produces a new signature. A failed stat
194+ * (deleted file, quoting artifact) degrades to the entry text alone — the
195+ * deletion itself is represented by the porcelain status. */
196+ static uint64_t sig_fold_path_stat (uint64_t h , const char * root_path , const char * rel ) {
197+ char abs [CBM_SZ_4K ];
198+ snprintf (abs , sizeof (abs ), "%s/%s" , root_path , rel );
199+ struct stat st ;
200+ if (stat (abs , & st ) == 0 ) {
201+ int64_t mt = sig_stat_mtime_ns (& st );
202+ int64_t sz = (int64_t )st .st_size ;
203+ h = sig_fold (h , & mt , sizeof (mt ));
204+ h = sig_fold (h , & sz , sizeof (sz ));
205+ }
206+ return h ;
207+ }
208+
209+ /* Signature of the current dirty state: FNV-1a over the entries of
210+ * `git status --porcelain -uall -z` plus each listed path's (size, mtime).
211+ * Returns 0 for a clean tree. Two polls over an untouched dirty tree yield
212+ * the same value; editing a dirty file, adding/removing one, or reverting
213+ * the tree to clean each yield a new one. -uall lists files inside
214+ * untracked directories individually (a nested addition under `?? dir/`
215+ * would otherwise be invisible); -z gives unquoted NUL-separated paths that
216+ * hash identically across polls and stat cleanly. */
217+ static uint64_t git_dirty_signature (const char * root_path ) {
157218 char cmd [CBM_SZ_1K ];
158- snprintf (cmd , sizeof (cmd ),
159- "git --no-optional-locks -C \"%s\" status --porcelain "
160- "--untracked-files=normal 2>%s" ,
219+ snprintf (cmd , sizeof (cmd ), "git --no-optional-locks -C \"%s\" status --porcelain -uall -z 2>%s" ,
161220 root_path , WATCHER_NULDEV );
162221 FILE * fp = cbm_popen (cmd , "r" );
163222 if (!fp ) {
164- return false ;
223+ return 0 ;
165224 }
166225
167- char line [CBM_SZ_256 ];
168- bool dirty = false;
169- if (fgets (line , sizeof (line ), fp )) {
170- size_t len = strlen (line );
171- while (len > 0 && (line [len - SKIP_ONE ] == '\n' || line [len - SKIP_ONE ] == '\r' )) {
172- line [-- len ] = '\0' ;
226+ uint64_t h = SIG_FNV_OFFSET ;
227+ bool any = false;
228+ char entry [CBM_SZ_4K ];
229+ size_t elen = 0 ;
230+ bool overflow = false;
231+ /* Rename/copy entries are followed by a second NUL token (the origin
232+ * path) that carries no XY prefix — fold it as text, don't stat it. */
233+ bool origin_token = false;
234+ int c ;
235+ for (;;) {
236+ c = fgetc (fp );
237+ if (c != EOF && c != '\0' ) {
238+ if (elen + 1 < sizeof (entry )) {
239+ entry [elen ] = (char )c ;
240+ } else {
241+ overflow = true; /* keep hashing prefix; skip stat for entry */
242+ }
243+ elen ++ ;
244+ continue ;
245+ }
246+ size_t stored = elen < sizeof (entry ) ? elen : sizeof (entry ) - 1 ;
247+ entry [stored ] = '\0' ;
248+ if (stored > 0 ) {
249+ any = true;
250+ h = sig_fold (h , entry , stored );
251+ h = sig_fold (h , "" , 1 ); /* token separator */
252+ if (origin_token ) {
253+ origin_token = false;
254+ } else if (!overflow && stored > 3 && entry [2 ] == ' ' ) {
255+ if (entry [0 ] == 'R' || entry [0 ] == 'C' ) {
256+ origin_token = true;
257+ }
258+ h = sig_fold_path_stat (h , root_path , entry + 3 );
259+ }
173260 }
174- if (len > 0 ) {
175- dirty = true;
261+ elen = 0 ;
262+ overflow = false;
263+ if (c == EOF ) {
264+ break ;
176265 }
177266 }
178267 cbm_pclose (fp );
179268
180- if (dirty ) {
181- return true;
182- }
183-
184269#if !defined(_WIN32 )
185- /* Check submodules: uncommitted changes inside a submodule are invisible
186- * to the parent's git status. Use `git submodule foreach` as a portable
187- * fallback (Apple Git lacks --recurse-submodules). POSIX-only: foreach takes
188- * an inner shell command that cmd.exe cannot pass intact; the parent-repo
189- * status check above already covers the common (non-submodule) case. */
270+ /* Submodules (POSIX-only, mirroring git_is_dirty): fold each submodule's
271+ * porcelain output with paths re-rooted at the superproject so they stat
272+ * correctly. Line mode here (foreach output is line-oriented); rename
273+ * lines fail the stat and degrade to text-only. */
190274 snprintf (cmd , sizeof (cmd ),
191275 "git --no-optional-locks -C '%s' submodule foreach --quiet --recursive "
192- "'git status --porcelain --untracked-files=normal 2>/dev/null' "
193- "2>/dev/null" ,
276+ "'git status --porcelain -uall 2>/dev/null | "
277+ "sed -e \"s@^\\(..\\) @\\1 $displaypath/@\"' 2>/dev/null" ,
194278 root_path );
195279 fp = cbm_popen (cmd , "r" );
196- if (!fp ) {
197- return false;
198- }
199- if (fgets (line , sizeof (line ), fp )) {
200- size_t len = strlen (line );
201- while (len > 0 && (line [len - SKIP_ONE ] == '\n' || line [len - SKIP_ONE ] == '\r' )) {
202- line [-- len ] = '\0' ;
203- }
204- if (len > 0 ) {
205- dirty = true;
280+ if (fp ) {
281+ char line [CBM_SZ_4K ];
282+ while (fgets (line , sizeof (line ), fp )) {
283+ size_t len = strlen (line );
284+ while (len > 0 && (line [len - SKIP_ONE ] == '\n' || line [len - SKIP_ONE ] == '\r' )) {
285+ line [-- len ] = '\0' ;
286+ }
287+ if (len == 0 ) {
288+ continue ;
289+ }
290+ any = true;
291+ h = sig_fold (h , line , len );
292+ h = sig_fold (h , "" , 1 );
293+ if (len > 3 && line [2 ] == ' ' ) {
294+ h = sig_fold_path_stat (h , root_path , line + 3 );
295+ }
206296 }
297+ cbm_pclose (fp );
207298 }
208- cbm_pclose (fp );
209299#endif
210- return dirty ;
300+
301+ if (!any ) {
302+ return 0 ;
303+ }
304+ return h ? h : 1 ; /* reserve 0 for "clean" */
211305}
212306
213307/* Count tracked files via git ls-files */
@@ -497,6 +591,11 @@ static void init_baseline(project_state_t *s) {
497591
498592 if (s -> is_git ) {
499593 git_head (s -> root_path , s -> last_head , sizeof (s -> last_head ));
594+ /* last_dirty_sig stays 0 ("clean known"): a tree that is ALREADY
595+ * dirty at baseline reindexes once on the first poll — the watcher
596+ * cannot know whether that state made it into the DB (e.g. server
597+ * restart with a stale artifact). At-least-once, then the signature
598+ * gates further polls (#937). */
500599 s -> file_count = git_file_count (s -> root_path );
501600 s -> interval_ms = cbm_watcher_poll_interval_ms (s -> file_count );
502601 cbm_log_info ("watcher.baseline" , "project" , s -> project_name , "strategy" , "git" , "files" ,
@@ -508,25 +607,41 @@ static void init_baseline(project_state_t *s) {
508607 s -> next_poll_ns = now_ns () + ((int64_t )s -> interval_ms * US_PER_MS );
509608}
510609
511- /* Check if a project has changes. Returns true if reindex needed. */
610+ /* Check if a project has changes. Returns true if reindex needed.
611+ * Must NOT mutate the committed baselines (last_head, last_dirty_sig):
612+ * poll_project commits them only after a SUCCESSFUL reindex so that
613+ * busy-skips and failed runs retry instead of silently losing the change
614+ * (#937). Observations are staged in the pending_* fields. */
512615static bool check_changes (project_state_t * s ) {
513616 if (!s -> is_git ) {
514617 return false;
515618 }
516619
517- /* Check HEAD movement */
620+ bool changed = false;
621+
622+ /* Check HEAD movement (commit, checkout, pull) */
623+ s -> pending_head [0 ] = '\0' ;
518624 char head [CBM_SZ_64 ] = {0 };
519625 if (git_head (s -> root_path , head , sizeof (head )) == 0 ) {
520- if (s -> last_head [0 ] != '\0' && strcmp (head , s -> last_head ) != 0 ) {
521- /* HEAD moved — commit, checkout, pull */
522- strncpy (s -> last_head , head , sizeof (s -> last_head ) - 1 );
523- return true;
626+ if (s -> last_head [0 ] == '\0' ) {
627+ /* First observed HEAD: adopt as baseline, not a change. */
628+ snprintf (s -> last_head , sizeof (s -> last_head ), "%s" , head );
629+ } else if (strcmp (head , s -> last_head ) != 0 ) {
630+ changed = true;
524631 }
525- strncpy (s -> last_head , head , sizeof (s -> last_head ) - 1 );
632+ snprintf (s -> pending_head , sizeof (s -> pending_head ), "%s" , head );
633+ }
634+
635+ /* Working tree: reindex only when the DIRTY STATE ITSELF changed —
636+ * a persistently dirty tree polled while idle must not re-trigger
637+ * full reindex/write cycles (#937 write amplification). */
638+ uint64_t sig = git_dirty_signature (s -> root_path );
639+ if (sig != s -> last_dirty_sig ) {
640+ changed = true;
526641 }
642+ s -> pending_dirty_sig = sig ;
527643
528- /* Check working tree */
529- return git_is_dirty (s -> root_path );
644+ return changed ;
530645}
531646
532647/* Context for poll_once foreach callback */
@@ -637,11 +752,20 @@ static void poll_project(const char *key, void *val, void *ud) {
637752 int rc = ctx -> w -> index_fn (s -> project_name , s -> root_path , ctx -> w -> user_data );
638753 if (rc == 0 ) {
639754 ctx -> reindexed ++ ;
640- /* Update HEAD after successful reindex */
641- git_head (s -> root_path , s -> last_head , sizeof (s -> last_head ));
755+ /* Commit the baselines OBSERVED AT CHECK TIME — the state whose
756+ * reindex just succeeded. A commit/edit landing during the
757+ * reindex is deliberately not absorbed: the next poll sees it
758+ * as a new delta (at-least-once, never lost). */
759+ if (s -> pending_head [0 ] != '\0' ) {
760+ snprintf (s -> last_head , sizeof (s -> last_head ), "%s" , s -> pending_head );
761+ }
762+ s -> last_dirty_sig = s -> pending_dirty_sig ;
642763 /* Refresh file count for interval */
643764 s -> file_count = git_file_count (s -> root_path );
644765 s -> interval_ms = cbm_watcher_poll_interval_ms (s -> file_count );
766+ } else if (rc > 0 ) {
767+ /* Busy-skip: baseline stays uncommitted, next poll retries. */
768+ cbm_log_info ("watcher.index.retry" , "project" , s -> project_name );
645769 } else {
646770 cbm_log_warn ("watcher.index.err" , "project" , s -> project_name );
647771 }
0 commit comments