Skip to main content

seshat_cli/
serve.rs

1//! Implementation of the `seshat serve` command.
2//!
3//! Discovers the project database via smart resolution (explicit repo argument,
4//! current working directory, git root walk-up, or single-DB fallback), displays
5//! startup information, and starts the MCP server on stdio transport with
6//! graceful Ctrl+C shutdown.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Instant;
13
14use seshat_core::{BranchId, Language, ScanConfig};
15use seshat_mcp::{ProjectConnection, ScanState};
16use seshat_scanner::{read_and_parse_file, record_branch_scan_complete, scan_project};
17use seshat_storage::{
18    BranchRepository, Database, FileIRRepository, SqliteBranchRepository, SqliteFileIRRepository,
19    SqliteSubmoduleRepository, SubmoduleRepository, SubmoduleRow,
20};
21use seshat_watcher::{WatcherError, WatcherParams, start_watcher};
22use tokio::sync::oneshot;
23
24use crate::config::AppConfig;
25use crate::db::{ServeTarget, detect_branch, gc_branch_snapshots};
26use crate::error::CliError;
27
28/// Handle for the GC background task.
29///
30/// Call [`GcHandle::shutdown`] (or simply drop) to stop the periodic GC task.
31pub struct GcHandle {
32    shutdown_tx: oneshot::Sender<()>,
33    task: tokio::task::JoinHandle<()>,
34}
35
36impl GcHandle {
37    /// Signal the GC task to stop and await its completion.
38    pub async fn shutdown(self) {
39        let _ = self.shutdown_tx.send(());
40        let _ = tokio::time::timeout(std::time::Duration::from_secs(5), self.task).await;
41    }
42}
43
44/// Metadata about a discovered scanned project database.
45struct RepoInfo {
46    /// Human-readable project name (derived from DB filename).
47    name: String,
48    /// Path to the `.db` file.
49    db_path: PathBuf,
50    /// Current branch stored in the database.
51    branch: BranchId,
52    /// Number of indexed files.
53    file_count: usize,
54    /// Number of convention nodes.
55    convention_count: usize,
56}
57
58/// Resolve the call log path from CLI flag and config.
59///
60/// Priority: CLI flag > config value > disabled.
61/// - `Some("")` (bare `--call-log`) → default path `$XDG_DATA_HOME/seshat/call-log.jsonl`
62/// - `Some("/path")` → explicit path
63/// - `None` + `Some(config)` → config value
64/// - `None` + `None` config → disabled
65fn resolve_call_log_path(cli_flag: Option<PathBuf>, config_value: Option<&str>) -> Option<PathBuf> {
66    match cli_flag {
67        Some(p) if p.as_os_str().is_empty() => {
68            // Bare --call-log with no value → use default path
69            let data_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
70            Some(data_dir.join("seshat").join("call-log.jsonl"))
71        }
72        Some(p) => Some(p),
73        None => config_value.map(PathBuf::from),
74    }
75}
76
77/// Decide whether the file watcher should start for this `serve` invocation.
78///
79/// Watcher is gated on **both**:
80/// - The user has not disabled it via `[watcher] enabled = false` (`enabled`
81///   parameter), AND
82/// - The auto-scan (if any) did not fail. A failed scan means the project
83///   is in an indeterminate state (e.g. too many files, scan timeout); we
84///   refuse to walk the filesystem with `notify-debouncer-full` because
85///   that is exactly what blew up to 91.8 GB in the original bug report.
86///
87/// `state.error_message()` returns `None` when the scan was not needed,
88/// is in progress, or completed successfully — so this gate proceeds in
89/// all the normal paths and only blocks the explicit failure case.
90fn watcher_should_start(enabled: bool, state: &ScanState) -> bool {
91    enabled && state.error_message().is_none()
92}
93
94/// Handle branch switching and snapshot logic for the serve flow.
95///
96/// For ExistingDb: if detected branch differs from DB's current branch,
97/// switch to it (creating a snapshot from source if target has no data).
98/// For AutoScan: if detected branch differs from "main" and "main" has data,
99/// create a snapshot from "main" to the detected branch.
100///
101/// Returns the final branch ID after any switch.
102fn handle_branch_switch(
103    db: &Database,
104    detected_branch: &str,
105    current_branch: &BranchId,
106    _is_auto_scan: bool,
107) -> Result<BranchId, CliError> {
108    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
109
110    // Check if we need to switch branches.
111    if detected_branch == current_branch.0 {
112        return Ok(current_branch.clone());
113    }
114
115    let detected_id = BranchId::from(detected_branch);
116
117    // Check if target branch already has data.
118    let branches = branch_repo
119        .list_branches()
120        .map_err(|e| CliError::CommandFailed {
121            command: "serve".to_owned(),
122            reason: format!("failed to list branches: {e}"),
123        })?;
124
125    let target_has_data = branches.iter().any(|b| b.0 == detected_branch);
126
127    if !target_has_data {
128        // Target branch has no data — check if source has data to snapshot.
129        let source_branch = current_branch.clone();
130
131        // Check source has actual data (not just registered).
132        let source_branches = branch_repo
133            .list_branches()
134            .map_err(|e| CliError::CommandFailed {
135                command: "serve".to_owned(),
136                reason: format!("failed to list branches: {e}"),
137            })?;
138        let source_has_data = source_branches.iter().any(|b| b.0 == source_branch.0);
139
140        if !source_has_data {
141            tracing::info!(
142                source_branch = %source_branch.0,
143                target_branch = %detected_branch,
144                "Source branch has no data — switching without snapshot"
145            );
146        } else {
147            tracing::info!(
148                source_branch = %source_branch.0,
149                target_branch = %detected_branch,
150                "Target branch has no data — creating snapshot from source"
151            );
152            branch_repo
153                .create_snapshot(&source_branch, &detected_id)
154                .map_err(|e| CliError::CommandFailed {
155                    command: "serve".to_owned(),
156                    reason: format!("failed to create snapshot: {e}"),
157                })?;
158        }
159    }
160
161    // Switch to the detected branch.
162    tracing::info!(
163        from = %current_branch.0,
164        to = %detected_branch,
165        "Switching branch"
166    );
167    branch_repo
168        .switch_branch(&detected_id)
169        .map_err(|e| CliError::CommandFailed {
170            command: "serve".to_owned(),
171            reason: format!("failed to switch branch: {e}"),
172        })?;
173
174    Ok(detected_id)
175}
176
177/// Handle branch snapshot for AutoScan path.
178///
179/// If detected branch differs from "main" and "main" has data,
180/// create a snapshot from "main" to the detected branch.
181///
182/// Returns the final branch ID after any switch.
183fn handle_auto_scan_snapshot(db: &Database, detected_branch: &str) -> Result<BranchId, CliError> {
184    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
185
186    if detected_branch == "main" {
187        return Ok(BranchId::from(detected_branch));
188    }
189
190    let detected_id = BranchId::from(detected_branch);
191
192    // Check if "main" has data.
193    let branches = branch_repo
194        .list_branches()
195        .map_err(|e| CliError::CommandFailed {
196            command: "serve".to_owned(),
197            reason: format!("failed to list branches: {e}"),
198        })?;
199
200    let main_has_data = branches.iter().any(|b| b.0 == "main");
201
202    if !main_has_data {
203        return Ok(detected_id);
204    }
205
206    // Create snapshot from "main" to detected branch.
207    let main_branch = BranchId::from("main");
208    tracing::info!(
209        source_branch = "main",
210        target_branch = %detected_branch,
211        "Auto-scan on non-main branch — creating snapshot from main"
212    );
213    branch_repo
214        .create_snapshot(&main_branch, &detected_id)
215        .map_err(|e| CliError::CommandFailed {
216            command: "serve".to_owned(),
217            reason: format!("failed to create snapshot: {e}"),
218        })?;
219
220    // Switch to the detected branch.
221    branch_repo
222        .switch_branch(&detected_id)
223        .map_err(|e| CliError::CommandFailed {
224            command: "serve".to_owned(),
225            reason: format!("failed to switch branch: {e}"),
226        })?;
227
228    Ok(detected_id)
229}
230
231/// Background sync after a branch switch.
232///
233/// Thin wrapper around [`incremental_sync_blocking`] for the serve-startup
234/// path: runs in a `std::thread::spawn`, no progress callback, and lets the
235/// caller's `sync_in_progress` flag track completion.
236fn background_sync(
237    root: &Path,
238    old_branch: Option<&str>,
239    new_branch: &str,
240    db: &Database,
241    branch_id: &BranchId,
242    scan_config: &ScanConfig,
243    detection_config: &seshat_core::DetectionConfig,
244) {
245    incremental_sync_blocking(
246        root,
247        old_branch,
248        new_branch,
249        db,
250        branch_id,
251        scan_config,
252        detection_config,
253        None,
254    );
255}
256
257/// Synchronous incremental sync of a branch's `files_ir` to match `new_branch`'s
258/// HEAD commit, with an optional 1-arg progress callback `(processed, total)`.
259///
260/// Collects file trees from the old and new branch HEAD commits via `gix`,
261/// then diffs at the path level: new/changed files are re-parsed and upserted,
262/// removed files are deleted from the new branch's `files_ir`. On `gix` failures,
263/// falls back to a full rescan. Runs the detection cycle on completion to rebuild
264/// conventions for the new branch, and records HEAD as `last_scanned_commit`.
265///
266/// The progress callback (if provided) fires on every iteration of the upsert
267/// loop with `(processed_so_far, new_paths_total)` and once more with
268/// `(total, total)` after the loop. Callers are responsible for any throttling
269/// they need (e.g. the `seshat review` blocking sync emits at 1 Hz to stderr).
270///
271/// Used by:
272/// - [`background_sync`] (no callback) — serve startup, runs in a spawned thread.
273/// - `run_review` — runs synchronously before opening the TUI so the user sees
274///   fresh data (US-011).
275#[allow(clippy::too_many_arguments)]
276pub(crate) fn incremental_sync_blocking(
277    root: &Path,
278    old_branch: Option<&str>,
279    new_branch: &str,
280    db: &Database,
281    branch_id: &BranchId,
282    scan_config: &ScanConfig,
283    detection_config: &seshat_core::DetectionConfig,
284    progress: Option<&dyn Fn(usize, usize)>,
285) {
286    let new_paths = match resolve_branch_tree_paths(root, new_branch) {
287        Some(p) => p,
288        None => {
289            tracing::warn!(
290                "incremental_sync_blocking: could not resolve new branch tree, falling back to full rescan"
291            );
292            fallback_rescan(root, db, branch_id, scan_config, detection_config);
293            return;
294        }
295    };
296
297    let old_paths = old_branch.and_then(|b| resolve_branch_tree_paths(root, b));
298
299    let file_ir_repo = SqliteFileIRRepository::new(db.connection().clone());
300
301    let exclude_set = if scan_config.exclude_paths.is_empty() {
302        None
303    } else {
304        let mut builder = globset::GlobSetBuilder::new();
305        for p in &scan_config.exclude_paths {
306            match globset::Glob::new(p) {
307                Ok(g) => {
308                    builder.add(g);
309                }
310                Err(e) => {
311                    tracing::warn!(pattern = %p, error = %e, "incremental_sync_blocking: invalid exclude pattern");
312                }
313            }
314        }
315        match builder.build() {
316            Ok(set) => Some(set),
317            Err(e) => {
318                tracing::warn!(error = %e, "incremental_sync_blocking: failed to build exclude globset");
319                None
320            }
321        }
322    };
323
324    let total = new_paths.len();
325    let mut synced = 0usize;
326    let mut removed = 0usize;
327    // Build a full source_map covering EVERY file in the new tree, not just
328    // the diff. The detection cycle below DELETEs all auto-detected nodes
329    // and re-emits them — feeding it an empty map (the previous bug) would
330    // drop snippets for all unchanged files. Reading the unchanged files
331    // costs a few hundred milliseconds even on large repos and matches the
332    // semantics of a full scan, where `scan_project` retains source for
333    // every file it touches.
334    let mut source_map: HashMap<PathBuf, String> = HashMap::with_capacity(total);
335
336    for (idx, (rel_path, oid)) in new_paths.iter().enumerate() {
337        // Fire progress at the top of each iteration so `continue` paths still
338        // advance the counter — otherwise large skipped runs would stall the UI.
339        if let Some(cb) = progress {
340            cb(idx, total);
341        }
342
343        let path_str = rel_path.as_str();
344        let abs_path = root.join(rel_path);
345        // Bug #3: store paths relative to the worktree root, matching the
346        // full-scan orchestrator. gix tree-walk already yields relative paths
347        // here, so PathBuf::from(rel_path) is the canonical IR key.
348        let stored_path = PathBuf::from(rel_path);
349
350        let ext = match abs_path.extension().and_then(|e| e.to_str()) {
351            Some(e) => e,
352            None => continue,
353        };
354        let language = match Language::from_extension(ext) {
355            Some(l) => l,
356            None => continue,
357        };
358
359        if let Some(ref exclude_set) = exclude_set {
360            if exclude_set.is_match(&abs_path) {
361                continue;
362            }
363        }
364
365        let max_bytes = scan_config.max_file_size_kb * 1024;
366        if max_bytes > 0 {
367            if let Ok(meta) = std::fs::metadata(&abs_path) {
368                if meta.len() > max_bytes {
369                    continue;
370                }
371            }
372        }
373
374        // Read every file (changed or not) so the detection cycle below has
375        // source available for snippet construction. The IR upsert is still
376        // skipped for unchanged files (oid match) — we only need source, not
377        // a fresh parse, for detectors to attach snippets.
378        let oid_unchanged = old_paths
379            .as_ref()
380            .is_some_and(|old| old.get(path_str) == Some(oid));
381
382        let (project_file, source) = match read_and_parse_file(
383            &abs_path,
384            &stored_path,
385            language,
386            &scan_config.local_packages,
387        ) {
388            Ok(pair) => pair,
389            Err(e) => {
390                tracing::warn!(path = %abs_path.display(), error = %e, "incremental_sync_blocking: cannot read file");
391                continue;
392            }
393        };
394
395        if !oid_unchanged {
396            if let Err(e) = file_ir_repo.upsert(branch_id, &project_file, None) {
397                tracing::warn!(path = %path_str, error = %e, "incremental_sync_blocking: upsert failed");
398            }
399            synced += 1;
400        }
401        // source_map keyed by relative path so it lines up with
402        // ProjectFile.path that detectors look up against.
403        source_map.insert(stored_path, source);
404    }
405
406    // Final tick so the UI snaps to "X / X" instead of stalling at "(X-1) / X".
407    if let Some(cb) = progress {
408        cb(total, total);
409    }
410
411    if let Some(ref old) = old_paths {
412        for rel_path in old.keys() {
413            if !new_paths.contains_key(rel_path.as_str()) {
414                let path_str = rel_path.as_str();
415                if let Err(e) = file_ir_repo.delete_by_path(branch_id, path_str) {
416                    match &e {
417                        seshat_storage::StorageError::NotFound { .. } => {}
418                        _ => {
419                            tracing::warn!(path = %path_str, error = %e, "incremental_sync_blocking: delete failed")
420                        }
421                    }
422                }
423                removed += 1;
424            }
425        }
426    }
427
428    tracing::info!(
429        synced = synced,
430        removed = removed,
431        new_total = new_paths.len(),
432        old_branch = ?old_branch,
433        new_branch = %new_branch,
434        "incremental_sync_blocking: completed diff-based sync"
435    );
436
437    // P24: skip the detection cycle when nothing actually changed in IR.
438    // Detection re-aggregates findings across the whole project IR — that's
439    // expensive on large codebases and the existing nodes are still valid
440    // when no file changed.
441    if synced > 0 || removed > 0 {
442        let conn = db.connection().clone();
443        let file_dates = SqliteFileIRRepository::new(conn.clone())
444            .get_file_dates_by_branch(branch_id)
445            .unwrap_or_default()
446            .into_iter()
447            .collect::<HashMap<_, _>>();
448        match seshat_graph::run_detection_cycle(
449            &conn,
450            branch_id,
451            detection_config,
452            &file_dates,
453            &source_map,
454        ) {
455            Ok(_) => tracing::info!("incremental_sync_blocking: detection cycle complete"),
456            Err(e) => {
457                tracing::warn!(error = %e, "incremental_sync_blocking: detection cycle failed")
458            }
459        }
460    } else {
461        tracing::debug!("incremental_sync_blocking: no IR changes; skipping detection cycle");
462    }
463
464    // Record HEAD as the last scanned commit so the next startup's freshness
465    // check can short-circuit when no commits have landed (US-009).
466    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
467    record_branch_scan_complete(&branch_repo, root, branch_id);
468}
469
470fn resolve_branch_tree_paths(
471    root: &Path,
472    branch_name: &str,
473) -> Option<HashMap<String, gix::ObjectId>> {
474    let git_root = crate::db::find_git_root(root)?;
475    let repo = gix::open(git_root).ok()?;
476
477    let object = {
478        let ref_name = format!("refs/heads/{branch_name}");
479        if let Some(id) = repo
480            .try_find_reference(&ref_name)
481            .ok()
482            .flatten()
483            .and_then(|r| r.into_fully_peeled_id().ok())
484        {
485            repo.find_object(id.detach()).ok()
486        } else {
487            gix::ObjectId::from_hex(branch_name.as_bytes())
488                .ok()
489                .and_then(|oid| repo.find_object(oid).ok())
490        }?
491    };
492
493    let tree = object.into_commit().tree().ok()?;
494
495    let mut recorder = gix::traverse::tree::Recorder::default();
496    tree.traverse().breadthfirst(&mut recorder).ok()?;
497
498    let mut paths = HashMap::new();
499    for entry in recorder.records {
500        if entry.mode.is_blob() {
501            paths.insert(entry.filepath.to_string(), entry.oid);
502        }
503    }
504    Some(paths)
505}
506
507fn fallback_rescan(
508    root: &Path,
509    db: &Database,
510    branch_id: &BranchId,
511    scan_config: &ScanConfig,
512    _detection_config: &seshat_core::DetectionConfig,
513) {
514    tracing::info!(root = %root.display(), "background_sync: falling back to full rescan");
515    // `scan_project` already runs the full detection cycle with the
516    // populated source_map — running it again with an empty source_map
517    // (the pre-fix behaviour) wiped every snippet. So we only need the
518    // scan call here.
519    if let Err(e) = scan_project(root, scan_config, db, branch_id.clone()) {
520        tracing::warn!(error = %e, "background_sync: full rescan scan_project failed");
521    }
522
523    // Record HEAD as the last scanned commit so the next startup's freshness
524    // check can short-circuit when no commits have landed (US-009).
525    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
526    record_branch_scan_complete(&branch_repo, root, branch_id);
527}
528
529/// Run the serve command.
530///
531/// Discovers the project database (from explicit repo arg, cwd, git root, or
532/// single-DB fallback), loads it, displays startup information, and starts the
533/// MCP server on stdio transport.
534pub fn run_serve(
535    repo: Option<&Path>,
536    host: Option<String>,
537    port: Option<u16>,
538    call_log: Option<PathBuf>,
539) -> Result<(), CliError> {
540    // -- Load config --------------------------------------------------
541    let mut config = AppConfig::load().map_err(|e| CliError::CommandFailed {
542        command: "serve".to_owned(),
543        reason: format!("failed to load config: {e}"),
544    })?;
545
546    // CLI flags override config values.
547    if let Some(h) = host {
548        config.server.host = h;
549    }
550    if let Some(p) = port {
551        config.server.port = p;
552    }
553
554    // -- Discover databases or project root --------------------------
555    let target =
556        crate::db::resolve_serve_db_or_project_root(repo, &config.scan.additional_denylist_paths)?;
557
558    let (db_path, db, mut repo_info, scan_state, auto_scan_project_root, detected_branch) =
559        match target {
560            ServeTarget::ExistingDb {
561                db_path,
562                project_root,
563            } => {
564                let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
565                    command: "serve".to_owned(),
566                    reason: format!("failed to open database: {e}"),
567                })?;
568                let detected = detect_branch(&project_root);
569                let repo_info = load_repo_info(&db, &db_path)?;
570                (
571                    db_path,
572                    db,
573                    repo_info,
574                    ScanState::not_needed(),
575                    None,
576                    detected,
577                )
578            }
579            ServeTarget::AutoScan {
580                project_root,
581                db_path,
582            } => {
583                // Detect git branch before creating DB.
584                let detected = detect_branch(&project_root);
585
586                // Create empty DB (migrations auto-apply).
587                let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
588                    command: "serve".to_owned(),
589                    reason: format!("failed to create database: {e}"),
590                })?;
591                tracing::info!(
592                    project_root = %project_root.display(),
593                    db_path = %db_path.display(),
594                    detected_branch = %detected,
595                    "No existing DB found — starting auto-scan"
596                );
597
598                // Create scan state before the discovery check so that any early
599                // error paths can still transition it to Failed.
600                let scan_state = ScanState::in_progress();
601
602                // File count pre-check: abort auto-scan if project is too large.
603                let scan_config = config.scan.clone();
604                let auto_scan_limit = scan_config.auto_scan_limit;
605                match seshat_scanner::discover_files(&project_root, &scan_config) {
606                    Ok(discovery_result) => {
607                        let file_count = discovery_result.files.len();
608
609                        if file_count > auto_scan_limit {
610                            scan_state.mark_failed(format!(
611                            "Project too large for auto-scan ({} files). Run: seshat scan --verbose",
612                            file_count
613                        ));
614                            let repo_info = load_repo_info(&db, &db_path)?;
615                            (db_path, db, repo_info, scan_state, None, detected)
616                        } else {
617                            let repo_info = load_repo_info(&db, &db_path)?;
618                            (
619                                db_path,
620                                db,
621                                repo_info,
622                                scan_state,
623                                Some(project_root),
624                                detected,
625                            )
626                        }
627                    }
628                    Err(e) => {
629                        // Discovery failed — continue with empty DB.
630                        // MCP calls will get AUTO_SCAN_FAILED error.
631                        scan_state.mark_failed(format!("auto-scan discovery failed: {e}"));
632                        let repo_info = load_repo_info(&db, &db_path)?;
633                        (db_path, db, repo_info, scan_state, None, detected)
634                    }
635                }
636            }
637        };
638
639    // -- Handle branch switching / snapshots --------------------------
640    let is_auto_scan = auto_scan_project_root.is_some();
641    let old_branch_for_sync = if is_auto_scan {
642        None
643    } else {
644        Some(repo_info.branch.0.clone())
645    };
646
647    let final_branch = if is_auto_scan {
648        handle_auto_scan_snapshot(&db, &detected_branch)?
649    } else {
650        handle_branch_switch(&db, &detected_branch, &repo_info.branch, is_auto_scan)?
651    };
652
653    // Update repo_info.branch to reflect the actual branch after any switch.
654    repo_info.branch = final_branch.clone();
655
656    // -- Resolve the project root used for git operations and sync --------
657    // Auto-scan owns its own root; otherwise use the shared sync_root_for
658    // helper from cwd. This routes through the same fallback semantics as
659    // ResolvedProject::sync_root (git common-dir, else cwd verbatim).
660    let sync_root = match &auto_scan_project_root {
661        Some(root) => root.clone(),
662        None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
663    };
664
665    // -- Detect HEAD change since last scan (US-010) ----------------------
666    // For the auto-scan path, scan_project below is the scan; running an
667    // additional sync on top would race with it. For the existing-DB path,
668    // compare branches.last_scanned_commit against git rev-parse HEAD;
669    // git-unavailable is treated as "no change" per AC#2.
670    let head_change_hint: Option<String> = if is_auto_scan {
671        None
672    } else {
673        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
674        match seshat_scanner::check_branch_freshness(&branch_repo, &sync_root, &final_branch) {
675            seshat_scanner::FreshnessCheck::UpToDate
676            | seshat_scanner::FreshnessCheck::GitUnavailable => None,
677            seshat_scanner::FreshnessCheck::Stale {
678                old_commit,
679                new_commit,
680            } => {
681                let old_short = old_commit
682                    .as_deref()
683                    .map(|c| c.chars().take(7).collect::<String>())
684                    .unwrap_or_else(|| "(none)".to_owned());
685                let new_short: String = new_commit.chars().take(7).collect();
686                tracing::info!(
687                    branch = %final_branch.0,
688                    old_head = %old_short,
689                    new_head = %new_short,
690                    "serve: detected HEAD change since last scan — triggering background sync"
691                );
692                old_commit
693            }
694        }
695    };
696
697    // -- Shared sync flag for MCP metadata ---------------------------------
698    let sync_in_progress = Arc::new(AtomicBool::new(false));
699    // -- Concurrent switch guard (prevents multiple parallel branch switches) --
700    let switch_in_progress = Arc::new(AtomicBool::new(false));
701
702    // -- Background diff-based sync (branch switch and/or HEAD change) ----
703    let sync_old_branch = old_branch_for_sync.filter(|b| *b != final_branch.0);
704    let needs_sync = sync_old_branch.is_some() || head_change_hint.is_some();
705    // Resolve the old-side hint for tree-diff. Branch-switch wins (it carries
706    // a refs/heads/<name> that gix resolves directly); on a same-branch HEAD
707    // change the last_scanned_commit hash works as a commit-ish via the
708    // ObjectId fallback in `resolve_branch_tree_paths`. None on the HEAD-change
709    // path means there was no recorded sentinel — background_sync will fall
710    // through to a full rescan when the new-branch tree itself is unresolvable.
711    let sync_old_hint: Option<String> =
712        sync_old_branch.clone().or_else(|| head_change_hint.clone());
713
714    if needs_sync {
715        let sync_root_clone = sync_root.clone();
716        let sync_db_path = db_path.clone();
717        let sync_branch = final_branch.clone();
718        let sync_scan_config = config.scan.clone();
719        let sync_detection_config = config.detection.clone();
720        let sync_flag = sync_in_progress.clone();
721        std::thread::spawn(move || {
722            struct ClearOnDrop(Arc<AtomicBool>);
723            impl Drop for ClearOnDrop {
724                fn drop(&mut self) {
725                    self.0.store(false, Ordering::Relaxed);
726                }
727            }
728            sync_flag.store(true, Ordering::Relaxed);
729            let _guard = ClearOnDrop(sync_flag);
730            let sync_db = match Database::open(&sync_db_path) {
731                Ok(d) => d,
732                Err(e) => {
733                    tracing::error!(error = %e, "background_sync: failed to open DB");
734                    return;
735                }
736            };
737            background_sync(
738                &sync_root_clone,
739                sync_old_hint.as_deref(),
740                &sync_branch.0,
741                &sync_db,
742                &sync_branch,
743                &sync_scan_config,
744                &sync_detection_config,
745            );
746        });
747    }
748
749    // -- Run branch snapshot garbage collection -----------------------
750    // Same resolution as `sync_root` above — branch GC reads git refs to
751    // decide which DB-side branches no longer exist on disk.
752    let gc_repo_path = match &auto_scan_project_root {
753        Some(root) => root.clone(),
754        None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
755    };
756    if let Ok(deleted) = gc_branch_snapshots(&db, &gc_repo_path) {
757        if !deleted.is_empty() {
758            tracing::info!(
759                deleted_count = deleted.len(),
760                deleted_branches = ?deleted,
761                "Garbage collected orphan branch snapshots on startup"
762            );
763        }
764    }
765
766    // -- Load submodule connections -----------------------------------
767    let submodule_rows = load_submodule_rows(&db);
768    let submodules = open_submodule_connections(&submodule_rows, &repo_info.name);
769
770    // -- Resolve call log path ----------------------------------------
771    let call_log_path = resolve_call_log_path(call_log, config.server.call_log.as_deref());
772
773    // -- Create embedding provider (optional) -------------------------
774    let embedding_provider: Option<Arc<dyn seshat_embedding::EmbeddingProvider>> =
775        config.embedding.as_ref().and_then(|emb_config| {
776            match seshat_embedding::create_provider(emb_config) {
777                Ok(provider) => {
778                    tracing::info!("Embedding provider enabled: {emb_config}");
779                    Some(Arc::from(provider))
780                }
781                Err(e) => {
782                    tracing::warn!("Failed to create embedding provider: {e}");
783                    eprintln!("  Warning: embedding provider unavailable: {e}");
784                    None
785                }
786            }
787        });
788
789    // -- Start MCP server (async via tokio) ---------------------------
790    let server_config = config.server.clone();
791    let _start = Instant::now();
792
793    let runtime = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
794        command: "serve".to_owned(),
795        reason: format!("failed to create tokio runtime: {e}"),
796    })?;
797
798    let root = ProjectConnection::new(
799        db.connection().clone(),
800        repo_info.name.clone(),
801        detected_branch.clone(),
802    );
803
804    // Derive project root: use the auto-scan root if available.
805    // Otherwise use the current working directory — for git worktrees, cwd is
806    // the worktree checkout directory, which is what we need for file diffing.
807    // find_git_root would walk up to the main repo root, which is wrong for
808    // worktrees (they live under a different path than the main .git dir).
809    let project_root = match &auto_scan_project_root {
810        Some(root) => root.clone(),
811        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
812    };
813
814    let watcher_enabled = config.watcher.enabled;
815    let watcher_params = WatcherParams {
816        enabled: watcher_enabled,
817        debounce_ms: config.watcher.debounce_ms,
818        ignore_patterns: config.watcher.ignore_patterns.clone(),
819        warm_tier_interval_seconds: config.watcher.warm_tier_interval_seconds,
820        bulk_change_threshold: config.watcher.bulk_change_threshold,
821    };
822    let watcher_scan_config = config.scan.clone();
823    let watcher_detection_config = config.detection.clone();
824
825    let has_auto_scan = auto_scan_project_root.is_some();
826    let auto_scan_root = auto_scan_project_root.clone();
827
828    runtime
829        .block_on(async {
830            let scan_state_clone = scan_state.clone();
831
832            // -- Launch background scan (if auto-scan) ------------------
833            if let Some(scan_root) = auto_scan_root.clone() {
834                let scan_config = config.scan.clone();
835                let scan_db = db.clone();
836                let scan_branch = detected_branch.clone();
837                tokio::spawn(async move {
838                    let branch = seshat_core::BranchId::from(scan_branch);
839                    let result = tokio::task::spawn_blocking(move || {
840                        scan_project(&scan_root, &scan_config, &scan_db, branch)
841                    })
842                    .await;
843                    match result {
844                        Ok(Ok(_scan_result)) => {
845                            tracing::info!("Auto-scan completed successfully");
846                            scan_state_clone.mark_complete();
847                        }
848                        Ok(Err(scan_err)) => {
849                            tracing::error!("Auto-scan failed: {scan_err}");
850                            scan_state_clone.mark_failed(scan_err.to_string());
851                        }
852                        Err(join_err) => {
853                            tracing::error!("Auto-scan task panicked: {join_err}");
854                            scan_state_clone.mark_failed(join_err.to_string());
855                        }
856                    }
857                });
858            }
859
860            // -- Launch periodic GC background task -------------------
861            let gc_db = db.clone();
862            let gc_repo_path = gc_repo_path.clone();
863            let (gc_shutdown_tx, mut gc_shutdown_rx) = oneshot::channel();
864            let gc_task = tokio::spawn(async move {
865                let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
866                loop {
867                    tokio::select! {
868                        _ = interval.tick() => {
869                            let db_clone = gc_db.clone();
870                            let path_clone = gc_repo_path.clone();
871                            match tokio::task::spawn_blocking(move || {
872                                gc_branch_snapshots(&db_clone, &path_clone)
873                            })
874                            .await
875                            {
876                                Ok(Ok(deleted_list)) => {
877                                    if !deleted_list.is_empty() {
878                                        tracing::info!(
879                                            deleted_count = deleted_list.len(),
880                                            deleted_branches = ?deleted_list,
881                                            "Periodic branch snapshot garbage collection"
882                                        );
883                                    }
884                                }
885                                Ok(Err(e)) => {
886                                    tracing::error!(error = %e, "Periodic GC failed");
887                                }
888                                Err(join_err) => {
889                                    tracing::error!(error = %join_err, "Periodic GC task panicked");
890                                }
891                            }
892                        }
893                        _ = &mut gc_shutdown_rx => {
894                            tracing::debug!("GC background task shutting down");
895                            break;
896                        }
897                    }
898                }
899            });
900            let gc_handle = GcHandle {
901                shutdown_tx: gc_shutdown_tx,
902                task: gc_task,
903            };
904
905            // -- Start watcher (delayed if auto-scan) ------------------
906            // When auto-scan is in progress, watcher must wait for scan to
907            // complete before starting (it needs a populated DB).
908            //
909            // P0 guardrail (see PRD US-004): refuse to spawn the watcher
910            // task when the auto-scan has already failed. `notify-debouncer-full`
911            // recursively walks the project root on init, which is what
912            // blew up to 91.8 GB on a dangerous cwd in the original report.
913            let watcher_rx = if watcher_should_start(watcher_enabled, &scan_state) {
914                let (watcher_tx, watcher_rx) = tokio::sync::oneshot::channel();
915                let params = watcher_params;
916                let root = project_root.clone();
917                let db_p = db_path.clone();
918                let conn = db.connection().clone();
919                let branch = BranchId::from(detected_branch.as_str());
920                let wait_scan = scan_state.clone();
921
922                let on_branch_switch: Arc<dyn Fn() + Send + Sync + 'static> = {
923                    let root_clone = project_root.clone();
924                    let db_path_clone = db_path.clone();
925                    let scan_cfg_clone = watcher_scan_config.clone();
926                    let detect_cfg_clone = watcher_detection_config.clone();
927                    let sync_flag = sync_in_progress.clone();
928                    let switch_guard = switch_in_progress.clone();
929                    Arc::new(move || {
930                        // CAS guard: skip if another switch is already in progress.
931                        if switch_guard
932                            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
933                            .is_err()
934                        {
935                            tracing::debug!("Branch switch already in progress — skipping duplicate event");
936                            return;
937                        }
938                        let root = root_clone.clone();
939                        let db_path = db_path_clone.clone();
940                        let scan_cfg = scan_cfg_clone.clone();
941                        let detect_cfg = detect_cfg_clone.clone();
942                        let sync_flag = sync_flag.clone();
943                        let switch_guard = switch_guard.clone();
944                        std::thread::spawn(move || {
945                            struct ClearOnDrop(Arc<AtomicBool>);
946                            impl Drop for ClearOnDrop {
947                                fn drop(&mut self) {
948                                    self.0.store(false, Ordering::Relaxed);
949                                }
950                            }
951                            let _guard = ClearOnDrop(switch_guard);
952                            sync_flag.store(true, Ordering::Relaxed);
953                            let _flag_guard = ClearOnDrop(sync_flag);
954                            let start = Instant::now();
955                            let new_branch = detect_branch(&root);
956                            let db = match Database::open(&db_path) {
957                                Ok(d) => d,
958                                Err(e) => {
959                                    tracing::error!(error = %e, "Failed to open DB for branch switch");
960                                    return;
961                                }
962                            };
963                            let branch_repo = SqliteBranchRepository::new(db.connection().clone());
964                            let current_branch = branch_repo
965                                .get_current_branch()
966                                .map(|b| b.0.clone())
967                                .unwrap_or_else(|e| {
968                                    tracing::debug!(error = %e, "Could not read current branch from DB, defaulting to 'main'");
969                                    "main".to_string()
970                                });
971
972                            tracing::info!(
973                                old_branch = %current_branch,
974                                new_branch = %new_branch,
975                                "Branch switch detected by watcher"
976                            );
977                            if new_branch == current_branch {
978                                tracing::debug!("Branch unchanged, no switch needed");
979                                return;
980                            }
981                            let new_id = BranchId::from(new_branch.as_str());
982                            let old_id = BranchId::from(current_branch.as_str());
983
984                            let branches = match branch_repo.list_branches() {
985                                Ok(b) => b,
986                                Err(e) => {
987                                    tracing::error!(error = %e, "Failed to list branches for switch");
988                                    return;
989                                }
990                            };
991                            let snapshot_exists = branches.iter().any(|b| b.0 == new_branch);
992                            if snapshot_exists {
993                                match branch_repo.switch_branch(&new_id) {
994                                    Ok(()) => {
995                                        let elapsed = start.elapsed();
996                                        tracing::info!(
997                                            to = %new_branch,
998                                            elapsed_ms = elapsed.as_millis(),
999                                            "Branch switch completed (instant, snapshot existed)"
1000                                        );
1001                                    }
1002                                    Err(e) => {
1003                                        tracing::error!(error = %e, "Failed to switch branch");
1004                                        return;
1005                                    }
1006                                }
1007                            } else {
1008                                tracing::info!(
1009                                    source = %current_branch,
1010                                    target = %new_branch,
1011                                    "No snapshot for target — creating"
1012                                );
1013                                match branch_repo.create_snapshot(&old_id, &new_id) {
1014                                    Ok(()) => {
1015                                        match branch_repo.switch_branch(&new_id) {
1016                                            Ok(()) => {
1017                                                let elapsed = start.elapsed();
1018                                                tracing::info!(
1019                                                    to = %new_branch,
1020                                                    elapsed_ms = elapsed.as_millis(),
1021                                                    "Branch switch completed (snapshot created)"
1022                                                );
1023                                            }
1024                                            Err(e) => {
1025                                                tracing::error!(error = %e, "Failed to switch after snapshot");
1026                                                return;
1027                                            }
1028                                        }
1029                                    }
1030                                    Err(e) => {
1031                                        tracing::error!(error = %e, "Failed to create snapshot");
1032                                        return;
1033                                    }
1034                                }
1035                            }
1036
1037                            let old_b = current_branch;
1038                            background_sync(
1039                                &root,
1040                                Some(&old_b),
1041                                &new_branch,
1042                                &db,
1043                                &new_id,
1044                                &scan_cfg,
1045                                &detect_cfg,
1046                            );
1047                        });
1048                    })
1049                };
1050
1051                tokio::spawn(async move {
1052                    // If auto-scan is in progress, wait for it to complete
1053                    // before starting the watcher.
1054                    wait_scan.wait_for_scan();
1055
1056                    // Race guard: at the time of the outer `watcher_should_start`
1057                    // check, the auto-scan was still running. It may have
1058                    // failed during the wait; re-check before constructing
1059                    // the OS watcher (which recursively walks the tree).
1060                    if let Some(msg) = wait_scan.error_message() {
1061                        tracing::info!(
1062                            error_message = %msg,
1063                            "Auto-scan failed during watcher wait; not starting file watcher",
1064                        );
1065                        let _ = watcher_tx.send(Err(WatcherError::ScanFailed(msg)));
1066                        return;
1067                    }
1068
1069                    let result = start_watcher(
1070                        params,
1071                        root,
1072                        db_p,
1073                        conn,
1074                        branch,
1075                        watcher_scan_config,
1076                        watcher_detection_config,
1077                        on_branch_switch,
1078                    )
1079                    .await;
1080                    if let Err(ref e) = result {
1081                        tracing::warn!(
1082                            "File watcher failed to start: {e}. \
1083                             Serving without incremental updates."
1084                        );
1085                    }
1086                    let _ = watcher_tx.send(result);
1087                });
1088                Some(watcher_rx)
1089            } else {
1090                None
1091            };
1092
1093            // -- Print startup banner ------------------------------------
1094            // Branch order (guards against confusing messaging when a user
1095            // disables the watcher in config AND auto-scan also fails):
1096            //
1097            //   1. `!watcher_enabled` → "disabled" (config says so)
1098            //   2. scan failed         → "disabled (auto-scan failed: …)"
1099            //   3. scan still running  → "starting (after scan)"
1100            //   4. otherwise           → "starting"
1101            //
1102            // The scan-failure branch matches on `scan_error.is_some()`
1103            // alone (without requiring `has_auto_scan`) because the
1104            // AutoScan failure path sets `auto_scan_project_root = None`,
1105            // i.e. `has_auto_scan` flips to `false` precisely on failure.
1106            // `error_message().is_some()` only ever becomes true on the
1107            // AutoScan failure path — encoded as a `debug_assert!` below
1108            // so the invariant breaks loudly in tests if anything in
1109            // `ScanState` evolves to violate it.
1110            let watcher_status: std::borrow::Cow<'_, str> = if !watcher_enabled {
1111                std::borrow::Cow::Borrowed("disabled")
1112            } else if let Some(msg) = scan_state.error_message() {
1113                debug_assert!(
1114                    !has_auto_scan,
1115                    "scan_state.error_message().is_some() should imply has_auto_scan=false \
1116                     (the AutoScan failure branch sets auto_scan_project_root=None)"
1117                );
1118                std::borrow::Cow::Owned(format!("disabled (auto-scan failed: {msg})"))
1119            } else if has_auto_scan && !scan_state.auto_scanned() {
1120                std::borrow::Cow::Borrowed("starting (after scan)")
1121            } else {
1122                std::borrow::Cow::Borrowed("starting")
1123            };
1124            print_startup(
1125                &repo_info,
1126                &submodules,
1127                &config,
1128                call_log_path.as_deref(),
1129                &watcher_status,
1130                is_auto_scan,
1131                &detected_branch,
1132            );
1133
1134            // -- Run MCP server -----------------------------------------
1135            let detached_head = final_branch.0.len() >= 7
1136                && final_branch.0.chars().all(|c| c.is_ascii_hexdigit());
1137
1138            let shutdown = async {
1139                tokio::signal::ctrl_c()
1140                    .await
1141                    .expect("failed to listen for Ctrl+C");
1142                eprintln!();
1143                eprintln!("Shutting down...");
1144            };
1145
1146            let result = seshat_mcp::start_stdio_with_shutdown(
1147                server_config,
1148                root,
1149                submodules,
1150                call_log_path,
1151                embedding_provider,
1152                scan_state,
1153                sync_in_progress.clone(),
1154                true,
1155                detached_head,
1156                project_root.clone(),
1157                shutdown,
1158                std::time::Duration::from_secs(5),
1159            )
1160            .await;
1161
1162            // -- Shutdown GC background task ------------------------------
1163            drop(gc_handle);
1164
1165            // -- Shutdown watcher ---------------------------------------
1166            if let Some(mut rx) = watcher_rx {
1167                if let Ok(Ok(handle)) = rx.try_recv() {
1168                    handle.shutdown().await;
1169                }
1170            }
1171
1172            result
1173        })
1174        .map_err(|e| CliError::CommandFailed {
1175            command: "serve".to_owned(),
1176            reason: format!("MCP server error: {e}"),
1177        })
1178}
1179
1180/// Load repository metadata from the database for startup display.
1181fn load_repo_info(db: &Database, db_path: &Path) -> Result<RepoInfo, CliError> {
1182    let name = db_path
1183        .file_stem()
1184        .map(|s| s.to_string_lossy().to_string())
1185        .unwrap_or_else(|| "unknown".to_owned());
1186
1187    let info = crate::db::load_project_info(db);
1188
1189    Ok(RepoInfo {
1190        name,
1191        db_path: db_path.to_path_buf(),
1192        branch: info.branch,
1193        file_count: info.file_count,
1194        convention_count: info.convention_count,
1195    })
1196}
1197
1198/// Load the list of submodule rows from the root database.
1199///
1200/// Returns an empty `Vec` if the query fails (e.g. empty DB, no submodules
1201/// table data).
1202fn load_submodule_rows(db: &Database) -> Vec<SubmoduleRow> {
1203    let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1204    match sub_repo.list() {
1205        Ok(rows) => rows,
1206        Err(e) => {
1207            eprintln!(
1208                "  Warning: could not read submodules table: {e}. Continuing without submodules."
1209            );
1210            Vec::new()
1211        }
1212    }
1213}
1214
1215/// Open database connections for each submodule and build the `ProjectConnection` map.
1216///
1217/// For each submodule row, resolves the DB path, opens the database, reads its
1218/// branch, and wraps it in a `ProjectConnection`. If a submodule DB is missing
1219/// or fails to open, a warning is logged and that submodule is skipped.
1220fn open_submodule_connections(
1221    rows: &[SubmoduleRow],
1222    root_project_name: &str,
1223) -> HashMap<String, ProjectConnection> {
1224    let mut submodules = HashMap::new();
1225
1226    for row in rows {
1227        let db_path =
1228            match crate::db::resolve_submodule_db_path(root_project_name, &row.relative_path) {
1229                Ok(p) => p,
1230                Err(e) => {
1231                    eprintln!(
1232                        "  Warning: could not resolve DB path for submodule '{}': {e}. Skipping.",
1233                        row.relative_path
1234                    );
1235                    continue;
1236                }
1237            };
1238
1239        if !db_path.exists() {
1240            eprintln!(
1241                "  Warning: submodule DB not found at '{}'. Skipping '{}'.",
1242                db_path.display(),
1243                row.relative_path
1244            );
1245            continue;
1246        }
1247
1248        let db = match Database::open(&db_path) {
1249            Ok(d) => d,
1250            Err(e) => {
1251                eprintln!(
1252                    "  Warning: failed to open submodule DB '{}': {e}. Skipping '{}'.",
1253                    db_path.display(),
1254                    row.relative_path
1255                );
1256                continue;
1257            }
1258        };
1259
1260        // Read the submodule's branch (default to "main" if not set).
1261        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1262        let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
1263            tracing::debug!("Could not detect submodule branch from DB, defaulting to 'main'");
1264            BranchId::from("main")
1265        });
1266
1267        let pc = ProjectConnection::new(
1268            db.connection().clone(),
1269            row.relative_path.clone(),
1270            branch.to_string(),
1271        );
1272
1273        submodules.insert(row.relative_path.clone(), pc);
1274    }
1275
1276    submodules
1277}
1278
1279/// Print the startup information block to stderr.
1280fn print_startup(
1281    info: &RepoInfo,
1282    submodules: &HashMap<String, ProjectConnection>,
1283    config: &AppConfig,
1284    call_log_path: Option<&Path>,
1285    watcher_status: &str,
1286    auto_scanning: bool,
1287    detected_branch: &str,
1288) {
1289    eprintln!("seshat v{}", env!("CARGO_PKG_VERSION"));
1290    eprintln!();
1291    eprintln!("  Repo:         {}", info.name);
1292    eprintln!("  Branch:       {}", detected_branch);
1293    if auto_scanning {
1294        eprintln!("  Files:        0 (auto-scanning...)");
1295    } else {
1296        eprintln!("  Files:        {}", info.file_count);
1297    }
1298    eprintln!("  Conventions:  {}", info.convention_count);
1299    eprintln!("  Database:     {}", info.db_path.display());
1300    eprintln!("  Watcher:      {watcher_status}");
1301
1302    if submodules.is_empty() {
1303        eprintln!("  Submodules:   none");
1304    } else {
1305        eprintln!("  Submodules:   {}", submodules.len());
1306        let mut names: Vec<&String> = submodules.keys().collect();
1307        names.sort();
1308        for name in names {
1309            eprintln!("    - {name}");
1310        }
1311    }
1312
1313    if let Some(path) = call_log_path {
1314        eprintln!("  Call log:     {}", path.display());
1315    }
1316
1317    eprintln!();
1318    eprintln!(
1319        "  Transport:    stdio ({}:{})",
1320        config.server.host, config.server.port
1321    );
1322    eprintln!();
1323    eprintln!("Ready. Waiting for MCP client connection...");
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329    use seshat_core::DetectionConfig;
1330    use std::collections::HashMap;
1331
1332    #[test]
1333    fn load_repo_info_empty_db() {
1334        // Verify that load_repo_info works with an empty in-memory DB.
1335        let db = Database::open(":memory:").expect("in-memory db");
1336        let path = PathBuf::from("/tmp/test-seshat-project.db");
1337        let info = load_repo_info(&db, &path).expect("should succeed with empty db");
1338        assert_eq!(info.name, "test-seshat-project");
1339        assert_eq!(info.file_count, 0);
1340        assert_eq!(info.convention_count, 0);
1341        assert_eq!(info.branch, BranchId::from("main"));
1342    }
1343
1344    #[test]
1345    fn load_submodule_rows_empty_db() {
1346        let db = Database::open(":memory:").expect("in-memory db");
1347        let rows = load_submodule_rows(&db);
1348        assert!(rows.is_empty());
1349    }
1350
1351    #[test]
1352    fn load_submodule_rows_with_data() {
1353        use seshat_storage::{SqliteSubmoduleRepository, SubmoduleInput, SubmoduleRepository};
1354
1355        let db = Database::open(":memory:").expect("in-memory db");
1356        let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1357        sub_repo
1358            .insert(&SubmoduleInput {
1359                relative_path: "vendor/libfoo".to_string(),
1360                name: "libfoo".to_string(),
1361                db_path: "/data/seshat/repos/proj/vendor/libfoo.db".to_string(),
1362                commit_hash: Some("abc123".to_string()),
1363            })
1364            .expect("insert");
1365        sub_repo
1366            .insert(&SubmoduleInput {
1367                relative_path: "libs/core".to_string(),
1368                name: "core".to_string(),
1369                db_path: "/data/seshat/repos/proj/libs/core.db".to_string(),
1370                commit_hash: Some("def456".to_string()),
1371            })
1372            .expect("insert");
1373
1374        let rows = load_submodule_rows(&db);
1375        assert_eq!(rows.len(), 2);
1376        // list() returns sorted by relative_path
1377        assert_eq!(rows[0].relative_path, "libs/core");
1378        assert_eq!(rows[1].relative_path, "vendor/libfoo");
1379    }
1380
1381    #[test]
1382    fn open_submodule_connections_empty_rows() {
1383        let submodules = open_submodule_connections(&[], "test-project");
1384        assert!(submodules.is_empty());
1385    }
1386
1387    #[test]
1388    fn open_submodule_connections_missing_db_skipped() {
1389        let project_name = "serve-test-missing-db";
1390
1391        let row = SubmoduleRow {
1392            id: 1,
1393            relative_path: "vendor/nonexistent".to_string(),
1394            name: "nonexistent".to_string(),
1395            db_path: "/no/such/path.db".to_string(),
1396            commit_hash: Some("abc123".to_string()),
1397            created_at: "2026-04-03T00:00:00".to_string(),
1398            updated_at: "2026-04-03T00:00:00".to_string(),
1399        };
1400
1401        let submodules = open_submodule_connections(&[row], project_name);
1402        // Should be empty since the DB file doesn't exist.
1403        assert!(submodules.is_empty());
1404
1405        // Clean up directories created as side effect of resolve_submodule_db_path.
1406        if let Ok(repos) = crate::db::xdg_repos_dir() {
1407            let _ = std::fs::remove_dir_all(repos.join(project_name));
1408        }
1409    }
1410
1411    #[test]
1412    fn resolve_call_log_bare_flag_uses_default_path() {
1413        // --call-log with no value → default_missing_value="" → empty PathBuf
1414        let result = resolve_call_log_path(Some(PathBuf::from("")), None);
1415        let path = result.expect("should resolve to default path");
1416        // Normalize path separators so the assertion holds on Windows where
1417        // PathBuf renders as `…\seshat\call-log.jsonl`.
1418        let normalized = path.to_string_lossy().replace('\\', "/");
1419        assert!(
1420            normalized.ends_with("seshat/call-log.jsonl"),
1421            "expected default path to end with seshat/call-log.jsonl, got {normalized}"
1422        );
1423    }
1424
1425    #[test]
1426    fn resolve_call_log_explicit_path() {
1427        let result = resolve_call_log_path(Some(PathBuf::from("/tmp/my-log.jsonl")), None);
1428        assert_eq!(result, Some(PathBuf::from("/tmp/my-log.jsonl")));
1429    }
1430
1431    #[test]
1432    fn resolve_call_log_from_config() {
1433        let result = resolve_call_log_path(None, Some("/config/path.jsonl"));
1434        assert_eq!(result, Some(PathBuf::from("/config/path.jsonl")));
1435    }
1436
1437    #[test]
1438    fn resolve_call_log_cli_overrides_config() {
1439        let result = resolve_call_log_path(
1440            Some(PathBuf::from("/cli/path.jsonl")),
1441            Some("/config/path.jsonl"),
1442        );
1443        assert_eq!(result, Some(PathBuf::from("/cli/path.jsonl")));
1444    }
1445
1446    #[test]
1447    fn resolve_call_log_disabled_when_no_flag_and_no_config() {
1448        let result = resolve_call_log_path(None, None);
1449        assert!(result.is_none());
1450    }
1451
1452    #[test]
1453    fn open_submodule_connections_with_real_dbs() {
1454        use std::fs;
1455
1456        let project_name = "serve-test-submod";
1457        let mount_path = "vendor/testlib";
1458
1459        // resolve_submodule_db_path creates the DB in the real XDG data dir
1460        // (required because open_submodule_connections resolves paths itself).
1461        let db_path =
1462            crate::db::resolve_submodule_db_path(project_name, mount_path).expect("resolve path");
1463
1464        // RAII guard: clean up the XDG directory on drop (even on panic).
1465        struct Cleanup(PathBuf);
1466        impl Drop for Cleanup {
1467            fn drop(&mut self) {
1468                let _ = fs::remove_dir_all(&self.0);
1469            }
1470        }
1471        let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1472        let _guard = Cleanup(repos_dir.join(project_name));
1473
1474        let db = Database::open(&db_path).expect("create submodule DB");
1475        drop(db);
1476
1477        let row = SubmoduleRow {
1478            id: 1,
1479            relative_path: mount_path.to_string(),
1480            name: "testlib".to_string(),
1481            db_path: db_path.to_string_lossy().to_string(),
1482            commit_hash: Some("abc123".to_string()),
1483            created_at: "2026-04-03T00:00:00".to_string(),
1484            updated_at: "2026-04-03T00:00:00".to_string(),
1485        };
1486
1487        let submodules = open_submodule_connections(&[row], project_name);
1488        assert_eq!(submodules.len(), 1);
1489        assert!(submodules.contains_key(mount_path));
1490
1491        let pc = &submodules[mount_path];
1492        assert_eq!(pc.name, mount_path);
1493        assert_eq!(pc.branch, "main"); // default branch for empty DB
1494        // _guard drops here, cleaning up the project dir.
1495    }
1496
1497    // ── handle_auto_scan_snapshot ─────────────────────────────────────
1498
1499    #[test]
1500    fn handle_auto_scan_snapshot_main_branch_no_op() {
1501        let db = Database::open(":memory:").expect("in-memory db");
1502        let result = handle_auto_scan_snapshot(&db, "main").expect("should succeed");
1503        assert_eq!(result, BranchId::from("main"));
1504    }
1505
1506    // ── print_startup ─────────────────────────────────────────────────
1507
1508    #[test]
1509    fn print_startup_does_not_panic() {
1510        let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1511        let _ = std::fs::create_dir_all(&repos_dir);
1512        let info = RepoInfo {
1513            name: "test-project".to_string(),
1514            db_path: PathBuf::from("/tmp/test.db"),
1515            file_count: 5,
1516            convention_count: 42,
1517            branch: BranchId::from("main"),
1518        };
1519        let config = AppConfig::load().unwrap_or_default();
1520        print_startup(
1521            &info,
1522            &HashMap::new(),
1523            &config,
1524            None,
1525            "running",
1526            false,
1527            "main",
1528        );
1529    }
1530
1531    // ── RepoInfo ──────────────────────────────────────────────────────
1532
1533    #[test]
1534    fn repo_info_default_name_extraction() {
1535        let info = RepoInfo {
1536            name: "my-awesome-project".to_string(),
1537            db_path: PathBuf::from("/tmp/test.db"),
1538            file_count: 10,
1539            convention_count: 20,
1540            branch: BranchId::from("feat/bar"),
1541        };
1542        assert_eq!(info.name, "my-awesome-project");
1543        assert_eq!(info.file_count, 10);
1544        assert_eq!(info.convention_count, 20);
1545        assert_eq!(info.branch, BranchId::from("feat/bar"));
1546    }
1547
1548    // ── fallback_rescan ───────────────────────────────────────────────
1549
1550    #[test]
1551    fn fallback_rescan_empty_dir_handles_gracefully() {
1552        use tempfile::tempdir;
1553        let dir = tempdir().expect("tempdir");
1554        let db = Database::open(":memory:").expect("in-memory db");
1555        let branch = BranchId::from("main");
1556        // Empty dir — fallback_rescan should log warnings but not panic.
1557        fallback_rescan(
1558            dir.path(),
1559            &db,
1560            &branch,
1561            &ScanConfig::default(),
1562            &DetectionConfig::default(),
1563        );
1564    }
1565
1566    // ── resolve_branch_tree_paths ─────────────────────────────────────
1567
1568    #[test]
1569    fn resolve_branch_tree_paths_not_a_git_repo_returns_none() {
1570        use tempfile::tempdir;
1571        let dir = tempdir().expect("tempdir");
1572        let result = resolve_branch_tree_paths(dir.path(), "main");
1573        assert!(result.is_none());
1574    }
1575
1576    // ── handle_branch_switch ───────────────────────────────────────────
1577
1578    fn seed_branch(db: &Database, branch_name: &str) -> BranchId {
1579        let branch = BranchId::from(branch_name);
1580        let br = SqliteBranchRepository::new(db.connection().clone());
1581        br.switch_branch(&branch).unwrap();
1582        // Insert a node so list_branches returns this branch.
1583        let c = db.connection().lock().unwrap();
1584        c.execute(
1585            "INSERT INTO nodes (branch_id, nature, weight, confidence, adoption_count, total_count, description, ext_data)
1586             VALUES (?1, 'convention', 'strong', 0.9, 5, 10, 'test', '{\"source\":\"auto_detected\"}')",
1587            rusqlite::params![branch_name],
1588        ).unwrap();
1589        branch
1590    }
1591
1592    #[test]
1593    fn handle_branch_switch_same_branch_returns_current() {
1594        let db = Database::open(":memory:").expect("in-memory db");
1595        let current = BranchId::from("main");
1596        let result = handle_branch_switch(&db, "main", &current, false).unwrap();
1597        assert_eq!(result, current);
1598    }
1599
1600    #[test]
1601    fn handle_branch_switch_target_has_data_no_snapshot() {
1602        let db = Database::open(":memory:").expect("in-memory db");
1603        let current = BranchId::from("main");
1604        seed_branch(&db, "feat/test");
1605        let result = handle_branch_switch(&db, "feat/test", &current, false).unwrap();
1606        assert_eq!(result, BranchId::from("feat/test"));
1607    }
1608
1609    #[test]
1610    fn handle_branch_switch_source_no_data_still_switches() {
1611        let db = Database::open(":memory:").expect("in-memory db");
1612        let current = BranchId::from("main");
1613        let result = handle_branch_switch(&db, "feat/empty", &current, false).unwrap();
1614        assert_eq!(result, BranchId::from("feat/empty"));
1615    }
1616
1617    #[test]
1618    fn handle_branch_switch_source_has_data_creates_snapshot() {
1619        let db = Database::open(":memory:").expect("in-memory db");
1620        let current = BranchId::from("main");
1621        seed_branch(&db, "main");
1622        let result = handle_branch_switch(&db, "feat/snap", &current, false).unwrap();
1623        assert_eq!(result, BranchId::from("feat/snap"));
1624        // Snapshot created — verify feat/snap now has nodes.
1625        let br = SqliteBranchRepository::new(db.connection().clone());
1626        let branches = br.list_branches().unwrap();
1627        assert!(branches.iter().any(|b| b.0 == "feat/snap"));
1628    }
1629
1630    // ── handle_auto_scan_snapshot ───────────────────────────────────────
1631
1632    #[test]
1633    fn auto_scan_snapshot_non_main_no_main_data_still_switches() {
1634        let db = Database::open(":memory:").expect("in-memory db");
1635        let result = handle_auto_scan_snapshot(&db, "feat/bar").unwrap();
1636        assert_eq!(result, BranchId::from("feat/bar"));
1637    }
1638
1639    #[test]
1640    fn auto_scan_snapshot_non_main_with_main_data_creates_snapshot() {
1641        let db = Database::open(":memory:").expect("in-memory db");
1642        seed_branch(&db, "main");
1643        let result = handle_auto_scan_snapshot(&db, "feat/baz").unwrap();
1644        assert_eq!(result, BranchId::from("feat/baz"));
1645        let br = SqliteBranchRepository::new(db.connection().clone());
1646        let branches = br.list_branches().unwrap();
1647        assert!(branches.iter().any(|b| b.0 == "feat/baz"));
1648    }
1649
1650    // ── watcher_should_start (P0 guardrail, US-004) ───────────────────
1651
1652    #[test]
1653    fn watcher_should_start_disabled_returns_false_regardless_of_scan_state() {
1654        // Even with a healthy scan_state, a disabled config blocks the watcher.
1655        let state_ok = ScanState::not_needed();
1656        assert!(!watcher_should_start(false, &state_ok));
1657
1658        let state_complete = ScanState::in_progress();
1659        state_complete.mark_complete();
1660        assert!(!watcher_should_start(false, &state_complete));
1661    }
1662
1663    #[test]
1664    fn watcher_should_start_enabled_with_no_scan_returns_true() {
1665        // ExistingDb path: ScanState::not_needed() — no auto-scan ran,
1666        // watcher should start as before this guardrail existed.
1667        let state = ScanState::not_needed();
1668        assert!(watcher_should_start(true, &state));
1669    }
1670
1671    #[test]
1672    fn watcher_should_start_enabled_with_completed_scan_returns_true() {
1673        // AutoScan happy path: scan finished successfully → watcher starts.
1674        let state = ScanState::in_progress();
1675        state.mark_complete();
1676        assert!(watcher_should_start(true, &state));
1677    }
1678
1679    #[test]
1680    fn watcher_should_start_enabled_with_in_progress_scan_returns_true() {
1681        // AutoScan in-progress path: outer gate decides to spawn the
1682        // watcher task NOW; the spawned task waits for completion via
1683        // wait_for_scan() and re-checks error_message() before walking.
1684        let state = ScanState::in_progress();
1685        assert!(watcher_should_start(true, &state));
1686    }
1687
1688    #[test]
1689    fn watcher_should_start_enabled_with_failed_scan_returns_false() {
1690        // P0: this is the bug class US-004 closes. A failed auto-scan
1691        // means we must NOT construct notify-debouncer-full / walk the tree.
1692        let state = ScanState::in_progress();
1693        state.mark_failed("project too large".to_owned());
1694        assert!(!watcher_should_start(true, &state));
1695    }
1696
1697    #[test]
1698    fn watcher_should_start_disabled_with_failed_scan_returns_false() {
1699        // Belt-and-suspenders: both gates closed.
1700        let state = ScanState::in_progress();
1701        state.mark_failed("scan timeout".to_owned());
1702        assert!(!watcher_should_start(false, &state));
1703    }
1704
1705    // ── Race-guard re-check inside spawned watcher task (FR-5) ─────────
1706    //
1707    // The outer `watcher_should_start` gate may pass while scan is still
1708    // `InProgress` — the spawned task then `wait_for_scan()`s. If the scan
1709    // transitions to `Failed` during that wait, the inner re-check
1710    // (`error_message().is_some()`) must catch it BEFORE `start_watcher`
1711    // gets to construct `notify-debouncer-full` and walk the tree.
1712    //
1713    // We can't drive the actual `tokio::spawn` block from here without
1714    // standing up the full serve flow, so we exercise the underlying
1715    // `ScanState` synchronisation pattern directly.
1716
1717    #[test]
1718    fn race_guard_pattern_detects_pre_wait_failure() {
1719        // Failure was set BEFORE wait_for_scan returns: the post-wait
1720        // error_message() check must surface it.
1721        let state = ScanState::in_progress();
1722        state.mark_failed("simulated pre-wait failure".to_owned());
1723        state.wait_for_scan(); // returns immediately — not InProgress anymore
1724        assert_eq!(
1725            state.error_message(),
1726            Some("simulated pre-wait failure".to_owned())
1727        );
1728    }
1729
1730    #[test]
1731    fn race_guard_pattern_returns_none_for_normal_completion() {
1732        let state = ScanState::in_progress();
1733        state.mark_complete();
1734        state.wait_for_scan();
1735        assert_eq!(state.error_message(), None);
1736    }
1737
1738    #[test]
1739    fn race_guard_pattern_observes_failure_set_during_wait() {
1740        // Honest race test: thread A enters wait_for_scan while state is
1741        // InProgress; thread B then mark_fails. A must wake up, observe
1742        // the failure via error_message(), and return Some(reason).
1743        use std::sync::Arc;
1744        use std::thread;
1745        use std::time::Duration;
1746
1747        let state = ScanState::in_progress();
1748        let waiter_state = state.clone();
1749        let observed: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1750        let observed_for_thread = Arc::clone(&observed);
1751        let waiter = thread::spawn(move || {
1752            waiter_state.wait_for_scan();
1753            *observed_for_thread.lock().expect("lock") = waiter_state.error_message();
1754        });
1755
1756        // Give the waiter time to enter `wait_for_scan` and park on the
1757        // condvar. A short sleep is fine because the waiter blocks until
1758        // we notify via mark_failed.
1759        thread::sleep(Duration::from_millis(50));
1760        state.mark_failed("simulated late failure".to_owned());
1761
1762        waiter.join().expect("waiter thread join");
1763        let captured = observed.lock().expect("lock").clone();
1764        assert_eq!(captured, Some("simulated late failure".to_owned()));
1765    }
1766}