kizu 0.3.1

Realtime diff monitor + inline scar review TUI for AI coding agents (Claude Code, etc.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;

use notify::RecursiveMode;
use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache};
#[cfg(not(target_os = "macos"))]
use notify_debouncer_full::{new_debouncer, notify::RecommendedWatcher};
#[cfg(target_os = "macos")]
use notify_debouncer_full::{
    new_debouncer_opt,
    notify::{Config as NotifyConfig, PollWatcher},
};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};

/// Worktree debounce window (SPEC.md).
const WORKTREE_DEBOUNCE: Duration = Duration::from_millis(300);
/// `<git_dir>` debounce window (SPEC.md). HEAD/refs move much less often than
/// random worktree edits, so we keep the window short.
const HEAD_DEBOUNCE: Duration = Duration::from_millis(100);
/// Top-level worktree directories we intentionally do not recurse into.
/// These are common dependency caches / build outputs whose initial poll scan
/// is far more expensive than the value they provide to the v0.1 diff UI.
const WORKTREE_EXCLUDED_DIR_NAMES: &[&str] = &[
    ".git",
    "target",
    "node_modules",
    ".direnv",
    ".venv",
    "dist",
    "build",
    ".next",
    ".turbo",
    ".cache",
    ".gradle",
    ".mvn",
    ".idea",
    ".vscode",
    "__pycache__",
];

#[cfg(target_os = "macos")]
type KizuWatcher = PollWatcher;
#[cfg(not(target_os = "macos"))]
type KizuWatcher = RecommendedWatcher;
type KizuDebouncer = Debouncer<KizuWatcher, RecommendedCache>;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum WatchSource {
    Worktree,
    GitPerWorktreeHead,
    GitRefs,
    GitCommonRoot,
}

impl WatchSource {
    pub fn label(self) -> &'static str {
        match self {
            WatchSource::Worktree => "worktree",
            WatchSource::GitPerWorktreeHead => "git.head",
            WatchSource::GitRefs => "git.refs",
            WatchSource::GitCommonRoot => "git.root",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct WatchRoot {
    path: PathBuf,
    recursive_mode: RecursiveMode,
    compare_contents: bool,
    source: WatchSource,
}

/// A coarse classification of file system activity that the app loop cares
/// about. The actual diff recompute is driven from these signals; we don't
/// pass payloads on this channel because the app always re-runs `git diff`
/// after coalescing (ADR-0005).
///
/// `Error` is surfaced when the underlying notify backend reports a
/// failure — a dropped event queue on macOS FSEvents, a watched
/// directory that was moved or deleted, a kqueue overflow on BSD,
/// etc. The app turns it into a visible `last_error` and forces a
/// recompute so the UI can't silently drift stale if the filesystem
/// hook has quietly fallen over.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchEvent {
    /// Something inside the worktree (excluding `<git_dir>`) changed.
    Worktree,
    /// Something baseline-affecting inside `<git_dir>` changed.
    GitHead(WatchSource),
    /// A new event-log file was created in `<state_dir>/events/`.
    /// Carries the absolute path to the new file so the app can
    /// read and parse it without re-scanning the directory.
    EventLog(PathBuf),
    /// The underlying notify backend reported an error. The app
    /// treats this as a forced recompute plus a visible error string.
    Error {
        source: WatchSource,
        message: String,
    },
}

/// Owns the running notify debouncers and exposes a tokio receiver that the
/// app loop drains. Dropping the handle stops every underlying watcher.
///
/// The `matcher` field is a shared, mutable view of the
/// [`BaselineMatcherInner`] that every debouncer callback consults on
/// each event. Holding it in an `Arc<RwLock<_>>` lets the app layer
/// reconfigure the tracked branch at runtime (e.g. after `R` detects
/// a `git checkout` to a different branch) without rebuilding the
/// debouncers or losing the event queue. See [`Self::update_current_branch_ref`]
/// and ADR-0008.
pub struct WatchHandle {
    pub events: UnboundedReceiver<WatchEvent>,
    /// Shared baseline path matcher. The debouncer closures hold a
    /// clone of this `Arc`; writes through the handle are visible to
    /// the next event without any restart.
    matcher: SharedMatcher,
    /// Per-worktree git dir, stashed so `update_current_branch_ref`
    /// can rebuild `BaselineMatcherInner` without the caller having
    /// to re-plumb it.
    git_dir: PathBuf,
    /// Common git dir (equal to `git_dir` for normal repos, different
    /// for linked worktrees). Same rationale as `git_dir`.
    common_git_dir: PathBuf,
    /// Worktree root, kept for `refresh_worktree_watches`.
    worktree_root: PathBuf,
    /// Set of top-level child directories that already have recursive
    /// watches. `refresh_worktree_watches` adds any new ones.
    watched_children: std::collections::HashSet<PathBuf>,
    // The debouncers must outlive `events`; dropping them stops the watchers.
    worktree_debouncer: KizuDebouncer,
    _git_state: Vec<KizuDebouncer>,
    /// Events dir debouncer for stream mode. `None` if the events
    /// directory could not be resolved or created.
    _events_debouncer: Option<KizuDebouncer>,
}

impl WatchHandle {
    /// Atomically reconfigure the set of baseline-affecting paths the
    /// debouncers match against. Called by the app layer when `R`
    /// discovers that the symbolic HEAD now points at a different
    /// branch than the one captured at startup — without this the
    /// matcher stays pinned to the old branch ref and subsequent
    /// commits on the new branch would silently stop raising
    /// `GitHead` (the core correctness break that ADR-0008 addresses).
    ///
    /// Passing the same branch that is already active is a cheap
    /// no-op: the rebuilt `BaselineMatcherInner` holds identical
    /// canonicalized paths.
    pub fn update_current_branch_ref(&self, current_branch_ref: Option<&str>) {
        let new_inner =
            BaselineMatcherInner::new(&self.git_dir, &self.common_git_dir, current_branch_ref);
        if let Ok(mut guard) = self.matcher.write() {
            *guard = new_inner;
        }
    }

