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