use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use seshat_core::{BranchId, Language, ScanConfig};
use seshat_mcp::{ProjectConnection, ScanState};
use seshat_scanner::{read_and_parse_file, record_branch_scan_complete, scan_project};
use seshat_storage::{
BranchRepository, Database, FileIRRepository, SqliteBranchRepository, SqliteFileIRRepository,
SqliteSubmoduleRepository, SubmoduleRepository, SubmoduleRow,
};
use seshat_watcher::{WatcherError, WatcherParams, start_watcher};
use tokio::sync::oneshot;
use crate::config::AppConfig;
use crate::db::{ServeTarget, detect_branch, gc_branch_snapshots};
use crate::error::CliError;
pub struct GcHandle {
shutdown_tx: oneshot::Sender<()>,
task: tokio::task::JoinHandle<()>,
}
impl GcHandle {
pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(());
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), self.task).await;
}
}
struct RepoInfo {
name: String,
db_path: PathBuf,
branch: BranchId,
file_count: usize,
convention_count: usize,
}
fn resolve_call_log_path(cli_flag: Option<PathBuf>, config_value: Option<&str>) -> Option<PathBuf> {
match cli_flag {
Some(p) if p.as_os_str().is_empty() => {
let data_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
Some(data_dir.join("seshat").join("call-log.jsonl"))
}
Some(p) => Some(p),
None => config_value.map(PathBuf::from),
}
}
fn watcher_should_start(enabled: bool, state: &ScanState) -> bool {
enabled && state.error_message().is_none()
}
fn handle_branch_switch(
db: &Database,
detected_branch: &str,
current_branch: &BranchId,
_is_auto_scan: bool,
) -> Result<BranchId, CliError> {
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
if detected_branch == current_branch.0 {
return Ok(current_branch.clone());
}
let detected_id = BranchId::from(detected_branch);
let branches = branch_repo
.list_branches()
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to list branches: {e}"),
})?;
let target_has_data = branches.iter().any(|b| b.0 == detected_branch);
if !target_has_data {
let source_branch = current_branch.clone();
let source_branches = branch_repo
.list_branches()
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to list branches: {e}"),
})?;
let source_has_data = source_branches.iter().any(|b| b.0 == source_branch.0);
if !source_has_data {
tracing::info!(
source_branch = %source_branch.0,
target_branch = %detected_branch,
"Source branch has no data — switching without snapshot"
);
} else {
tracing::info!(
source_branch = %source_branch.0,
target_branch = %detected_branch,
"Target branch has no data — creating snapshot from source"
);
branch_repo
.create_snapshot(&source_branch, &detected_id)
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to create snapshot: {e}"),
})?;
}
}
tracing::info!(
from = %current_branch.0,
to = %detected_branch,
"Switching branch"
);
branch_repo
.switch_branch(&detected_id)
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to switch branch: {e}"),
})?;
Ok(detected_id)
}
fn handle_auto_scan_snapshot(db: &Database, detected_branch: &str) -> Result<BranchId, CliError> {
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
if detected_branch == "main" {
return Ok(BranchId::from(detected_branch));
}
let detected_id = BranchId::from(detected_branch);
let branches = branch_repo
.list_branches()
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to list branches: {e}"),
})?;
let main_has_data = branches.iter().any(|b| b.0 == "main");
if !main_has_data {
return Ok(detected_id);
}
let main_branch = BranchId::from("main");
tracing::info!(
source_branch = "main",
target_branch = %detected_branch,
"Auto-scan on non-main branch — creating snapshot from main"
);
branch_repo
.create_snapshot(&main_branch, &detected_id)
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to create snapshot: {e}"),
})?;
branch_repo
.switch_branch(&detected_id)
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to switch branch: {e}"),
})?;
Ok(detected_id)
}
#[allow(clippy::too_many_arguments)]
fn background_sync(
project_root: &Path,
sync_root: &Path,
old_branch: Option<&str>,
new_branch: &str,
db: &Database,
branch_id: &BranchId,
scan_config: &ScanConfig,
detection_config: &seshat_core::DetectionConfig,
) {
incremental_sync_blocking(
project_root,
sync_root,
old_branch,
new_branch,
db,
branch_id,
scan_config,
detection_config,
None,
);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn incremental_sync_blocking(
project_root: &Path,
sync_root: &Path,
old_branch: Option<&str>,
new_branch: &str,
db: &Database,
branch_id: &BranchId,
scan_config: &ScanConfig,
detection_config: &seshat_core::DetectionConfig,
progress: Option<&dyn Fn(usize, usize)>,
) {
let new_paths = match resolve_branch_tree_paths(sync_root, new_branch) {
Some(p) => p,
None => {
tracing::warn!(
"incremental_sync_blocking: could not resolve new branch tree, falling back to full rescan"
);
fallback_rescan(project_root, db, branch_id, scan_config, detection_config);
return;
}
};
let old_paths = old_branch.and_then(|b| resolve_branch_tree_paths(sync_root, b));
let file_ir_repo = SqliteFileIRRepository::new(db.connection().clone());
let exclude_set = if scan_config.exclude_paths.is_empty() {
None
} else {
let mut builder = globset::GlobSetBuilder::new();
for p in &scan_config.exclude_paths {
match globset::Glob::new(p) {
Ok(g) => {
builder.add(g);
}
Err(e) => {
tracing::warn!(pattern = %p, error = %e, "incremental_sync_blocking: invalid exclude pattern");
}
}
}
match builder.build() {
Ok(set) => Some(set),
Err(e) => {
tracing::warn!(error = %e, "incremental_sync_blocking: failed to build exclude globset");
None
}
}
};
let total = new_paths.len();
let mut synced = 0usize;
let mut removed = 0usize;
let mut source_map: HashMap<PathBuf, String> = HashMap::with_capacity(total);
for (idx, (rel_path, oid)) in new_paths.iter().enumerate() {
if let Some(cb) = progress {
cb(idx, total);
}
let path_str = rel_path.as_str();
let abs_path = project_root.join(rel_path);
let stored_path = PathBuf::from(rel_path);
let ext = match abs_path.extension().and_then(|e| e.to_str()) {
Some(e) => e,
None => continue,
};
let language = match Language::from_extension(ext) {
Some(l) => l,
None => continue,
};
if let Some(ref exclude_set) = exclude_set {
if exclude_set.is_match(&abs_path) {
continue;
}
}
let max_bytes = scan_config.max_file_size_kb * 1024;
if max_bytes > 0 {
if let Ok(meta) = std::fs::metadata(&abs_path) {
if meta.len() > max_bytes {
continue;
}
}
}
let oid_unchanged = old_paths
.as_ref()
.is_some_and(|old| old.get(path_str) == Some(oid));
let (project_file, source) = match read_and_parse_file(
&abs_path,
&stored_path,
language,
&scan_config.local_packages,
) {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(path = %abs_path.display(), error = %e, "incremental_sync_blocking: cannot read file");
continue;
}
};
if !oid_unchanged {
if let Err(e) = file_ir_repo.upsert_with_symbol_index(branch_id, &project_file, None) {
tracing::error!(
path = %path_str,
error = %e,
"incremental_sync_blocking: upsert failed — symbol-index may be inconsistent for this file until next save",
);
}
synced += 1;
}
source_map.insert(stored_path, source);
}
if let Some(cb) = progress {
cb(total, total);
}
if let Some(ref old) = old_paths {
for rel_path in old.keys() {
if !new_paths.contains_key(rel_path.as_str()) {
let path_str = rel_path.as_str();
if let Err(e) = file_ir_repo.delete_with_symbol_index(branch_id, path_str) {
match &e {
seshat_storage::StorageError::NotFound { .. } => {}
_ => {
tracing::error!(
path = %path_str,
error = %e,
"incremental_sync_blocking: delete failed — orphan symbol-index rows may remain",
);
}
}
}
removed += 1;
}
}
}
tracing::info!(
synced = synced,
removed = removed,
new_total = new_paths.len(),
old_branch = ?old_branch,
new_branch = %new_branch,
"incremental_sync_blocking: completed diff-based sync"
);
if synced > 0 || removed > 0 {
let conn = db.connection().clone();
let file_dates = SqliteFileIRRepository::new(conn.clone())
.get_file_dates_by_branch(branch_id)
.unwrap_or_default()
.into_iter()
.collect::<HashMap<_, _>>();
match seshat_graph::run_detection_cycle(
&conn,
branch_id,
detection_config,
&file_dates,
&source_map,
) {
Ok(_) => tracing::info!("incremental_sync_blocking: detection cycle complete"),
Err(e) => {
tracing::warn!(error = %e, "incremental_sync_blocking: detection cycle failed")
}
}
} else {
tracing::debug!("incremental_sync_blocking: no IR changes; skipping detection cycle");
}
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
record_branch_scan_complete(&branch_repo, project_root, branch_id);
}
fn resolve_branch_tree_paths(
root: &Path,
branch_name: &str,
) -> Option<HashMap<String, gix::ObjectId>> {
let git_root = crate::db::find_git_root(root)?;
let repo = gix::open(git_root).ok()?;
let object = {
let ref_name = format!("refs/heads/{branch_name}");
if let Some(id) = repo
.try_find_reference(&ref_name)
.ok()
.flatten()
.and_then(|r| r.into_fully_peeled_id().ok())
{
repo.find_object(id.detach()).ok()
} else {
gix::ObjectId::from_hex(branch_name.as_bytes())
.ok()
.and_then(|oid| repo.find_object(oid).ok())
}?
};
let tree = object.into_commit().tree().ok()?;
let mut recorder = gix::traverse::tree::Recorder::default();
tree.traverse().breadthfirst(&mut recorder).ok()?;
let mut paths = HashMap::new();
for entry in recorder.records {
if entry.mode.is_blob() {
paths.insert(entry.filepath.to_string(), entry.oid);
}
}
Some(paths)
}
fn fallback_rescan(
project_root: &Path,
db: &Database,
branch_id: &BranchId,
scan_config: &ScanConfig,
_detection_config: &seshat_core::DetectionConfig,
) {
tracing::info!(root = %project_root.display(), "background_sync: falling back to full rescan");
if let Err(e) = scan_project(project_root, scan_config, db, branch_id.clone()) {
tracing::warn!(error = %e, "background_sync: full rescan scan_project failed");
}
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
record_branch_scan_complete(&branch_repo, project_root, branch_id);
}
pub fn run_serve(
repo: Option<&Path>,
host: Option<String>,
port: Option<u16>,
call_log: Option<PathBuf>,
) -> Result<(), CliError> {
let mut config = AppConfig::load().map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to load config: {e}"),
})?;
if let Some(h) = host {
config.server.host = h;
}
if let Some(p) = port {
config.server.port = p;
}
let target =
crate::db::resolve_serve_db_or_project_root(repo, &config.scan.additional_denylist_paths)?;
let (db_path, db, mut repo_info, scan_state, auto_scan_project_root, detected_branch) =
match target {
ServeTarget::ExistingDb {
db_path,
project_root,
} => {
let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
let detected = detect_branch(&project_root);
let repo_info = load_repo_info(&db, &db_path)?;
(
db_path,
db,
repo_info,
ScanState::not_needed(),
None,
detected,
)
}
ServeTarget::AutoScan {
project_root,
db_path,
} => {
let detected = detect_branch(&project_root);
let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to create database: {e}"),
})?;
tracing::info!(
project_root = %project_root.display(),
db_path = %db_path.display(),
detected_branch = %detected,
"No existing DB found — starting auto-scan"
);
let scan_state = ScanState::in_progress();
let scan_config = config.scan.clone();
let auto_scan_limit = scan_config.auto_scan_limit;
match seshat_scanner::discover_files(&project_root, &scan_config) {
Ok(discovery_result) => {
let file_count = discovery_result.files.len();
if file_count > auto_scan_limit {
scan_state.mark_failed(format!(
"Project too large for auto-scan ({} files). Run: seshat scan --verbose",
file_count
));
let repo_info = load_repo_info(&db, &db_path)?;
(db_path, db, repo_info, scan_state, None, detected)
} else {
let repo_info = load_repo_info(&db, &db_path)?;
(
db_path,
db,
repo_info,
scan_state,
Some(project_root),
detected,
)
}
}
Err(e) => {
scan_state.mark_failed(format!("auto-scan discovery failed: {e}"));
let repo_info = load_repo_info(&db, &db_path)?;
(db_path, db, repo_info, scan_state, None, detected)
}
}
}
};
let is_auto_scan = auto_scan_project_root.is_some();
let old_branch_for_sync = if is_auto_scan {
None
} else {
Some(repo_info.branch.0.clone())
};
let final_branch = if is_auto_scan {
handle_auto_scan_snapshot(&db, &detected_branch)?
} else {
handle_branch_switch(&db, &detected_branch, &repo_info.branch, is_auto_scan)?
};
repo_info.branch = final_branch.clone();
let sync_root = match &auto_scan_project_root {
Some(root) => root.clone(),
None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
};
let sync_project_root: PathBuf = match &auto_scan_project_root {
Some(root) => root.clone(),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let head_change_hint: Option<String> = if is_auto_scan {
None
} else {
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
match seshat_scanner::check_branch_freshness(
&branch_repo,
&sync_project_root,
&final_branch,
) {
seshat_scanner::FreshnessCheck::UpToDate
| seshat_scanner::FreshnessCheck::GitUnavailable => None,
seshat_scanner::FreshnessCheck::Stale {
old_commit,
new_commit,
} => {
let old_short = old_commit
.as_deref()
.map(|c| c.chars().take(7).collect::<String>())
.unwrap_or_else(|| "(none)".to_owned());
let new_short: String = new_commit.chars().take(7).collect();
tracing::info!(
branch = %final_branch.0,
old_head = %old_short,
new_head = %new_short,
"serve: detected HEAD change since last scan — triggering background sync"
);
old_commit
}
}
};
let sync_in_progress = Arc::new(AtomicBool::new(false));
let switch_in_progress = Arc::new(AtomicBool::new(false));
let sync_old_branch = old_branch_for_sync.filter(|b| *b != final_branch.0);
let needs_sync = sync_old_branch.is_some() || head_change_hint.is_some();
let sync_old_hint: Option<String> =
sync_old_branch.clone().or_else(|| head_change_hint.clone());
if needs_sync {
let sync_root_clone = sync_root.clone();
let project_root_clone = sync_project_root.clone();
let sync_db_path = db_path.clone();
let sync_branch = final_branch.clone();
let sync_scan_config = config.scan.clone();
let sync_detection_config = config.detection.clone();
let sync_flag = sync_in_progress.clone();
std::thread::spawn(move || {
struct ClearOnDrop(Arc<AtomicBool>);
impl Drop for ClearOnDrop {
fn drop(&mut self) {
self.0.store(false, Ordering::Relaxed);
}
}
sync_flag.store(true, Ordering::Relaxed);
let _guard = ClearOnDrop(sync_flag);
let sync_db = match Database::open(&sync_db_path) {
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, "background_sync: failed to open DB");
return;
}
};
background_sync(
&project_root_clone,
&sync_root_clone,
sync_old_hint.as_deref(),
&sync_branch.0,
&sync_db,
&sync_branch,
&sync_scan_config,
&sync_detection_config,
);
});
}
let gc_repo_path = match &auto_scan_project_root {
Some(root) => root.clone(),
None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
};
if let Ok(deleted) = gc_branch_snapshots(&db, &gc_repo_path) {
if !deleted.is_empty() {
tracing::info!(
deleted_count = deleted.len(),
deleted_branches = ?deleted,
"Garbage collected orphan branch snapshots on startup"
);
}
}
let submodule_rows = load_submodule_rows(&db);
let submodules = open_submodule_connections(&submodule_rows, &repo_info.name);
let call_log_path = resolve_call_log_path(call_log, config.server.call_log.as_deref());
let embedding_provider: Option<Arc<dyn seshat_embedding::EmbeddingProvider>> =
config.embedding.as_ref().and_then(|emb_config| {
match seshat_embedding::create_provider(emb_config) {
Ok(provider) => {
tracing::info!("Embedding provider enabled: {emb_config}");
Some(Arc::from(provider))
}
Err(e) => {
tracing::warn!("Failed to create embedding provider: {e}");
eprintln!(" Warning: embedding provider unavailable: {e}");
None
}
}
});
let server_config = config.server.clone();
let _start = Instant::now();
let runtime = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("failed to create tokio runtime: {e}"),
})?;
let root = ProjectConnection::new(
db.connection().clone(),
repo_info.name.clone(),
detected_branch.clone(),
);
let project_root = match &auto_scan_project_root {
Some(root) => root.clone(),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let watcher_enabled = config.watcher.enabled;
let watcher_params = WatcherParams {
enabled: watcher_enabled,
debounce_ms: config.watcher.debounce_ms,
ignore_patterns: config.watcher.ignore_patterns.clone(),
warm_tier_interval_seconds: config.watcher.warm_tier_interval_seconds,
bulk_change_threshold: config.watcher.bulk_change_threshold,
};
let watcher_scan_config = config.scan.clone();
let watcher_detection_config = config.detection.clone();
let has_auto_scan = auto_scan_project_root.is_some();
let auto_scan_root = auto_scan_project_root.clone();
runtime
.block_on(async {
let scan_state_clone = scan_state.clone();
if let Some(scan_root) = auto_scan_root.clone() {
let scan_config = config.scan.clone();
let scan_db = db.clone();
let scan_branch = detected_branch.clone();
tokio::spawn(async move {
let branch = seshat_core::BranchId::from(scan_branch);
let result = tokio::task::spawn_blocking(move || {
scan_project(&scan_root, &scan_config, &scan_db, branch)
})
.await;
match result {
Ok(Ok(_scan_result)) => {
tracing::info!("Auto-scan completed successfully");
scan_state_clone.mark_complete();
}
Ok(Err(scan_err)) => {
tracing::error!("Auto-scan failed: {scan_err}");
scan_state_clone.mark_failed(scan_err.to_string());
}
Err(join_err) => {
tracing::error!("Auto-scan task panicked: {join_err}");
scan_state_clone.mark_failed(join_err.to_string());
}
}
});
}
let gc_db = db.clone();
let gc_repo_path = gc_repo_path.clone();
let (gc_shutdown_tx, mut gc_shutdown_rx) = oneshot::channel();
let gc_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
tokio::select! {
_ = interval.tick() => {
let db_clone = gc_db.clone();
let path_clone = gc_repo_path.clone();
match tokio::task::spawn_blocking(move || {
gc_branch_snapshots(&db_clone, &path_clone)
})
.await
{
Ok(Ok(deleted_list)) => {
if !deleted_list.is_empty() {
tracing::info!(
deleted_count = deleted_list.len(),
deleted_branches = ?deleted_list,
"Periodic branch snapshot garbage collection"
);
}
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Periodic GC failed");
}
Err(join_err) => {
tracing::error!(error = %join_err, "Periodic GC task panicked");
}
}
}
_ = &mut gc_shutdown_rx => {
tracing::debug!("GC background task shutting down");
break;
}
}
}
});
let gc_handle = GcHandle {
shutdown_tx: gc_shutdown_tx,
task: gc_task,
};
let watcher_rx = if watcher_should_start(watcher_enabled, &scan_state) {
let (watcher_tx, watcher_rx) = tokio::sync::oneshot::channel();
let params = watcher_params;
let root = project_root.clone();
let db_p = db_path.clone();
let conn = db.connection().clone();
let branch = BranchId::from(detected_branch.as_str());
let wait_scan = scan_state.clone();
let on_branch_switch: Arc<dyn Fn() + Send + Sync + 'static> = {
let root_clone = project_root.clone();
let sync_root_clone = sync_root.clone();
let db_path_clone = db_path.clone();
let scan_cfg_clone = watcher_scan_config.clone();
let detect_cfg_clone = watcher_detection_config.clone();
let sync_flag = sync_in_progress.clone();
let switch_guard = switch_in_progress.clone();
Arc::new(move || {
if switch_guard
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
tracing::debug!("Branch switch already in progress — skipping duplicate event");
return;
}
let root = root_clone.clone();
let sync_root = sync_root_clone.clone();
let db_path = db_path_clone.clone();
let scan_cfg = scan_cfg_clone.clone();
let detect_cfg = detect_cfg_clone.clone();
let sync_flag = sync_flag.clone();
let switch_guard = switch_guard.clone();
std::thread::spawn(move || {
struct ClearOnDrop(Arc<AtomicBool>);
impl Drop for ClearOnDrop {
fn drop(&mut self) {
self.0.store(false, Ordering::Relaxed);
}
}
let _guard = ClearOnDrop(switch_guard);
sync_flag.store(true, Ordering::Relaxed);
let _flag_guard = ClearOnDrop(sync_flag);
let start = Instant::now();
let new_branch = detect_branch(&root);
let db = match Database::open(&db_path) {
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, "Failed to open DB for branch switch");
return;
}
};
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let current_branch = branch_repo
.get_current_branch()
.map(|b| b.0.clone())
.unwrap_or_else(|e| {
tracing::debug!(error = %e, "Could not read current branch from DB, defaulting to 'main'");
"main".to_string()
});
tracing::info!(
old_branch = %current_branch,
new_branch = %new_branch,
"Branch switch detected by watcher"
);
if new_branch == current_branch {
tracing::debug!("Branch unchanged, no switch needed");
return;
}
let new_id = BranchId::from(new_branch.as_str());
let old_id = BranchId::from(current_branch.as_str());
let branches = match branch_repo.list_branches() {
Ok(b) => b,
Err(e) => {
tracing::error!(error = %e, "Failed to list branches for switch");
return;
}
};
let snapshot_exists = branches.iter().any(|b| b.0 == new_branch);
if snapshot_exists {
match branch_repo.switch_branch(&new_id) {
Ok(()) => {
let elapsed = start.elapsed();
tracing::info!(
to = %new_branch,
elapsed_ms = elapsed.as_millis(),
"Branch switch completed (instant, snapshot existed)"
);
}
Err(e) => {
tracing::error!(error = %e, "Failed to switch branch");
return;
}
}
} else {
tracing::info!(
source = %current_branch,
target = %new_branch,
"No snapshot for target — creating"
);
match branch_repo.create_snapshot(&old_id, &new_id) {
Ok(()) => {
match branch_repo.switch_branch(&new_id) {
Ok(()) => {
let elapsed = start.elapsed();
tracing::info!(
to = %new_branch,
elapsed_ms = elapsed.as_millis(),
"Branch switch completed (snapshot created)"
);
}
Err(e) => {
tracing::error!(error = %e, "Failed to switch after snapshot");
return;
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to create snapshot");
return;
}
}
}
let old_b = current_branch;
background_sync(
&root,
&sync_root,
Some(&old_b),
&new_branch,
&db,
&new_id,
&scan_cfg,
&detect_cfg,
);
});
})
};
tokio::spawn(async move {
wait_scan.wait_for_scan();
if let Some(msg) = wait_scan.error_message() {
tracing::info!(
error_message = %msg,
"Auto-scan failed during watcher wait; not starting file watcher",
);
let _ = watcher_tx.send(Err(WatcherError::ScanFailed(msg)));
return;
}
let result = start_watcher(
params,
root,
db_p,
conn,
branch,
watcher_scan_config,
watcher_detection_config,
on_branch_switch,
)
.await;
if let Err(ref e) = result {
tracing::warn!(
"File watcher failed to start: {e}. \
Serving without incremental updates."
);
}
let _ = watcher_tx.send(result);
});
Some(watcher_rx)
} else {
None
};
let watcher_status: std::borrow::Cow<'_, str> = if !watcher_enabled {
std::borrow::Cow::Borrowed("disabled")
} else if let Some(msg) = scan_state.error_message() {
debug_assert!(
!has_auto_scan,
"scan_state.error_message().is_some() should imply has_auto_scan=false \
(the AutoScan failure branch sets auto_scan_project_root=None)"
);
std::borrow::Cow::Owned(format!("disabled (auto-scan failed: {msg})"))
} else if has_auto_scan && !scan_state.auto_scanned() {
std::borrow::Cow::Borrowed("starting (after scan)")
} else {
std::borrow::Cow::Borrowed("starting")
};
print_startup(
&repo_info,
&submodules,
&config,
call_log_path.as_deref(),
&watcher_status,
is_auto_scan,
&detected_branch,
);
let detached_head = final_branch.0.len() >= 7
&& final_branch.0.chars().all(|c| c.is_ascii_hexdigit());
let shutdown = async {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for Ctrl+C");
eprintln!();
eprintln!("Shutting down...");
};
let result = seshat_mcp::start_stdio_with_shutdown(
server_config,
root,
submodules,
call_log_path,
embedding_provider,
scan_state,
sync_in_progress.clone(),
true,
detached_head,
project_root.clone(),
shutdown,
std::time::Duration::from_secs(5),
)
.await;
drop(gc_handle);
if let Some(mut rx) = watcher_rx {
if let Ok(Ok(handle)) = rx.try_recv() {
handle.shutdown().await;
}
}
result
})
.map_err(|e| CliError::CommandFailed {
command: "serve".to_owned(),
reason: format!("MCP server error: {e}"),
})
}
fn load_repo_info(db: &Database, db_path: &Path) -> Result<RepoInfo, CliError> {
let name = db_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_owned());
let info = crate::db::load_project_info(db);
Ok(RepoInfo {
name,
db_path: db_path.to_path_buf(),
branch: info.branch,
file_count: info.file_count,
convention_count: info.convention_count,
})
}
fn load_submodule_rows(db: &Database) -> Vec<SubmoduleRow> {
let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
match sub_repo.list() {
Ok(rows) => rows,
Err(e) => {
eprintln!(
" Warning: could not read submodules table: {e}. Continuing without submodules."
);
Vec::new()
}
}
}
fn open_submodule_connections(
rows: &[SubmoduleRow],
root_project_name: &str,
) -> HashMap<String, ProjectConnection> {
let mut submodules = HashMap::new();
for row in rows {
let db_path =
match crate::db::resolve_submodule_db_path(root_project_name, &row.relative_path) {
Ok(p) => p,
Err(e) => {
eprintln!(
" Warning: could not resolve DB path for submodule '{}': {e}. Skipping.",
row.relative_path
);
continue;
}
};
if !db_path.exists() {
eprintln!(
" Warning: submodule DB not found at '{}'. Skipping '{}'.",
db_path.display(),
row.relative_path
);
continue;
}
let db = match Database::open(&db_path) {
Ok(d) => d,
Err(e) => {
eprintln!(
" Warning: failed to open submodule DB '{}': {e}. Skipping '{}'.",
db_path.display(),
row.relative_path
);
continue;
}
};
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
tracing::debug!("Could not detect submodule branch from DB, defaulting to 'main'");
BranchId::from("main")
});
let pc = ProjectConnection::new(
db.connection().clone(),
row.relative_path.clone(),
branch.to_string(),
);
submodules.insert(row.relative_path.clone(), pc);
}
submodules
}
fn print_startup(
info: &RepoInfo,
submodules: &HashMap<String, ProjectConnection>,
config: &AppConfig,
call_log_path: Option<&Path>,
watcher_status: &str,
auto_scanning: bool,
detected_branch: &str,
) {
eprintln!("seshat v{}", env!("CARGO_PKG_VERSION"));
eprintln!();
eprintln!(" Repo: {}", info.name);
eprintln!(" Branch: {}", detected_branch);
if auto_scanning {
eprintln!(" Files: 0 (auto-scanning...)");
} else {
eprintln!(" Files: {}", info.file_count);
}
eprintln!(" Conventions: {}", info.convention_count);
eprintln!(" Database: {}", info.db_path.display());
eprintln!(" Watcher: {watcher_status}");
if submodules.is_empty() {
eprintln!(" Submodules: none");
} else {
eprintln!(" Submodules: {}", submodules.len());
let mut names: Vec<&String> = submodules.keys().collect();
names.sort();
for name in names {
eprintln!(" - {name}");
}
}
if let Some(path) = call_log_path {
eprintln!(" Call log: {}", path.display());
}
eprintln!();
eprintln!(
" Transport: stdio ({}:{})",
config.server.host, config.server.port
);
eprintln!();
eprintln!("Ready. Waiting for MCP client connection...");
}
#[cfg(test)]
mod tests {
use super::*;
use seshat_core::DetectionConfig;
use std::collections::HashMap;
#[test]
fn load_repo_info_empty_db() {
let db = Database::open(":memory:").expect("in-memory db");
let path = PathBuf::from("/tmp/test-seshat-project.db");
let info = load_repo_info(&db, &path).expect("should succeed with empty db");
assert_eq!(info.name, "test-seshat-project");
assert_eq!(info.file_count, 0);
assert_eq!(info.convention_count, 0);
assert_eq!(info.branch, BranchId::from("main"));
}
#[test]
fn load_submodule_rows_empty_db() {
let db = Database::open(":memory:").expect("in-memory db");
let rows = load_submodule_rows(&db);
assert!(rows.is_empty());
}
#[test]
fn load_submodule_rows_with_data() {
use seshat_storage::{SqliteSubmoduleRepository, SubmoduleInput, SubmoduleRepository};
let db = Database::open(":memory:").expect("in-memory db");
let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
sub_repo
.insert(&SubmoduleInput {
relative_path: "vendor/libfoo".to_string(),
name: "libfoo".to_string(),
db_path: "/data/seshat/repos/proj/vendor/libfoo.db".to_string(),
commit_hash: Some("abc123".to_string()),
})
.expect("insert");
sub_repo
.insert(&SubmoduleInput {
relative_path: "libs/core".to_string(),
name: "core".to_string(),
db_path: "/data/seshat/repos/proj/libs/core.db".to_string(),
commit_hash: Some("def456".to_string()),
})
.expect("insert");
let rows = load_submodule_rows(&db);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].relative_path, "libs/core");
assert_eq!(rows[1].relative_path, "vendor/libfoo");
}
#[test]
fn open_submodule_connections_empty_rows() {
let submodules = open_submodule_connections(&[], "test-project");
assert!(submodules.is_empty());
}
#[test]
fn open_submodule_connections_missing_db_skipped() {
let project_name = "serve-test-missing-db";
let row = SubmoduleRow {
id: 1,
relative_path: "vendor/nonexistent".to_string(),
name: "nonexistent".to_string(),
db_path: "/no/such/path.db".to_string(),
commit_hash: Some("abc123".to_string()),
created_at: "2026-04-03T00:00:00".to_string(),
updated_at: "2026-04-03T00:00:00".to_string(),
};
let submodules = open_submodule_connections(&[row], project_name);
assert!(submodules.is_empty());
if let Ok(repos) = crate::db::xdg_repos_dir() {
let _ = std::fs::remove_dir_all(repos.join(project_name));
}
}
#[test]
fn resolve_call_log_bare_flag_uses_default_path() {
let result = resolve_call_log_path(Some(PathBuf::from("")), None);
let path = result.expect("should resolve to default path");
let normalized = path.to_string_lossy().replace('\\', "/");
assert!(
normalized.ends_with("seshat/call-log.jsonl"),
"expected default path to end with seshat/call-log.jsonl, got {normalized}"
);
}
#[test]
fn resolve_call_log_explicit_path() {
let result = resolve_call_log_path(Some(PathBuf::from("/tmp/my-log.jsonl")), None);
assert_eq!(result, Some(PathBuf::from("/tmp/my-log.jsonl")));
}
#[test]
fn resolve_call_log_from_config() {
let result = resolve_call_log_path(None, Some("/config/path.jsonl"));
assert_eq!(result, Some(PathBuf::from("/config/path.jsonl")));
}
#[test]
fn resolve_call_log_cli_overrides_config() {
let result = resolve_call_log_path(
Some(PathBuf::from("/cli/path.jsonl")),
Some("/config/path.jsonl"),
);
assert_eq!(result, Some(PathBuf::from("/cli/path.jsonl")));
}
#[test]
fn resolve_call_log_disabled_when_no_flag_and_no_config() {
let result = resolve_call_log_path(None, None);
assert!(result.is_none());
}
#[test]
fn open_submodule_connections_with_real_dbs() {
use std::fs;
let project_name = "serve-test-submod";
let mount_path = "vendor/testlib";
let db_path =
crate::db::resolve_submodule_db_path(project_name, mount_path).expect("resolve path");
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
let _guard = Cleanup(repos_dir.join(project_name));
let db = Database::open(&db_path).expect("create submodule DB");
drop(db);
let row = SubmoduleRow {
id: 1,
relative_path: mount_path.to_string(),
name: "testlib".to_string(),
db_path: db_path.to_string_lossy().to_string(),
commit_hash: Some("abc123".to_string()),
created_at: "2026-04-03T00:00:00".to_string(),
updated_at: "2026-04-03T00:00:00".to_string(),
};
let submodules = open_submodule_connections(&[row], project_name);
assert_eq!(submodules.len(), 1);
assert!(submodules.contains_key(mount_path));
let pc = &submodules[mount_path];
assert_eq!(pc.name, mount_path);
assert_eq!(pc.branch, "main"); }
#[test]
fn handle_auto_scan_snapshot_main_branch_no_op() {
let db = Database::open(":memory:").expect("in-memory db");
let result = handle_auto_scan_snapshot(&db, "main").expect("should succeed");
assert_eq!(result, BranchId::from("main"));
}
#[test]
fn print_startup_does_not_panic() {
let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
let _ = std::fs::create_dir_all(&repos_dir);
let info = RepoInfo {
name: "test-project".to_string(),
db_path: PathBuf::from("/tmp/test.db"),
file_count: 5,
convention_count: 42,
branch: BranchId::from("main"),
};
let config = AppConfig::load().unwrap_or_default();
print_startup(
&info,
&HashMap::new(),
&config,
None,
"running",
false,
"main",
);
}
#[test]
fn repo_info_default_name_extraction() {
let info = RepoInfo {
name: "my-awesome-project".to_string(),
db_path: PathBuf::from("/tmp/test.db"),
file_count: 10,
convention_count: 20,
branch: BranchId::from("feat/bar"),
};
assert_eq!(info.name, "my-awesome-project");
assert_eq!(info.file_count, 10);
assert_eq!(info.convention_count, 20);
assert_eq!(info.branch, BranchId::from("feat/bar"));
}
#[test]
fn fallback_rescan_empty_dir_handles_gracefully() {
use tempfile::tempdir;
let dir = tempdir().expect("tempdir");
let db = Database::open(":memory:").expect("in-memory db");
let branch = BranchId::from("main");
fallback_rescan(
dir.path(),
&db,
&branch,
&ScanConfig::default(),
&DetectionConfig::default(),
);
}
#[test]
fn resolve_branch_tree_paths_not_a_git_repo_returns_none() {
use tempfile::tempdir;
let dir = tempdir().expect("tempdir");
let result = resolve_branch_tree_paths(dir.path(), "main");
assert!(result.is_none());
}
fn seed_branch(db: &Database, branch_name: &str) -> BranchId {
let branch = BranchId::from(branch_name);
let br = SqliteBranchRepository::new(db.connection().clone());
br.switch_branch(&branch).unwrap();
let c = db.connection().lock().unwrap();
c.execute(
"INSERT INTO nodes (branch_id, nature, weight, confidence, adoption_count, total_count, description, ext_data)
VALUES (?1, 'convention', 'strong', 0.9, 5, 10, 'test', '{\"source\":\"auto_detected\"}')",
rusqlite::params![branch_name],
).unwrap();
branch
}
#[test]
fn handle_branch_switch_same_branch_returns_current() {
let db = Database::open(":memory:").expect("in-memory db");
let current = BranchId::from("main");
let result = handle_branch_switch(&db, "main", ¤t, false).unwrap();
assert_eq!(result, current);
}
#[test]
fn handle_branch_switch_target_has_data_no_snapshot() {
let db = Database::open(":memory:").expect("in-memory db");
let current = BranchId::from("main");
seed_branch(&db, "feat/test");
let result = handle_branch_switch(&db, "feat/test", ¤t, false).unwrap();
assert_eq!(result, BranchId::from("feat/test"));
}
#[test]
fn handle_branch_switch_source_no_data_still_switches() {
let db = Database::open(":memory:").expect("in-memory db");
let current = BranchId::from("main");
let result = handle_branch_switch(&db, "feat/empty", ¤t, false).unwrap();
assert_eq!(result, BranchId::from("feat/empty"));
}
#[test]
fn handle_branch_switch_source_has_data_creates_snapshot() {
let db = Database::open(":memory:").expect("in-memory db");
let current = BranchId::from("main");
seed_branch(&db, "main");
let result = handle_branch_switch(&db, "feat/snap", ¤t, false).unwrap();
assert_eq!(result, BranchId::from("feat/snap"));
let br = SqliteBranchRepository::new(db.connection().clone());
let branches = br.list_branches().unwrap();
assert!(branches.iter().any(|b| b.0 == "feat/snap"));
}
#[test]
fn auto_scan_snapshot_non_main_no_main_data_still_switches() {
let db = Database::open(":memory:").expect("in-memory db");
let result = handle_auto_scan_snapshot(&db, "feat/bar").unwrap();
assert_eq!(result, BranchId::from("feat/bar"));
}
#[test]
fn auto_scan_snapshot_non_main_with_main_data_creates_snapshot() {
let db = Database::open(":memory:").expect("in-memory db");
seed_branch(&db, "main");
let result = handle_auto_scan_snapshot(&db, "feat/baz").unwrap();
assert_eq!(result, BranchId::from("feat/baz"));
let br = SqliteBranchRepository::new(db.connection().clone());
let branches = br.list_branches().unwrap();
assert!(branches.iter().any(|b| b.0 == "feat/baz"));
}
#[test]
fn watcher_should_start_disabled_returns_false_regardless_of_scan_state() {
let state_ok = ScanState::not_needed();
assert!(!watcher_should_start(false, &state_ok));
let state_complete = ScanState::in_progress();
state_complete.mark_complete();
assert!(!watcher_should_start(false, &state_complete));
}
#[test]
fn watcher_should_start_enabled_with_no_scan_returns_true() {
let state = ScanState::not_needed();
assert!(watcher_should_start(true, &state));
}
#[test]
fn watcher_should_start_enabled_with_completed_scan_returns_true() {
let state = ScanState::in_progress();
state.mark_complete();
assert!(watcher_should_start(true, &state));
}
#[test]
fn watcher_should_start_enabled_with_in_progress_scan_returns_true() {
let state = ScanState::in_progress();
assert!(watcher_should_start(true, &state));
}
#[test]
fn watcher_should_start_enabled_with_failed_scan_returns_false() {
let state = ScanState::in_progress();
state.mark_failed("project too large".to_owned());
assert!(!watcher_should_start(true, &state));
}
#[test]
fn watcher_should_start_disabled_with_failed_scan_returns_false() {
let state = ScanState::in_progress();
state.mark_failed("scan timeout".to_owned());
assert!(!watcher_should_start(false, &state));
}
#[test]
fn race_guard_pattern_detects_pre_wait_failure() {
let state = ScanState::in_progress();
state.mark_failed("simulated pre-wait failure".to_owned());
state.wait_for_scan(); assert_eq!(
state.error_message(),
Some("simulated pre-wait failure".to_owned())
);
}
#[test]
fn race_guard_pattern_returns_none_for_normal_completion() {
let state = ScanState::in_progress();
state.mark_complete();
state.wait_for_scan();
assert_eq!(state.error_message(), None);
}
#[test]
fn race_guard_pattern_observes_failure_set_during_wait() {
use std::sync::Arc;
use std::thread;
use std::time::Duration;
let state = ScanState::in_progress();
let waiter_state = state.clone();
let observed: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
let observed_for_thread = Arc::clone(&observed);
let waiter = thread::spawn(move || {
waiter_state.wait_for_scan();
*observed_for_thread.lock().expect("lock") = waiter_state.error_message();
});
thread::sleep(Duration::from_millis(50));
state.mark_failed("simulated late failure".to_owned());
waiter.join().expect("waiter thread join");
let captured = observed.lock().expect("lock").clone();
assert_eq!(captured, Some("simulated late failure".to_owned()));
}
}