    /// Re-scan the worktree root for new top-level directories and
    /// add recursive watches for any that appeared since the last scan.
    /// Called by the app after `WatchEvent::Worktree` to close the
    /// blind spot where a directory created after startup would not be
    /// watched recursively.
    pub fn refresh_worktree_watches(&mut self) {
        let children = match recursive_worktree_children(&self.worktree_root) {
            Ok(c) => c,
            Err(_) => return,
        };
        for child in children {
            if self.watched_children.contains(&child) {
                continue;
            }
            if self
                .worktree_debouncer
                .watch(&child, RecursiveMode::Recursive)
                .is_ok()
            {
                self.watched_children.insert(child);
            }
        }
    }
}

/// Start watching `root` (the worktree), the per-worktree `git_dir`
/// (resolved via `git rev-parse --absolute-git-dir`, see ADR-0005), and
/// the `common_git_dir` (`git rev-parse --git-common-dir`).
///
/// `current_branch_ref` is the full ref name HEAD currently points at
/// (for example `refs/heads/main`), or `None` when HEAD is detached.
/// It is the single most important input for false-positive control:
/// the watcher only raises `GitHead` when the active branch ref, the
/// per-worktree `HEAD`, or the common `packed-refs` file is touched.
/// Unrelated ref activity (`git fetch` writing `refs/remotes/*`, a
/// tag write, another linked worktree committing to a sibling branch)
/// is ignored. A stale `current_branch_ref` is harmless: the watcher
/// will simply miss a new branch the session was not started on,
/// which is the correct behavior for a frozen baseline.
///
/// For a normal repository `git_dir == common_git_dir` and only one
/// git-dir watcher is spawned. For a **linked worktree** the two
/// differ — `git_dir` is `.git/worktrees/<name>/` and `common_git_dir`
/// is the main `.git/`. Branch refs (`refs/heads/**`, `packed-refs`)
/// live in the common dir, so `git commit` inside the linked worktree
/// would otherwise be invisible to the watcher. Watching both lets
/// any HEAD/refs movement raise `WatchEvent::GitHead`.
///
/// The worktree watcher swallows any event whose paths all sit inside
/// `git_dir`, so git's own bookkeeping can't trigger a recompute storm.
pub fn start(
    root: &Path,
    git_dir: &Path,
    common_git_dir: &Path,
    current_branch_ref: Option<&str>,
) -> Result<WatchHandle> {
    let (tx, rx) = unbounded_channel::<WatchEvent>();

    let worktree_root = root.to_path_buf();
    let git_dir_owned = git_dir.to_path_buf();
    let common_git_dir_owned = common_git_dir.to_path_buf();
    let matcher: SharedMatcher = Arc::new(RwLock::new(BaselineMatcherInner::new(
        &git_dir_owned,
        &common_git_dir_owned,
        current_branch_ref,
    )));

    let (worktree_debouncer, initial_children) =
        spawn_worktree_debouncer(&worktree_root, &git_dir_owned, tx.clone())?;
    let mut git_state = Vec::new();
    for watch_root in git_state_watch_roots(&git_dir_owned, &common_git_dir_owned) {
        git_state.push(spawn_git_state_debouncer(
            &watch_root,
            Arc::clone(&matcher),
            tx.clone(),
        )?);
    }

    // Stream mode: watch the events directory for new event-log files.
    let events_debouncer = spawn_events_dir_debouncer(&worktree_root, tx.clone());

    Ok(WatchHandle {
        events: rx,
        matcher,
        git_dir: git_dir_owned,
        common_git_dir: common_git_dir_owned,
        worktree_root,
        watched_children: initial_children,
        worktree_debouncer,
        _git_state: git_state,
        _events_debouncer: events_debouncer,
    })
}

/// Shared, runtime-mutable handle to the baseline path set. Every
/// debouncer callback holds a clone of this `Arc` and read-locks on
/// each event; the app layer can hot-swap the inner value through
/// [`WatchHandle::update_current_branch_ref`].
pub(crate) type SharedMatcher = Arc<RwLock<BaselineMatcherInner>>;

/// Set of git-dir paths that, when touched, genuinely indicate the
/// session baseline SHA has drifted. Captured at watcher startup
/// **and refreshed at runtime** whenever `R` discovers a new
/// symbolic HEAD (ADR-0008). Paths are canonicalized so byte
/// comparisons work across symlinked tempdirs (e.g. macOS
/// `/var/folders` → `/private/var/folders`).
#[derive(Debug, Clone)]
pub(crate) struct BaselineMatcherInner {
    /// `<per-worktree git_dir>/HEAD` — moves on `git checkout`, or
    /// on reseating HEAD to a different branch via `symbolic-ref`.
    head_file: PathBuf,
    /// `<common git_dir>/refs/heads/<current branch>` — moves on
    /// `git commit`, `git reset`, or any direct ref write. `None`
    /// when HEAD is detached: in that case the session baseline is
    /// a raw SHA and only `head_file` can move it (via checkout).
    branch_ref: Option<PathBuf>,
    /// `<common git_dir>/packed-refs` — touched when loose refs get
    /// packed, which can atomically replace the loose branch ref
    /// file with an entry inside packed-refs. Tracking this catches
    /// the corner case where a `git pack-refs` happens between two
    /// HEAD movements.
    packed_refs: PathBuf,
}

impl BaselineMatcherInner {
    pub(crate) fn new(
        git_dir: &Path,
        common_git_dir: &Path,
        current_branch_ref: Option<&str>,
    ) -> Self {
        let head_file = canonicalize_or_self(&git_dir.join("HEAD"));
        let branch_ref = current_branch_ref.map(|r| {
            // `r` looks like `refs/heads/foo/bar` — split on `/` and
            // join to preserve nested branch names on platforms where
            // path joining with a multi-segment string works differently.
            let mut p = common_git_dir.to_path_buf();
            for segment in r.split('/') {
                p.push(segment);
            }
            canonicalize_or_self(&p)
        });
        let packed_refs = canonicalize_or_self(&common_git_dir.join("packed-refs"));
        Self {
            head_file,
            branch_ref,
            packed_refs,
        }
    }

