maproom 0.1.0

Semantic code search powered by embeddings and SQLite
Documentation
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
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
use std::{
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use ignore::WalkBuilder;
use tracing::{debug, info, warn};

use crate::db::traits::StoreChunks;
use crate::db::traits::StoreCore;
use crate::db::{ChunkRecord, FileRecord, SqliteStore};
use crate::incremental::edge_updater::Edge;
use crate::incremental::ignore::load_ignore_patterns;

pub mod edges;
pub mod parser;

/// Debouncer to prevent rapid successive event handling
///
/// Implements time-based debouncing to avoid triggering operations
/// too frequently. This prevents issues with:
/// - Multiple rapid branch switches
/// - Git operations that modify files multiple times
/// - File system noise (duplicate events from the OS)
///
/// # Debouncing Strategy
///
/// Events that occur within the debounce duration (default: 2 seconds) of the
/// previous event are ignored. This ensures at most one operation
/// per debounce window.
///
/// # Thread Safety
///
/// The last event timestamp is protected by a `Mutex` to allow safe access
/// from the event handler thread.
pub struct DebouncedHandler {
    /// Timestamp of the last processed event, protected by mutex for thread safety
    last_event: std::sync::Mutex<std::time::Instant>,
    /// Minimum duration between processed events
    debounce_duration: std::time::Duration,
}

impl DebouncedHandler {
    /// Creates a new debounced handler with the specified duration
    ///
    /// # Arguments
    ///
    /// * `debounce_duration` - Minimum time between processed events
    ///
    /// # Example
    ///
    /// ```ignore
    /// use std::time::Duration;
    ///
    /// let debouncer = DebouncedHandler::new(Duration::from_secs(2));
    /// ```
    pub fn new(debounce_duration: std::time::Duration) -> Self {
        Self {
            last_event: std::sync::Mutex::new(std::time::Instant::now() - debounce_duration),
            debounce_duration,
        }
    }

    /// Checks if an event should be processed or debounced
    ///
    /// Returns `true` if sufficient time has passed since the last event,
    /// `false` if the event should be debounced (ignored).
    ///
    /// # Thread Safety
    ///
    /// This method acquires a lock on the last event timestamp. If the lock
    /// is poisoned (due to a panic while holding the lock), this will panic.
    ///
    /// # Returns
    ///
    /// - `true` - Process the event (>= debounce_duration since last event)
    /// - `false` - Ignore the event (< debounce_duration since last event)
    pub fn should_handle(&self) -> bool {
        let mut last = self.last_event.lock().unwrap();
        let now = std::time::Instant::now();

        if now.duration_since(*last) >= self.debounce_duration {
            *last = now;
            true
        } else {
            false
        }
    }
}

/// NDJSON event emitted when a branch switch is detected (UNIWATCH-2002)
///
/// This struct is serialized to JSON and written to stdout for consumption
/// by external tools (e.g., VSCode extension, CLI orchestrator).
///
/// # JSON Format
///
/// Serializes to single-line NDJSON (newline-delimited JSON):
/// ```json
/// {"type":"branch_switched","timestamp":"2025-01-16T10:30:00Z","repo":"crewchief","old_branch":"main","new_branch":"feature-auth","old_worktree_id":1,"new_worktree_id":42,"worktree_created":false}
/// ```
///
/// # Fields
///
/// - `event_type`: Always "branch_switched" (serialized as "type")
/// - `timestamp`: ISO 8601 timestamp of when the event occurred
/// - `repo`: Repository name (e.g., "crewchief")
/// - `old_branch`: Branch name before the switch
/// - `new_branch`: Branch name after the switch
/// - `old_worktree_id`: Database worktree ID before the switch (BIGINT/i64)
/// - `new_worktree_id`: Database worktree ID after the switch (BIGINT/i64)
/// - `worktree_created`: Whether a new worktree record was created in the database
#[derive(serde::Serialize)]
pub struct BranchSwitchEvent {
    #[serde(rename = "type")]
    pub event_type: &'static str,
    pub timestamp: String,
    pub repo: String,
    pub old_branch: String,
    pub new_branch: String,
    pub old_worktree_id: i64,
    pub new_worktree_id: i64,
    pub worktree_created: bool,
}

/// Process Python imports from chunk metadata and create import edges in chunk_edges table
async fn process_python_imports(
    store: &SqliteStore,
    repo_id: i64,
    worktree_id: i64,
    _file_id: i64,
    chunks: &[SymbolChunk],
) -> anyhow::Result<()> {
    // Find the imports chunk if it exists
    let imports_chunk = chunks
        .iter()
        .find(|c| c.kind == "imports" && c.metadata.is_some());

    if let Some(imports) = imports_chunk {
        if let Some(metadata) = &imports.metadata {
            if let Some(imports_array) = metadata.get("imports").and_then(|v| v.as_array()) {
                // Get the chunk_id for the imports chunk itself
                let imports_chunk_id = store
                    .find_chunk_by_symbol(repo_id, Some(worktree_id), "__imports__", None)
                    .await?;

                if let Some(src_chunk_id) = imports_chunk_id {
                    // Process each import
                    for import_obj in imports_array {
                        // Extract symbol names from the import
                        let names = import_obj
                            .get("names")
                            .and_then(|v| v.as_array())
                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
                            .unwrap_or_default();

                        // For each imported name, try to find the target chunk
                        for name in names {
                            if let Ok(Some(dst_chunk_id)) = store
                                .find_chunk_by_symbol(repo_id, Some(worktree_id), name, None)
                                .await
                            {
                                // Create the import edge
                                if let Err(e) = store
                                    .insert_chunk_edge(src_chunk_id, dst_chunk_id, "imports")
                                    .await
                                {
                                    warn!("Failed to create import edge for {}: {}", name, e);
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

/// Batch insert edges into the database
async fn insert_edges(store: &SqliteStore, edges: &[Edge]) -> Result<()> {
    for edge in edges {
        store
            .insert_chunk_edge(
                edge.src_chunk_id,
                edge.dst_chunk_id,
                edge.edge_type.as_str(),
            )
            .await?;
    }
    Ok(())
}

pub fn detect_language_from_path(path: &Path) -> Option<&'static str> {
    // Check for go.mod file specifically
    if path.file_name().and_then(|n| n.to_str()) == Some("go.mod") {
        return Some("gomod");
    }

    // Check for Ruby special filenames
    match path.file_name().and_then(|n| n.to_str()) {
        Some("Gemfile") | Some("Rakefile") => return Some("rb"),
        _ => {}
    }

    match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
        "ts" => Some("ts"),
        "tsx" => Some("tsx"),
        "js" => Some("js"),
        "jsx" => Some("jsx"),
        "rs" => Some("rs"),
        "py" => Some("py"),
        "go" => Some("go"),
        "rb" | "rake" => Some("rb"),
        "c" => Some("c"),
        "cs" => Some("cs"),
        "java" => Some("java"),
        "cpp" | "cxx" | "cc" | "c++" => Some("cpp"),
        "hpp" | "hxx" => Some("cpp"),
        "h" => Some("cpp"), // Default .h to C++ (tree-sitter-cpp handles C too)
        "md" => Some("md"),
        "mdx" => Some("mdx"),
        "json" => Some("json"),
        "yaml" | "yml" => Some("yaml"),
        "toml" => Some("toml"),
        _ => None,
    }
}

fn build_ts_doc(
    relpath: &str,
    symbol_name: Option<&str>,
    signature: Option<&str>,
    docstring: Option<&str>,
    preview: &str,
) -> String {
    let mut parts: Vec<String> = Vec::new();
    parts.push(relpath.to_owned());
    if let Some(s) = symbol_name {
        parts.push(s.to_owned());
    }
    if let Some(s) = signature {
        parts.push(s.to_owned());
    }
    if let Some(s) = docstring {
        parts.push(s.to_owned());
    }
    parts.push(preview.to_owned());
    parts.join(" \n ")
}

fn first_n_lines(s: &str, n: usize) -> String {
    s.lines().take(n).collect::<Vec<_>>().join("\n")
}

fn file_modified_time(path: &Path) -> Option<chrono::DateTime<chrono::Utc>> {
    use std::time::UNIX_EPOCH;
    let t = fs::metadata(path).and_then(|m| m.modified()).ok()?;
    let dur = t.duration_since(UNIX_EPOCH).ok()?;
    chrono::DateTime::<chrono::Utc>::from_timestamp(dur.as_secs() as i64, dur.subsec_nanos())
}

#[allow(clippy::too_many_arguments)] // Public API; parameters represent distinct scan configuration
pub async fn scan_worktree(
    store: &SqliteStore,
    repo: &str,
    worktree: &str,
    root: &Path,
    commit: &str,
    _concurrency: usize,
    languages: Option<Vec<String>>,
    exclude: Option<Vec<String>>,
    progress: Option<&crate::progress::ProgressTracker>,
) -> anyhow::Result<()> {
    let start_time = std::time::Instant::now();
    let root_abs = root.canonicalize().with_context(|| "invalid root path")?;
    let repo_id = store
        .get_or_create_repo(repo, root_abs.to_string_lossy().as_ref())
        .await?;
    let worktree_id = store
        .get_or_create_worktree(repo_id, worktree, root_abs.to_string_lossy().as_ref())
        .await?;
    let commit_id = store.get_or_create_commit(repo_id, commit, None).await?;

    // Stats tracking
    let mut files_processed = 0;
    let mut files_skipped = 0;
    let mut total_chunks = 0;
    let mut total_bytes = 0usize;
    let mut language_counts: std::collections::HashMap<String, usize> =
        std::collections::HashMap::new();

    // Suppress human-readable output in JSON mode (for VSCode extension)
    let json_mode = progress.as_ref().map(|p| p.is_json_mode()).unwrap_or(false);
    if !json_mode {
        println!(
            "🔍 Scanning worktree: {} @ {}",
            worktree,
            &commit[..8.min(commit.len())]
        );
        println!("   Repository: {}", repo);
        println!("   Path: {}", root_abs.display());
    }

    // Load .maproomignore patterns and merge with programmatic exclude patterns
    let maproomignore_patterns = load_ignore_patterns(&root_abs)
        .with_context(|| format!("Failed to load .maproomignore patterns from {:?}", root_abs))?;

    let mut walk = WalkBuilder::new(&root_abs);
    walk.hidden(false)
        .ignore(true)
        .git_ignore(true)
        .git_exclude(true);

    // Build combined overrides from .maproomignore and programmatic exclude patterns
    if !maproomignore_patterns.is_empty() || exclude.is_some() {
        let mut ob = ignore::overrides::OverrideBuilder::new(&root_abs);

        // Add .maproomignore patterns as negative overrides
        for pattern in &maproomignore_patterns {
            ob.add(&format!("!{}", pattern))
                .with_context(|| format!("Invalid pattern in .maproomignore: {}", pattern))?;
        }

        // Merge programmatic exclude patterns
        if let Some(globs) = &exclude {
            for g in globs {
                ob.add(&format!("!{}", g))
                    .with_context(|| format!("Invalid exclude pattern: {}", g))?;
            }
        }

        walk.overrides(ob.build().context("Failed to build override patterns")?);
    }

    let allow_langs: Option<Vec<String>> =
        languages.map(|v| v.into_iter().map(|s| s.to_lowercase()).collect());

    // Collect all file paths first to set progress totals
    let mut file_paths = Vec::new();
    for dent in walk.build() {
        let dent = match dent {
            Ok(d) => d,
            Err(_) => continue,
        };
        if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) {
            continue;
        }
        let path = dent.path();
        let language = detect_language_from_path(path);
        if language.is_none() {
            continue;
        }
        if let Some(ref allow) = allow_langs {
            if !allow.iter().any(|l| l == language.unwrap()) {
                continue;
            }
        }
        file_paths.push(path.to_path_buf());
    }

    // Set progress totals now that we know file count
    if let Some(p) = &progress {
        p.set_totals(file_paths.len(), None);
    }

    for (idx, path) in file_paths.iter().enumerate() {
        let relpath = path.strip_prefix(&root_abs).unwrap_or(path);
        let language = detect_language_from_path(path).unwrap(); // Already filtered

        let content = match fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => {
                files_skipped += 1;
                continue;
            }
        };

        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        let size_bytes = content.len().min(i32::MAX as usize) as i32;
        let last_modified = file_modified_time(path);

        // Update stats
        files_processed += 1;
        total_bytes += content.len();
        *language_counts.entry(language.to_string()).or_insert(0) += 1;

        let file_record = FileRecord {
            repo_id,
            worktree_id,
            commit_id,
            relpath: relpath.to_string_lossy().to_string(),
            language: Some(language.to_string()),
            content_hash,
            size_bytes,
            last_modified,
        };
        let file_id = store.upsert_file(&file_record).await?;

        let chunks = parser::extract_chunks(&content, language);
        if chunks.is_empty() {
            // Fallback: single module chunk
            total_chunks += 1;
            let preview = first_n_lines(&content, 40);
            let blob_sha = crate::content_hash::compute_blob_sha(&preview);
            let ts_doc = build_ts_doc(
                relpath.to_string_lossy().as_ref(),
                None,
                None,
                None,
                &preview,
            );
            let chunk_record = ChunkRecord {
                file_id,
                blob_sha,
                symbol_name: None,
                kind: "module".to_string(),
                signature: None,
                docstring: None,
                start_line: 1,
                end_line: content.lines().count() as i32,
                preview,
                ts_doc_text: ts_doc,
                recency_score: 1.0,
                churn_score: 0.0,
                metadata: None,
                worktree_id,
            };
            store.insert_chunk(&chunk_record).await?;
        } else {
            total_chunks += chunks.len();

            // Collect chunk IDs during insertion
            let mut chunks_with_ids = Vec::new();
            for ch in &chunks {
                let chunk_content = content
                    .split('\n')
                    .skip(ch.start_line as usize - 1)
                    .take((ch.end_line - ch.start_line + 1) as usize)
                    .collect::<Vec<&str>>()
                    .join("\n");
                let preview = first_n_lines(&chunk_content, 40);
                let blob_sha = crate::content_hash::compute_blob_sha(&chunk_content);
                let ts_doc = build_ts_doc(
                    relpath.to_string_lossy().as_ref(),
                    ch.symbol_name.as_deref(),
                    ch.signature.as_deref(),
                    ch.docstring.as_deref(),
                    &preview,
                );
                let chunk_record = ChunkRecord {
                    file_id,
                    blob_sha,
                    symbol_name: ch.symbol_name.clone(),
                    kind: ch.kind.clone(),
                    signature: ch.signature.clone(),
                    docstring: ch.docstring.clone(),
                    start_line: ch.start_line,
                    end_line: ch.end_line,
                    preview,
                    ts_doc_text: ts_doc,
                    recency_score: 1.0,
                    churn_score: 0.0,
                    metadata: ch.metadata.clone(),
                    worktree_id,
                };
                let chunk_id = store.insert_chunk(&chunk_record).await?;
                chunks_with_ids.push(edges::ChunkWithId {
                    id: chunk_id,
                    symbol_name: ch.symbol_name.clone(),
                    kind: ch.kind.clone(),
                    start_line: ch.start_line,
                    end_line: ch.end_line,
                    file_id,
                });
            }

            // Process Python imports and create edges
            if language == "py" {
                if let Err(e) =
                    process_python_imports(store, repo_id, worktree_id, file_id, &chunks).await
                {
                    warn!(
                        "Failed to process Python imports for {}: {}",
                        relpath.display(),
                        e
                    );
                }
            }

            // Extract edges for TypeScript/JavaScript
            if matches!(language, "ts" | "tsx" | "js" | "jsx") {
                match edges::extract_edges(&content, language, &chunks_with_ids) {
                    Ok(edges_to_insert) if !edges_to_insert.is_empty() => {
                        if let Err(e) = insert_edges(store, &edges_to_insert).await {
                            warn!("Failed to insert edges for {}: {}", relpath.display(), e);
                            // Continue scan despite edge insertion failure
                        } else {
                            debug!(
                                "Inserted {} edges for {}",
                                edges_to_insert.len(),
                                relpath.display()
                            );
                        }
                    }
                    Ok(_) => {
                        // No edges extracted (empty file or no calls)
                        debug!("No edges extracted for {}", relpath.display());
                    }
                    Err(e) => {
                        warn!("Edge extraction failed for {}: {}", relpath.display(), e);
                        // Continue scan despite extraction failure
                    }
                }
            }
        }

        // Update progress after processing this file
        if let Some(p) = &progress {
            p.update_files(idx + 1);
            if p.should_print() {
                p.print_progress();
            }
        }
    }

    // Finish progress tracking and show timing
    if let Some(p) = &progress {
        p.finish();
    } else {
        // If no progress tracker, show timing manually (not in JSON mode)
        if !json_mode {
            let elapsed = start_time.elapsed();
            println!("\n✅ Completed in {:.1}s", elapsed.as_secs_f64());
        }
    }

    // Print summary (suppress in JSON mode)
    if !json_mode {
        println!("\n✅ Scan completed successfully!");
        println!("   Files processed: {}", files_processed);
        if files_skipped > 0 {
            println!("   Files skipped: {}", files_skipped);
        }
        println!("   Total chunks: {}", total_chunks);
        println!("   Total size: {:.2} MB", total_bytes as f64 / 1_048_576.0);

        if !language_counts.is_empty() {
            println!("\n   Languages indexed:");
            let mut langs: Vec<_> = language_counts.iter().collect();
            langs.sort_by(|a, b| b.1.cmp(a.1));
            for (lang, count) in langs {
                println!(
                    "     {} {}: {}",
                    match lang.as_str() {
                        "ts" | "tsx" => "📘",
                        "js" | "jsx" => "📙",
                        "rs" => "🦀",
                        "py" => "🐍",
                        "go" => "🔷",
                        "md" => "📝",
                        "json" => "📋",
                        "yaml" | "yml" => "📄",
                        "toml" => "⚙️",
                        _ => "📄",
                    },
                    lang,
                    count
                );
            }
        }
    }

    info!(?repo, ?worktree, ?commit, "scan complete");
    Ok(())
}

pub async fn upsert_files(
    store: &SqliteStore,
    repo: &str,
    worktree: &str,
    root: &Path,
    commit: &str,
    paths: &[PathBuf],
) -> anyhow::Result<()> {
    let root_abs = root.canonicalize().with_context(|| "invalid root path")?;
    let repo_id = store
        .get_or_create_repo(repo, root_abs.to_string_lossy().as_ref())
        .await?;
    let worktree_id = store
        .get_or_create_worktree(repo_id, worktree, root_abs.to_string_lossy().as_ref())
        .await?;
    let commit_id = store.get_or_create_commit(repo_id, commit, None).await?;

    for path in paths {
        let abs = if path.is_absolute() {
            path.clone()
        } else {
            root_abs.join(path)
        };
        if !abs.exists() {
            continue;
        }
        if abs.is_dir() {
            continue;
        }
        let relpath = abs.strip_prefix(&root_abs).unwrap_or(&abs).to_path_buf();
        let language = detect_language_from_path(&abs);
        if language.is_none() {
            continue;
        }
        let content = match fs::read_to_string(&abs) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        let size_bytes = content.len().min(i32::MAX as usize) as i32;
        let last_modified = file_modified_time(&abs);
        let file_record = FileRecord {
            repo_id,
            worktree_id,
            commit_id,
            relpath: relpath.to_string_lossy().to_string(),
            language: language.map(|l| l.to_string()),
            content_hash,
            size_bytes,
            last_modified,
        };
        let file_id = store.upsert_file(&file_record).await?;
        let chunks = parser::extract_chunks(&content, language.unwrap());
        if chunks.is_empty() {
            let preview = first_n_lines(&content, 40);
            let blob_sha = crate::content_hash::compute_blob_sha(&preview);
            let ts_doc = build_ts_doc(
                relpath.to_string_lossy().as_ref(),
                None,
                None,
                None,
                &preview,
            );
            let chunk_record = ChunkRecord {
                file_id,
                blob_sha,
                symbol_name: None,
                kind: "module".to_string(),
                signature: None,
                docstring: None,
                start_line: 1,
                end_line: content.lines().count() as i32,
                preview,
                ts_doc_text: ts_doc,
                recency_score: 1.0,
                churn_score: 0.0,
                metadata: None,
                worktree_id,
            };
            store.insert_chunk(&chunk_record).await?;
        } else {
            // Collect chunk IDs during insertion
            let mut chunks_with_ids = Vec::new();
            for ch in &chunks {
                let chunk_content = content
                    .split('\n')
                    .skip(ch.start_line as usize - 1)
                    .take((ch.end_line - ch.start_line + 1) as usize)
                    .collect::<Vec<&str>>()
                    .join("\n");
                let preview = first_n_lines(&chunk_content, 40);
                let blob_sha = crate::content_hash::compute_blob_sha(&chunk_content);
                let ts_doc = build_ts_doc(
                    relpath.to_string_lossy().as_ref(),
                    ch.symbol_name.as_deref(),
                    ch.signature.as_deref(),
                    ch.docstring.as_deref(),
                    &preview,
                );
                let chunk_record = ChunkRecord {
                    file_id,
                    blob_sha,
                    symbol_name: ch.symbol_name.clone(),
                    kind: ch.kind.clone(),
                    signature: ch.signature.clone(),
                    docstring: ch.docstring.clone(),
                    start_line: ch.start_line,
                    end_line: ch.end_line,
                    preview,
                    ts_doc_text: ts_doc,
                    recency_score: 1.0,
                    churn_score: 0.0,
                    metadata: ch.metadata.clone(),
                    worktree_id,
                };
                let chunk_id = store.insert_chunk(&chunk_record).await?;
                chunks_with_ids.push(edges::ChunkWithId {
                    id: chunk_id,
                    symbol_name: ch.symbol_name.clone(),
                    kind: ch.kind.clone(),
                    start_line: ch.start_line,
                    end_line: ch.end_line,
                    file_id,
                });
            }

            // Process Python imports and create edges
            if language.unwrap() == "py" {
                if let Err(e) =
                    process_python_imports(store, repo_id, worktree_id, file_id, &chunks).await
                {
                    warn!(
                        "Failed to process Python imports for {}: {}",
                        relpath.display(),
                        e
                    );
                }
            }

            // Extract edges for TypeScript/JavaScript
            if matches!(language.unwrap(), "ts" | "tsx" | "js" | "jsx") {
                match edges::extract_edges(&content, language.unwrap(), &chunks_with_ids) {
                    Ok(edges_to_insert) if !edges_to_insert.is_empty() => {
                        if let Err(e) = insert_edges(store, &edges_to_insert).await {
                            warn!("Failed to insert edges for {}: {}", relpath.display(), e);
                        } else {
                            debug!(
                                "Inserted {} edges for {}",
                                edges_to_insert.len(),
                                relpath.display()
                            );
                        }
                    }
                    Ok(_) => {
                        debug!("No edges extracted for {}", relpath.display());
                    }
                    Err(e) => {
                        warn!("Edge extraction failed for {}: {}", relpath.display(), e);
                    }
                }
            }
        }
    }

    info!(?repo, ?worktree, ?commit, updated_files=?paths.len(), "upsert selective complete");
    Ok(())
}

/// Sets up file watching for .git/HEAD with channel bridging from sync to async
///
/// Creates a `notify::RecommendedWatcher` that monitors the `.git/HEAD` file for changes
/// (e.g., branch switches). Events from the synchronous `notify` crate are bridged to
/// tokio's async channels via a spawned task.
///
/// # Arguments
///
/// * `git_head` - Path to the .git/HEAD file to watch
/// * `tx` - Tokio async channel sender for forwarding file system events
///
/// # Returns
///
/// Returns the watcher handle which must be kept alive. When the watcher is dropped,
/// file watching stops automatically.
///
/// # Channel Bridging
///
/// The notify crate uses synchronous `std::sync::mpsc` channels, while tokio uses
/// async channels. This function bridges the two by:
/// 1. Creating a sync channel for notify events
/// 2. Spawning a tokio task that forwards events to the async channel
/// 3. Breaking the loop when the async channel is closed (receiver dropped)
///
/// # Example
///
/// ```ignore
/// use std::path::Path;
/// use tokio::sync::mpsc;
///
/// #[tokio::main]
/// async fn main() -> anyhow::Result<()> {
///     let git_head = Path::new("/workspace/repo/.git/HEAD");
///     let (tx, mut rx) = mpsc::channel(100);
///
///     let _watcher = setup_head_watcher(git_head, tx)?;
///
///     // Receive events
///     while let Some(event) = rx.recv().await {
///         println!("Branch switch detected: {:?}", event);
///     }
///
///     Ok(())
/// }
/// ```
pub fn setup_head_watcher(
    git_head: &Path,
    tx: tokio::sync::mpsc::Sender<notify::Event>,
) -> anyhow::Result<notify::RecommendedWatcher> {
    use notify::{RecursiveMode, Watcher};

    // Create sync channel for notify crate
    let (sync_tx, sync_rx) = std::sync::mpsc::channel();

    // Create watcher with sync callback
    let mut watcher = notify::recommended_watcher(move |res| {
        if let Ok(event) = res {
            let _ = sync_tx.send(event);
        }
    })?;

    // Watch the .git/HEAD file (non-recursive, file only)
    watcher.watch(git_head, RecursiveMode::NonRecursive)?;

    // Bridge sync to async: spawn blocking task to forward events
    // Use spawn_blocking because sync_rx.recv() is a blocking call
    tokio::task::spawn_blocking(move || {
        while let Ok(event) = sync_rx.recv() {
            // Send to async channel - need to block_on since we're in a blocking context
            if tx.blocking_send(event).is_err() {
                // Channel closed, exit task
                break;
            }
        }
    });

    Ok(watcher)
}

// NOTE: watch_worktree, handle_branch_switch, get_file_id_by_path, and get_file_id_by_worktree_id
// functions have been removed as part of IDXABS-2001 (SQLite-only migration).
// They depended on PostgreSQL's PgPool and will be reimplemented in IDXABS-2006
// (Refactor Incremental Module) with SqliteStore support.

#[derive(Debug, Clone)]
pub struct SymbolChunk {
    pub symbol_name: Option<String>,
    pub kind: String,
    pub signature: Option<String>,
    pub docstring: Option<String>,
    pub start_line: i32,
    pub end_line: i32,
    pub metadata: Option<serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::traits::StoreMigration;
    use std::io::Write;
    use tempfile::NamedTempFile;

    /// Test that setup_head_watcher creates a working channel bridge from sync to async
    ///
    /// This test verifies:
    /// 1. The function creates a notify::RecommendedWatcher without errors
    /// 2. The watcher can be configured to watch a file path
    /// 3. The async channel is created and ready to receive events
    /// 4. The function returns a valid watcher handle
    /// 5. Cleanup works properly when the watcher is dropped
    #[tokio::test]
    async fn test_setup_head_watcher_creates_bridge() {
        // Create a temporary file to watch (simulates .git/HEAD)
        let mut temp_file = NamedTempFile::new().unwrap();
        let temp_path = temp_file.path().to_path_buf();

        // Write initial content
        writeln!(temp_file, "ref: refs/heads/main").unwrap();
        temp_file.flush().unwrap();

        // Create async channel
        let (tx, rx) = tokio::sync::mpsc::channel(10);

        // Setup the watcher - this is the main test
        // It should not panic or return an error
        let watcher_result = setup_head_watcher(&temp_path, tx);

        // Verify the watcher was created successfully
        assert!(
            watcher_result.is_ok(),
            "Failed to create watcher: {:?}",
            watcher_result.err()
        );

        // Drop the watcher to stop watching and close the sync channel
        // This will cause the bridging task to exit when sync_rx.recv() returns Err
        drop(watcher_result.unwrap());

        // Drop the receiver to close the async channel
        // This ensures the bridging task will exit if it's still trying to send
        drop(rx);

        // Test passes if we reach here without panicking
        // The bridging task should exit cleanly when the watcher is dropped
    }

    /// Test that worktree tracking state is initialized correctly (UNIWATCH-1002)
    ///
    /// This test verifies:
    /// 1. Arc<RwLock<String>> for current_branch is created and initialized
    /// 2. Arc<RwLock<i64>> for current_worktree_id is created and initialized
    /// 3. Initialization uses get_or_create_repo() and get_or_create_worktree()
    /// 4. Arc/RwLock semantics work (can acquire read/write locks)
    /// 5. Values match the input parameters
    ///
    /// MIGRATED from PostgreSQL to SQLite (UNIWATCH-4001)
    #[tokio::test]
    async fn test_worktree_tracking_initialization() {
        use std::sync::atomic::{AtomicUsize, Ordering};
        static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);

        // Setup SQLite test database
        let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
        let db_name = format!(
            "file:memdb_worktree_init_{}?mode=memory&cache=shared",
            counter
        );
        let store = crate::db::SqliteStore::connect(&db_name)
            .await
            .expect("Failed to create test store");
        store.migrate().await.expect("Failed to run migrations");

        // Test parameters
        let repo = "test-repo";
        let worktree = "test-branch";
        let root = "/tmp/test-root";

        // Initialize tracking state (mirrors watch command logic)
        let repo_id = store
            .get_or_create_repo(repo, root)
            .await
            .expect("Failed to get_or_create_repo");
        let worktree_id = store
            .get_or_create_worktree(repo_id, worktree, root)
            .await
            .expect("Failed to get_or_create_worktree");

        let current_branch = std::sync::Arc::new(std::sync::RwLock::new(worktree.to_string()));
        let current_worktree_id = std::sync::Arc::new(std::sync::RwLock::new(worktree_id));

        // Test 1: Verify current_branch initialized correctly
        {
            let branch_guard = current_branch
                .read()
                .expect("Failed to acquire read lock on current_branch");
            assert_eq!(
                *branch_guard, worktree,
                "current_branch should be initialized to worktree parameter"
            );
        }

        // Test 2: Verify current_worktree_id initialized correctly
        {
            let worktree_id_guard = current_worktree_id
                .read()
                .expect("Failed to acquire read lock on current_worktree_id");
            assert!(
                *worktree_id_guard > 0,
                "current_worktree_id should be a valid positive integer"
            );
        }

        // Test 3: Verify Arc semantics work (can clone and access from multiple locations)
        let branch_clone = std::sync::Arc::clone(&current_branch);
        let worktree_id_clone = std::sync::Arc::clone(&current_worktree_id);

        {
            let branch_guard = branch_clone.read().expect("Failed to acquire read lock");
            assert_eq!(*branch_guard, worktree, "Arc clone should have same value");
        }

        {
            let worktree_id_guard = worktree_id_clone
                .read()
                .expect("Failed to acquire read lock");
            assert!(*worktree_id_guard > 0, "Arc clone should have same value");
        }

        // Test 4: Verify write locks work (for branch switch logic)
        {
            let mut branch_guard = current_branch
                .write()
                .expect("Failed to acquire write lock on current_branch");
            let new_branch = "feature-branch";
            *branch_guard = new_branch.to_string();
            assert_eq!(
                *branch_guard, new_branch,
                "Write lock should allow mutation"
            );
        }

        // Test 5: Verify value persisted after write lock released
        {
            let branch_guard = current_branch.read().expect("Failed to acquire read lock");
            assert_eq!(
                *branch_guard, "feature-branch",
                "Value should persist after write lock released"
            );
        }
    }

    /// Test that DebouncedHandler prevents rapid successive events (UNIWATCH-1003)
    ///
    /// This test verifies:
    /// 1. First call to should_handle() returns true (event processed)
    /// 2. Immediate second call returns false (debounced, too soon)
    /// 3. After debounce duration expires, should_handle() returns true again
    /// 4. Thread-safe Mutex<Instant> pattern works correctly
    /// 5. Configurable debounce duration is respected
    #[test]
    fn test_debouncer_prevents_rapid_events() {
        use std::time::Duration;

        // Create debouncer with short duration for testing (100ms)
        let debounce_duration = Duration::from_millis(100);
        let debouncer = DebouncedHandler::new(debounce_duration);

        // Test 1: First call should return true (enough time has passed since initialization)
        assert!(
            debouncer.should_handle(),
            "First call to should_handle() should return true"
        );

        // Test 2: Immediate second call should return false (debounced)
        assert!(
            !debouncer.should_handle(),
            "Immediate second call should return false (debounced)"
        );

        // Test 3: Another immediate call should also return false
        assert!(
            !debouncer.should_handle(),
            "Third immediate call should also return false (still debounced)"
        );

        // Test 4: Wait for debounce duration to expire
        std::thread::sleep(debounce_duration + Duration::from_millis(10));

        // Test 5: After waiting, should_handle() should return true again
        assert!(
            debouncer.should_handle(),
            "After waiting for debounce duration, should_handle() should return true"
        );

        // Test 6: Immediate call after the previous one should be debounced again
        assert!(
            !debouncer.should_handle(),
            "Immediate call after previous success should be debounced"
        );
    }

    /// Test that branch switch state update pattern works correctly (UNIWATCH-2001)
    ///
    /// This test verifies the state update logic that handle_branch_switch uses:
    /// 1. Database records are created for new worktrees
    /// 2. current_branch Arc<RwLock<String>> can be updated to new branch
    /// 3. current_worktree_id Arc<RwLock<i64>> can be updated to new worktree_id
    /// 4. State remains consistent after update
    ///
    /// Note: Full integration test of handle_branch_switch is in UNIWATCH-4002.
    ///
    /// MIGRATED from PostgreSQL to SQLite (UNIWATCH-4001)
    #[tokio::test]
    async fn test_handle_branch_switch_updates_state() {
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::sync::{Arc, RwLock};

        static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);

        // Setup SQLite test database
        let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
        let db_name = format!(
            "file:memdb_branch_switch_{}?mode=memory&cache=shared",
            counter
        );
        let store = crate::db::SqliteStore::connect(&db_name)
            .await
            .expect("Failed to create test store");
        store.migrate().await.expect("Failed to run migrations");

        // Test parameters
        let repo_name = "test-repo";
        let root = "/tmp/test-root";

        // Create repo
        let repo_id = store
            .get_or_create_repo(repo_name, root)
            .await
            .expect("Failed to create repo");

        // Create initial worktree for "main"
        let main_worktree_id = store
            .get_or_create_worktree(repo_id, "main", root)
            .await
            .expect("Failed to create main worktree");

        // Initialize shared state with "main"
        let current_branch = Arc::new(RwLock::new("main".to_string()));
        let current_worktree_id = Arc::new(RwLock::new(main_worktree_id));

        // Verify initial state
        assert_eq!(*current_branch.read().unwrap(), "main");
        assert_eq!(*current_worktree_id.read().unwrap(), main_worktree_id);

        // Simulate branch switch to "feature"
        let new_branch = "feature";
        let feature_worktree_id = store
            .get_or_create_worktree(repo_id, new_branch, root)
            .await
            .expect("Failed to create feature worktree");

        // Update state (simulating handle_branch_switch logic)
        {
            *current_branch.write().unwrap() = new_branch.to_string();
            *current_worktree_id.write().unwrap() = feature_worktree_id;
        }

        // Verify current_branch was updated to "feature"
        {
            let branch_guard = current_branch.read().unwrap();
            assert_eq!(
                *branch_guard, "feature",
                "current_branch should be updated to 'feature'"
            );
        }

        // Verify current_worktree_id was updated
        {
            let worktree_id_guard = current_worktree_id.read().unwrap();
            assert_eq!(
                *worktree_id_guard, feature_worktree_id,
                "current_worktree_id should be updated to feature worktree"
            );
            assert!(
                *worktree_id_guard > 0,
                "current_worktree_id should be a valid positive integer"
            );
        }

        // Verify different worktrees get different IDs
        assert_ne!(
            main_worktree_id, feature_worktree_id,
            "Different branches should have different worktree IDs"
        );
    }

    /// Test that same-branch detection skips state updates (UNIWATCH-2001)
    ///
    /// This test verifies the same-branch detection logic used in handle_branch_switch:
    /// 1. Comparison of old_branch == effective_branch triggers early return
    /// 2. Shared state remains unchanged when branch hasn't changed
    /// 3. No unnecessary database operations
    ///
    /// Note: Full integration test of handle_branch_switch is in UNIWATCH-4002.
    ///
    /// MIGRATED from PostgreSQL to SQLite (UNIWATCH-4001)
    #[test]
    fn test_handle_branch_switch_skips_if_same_branch() {
        use std::sync::{Arc, RwLock};

        // Initialize shared state with "main"
        let current_branch = Arc::new(RwLock::new("main".to_string()));
        let current_worktree_id = Arc::new(RwLock::new(42i64));

        // Simulate detecting "main" as the effective branch (same as current)
        let effective_branch = "main";
        let old_branch = current_branch.read().unwrap().clone();
        let old_wt_id = *current_worktree_id.read().unwrap();

        // Same-branch check (this is the logic from handle_branch_switch)
        let should_skip = old_branch == effective_branch;
        assert!(should_skip, "Same branch should be detected for skipping");

        // When skipping, state should NOT be modified
        // (Simulate the early return by not modifying state)

        // Verify current_branch was NOT changed
        {
            let branch_guard = current_branch.read().unwrap();
            assert_eq!(
                *branch_guard, "main",
                "current_branch should remain unchanged when branch is same"
            );
        }

        // Verify current_worktree_id was NOT changed
        {
            let worktree_id_guard = current_worktree_id.read().unwrap();
            assert_eq!(
                *worktree_id_guard, 42i64,
                "current_worktree_id should remain unchanged when branch is same"
            );
        }

        // Verify the old values we captured are preserved
        assert_eq!(old_branch, "main");
        assert_eq!(old_wt_id, 42i64);
    }

    /// Test BranchSwitchEvent serialization to NDJSON (UNIWATCH-2002)
    ///
    /// This test verifies:
    /// 1. BranchSwitchEvent struct serializes successfully to JSON
    /// 2. JSON is valid and can be parsed back
    /// 3. All fields are present with correct names
    /// 4. "event_type" field is renamed to "type" in JSON
    /// 5. Timestamp is in ISO 8601 format
    /// 6. Worktree IDs are i64 (BIGINT)
    /// 7. JSON is single-line (no newlines in output)
    #[test]
    fn test_branch_switch_event_serialization() {
        // Create a test event with sample data
        let event = BranchSwitchEvent {
            event_type: "branch_switched",
            timestamp: "2025-01-16T10:30:00Z".to_string(),
            repo: "crewchief".to_string(),
            old_branch: "main".to_string(),
            new_branch: "feature-auth".to_string(),
            old_worktree_id: 1,
            new_worktree_id: 42,
            worktree_created: false,
        };

        // Serialize to JSON string
        let json_result = serde_json::to_string(&event);

        // Test 1: Serialization should succeed
        assert!(
            json_result.is_ok(),
            "BranchSwitchEvent serialization should succeed, got: {:?}",
            json_result.err()
        );

        let json = json_result.unwrap();

        // Test 2: JSON should be single-line (no newlines)
        assert!(
            !json.contains('\n'),
            "JSON should be single-line, got: {}",
            json
        );

        // Test 3: Parse JSON back to verify structure
        let parsed: serde_json::Value =
            serde_json::from_str(&json).expect("JSON should be valid and parseable");

        // Test 4: Verify "type" field (not "event_type")
        assert_eq!(
            parsed.get("type").and_then(|v| v.as_str()),
            Some("branch_switched"),
            "JSON should have 'type' field with value 'branch_switched'"
        );

        // Test 5: Verify event_type field does NOT exist (should be renamed)
        assert!(
            parsed.get("event_type").is_none(),
            "JSON should NOT have 'event_type' field (should be renamed to 'type')"
        );

        // Test 6: Verify timestamp field
        assert_eq!(
            parsed.get("timestamp").and_then(|v| v.as_str()),
            Some("2025-01-16T10:30:00Z"),
            "JSON should have 'timestamp' field"
        );

        // Test 7: Verify repo field
        assert_eq!(
            parsed.get("repo").and_then(|v| v.as_str()),
            Some("crewchief"),
            "JSON should have 'repo' field"
        );

        // Test 8: Verify old_branch field
        assert_eq!(
            parsed.get("old_branch").and_then(|v| v.as_str()),
            Some("main"),
            "JSON should have 'old_branch' field"
        );

        // Test 9: Verify new_branch field
        assert_eq!(
            parsed.get("new_branch").and_then(|v| v.as_str()),
            Some("feature-auth"),
            "JSON should have 'new_branch' field"
        );

        // Test 10: Verify old_worktree_id field (should be i64)
        assert_eq!(
            parsed.get("old_worktree_id").and_then(|v| v.as_i64()),
            Some(1),
            "JSON should have 'old_worktree_id' field as i64"
        );

        // Test 11: Verify new_worktree_id field (should be i64)
        assert_eq!(
            parsed.get("new_worktree_id").and_then(|v| v.as_i64()),
            Some(42),
            "JSON should have 'new_worktree_id' field as i64"
        );

        // Test 12: Verify worktree_created field
        assert_eq!(
            parsed.get("worktree_created").and_then(|v| v.as_bool()),
            Some(false),
            "JSON should have 'worktree_created' field"
        );

        // Test 13: Verify all expected fields are present
        let expected_fields = vec![
            "type",
            "timestamp",
            "repo",
            "old_branch",
            "new_branch",
            "old_worktree_id",
            "new_worktree_id",
            "worktree_created",
        ];
        for field in expected_fields {
            assert!(
                parsed.get(field).is_some(),
                "JSON should have '{}' field",
                field
            );
        }

        // Test 14: Verify no extra fields
        let field_count = parsed.as_object().map(|o| o.len()).unwrap_or(0);
        assert_eq!(
            field_count, 8,
            "JSON should have exactly 8 fields, got {}",
            field_count
        );

        // Test 15: Verify timestamp format matches ISO 8601
        let timestamp_str = parsed.get("timestamp").and_then(|v| v.as_str()).unwrap();
        assert!(
            timestamp_str.ends_with('Z'),
            "Timestamp should be in UTC (end with 'Z')"
        );
        assert!(
            timestamp_str.contains('T'),
            "Timestamp should use 'T' separator (ISO 8601)"
        );
    }

    /// Test that dual watchers (file + head) initialize correctly (UNIWATCH-3001)
    ///
    /// This test verifies the integration point where both the file watcher and
    /// .git/HEAD watcher are created in watch_worktree(). It tests:
    /// 1. File watcher channel is created
    /// 2. .git/HEAD path is calculated correctly from root
    /// 3. Head watcher channel is created (capacity 10)
    /// 4. setup_head_watcher() is called successfully
    /// 5. Head watcher handle is stored for cleanup
    /// 6. Graceful degradation when .git/HEAD doesn't exist
    #[tokio::test]
    async fn test_dual_watchers_initialize() {
        use tempfile::TempDir;

        // Test 1: Verify head watcher succeeds when .git/HEAD exists
        {
            let temp_dir = TempDir::new().expect("Failed to create temp dir");
            let root_abs = temp_dir.path();
            let git_dir = root_abs.join(".git");
            std::fs::create_dir_all(&git_dir).expect("Failed to create .git dir");

            // Create .git/HEAD file
            let git_head = git_dir.join("HEAD");
            std::fs::write(&git_head, "ref: refs/heads/main\n").expect("Failed to write .git/HEAD");

            // Verify path calculation (this mimics watch_worktree logic)
            let calculated_git_head = root_abs.join(".git/HEAD");
            assert_eq!(
                calculated_git_head, git_head,
                "Path calculation should match actual .git/HEAD location"
            );

            // Create head event channel (capacity 10 as per spec)
            let (head_tx, mut head_rx) = tokio::sync::mpsc::channel(10);
            assert_eq!(
                head_rx.try_recv().unwrap_err(),
                tokio::sync::mpsc::error::TryRecvError::Empty,
                "Channel should be empty initially"
            );

            // Call setup_head_watcher (should succeed)
            let watcher_result = setup_head_watcher(&git_head, head_tx);
            assert!(
                watcher_result.is_ok(),
                "setup_head_watcher should succeed when .git/HEAD exists: {:?}",
                watcher_result.err()
            );

            // Store watcher handle (with underscore to prevent unused warning)
            let _head_watcher = watcher_result.unwrap();

            // Verify watcher stays alive while handle is in scope
            // (If this test completes without panic, the handle is valid)
        }

        // Test 2: Verify graceful degradation when .git/HEAD doesn't exist
        {
            let temp_dir = TempDir::new().expect("Failed to create temp dir");
            let root_abs = temp_dir.path();
            // Intentionally NOT creating .git/HEAD

            let git_head = root_abs.join(".git/HEAD");
            let (head_tx, _head_rx) = tokio::sync::mpsc::channel(10);

            // Call setup_head_watcher (should fail gracefully)
            let watcher_result = setup_head_watcher(&git_head, head_tx);
            assert!(
                watcher_result.is_err(),
                "setup_head_watcher should fail when .git/HEAD doesn't exist"
            );

            // In watch_worktree, this error is caught and logged as a warning,
            // allowing file watching to continue. The watcher variable is set to None.
            let _head_watcher = match watcher_result {
                Ok(watcher) => Some(watcher),
                Err(_e) => {
                    // This is the expected path - .git/HEAD doesn't exist
                    // In production, a warning would be logged here
                    None
                }
            };

            // Test passes if we reach here - graceful degradation works
        }

        // Test 3: Verify both watchers can coexist
        {
            let temp_dir = TempDir::new().expect("Failed to create temp dir");
            let root_abs = temp_dir.path();
            let git_dir = root_abs.join(".git");
            std::fs::create_dir_all(&git_dir).expect("Failed to create .git dir");
            let git_head = git_dir.join("HEAD");
            std::fs::write(&git_head, "ref: refs/heads/main\n").expect("Failed to write .git/HEAD");

            // Create file watcher channel (simulating WorktreeWatcher)
            let (_file_tx, _file_rx) = tokio::sync::mpsc::channel::<()>(1000);

            // Create head watcher channel
            let (head_tx, _head_rx) = tokio::sync::mpsc::channel(10);

            // Setup head watcher
            let head_watcher_result = setup_head_watcher(&git_head, head_tx);
            assert!(
                head_watcher_result.is_ok(),
                "Head watcher should initialize successfully"
            );

            let _head_watcher = head_watcher_result.unwrap();

            // Both watchers coexist in scope - if test completes, they're compatible
        }
    }

    /// Test that event loop handles both file and head events using tokio::select! (UNIWATCH-3002)
    ///
    /// This test verifies:
    /// 1. Event loop processes file events correctly
    /// 2. Event loop processes head events correctly
    /// 3. Debouncing works for rapid head events
    /// 4. Both event types can be handled in same loop
    /// 5. Graceful shutdown when both channels close
    /// 6. File processing logic unchanged from original implementation
    #[tokio::test]
    async fn test_event_loop_handles_both_sources() {
        use crate::incremental::{EventType, IndexingEvent};
        use std::sync::Arc;
        use tokio::sync::Mutex;

        // Create channels for file events and head events
        let (file_tx, mut file_rx) = tokio::sync::mpsc::channel(100);
        let (head_tx, mut head_rx) = tokio::sync::mpsc::channel(10);

        // Create a temporary directory for test files
        let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
        let root = temp_dir.path().to_path_buf();

        // Create test file
        let test_file = root.join("test.txt");
        std::fs::write(&test_file, "test content").expect("Failed to write test file");

        // Create shared state for tracking events processed
        let file_events_processed = Arc::new(Mutex::new(0usize));
        let head_events_processed = Arc::new(Mutex::new(0usize));

        let file_count_clone = file_events_processed.clone();
        let head_count_clone = head_events_processed.clone();

        // Spawn event processing loop (mimics processor_task in watch_worktree)
        let event_task = tokio::spawn(async move {
            let debouncer = DebouncedHandler::new(std::time::Duration::from_millis(50));

            loop {
                tokio::select! {
                    Some(_file_event) = file_rx.recv() => {
                        // Simulate file event processing
                        let mut count = file_count_clone.lock().await;
                        *count += 1;
                    }
                    Some(_head_event) = head_rx.recv() => {
                        // Simulate head event processing with debouncing
                        if !debouncer.should_handle() {
                            continue; // Debounced
                        }

                        let mut count = head_count_clone.lock().await;
                        *count += 1;
                    }
                    else => break, // Both channels closed
                }
            }
        });

        // Test 1: Send file events
        for _ in 0..3 {
            let event = IndexingEvent {
                worktree_id: "test:main".to_string(),
                path: test_file.clone(),
                event_type: EventType::Modified,
                timestamp: std::time::SystemTime::now(),
                old_path: None,
            };
            file_tx
                .send(event)
                .await
                .expect("Failed to send file event");
        }

        // Wait briefly for processing
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;

        // Test 2: Send head events (including rapid events to test debouncing)
        for _ in 0..5 {
            head_tx
                .send(notify::Event::default())
                .await
                .expect("Failed to send head event");
        }

        // Wait briefly for processing
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;

        // Test 3: Send more rapid head events (should be debounced)
        for _ in 0..3 {
            head_tx
                .send(notify::Event::default())
                .await
                .expect("Failed to send head event");
        }

        // Wait for debounce duration to expire
        tokio::time::sleep(tokio::time::Duration::from_millis(60)).await;

        // Test 4: Send one more head event after debounce expires
        head_tx
            .send(notify::Event::default())
            .await
            .expect("Failed to send head event");

        // Wait briefly for processing
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;

        // Test 5: Close channels to trigger graceful shutdown
        drop(file_tx);
        drop(head_tx);

        // Wait for event loop to exit
        let result = tokio::time::timeout(tokio::time::Duration::from_secs(1), event_task).await;

        assert!(
            result.is_ok(),
            "Event loop should exit gracefully when channels close"
        );
        assert!(
            result.unwrap().is_ok(),
            "Event task should complete without panic"
        );

        // Test 6: Verify file events were processed
        let file_count = *file_events_processed.lock().await;
        assert_eq!(file_count, 3, "All 3 file events should be processed");

        // Test 7: Verify head events were processed with debouncing
        // First batch of 5 events: only first should process
        // Second batch of 3 rapid events: all debounced
        // Final event after debounce expires: should process
        // Total: 2 events processed (first from batch 1, final after debounce)
        let head_count = *head_events_processed.lock().await;
        assert!(
            head_count >= 2,
            "At least 2 head events should be processed (first + after debounce), got {}",
            head_count
        );
        assert!(
            head_count <= 3,
            "No more than 3 head events should be processed (debouncing active), got {}",
            head_count
        );
    }
}