use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use seshat_core::BranchId;
use seshat_scanner::{FreshnessCheck, check_branch_freshness};
use seshat_storage::{Database, SqliteBranchRepository};
use crate::config::AppConfig;
use crate::error::CliError;
const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(950);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewSyncOutcome {
Skipped,
UpToDate,
GitUnavailable,
Synced {
old_commit: Option<String>,
new_commit: String,
progress_emits: usize,
},
}
pub fn prepare_review_sync(
db: &Database,
project_root: &std::path::Path,
branch_id: &BranchId,
no_sync: bool,
progress_callback: Option<&dyn Fn(usize, usize)>,
) -> ReviewSyncOutcome {
if no_sync {
tracing::debug!(
branch = %branch_id.0,
"review: --no-sync passed, skipping freshness check"
);
return ReviewSyncOutcome::Skipped;
}
let sync_root = crate::db::sync_root_for(project_root);
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let freshness = check_branch_freshness(&branch_repo, &sync_root, branch_id);
run_review_sync_with_freshness(db, &sync_root, branch_id, freshness, progress_callback)
}
pub fn run_review_sync_with_freshness(
db: &Database,
sync_root: &std::path::Path,
branch_id: &BranchId,
freshness: FreshnessCheck,
progress_callback: Option<&dyn Fn(usize, usize)>,
) -> ReviewSyncOutcome {
let (old_commit, new_commit) = match freshness {
FreshnessCheck::UpToDate => return ReviewSyncOutcome::UpToDate,
FreshnessCheck::GitUnavailable => return ReviewSyncOutcome::GitUnavailable,
FreshnessCheck::Stale {
old_commit,
new_commit,
} => (old_commit, new_commit),
};
let config = AppConfig::load().unwrap_or_default();
let emits = std::sync::atomic::AtomicUsize::new(0);
let counted_cb = |processed: usize, total: usize| {
emits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(cb) = progress_callback {
cb(processed, total);
}
};
crate::serve::incremental_sync_blocking(
sync_root,
old_commit.as_deref(),
&branch_id.0,
db,
branch_id,
&config.scan,
&config.detection,
Some(&counted_cb),
);
ReviewSyncOutcome::Synced {
old_commit,
new_commit,
progress_emits: emits.load(std::sync::atomic::Ordering::Relaxed),
}
}
fn tty_progress_printer(head_short: String) -> impl Fn(usize, usize) {
let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
move |processed: usize, total: usize| {
let mut guard = match last_emit.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
return;
}
*guard = Instant::now();
drop(guard);
let mut stderr = std::io::stderr().lock();
let _ = write!(
stderr,
"\rSyncing project state to {head_short}... Files: {processed} / {total} "
);
let _ = stderr.flush();
}
}
fn piped_progress_printer(head_short: String) -> impl Fn(usize, usize) {
let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
move |processed: usize, total: usize| {
let mut guard = match last_emit.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
return;
}
*guard = Instant::now();
drop(guard);
eprintln!("Syncing project state to {head_short}: {processed} / {total} files");
}
}
pub fn run_review(project_path: Option<PathBuf>, no_sync: bool) -> Result<(), CliError> {
let explicit = project_path.as_deref();
let resolved = crate::db::resolve_project(explicit, "review")?;
if !resolved.db_path.exists() {
return Err(CliError::CommandFailed {
command: "review".to_owned(),
reason: "No database found. Run `seshat scan` first.".to_owned(),
});
}
let branch_id_str =
crate::db::get_current_branch(&resolved.project_root).unwrap_or_else(|| {
tracing::debug!(
path = %resolved.project_root.display(),
"Could not detect git branch, defaulting to 'main'"
);
"main".to_string()
});
let branch_id = BranchId::from(branch_id_str.as_str());
let db = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
command: "review".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
if no_sync {
prepare_review_sync(&db, &resolved.project_root, &branch_id, true, None);
} else {
let sync_root = resolved.sync_root().to_path_buf();
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let freshness = check_branch_freshness(&branch_repo, &sync_root, &branch_id);
match &freshness {
FreshnessCheck::UpToDate => {
tracing::debug!(branch = %branch_id.0, "review: DB is up to date with HEAD");
}
FreshnessCheck::GitUnavailable => {
tracing::debug!(
root = %sync_root.display(),
"review: git unavailable, skipping freshness check"
);
}
FreshnessCheck::Stale { new_commit, .. } => {
let head_short: String = new_commit.chars().take(7).collect();
let is_stdout_tty = std::io::stdout().is_terminal();
if is_stdout_tty {
print!("Syncing project state to {head_short}... ");
let _ = std::io::stdout().lock().flush();
} else {
println!("Syncing project state to {head_short}...");
}
if is_stdout_tty {
let printer = tty_progress_printer(head_short.clone());
run_review_sync_with_freshness(
&db,
&sync_root,
&branch_id,
freshness.clone(),
Some(&printer),
);
println!("\rSyncing project state to {head_short}... done. ");
let _ = std::io::stdout().lock().flush();
} else {
let printer = piped_progress_printer(head_short.clone());
run_review_sync_with_freshness(
&db,
&sync_root,
&branch_id,
freshness.clone(),
Some(&printer),
);
println!("Sync complete.");
let _ = std::io::stdout().lock().flush();
}
}
}
}
let conn = db.connection().clone();
crate::tui::run_review_tui_with_conn(&branch_id_str, &conn)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn run_review_nonexistent_project_returns_error() {
let result = run_review(
Some(PathBuf::from("/tmp/seshat-nonexistent-review-test-xyz")),
false,
);
assert!(result.is_err());
}
#[test]
fn run_review_with_some_path_sets_deref() {
let tmp = tempdir().unwrap();
let db_path = tmp.path().join("seshat.db");
std::fs::write(&db_path, "fake db").unwrap();
let result = run_review(Some(tmp.path().to_path_buf()), false);
assert!(result.is_err());
}
#[test]
fn run_review_file_instead_of_directory_error() {
let tmp = tempdir().unwrap();
let file_path = tmp.path().join("just_a_file");
std::fs::write(&file_path, "hello").unwrap();
let result = run_review(Some(file_path), false);
assert!(result.is_err());
}
#[test]
fn prepare_review_sync_returns_skipped_when_no_sync_passed() {
let dir = tempdir().expect("tempdir");
let db = Database::open(":memory:").expect("open db");
let branch = BranchId::from("main");
let outcome = prepare_review_sync(&db, dir.path(), &branch, true, None);
assert_eq!(outcome, ReviewSyncOutcome::Skipped);
}
#[test]
fn prepare_review_sync_returns_git_unavailable_for_non_git_directory() {
let dir = tempdir().expect("tempdir");
let db = Database::open(":memory:").expect("open db");
let branch = BranchId::from("main");
let outcome = prepare_review_sync(&db, dir.path(), &branch, false, None);
assert_eq!(outcome, ReviewSyncOutcome::GitUnavailable);
}
}