    pub(crate) fn matches(&self, path: &Path) -> bool {
        let p = canonicalize_or_self(path);
        p == self.head_file
            || self.branch_ref.as_ref().is_some_and(|r| p == *r)
            || p == self.packed_refs
    }
}

fn spawn_worktree_debouncer(
    root: &Path,
    git_dir: &Path,
    tx: UnboundedSender<WatchEvent>,
) -> Result<(KizuDebouncer, std::collections::HashSet<PathBuf>)> {
    let git_dir = git_dir.to_path_buf();
    // Pre-resolve excluded directory paths so the callback can filter
    // cheaply. On Linux inotify, a non-recursive root watch still
    // reports mtime changes on excluded child directories (e.g. when
    // a file inside target/ is written, target/'s mtime updates and
    // inotify fires on the root watch). Without this filter, those
    // events leak through as Worktree and cause spurious recomputes.
    let excluded_dirs: Vec<PathBuf> = WORKTREE_EXCLUDED_DIR_NAMES
        .iter()
        .map(|name| root.join(name))
        .collect();
    let callback_tx = tx.clone();
    let mut debouncer = new_kizu_debouncer(
        WORKTREE_DEBOUNCE,
        true,
        move |result: DebounceEventResult| {
            let events = match result {
                Ok(events) => events,
                Err(errors) => {
                    let message = format_notify_errors(WatchSource::Worktree, &errors);
                    let _ = callback_tx.send(WatchEvent::Error {
                        source: WatchSource::Worktree,
                        message,
                    });
                    return;
                }
            };
            // Swallow events whose paths all live inside git_dir or an
            // excluded directory (target/, node_modules/, etc.). Only
            // wake the app loop when a genuine worktree path is touched.
            let dominated = |p: &Path| {
                is_inside(p, &git_dir) || excluded_dirs.iter().any(|excl| is_inside(p, excl))
            };
            let touches_worktree = events
                .iter()
                .any(|ev| ev.event.paths.iter().any(|p| !dominated(p)));
            if touches_worktree {
                let _ = callback_tx.send(WatchEvent::Worktree);
            }
        },
    )
    .context("failed to create worktree debouncer")?;

    debouncer
        .watch(root, RecursiveMode::NonRecursive)
        .with_context(|| format!("failed to watch worktree at {}", root.display()))?;

    let recursive_children = match recursive_worktree_children(root) {
        Ok(children) => children,
        Err(err) => {
            let _ = tx.send(WatchEvent::Error {
                source: WatchSource::Worktree,
                message: format!("watcher [{}]: {err:#}", WatchSource::Worktree.label()),
            });
            return Ok((debouncer, std::collections::HashSet::new()));
        }
    };

    let mut watched = std::collections::HashSet::with_capacity(recursive_children.len());
    for child in recursive_children {
        debouncer
            .watch(&child, RecursiveMode::Recursive)
            .with_context(|| format!("failed to watch worktree at {}", child.display()))?;
        watched.insert(child);
    }
    Ok((debouncer, watched))
}

fn recursive_worktree_children(root: &Path) -> Result<Vec<PathBuf>> {
    let mut children = Vec::new();
    let entries = std::fs::read_dir(root)
        .with_context(|| format!("failed to read worktree root {}", root.display()))?;
    for entry in entries {
        let entry = entry
            .with_context(|| format!("failed to enumerate worktree root {}", root.display()))?;
        if entry
            .file_name()
            .to_str()
            .is_some_and(is_excluded_worktree_dir_name)
        {
            continue;
        }

        let path = entry.path();
        if path.is_dir() {
            children.push(path);
        }
    }
    children.sort();
    Ok(children)
}

fn is_excluded_worktree_dir_name(name: &str) -> bool {
    WORKTREE_EXCLUDED_DIR_NAMES.contains(&name)
}

fn git_state_watch_roots(git_dir: &Path, common_git_dir: &Path) -> Vec<WatchRoot> {
    vec![
        WatchRoot {
            path: git_dir.join("HEAD"),
            recursive_mode: RecursiveMode::NonRecursive,
            compare_contents: true,
            source: WatchSource::GitPerWorktreeHead,
        },
        // Branch refs live under the shared `refs/` tree. Watching only
        // this subtree keeps the poll fallback off `.git/objects/**`
        // while still catching new branch files and nested branch names.
        WatchRoot {
            path: common_git_dir.join("refs"),
            recursive_mode: RecursiveMode::Recursive,
            compare_contents: true,
            source: WatchSource::GitRefs,
        },
        // Watch the common git-dir root non-recursively so `packed-refs`
        // is covered whether it exists at startup or is created later.
        // Keeping this non-recursive avoids polling `objects/**` while
        // still tracking root-level files like `packed-refs`.
        WatchRoot {
            path: common_git_dir.to_path_buf(),
            recursive_mode: RecursiveMode::NonRecursive,
            compare_contents: true,
            source: WatchSource::GitCommonRoot,
        },
    ]
}

fn spawn_git_state_debouncer(
    watch_root: &WatchRoot,
    matcher: SharedMatcher,
    tx: UnboundedSender<WatchEvent>,
) -> Result<KizuDebouncer> {
    let source = watch_root.source;
    let compare_contents = watch_root.compare_contents;
    let mut debouncer = new_kizu_debouncer(
        HEAD_DEBOUNCE,
        compare_contents,
        move |result: DebounceEventResult| {
            let events = match result {
                Ok(events) => events,
                Err(errors) => {
                    let message = format_notify_errors(source, &errors);
                    let _ = tx.send(WatchEvent::Error { source, message });
                    return;
                }
            };
            // Read-lock the shared matcher once per burst. `R` may have
            // hot-swapped the inner value since the previous firing (the
            // user checked out a different branch and re-baselined), so
            // we always read through the Arc rather than capturing a
            // snapshot in the closure.
            //
            // Only treat baseline-affecting paths (the per-worktree HEAD,
            // the common-dir branch ref the session is currently tracking,
            // packed-refs) as real head movement. Plain bookkeeping churn
            // — `index`, `index.lock`, `logs/`, pack files, reflog writes
            // — and unrelated refs (remotes, tags, other branches) must
            // not raise the stale-baseline indicator, otherwise a
            // `git fetch` or a sibling linked worktree's commit would
            // wrongly flag our HEAD as drifted.
            let Ok(guard) = matcher.read() else {
                // Poisoned RwLock: refuse to swallow the burst silently —
                // bubble a health-level error so the app layer forces a
                // recompute and marks the watcher unhealthy.
                let _ = tx.send(WatchEvent::Error {
                    source,
                    message: format!(
                        "watcher [{}]: baseline matcher lock poisoned",
                        source.label()
                    ),
                });
                return;
            };
            let baseline_touched = events
                .iter()
                .any(|ev| ev.event.paths.iter().any(|p| guard.matches(p)));
            drop(guard);
            if baseline_touched {
                let _ = tx.send(WatchEvent::GitHead(source));
            }
        },
    )
    .context("failed to create git_dir debouncer")?;

    debouncer
        .watch(&watch_root.path, watch_root.recursive_mode)
        .with_context(|| format!("failed to watch git_dir at {}", watch_root.path.display()))?;
    Ok(debouncer)
}

fn new_kizu_debouncer<F>(
    timeout: Duration,
    compare_contents: bool,
    event_handler: F,
) -> notify::Result<KizuDebouncer>
where
    F: notify_debouncer_full::DebounceEventHandler,
{
    #[cfg(target_os = "macos")]
    {
        // The native FSEvents-backed `RecommendedWatcher` is unreliable in
        // this project's real macOS environments and in cargo test: create
        // events can vanish entirely. PollWatcher is slower but observable.
        //
        // Keep the poll cadence below the public debounce window so the
        // worst-case latency stays close to the advertised 300ms / 100ms.
        let poll_interval = timeout.checked_div(4).unwrap_or(timeout);
        new_debouncer_opt::<F, KizuWatcher, RecommendedCache>(
            timeout,
            None,
            event_handler,
            RecommendedCache::new(),
            NotifyConfig::default()
                .with_poll_interval(poll_interval)
                .with_compare_contents(compare_contents),
        )
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = compare_contents;
        new_debouncer(timeout, None, event_handler)
    }
}

/// Format one or more notify errors into the human-readable footer
/// string the app surfaces in `last_error`. Prefixed with the
/// watcher layer so users can tell `worktree` failures apart from
/// `git_dir` failures when triaging.
fn format_notify_errors(source: WatchSource, errors: &[notify::Error]) -> String {
    let joined = errors
        .iter()
        .map(|e| e.to_string())
        .collect::<Vec<_>>()
        .join("; ");
    if joined.is_empty() {
        format!("watcher [{}]: unknown backend failure", source.label())
    } else {
        format!("watcher [{}]: {joined}", source.label())
    }
}

/// Return true when `path` is `git_dir` itself or any descendant of it.
/// Both sides are canonicalized when possible so that symlink-y temp
/// directories on macOS (`/var/folders` vs `/private/var/folders`) compare
/// correctly.
fn is_inside(path: &Path, git_dir: &Path) -> bool {
    let p = canonicalize_or_self(path);
    let g = canonicalize_or_self(git_dir);
    p.starts_with(&g)
}

pub(crate) fn canonicalize_or_self(p: &Path) -> PathBuf {
    if let Ok(canonical) = p.canonicalize() {
        return canonical;
    }

    // Some paths we must compare against legitimately do not exist yet
    // when the watcher starts: a freshly checked-out branch can create
    // `refs/heads/<branch>` after startup, and packed-refs can be born
    // later via `git pack-refs`. Canonicalizing only existing ancestors
    // keeps symlinked temp roots (`/var` vs `/private/var`) stable while
    // preserving the not-yet-created tail we still need to match.
    let mut missing_tail = Vec::new();
    let mut cursor = p;
    while let Some(parent) = cursor.parent() {
        let Some(name) = cursor.file_name() else {
            break;
        };
        missing_tail.push(name.to_os_string());
        if let Ok(mut canonical_parent) = parent.canonicalize() {
            for segment in missing_tail.iter().rev() {
                canonical_parent.push(segment);
            }
            return canonical_parent;
        }
        cursor = parent;
    }

    p.to_path_buf()
}

/// Debounce window for the events directory — short, since each
/// hook-log-event writes exactly one file.
const EVENTS_DEBOUNCE: Duration = Duration::from_millis(100);

/// Spawn a debouncer that watches `<state_dir>/events/` for new
/// event-log files and emits [`WatchEvent::EventLog`]. Returns
/// `None` if the events directory cannot be resolved or the watcher
/// fails to start (non-fatal: stream mode simply won't get live
/// updates).
fn spawn_events_dir_debouncer(
    root: &Path,
    tx: UnboundedSender<WatchEvent>,
) -> Option<KizuDebouncer> {
    let events_dir = crate::paths::events_dir(root)?;
    // Ensure the directory exists so the watcher has something to watch.
    let _ = crate::paths::ensure_private_dir(&events_dir);
    if !events_dir.is_dir() {
        return None;
    }

    let events_dir_owned = events_dir.clone();
    let mut debouncer = new_kizu_debouncer(
        EVENTS_DEBOUNCE,
        false, // No compare_contents needed for event log files
        move |result: DebounceEventResult| {
            let events = match result {
                Ok(events) => events,
                Err(_) => return, // Swallow errors — stream mode is best-effort
            };
            for event in events {
                for path in &event.event.paths {
                    // Skip temp files written by write_event.
                    if path
                        .file_name()
                        .is_some_and(|n| n.to_string_lossy().starts_with('.'))
                    {
                        continue;
                    }
                    // Only emit for files inside the events dir.
                    if path.starts_with(&events_dir_owned) {
                        let _ = tx.send(WatchEvent::EventLog(path.clone()));
                    }
                }
            }
        },
    )
    .ok()?;

    debouncer
        .watch(&events_dir, RecursiveMode::NonRecursive)
        .ok()?;
    Some(debouncer)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::process::Command;
    use std::sync::mpsc;
    use tempfile::TempDir;
    use tokio::time::{Duration as TokioDuration, timeout};

    /// Build a fresh git repo in a tempdir so HEAD/refs are real and
    /// `git rev-parse --absolute-git-dir` resolves to a watchable directory.
    fn init_repo() -> TempDir {
        let dir = tempfile::tempdir().expect("create tempdir");
        run_git(dir.path(), &["init", "--quiet", "--initial-branch=main"]);
        run_git(dir.path(), &["config", "user.email", "test@example.com"]);
        run_git(dir.path(), &["config", "user.name", "kizu test"]);
        dir
    }

    fn run_git(cwd: &Path, args: &[&str]) {
        let status = Command::new("git")
            .args(args)
            .current_dir(cwd)
            .status()
            .unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
        assert!(status.success(), "git {args:?} exited with {status:?}");
    }

    /// Wait long enough for a debouncer cycle to elapse, since the worktree
    /// debounce is 300 ms — anything shorter is racy.
    const DRAIN_WAIT: TokioDuration = TokioDuration::from_millis(2_000);

    /// Drain all pending events for `wait` duration, discarding them.
    /// Used to clear startup noise before testing a specific write.
    async fn drain_events(handle: &mut WatchHandle, wait: TokioDuration) {
        let deadline = tokio::time::Instant::now() + wait;
        while tokio::time::Instant::now() < deadline {
            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
            let poll = remaining.min(TokioDuration::from_millis(200));
            match timeout(poll, handle.events.recv()).await {
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
    }

    async fn saw_matching_event<F>(
        handle: &mut WatchHandle,
        wait: TokioDuration,
        mut matches: F,
    ) -> bool
    where
        F: FnMut(&WatchEvent) -> bool,
    {
        let deadline = tokio::time::Instant::now() + wait;
        while tokio::time::Instant::now() < deadline {
            let now = tokio::time::Instant::now();
            let remaining = deadline.saturating_duration_since(now);
            let next_poll = if remaining > TokioDuration::from_millis(200) {
                TokioDuration::from_millis(200)
            } else {
                remaining
            };
            match timeout(next_poll, handle.events.recv()).await {
                Ok(Some(event)) if matches(&event) => return true,
                Ok(Some(_)) => continue,
                Ok(None) => return false,
                Err(_) => continue,
            }
        }
        false
    }

    #[tokio::test(flavor = "current_thread")]
    async fn worktree_event_is_received_for_a_new_file() {
        let repo = init_repo();
        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        // Give the debouncer a moment to install its OS hook before we touch
        // the worktree, otherwise the create event can land before notify is
        // listening.
        tokio::time::sleep(TokioDuration::from_millis(150)).await;
        fs::write(root.join("hello.txt"), "hello\n").expect("write file");

        let event = timeout(DRAIN_WAIT, handle.events.recv())
            .await
            .expect("worktree event arrived")
            .expect("channel still open");
        assert_eq!(event, WatchEvent::Worktree);
    }

    #[tokio::test(flavor = "current_thread")]
    async fn worktree_watcher_skips_target_directory() {
        let repo = init_repo();
        fs::create_dir_all(repo.path().join("target")).expect("create target");
        fs::create_dir_all(repo.path().join("src")).expect("create src");
        fs::write(repo.path().join("target").join("foo.rs"), "fn build() {}\n")
            .expect("write target file");
        fs::write(repo.path().join("src").join("bar.rs"), "fn app() {}\n").expect("write src file");

        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        // Drain any startup events (inotify may fire for files that
        // existed before the watcher was created).
        drain_events(&mut handle, TokioDuration::from_millis(800)).await;

        fs::write(root.join("target").join("foo.rs"), "fn build() { 1 }\n")
            .expect("rewrite target file");

        let saw_target_event =
            saw_matching_event(&mut handle, TokioDuration::from_millis(1_000), |event| {
                *event == WatchEvent::Worktree
            })
            .await;
        assert!(
            !saw_target_event,
            "nested writes under excluded target/ must not emit Worktree"
        );

        fs::write(root.join("src").join("bar.rs"), "fn app() { 1 }\n").expect("rewrite src file");

        let saw_src_event = saw_matching_event(&mut handle, DRAIN_WAIT, |event| {
            *event == WatchEvent::Worktree
        })
        .await;
        assert!(
            saw_src_event,
            "nested writes under non-excluded top-level directories must still emit Worktree"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn worktree_watcher_still_sees_root_level_file_writes() {
        let repo = init_repo();
        fs::write(repo.path().join("README.md"), "before\n").expect("write root file");

        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        tokio::time::sleep(TokioDuration::from_millis(250)).await;
        fs::write(root.join("README.md"), "after!\n").expect("rewrite root file");

        let saw_root_event = saw_matching_event(&mut handle, DRAIN_WAIT, |event| {
            *event == WatchEvent::Worktree
        })
        .await;
        assert!(
            saw_root_event,
            "root-level file writes must still emit Worktree with a non-recursive root watch"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn writes_inside_git_dir_do_not_emit_worktree_event() {
        let repo = init_repo();
        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        // Drain startup events before testing the specific write.
        drain_events(&mut handle, TokioDuration::from_millis(800)).await;

        // Drop a file inside .git/ to mimic git's own bookkeeping. notify
        // should fire — but the worktree filter must swallow it, and since
        // the path is not HEAD/refs/packed-refs the git_dir watcher must
        // also stay silent.
        fs::write(git_dir.join("kizu_test_marker"), b"x").expect("write inside git_dir");

        // Drain whatever shows up within the debounce window. We must not
        // see either event type: non-baseline writes inside `.git/` are
        // git's own bookkeeping and should be completely swallowed.
        let mut saw_worktree = false;
        let mut saw_head = false;
        let drain_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < drain_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::Worktree)) => {
                    saw_worktree = true;
                    break;
                }
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head = true;
                    break;
                }
                Ok(Some(WatchEvent::Error { .. } | WatchEvent::EventLog(_))) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            !saw_worktree,
            "git_dir-only writes must not surface as Worktree events"
        );
        assert!(
            !saw_head,
            "non-HEAD/refs writes inside git_dir must not surface as GitHead"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn writing_current_branch_ref_emits_head_event() {
        // The active-branch-only narrowing still has to fire for the
        // session's own branch. Create a ref under refs/heads/<branch>
        // and verify GitHead lands; the next test verifies that an
        // unrelated ref DOES NOT fire.
        let repo = init_repo();
        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");

        // `git init --initial-branch=main` leaves HEAD pointing at
        // `refs/heads/main`, but the branch ref file does not exist
        // until the first commit. Pretend the session's branch is
        // `kizu-test-branch` so we can watch its birth and drive the
        // event from a direct file write (no `git commit` rigging).
        let mut handle = start(
            &root,
            &git_dir,
            &common,
            Some("refs/heads/kizu-test-branch"),
        )
        .expect("start watcher");

        tokio::time::sleep(TokioDuration::from_millis(150)).await;
        let refs_heads = git_dir.join("refs").join("heads");
        fs::create_dir_all(&refs_heads).expect("create refs/heads");
        fs::write(
            refs_heads.join("kizu-test-branch"),
            b"0000000000000000000000000000000000000000\n",
        )
        .expect("write ref");

        let mut saw_head = false;
        let drain_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < drain_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head = true;
                    break;
                }
                Ok(Some(WatchEvent::Worktree)) => continue,
                Ok(Some(WatchEvent::Error { .. } | WatchEvent::EventLog(_))) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            saw_head,
            "writes under the session's own refs/heads/<branch> must emit GitHead"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn writing_unrelated_refs_does_not_emit_head_event() {
        // Adversarial finding: the previous matcher treated every
        // `refs/**` path as baseline-affecting, so a plain `git fetch`
        // updating `refs/remotes/*` would wrongly fire GitHead and
        // push users to re-baseline. With the narrowed matcher only
        // the session's own branch ref should count.
        let repo = init_repo();
        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");

        let mut handle = start(&root, &git_dir, &common, Some("refs/heads/main"))
            .expect("start watcher with main as active branch");

        tokio::time::sleep(TokioDuration::from_millis(150)).await;
        // Write a sibling branch, a remote ref, and a tag — none of
        // which the session is tracking. The matcher must reject all
        // three.
        let refs_heads = git_dir.join("refs").join("heads");
        fs::create_dir_all(&refs_heads).expect("create refs/heads");
        fs::write(
            refs_heads.join("sibling-branch"),
            b"0000000000000000000000000000000000000000\n",
        )
        .expect("write sibling");
        let refs_remotes = git_dir.join("refs").join("remotes").join("origin");
        fs::create_dir_all(&refs_remotes).expect("create refs/remotes/origin");
        fs::write(
            refs_remotes.join("feature"),
            b"0000000000000000000000000000000000000000\n",
        )
        .expect("write remote ref");
        let refs_tags = git_dir.join("refs").join("tags");
        fs::create_dir_all(&refs_tags).expect("create refs/tags");
        fs::write(
            refs_tags.join("v1.0"),
            b"0000000000000000000000000000000000000000\n",
        )
        .expect("write tag");

        let mut saw_head = false;
        let drain_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < drain_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head = true;
                    break;
                }
                Ok(Some(WatchEvent::Worktree)) => continue,
                Ok(Some(WatchEvent::Error { .. } | WatchEvent::EventLog(_))) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            !saw_head,
            "unrelated ref activity (sibling branch, remotes, tags) \
             must not raise GitHead under the narrowed matcher"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn linked_worktree_commit_raises_head_event_via_common_git_dir() {
        // Regression for Codex's linked-worktree finding: a commit inside
        // a linked worktree updates `refs/heads/<branch>` in the *common*
        // git dir (the main repo's `.git/`), not in the per-worktree dir
        // that `git rev-parse --absolute-git-dir` returns. If the watcher
        // only looks at the per-worktree dir, the commit never raises
        // GitHead and the UI stays pinned to a stale baseline.
        let main = init_repo();
        // Need an initial commit so we can spin off a branch.
        fs::write(main.path().join("seed.txt"), "seed\n").expect("write seed");
        run_git(main.path(), &["add", "seed.txt"]);
        run_git(main.path(), &["commit", "--quiet", "-m", "init"]);

        // Create a linked worktree at a sibling path. `git worktree add`
        // materializes `main/.git/worktrees/linked/` and a worktree tree
        // whose `.git` file points there.
        let linked_path = main
            .path()
            .parent()
            .expect("tempdir has parent")
            .join(format!("kizu-linked-wt-{}", std::process::id()));
        let _ = fs::remove_dir_all(&linked_path);
        run_git(
            main.path(),
            &[
                "worktree",
                "add",
                "-b",
                "feature-branch",
                linked_path.to_str().expect("linked path utf8"),
            ],
        );

        let linked_root = crate::git::find_root(&linked_path).expect("find_root linked");
        let linked_git_dir =
            crate::git::git_dir(&linked_root).expect("linked per-worktree git_dir");
        let common_git_dir =
            crate::git::git_common_dir(&linked_root).expect("linked common git_dir");
        assert_ne!(
            canonicalize_or_self(&linked_git_dir),
            canonicalize_or_self(&common_git_dir),
            "linked worktree must have distinct per-worktree and common git dirs \
             (got both = {})",
            linked_git_dir.display()
        );

        // Linked worktree starts on `feature-branch`, resolve that
        // at runtime instead of hard-coding.
        let linked_branch = crate::git::current_branch_ref(&linked_root).expect("linked branch");

        let mut handle = start(
            &linked_root,
            &linked_git_dir,
            &common_git_dir,
            linked_branch.as_deref(),
        )
        .expect("start watcher with common git dir");

        tokio::time::sleep(TokioDuration::from_millis(150)).await;

        // Commit inside the linked worktree. This writes the new commit
        // object + updates `refs/heads/feature-branch` in the common git
        // dir. The per-worktree git dir only gets index/logs churn,
        // which the BaselineMatcher correctly rejects.
        fs::write(linked_root.join("new.txt"), "hi\n").expect("write new");
        run_git(&linked_root, &["add", "new.txt"]);
        run_git(&linked_root, &["commit", "--quiet", "-m", "linked commit"]);

        let mut saw_head = false;
        let drain_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < drain_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head = true;
                    break;
                }
                Ok(Some(WatchEvent::Worktree)) => continue,
                Ok(Some(WatchEvent::Error { .. } | WatchEvent::EventLog(_))) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            saw_head,
            "commit in a linked worktree must raise GitHead via the common git dir"
        );

        drop(handle);
        let _ = fs::remove_dir_all(&linked_path);
    }

    #[test]
    fn baseline_matcher_accepts_head_branch_ref_and_packed_refs_only() {
        // The matcher must recognize exactly three path classes: the
        // per-worktree HEAD, the common-dir branch ref the session
        // baseline was captured from, and common-dir packed-refs.
        // Anything else — unrelated refs, remotes, tags, bookkeeping
        // files — must be rejected.
        let git_dir = Path::new("/tmp/repo/.git");
        let matcher = BaselineMatcherInner::new(git_dir, git_dir, Some("refs/heads/main"));

        // Accepted: HEAD, the current branch ref, packed-refs.
        assert!(matcher.matches(&git_dir.join("HEAD")));
        assert!(matcher.matches(&git_dir.join("refs").join("heads").join("main")));
        assert!(matcher.matches(&git_dir.join("packed-refs")));

        // Rejected: unrelated refs.
        assert!(!matcher.matches(&git_dir.join("refs").join("heads").join("feature")));
        assert!(
            !matcher.matches(
                &git_dir
                    .join("refs")
                    .join("remotes")
                    .join("origin")
                    .join("main")
            )
        );
        assert!(!matcher.matches(&git_dir.join("refs").join("tags").join("v1.0")));

        // Rejected: pure bookkeeping.
        assert!(!matcher.matches(&git_dir.join("index")));
        assert!(!matcher.matches(&git_dir.join("index.lock")));
        assert!(!matcher.matches(&git_dir.join("logs").join("HEAD")));
        assert!(!matcher.matches(&git_dir.join("objects").join("pack").join("pack-abc.idx")));
        assert!(!matcher.matches(&git_dir.join("COMMIT_EDITMSG")));
        assert!(!matcher.matches(&git_dir.join("ORIG_HEAD")));
        assert!(!matcher.matches(&git_dir.join("FETCH_HEAD")));
    }

    #[test]
    fn baseline_matcher_detached_head_tracks_head_file_only() {
        // Detached HEAD: no current branch ref, so only the HEAD
        // file and packed-refs matter. Every refs/** path — including
        // what would otherwise have been "our" branch — must be
        // rejected, because in a detached session the baseline is a
        // raw SHA and no branch ref can move it.
        let git_dir = Path::new("/tmp/repo/.git");
        let matcher = BaselineMatcherInner::new(git_dir, git_dir, None);

        assert!(matcher.matches(&git_dir.join("HEAD")));
        assert!(matcher.matches(&git_dir.join("packed-refs")));
        assert!(!matcher.matches(&git_dir.join("refs").join("heads").join("main")));
        assert!(!matcher.matches(&git_dir.join("refs").join("heads").join("feature")));
    }

    #[test]
    fn baseline_matcher_linked_worktree_splits_head_and_branch_ref() {
        // Linked worktree: the per-worktree HEAD lives inside
        // `.git/worktrees/<name>/`, while the branch ref lives under
        // the main repo's `.git/refs/heads/`. The matcher must
        // recognize HEAD in the per-worktree dir and the branch ref
        // in the common dir simultaneously.
        let per = Path::new("/tmp/repo/.git/worktrees/wt1");
        let common = Path::new("/tmp/repo/.git");
        let matcher = BaselineMatcherInner::new(per, common, Some("refs/heads/feature"));

        assert!(matcher.matches(&per.join("HEAD")));
        assert!(matcher.matches(&common.join("refs").join("heads").join("feature")));
        assert!(matcher.matches(&common.join("packed-refs")));
        // HEAD in the common dir (the main worktree's HEAD) must NOT
        // match — a checkout in the main worktree is a different
        // session's concern.
        assert!(!matcher.matches(&common.join("HEAD")));
        // A sibling linked worktree's HEAD file is also unrelated.
        assert!(!matcher.matches(&common.join("worktrees").join("wt2").join("HEAD")));
    }

    #[test]
    fn canonicalize_or_self_preserves_missing_tail_under_canonical_parent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let parent = temp.path().join("refs").join("heads");
        fs::create_dir_all(&parent).expect("create existing parent");

        let missing = parent.join("future-branch");
        let canonical_parent = parent.canonicalize().expect("canonical parent");
        assert_eq!(
            canonicalize_or_self(&missing),
            canonical_parent.join("future-branch")
        );
    }

    #[test]
    fn git_state_watch_roots_focus_on_head_refs_and_common_root() {
        let temp = tempfile::tempdir().expect("tempdir");
        let git_dir = temp.path().join(".git");
        fs::create_dir_all(git_dir.join("refs").join("heads")).expect("create refs/heads");

        let roots = git_state_watch_roots(&git_dir, &git_dir);
        assert_eq!(
            roots,
            vec![
                WatchRoot {
                    path: git_dir.join("HEAD"),
                    recursive_mode: RecursiveMode::NonRecursive,
                    compare_contents: true,
                    source: WatchSource::GitPerWorktreeHead,
                },
                WatchRoot {
                    path: git_dir.join("refs"),
                    recursive_mode: RecursiveMode::Recursive,
                    compare_contents: true,
                    source: WatchSource::GitRefs,
                },
                WatchRoot {
                    path: git_dir.clone(),
                    recursive_mode: RecursiveMode::NonRecursive,
                    compare_contents: true,
                    source: WatchSource::GitCommonRoot,
                },
            ]
        );
    }

    #[test]
    fn selected_kizu_backend_smoke_receives_create_event() {
        let dir = tempfile::tempdir().expect("tempdir");
        let (tx, rx) = mpsc::channel();
        let mut debouncer =
            new_kizu_debouncer(TokioDuration::from_millis(50), false, tx).expect("new debouncer");
        debouncer
            .watch(dir.path(), RecursiveMode::Recursive)
            .expect("watch tempdir");

        let file = dir.path().join("smoke.txt");
        fs::write(&file, "ok\n").expect("write smoke file");

        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
        while std::time::Instant::now() < deadline {
            let batch = rx
                .recv_timeout(deadline - std::time::Instant::now())
                .expect("receive debounced event")
                .expect("notify backend error");
            if batch.iter().any(|event| {
                event.event.paths.iter().any(|path| {
                    *path == file
                        || path
                            .canonicalize()
                            .ok()
                            .is_some_and(|canonical| canonical == file)
                })
            }) {
                return;
            }
        }

        panic!(
            "selected kizu watcher backend never observed {}",
            file.display()
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn update_current_branch_ref_reroutes_head_detection_without_restart() {
        // Regression for Codex round-3 finding: previously the
        // watcher captured the startup branch into an immutable
        // struct, so `R`'ing after a `git checkout` to a different
        // branch silently stopped raising `GitHead` for commits
        // on the new branch. The new design wraps the matcher in
        // an `Arc<RwLock<_>>` so the app layer can hot-swap the
        // tracked branch through `WatchHandle::update_current_branch_ref`.
        //
        // Setup: start watching branch `main`, write to
        // `refs/heads/sibling` (ignored by the matcher), confirm
        // GitHead does NOT fire. Then update the matcher to track
        // `sibling`, write to it again, confirm GitHead fires.
        let repo = init_repo();
        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");

        let mut handle =
            start(&root, &git_dir, &common, Some("refs/heads/main")).expect("start watcher");

        tokio::time::sleep(TokioDuration::from_millis(150)).await;

        // Phase 1: write a sibling branch the matcher is NOT
        // tracking — must be ignored.
        let refs_heads = git_dir.join("refs").join("heads");
        fs::create_dir_all(&refs_heads).expect("create refs/heads");
        fs::write(
            refs_heads.join("sibling"),
            b"1111111111111111111111111111111111111111\n",
        )
        .expect("write sibling phase 1");

        let mut saw_head_before_update = false;
        let phase1_until = tokio::time::Instant::now() + TokioDuration::from_millis(600);
        while tokio::time::Instant::now() < phase1_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head_before_update = true;
                    break;
                }
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            !saw_head_before_update,
            "writes to a branch the matcher is not tracking must not fire GitHead"
        );

        // Phase 2: hot-swap the matcher to point at `sibling`, write
        // to it again, confirm GitHead fires this time. The handle
        // is `&self` for the update call, so no mutable borrow
        // conflict with the subsequent `events.recv()`.
        handle.update_current_branch_ref(Some("refs/heads/sibling"));
        tokio::time::sleep(TokioDuration::from_millis(150)).await;
        fs::write(
            refs_heads.join("sibling"),
            b"2222222222222222222222222222222222222222\n",
        )
        .expect("write sibling phase 2");

        let mut saw_head_after_update = false;
        let phase2_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < phase2_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_head_after_update = true;
                    break;
                }
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            saw_head_after_update,
            "after update_current_branch_ref the matcher must see the newly tracked branch"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn packed_refs_rewrites_after_birth_still_emit_head_event() {
        let repo = init_repo();
        fs::write(repo.path().join("seed.txt"), "seed\n").expect("write seed");
        run_git(repo.path(), &["add", "seed.txt"]);
        run_git(repo.path(), &["commit", "--quiet", "-m", "init"]);

        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");
        let packed_refs = common.join("packed-refs");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        tokio::time::sleep(TokioDuration::from_millis(150)).await;

        // Phase 1: create packed-refs after startup. This simulates a repo
        // that was born with loose refs only and later ran pack-refs.
        fs::write(
            &packed_refs,
            "0000000000000000000000000000000000000000 refs/heads/main\n",
        )
        .expect("create packed-refs");

        let mut saw_birth = false;
        let phase1_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < phase1_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_birth = true;
                    break;
                }
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(saw_birth, "creating packed-refs must emit GitHead");

        // Phase 2: rewrite the same packed-refs file in place. The watcher
        // must still see this even though packed-refs did not exist at
        // startup and therefore did not have a dedicated file watcher.
        fs::write(
            &packed_refs,
            "1111111111111111111111111111111111111111 refs/heads/main\n",
        )
        .expect("rewrite packed-refs");

        let mut saw_rewrite = false;
        let phase2_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < phase2_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::GitHead(_))) => {
                    saw_rewrite = true;
                    break;
                }
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            saw_rewrite,
            "rewriting packed-refs after it is created must still emit GitHead"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn same_size_existing_file_rewrite_emits_worktree_event() {
        let repo = init_repo();
        fs::write(repo.path().join("same.txt"), "alpha\n").expect("write seed");
        run_git(repo.path(), &["add", "same.txt"]);
        run_git(repo.path(), &["commit", "--quiet", "-m", "init"]);

        let root = crate::git::find_root(repo.path()).expect("find_root");
        let git_dir = crate::git::git_dir(&root).expect("git_dir");
        let common = crate::git::git_common_dir(&root).expect("common git_dir");
        let branch = crate::git::current_branch_ref(&root).expect("current branch");

        let mut handle = start(&root, &git_dir, &common, branch.as_deref()).expect("start watcher");

        tokio::time::sleep(TokioDuration::from_millis(250)).await;
        fs::write(root.join("same.txt"), "omega\n").expect("rewrite same-size file");

        let mut saw_worktree = false;
        let drain_until = tokio::time::Instant::now() + DRAIN_WAIT;
        while tokio::time::Instant::now() < drain_until {
            match timeout(TokioDuration::from_millis(200), handle.events.recv()).await {
                Ok(Some(WatchEvent::Worktree)) => {
                    saw_worktree = true;
                    break;
                }
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => continue,
            }
        }
        assert!(
            saw_worktree,
            "rewriting an existing file with the same size must still emit Worktree"
        );
    }
}