1use std::io::{IsTerminal, Write};
10use std::path::PathBuf;
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14use seshat_core::BranchId;
15use seshat_scanner::{FreshnessCheck, check_branch_freshness};
16use seshat_storage::{Database, SqliteBranchRepository};
17
18use crate::config::AppConfig;
19use crate::error::CliError;
20
21const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(950);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewSyncOutcome {
37 Skipped,
39 UpToDate,
41 GitUnavailable,
43 Synced {
47 old_commit: Option<String>,
48 new_commit: String,
49 progress_emits: usize,
50 },
51}
52
53pub fn prepare_review_sync(
64 db: &Database,
65 project_root: &std::path::Path,
66 branch_id: &BranchId,
67 no_sync: bool,
68 progress_callback: Option<&dyn Fn(usize, usize)>,
69) -> ReviewSyncOutcome {
70 if no_sync {
71 tracing::debug!(
72 branch = %branch_id.0,
73 "review: --no-sync passed, skipping freshness check"
74 );
75 return ReviewSyncOutcome::Skipped;
76 }
77
78 let sync_root = crate::db::sync_root_for(project_root);
82
83 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
84 let freshness = check_branch_freshness(&branch_repo, &sync_root, branch_id);
85 run_review_sync_with_freshness(db, &sync_root, branch_id, freshness, progress_callback)
86}
87
88pub fn run_review_sync_with_freshness(
95 db: &Database,
96 sync_root: &std::path::Path,
97 branch_id: &BranchId,
98 freshness: FreshnessCheck,
99 progress_callback: Option<&dyn Fn(usize, usize)>,
100) -> ReviewSyncOutcome {
101 let (old_commit, new_commit) = match freshness {
102 FreshnessCheck::UpToDate => return ReviewSyncOutcome::UpToDate,
103 FreshnessCheck::GitUnavailable => return ReviewSyncOutcome::GitUnavailable,
104 FreshnessCheck::Stale {
105 old_commit,
106 new_commit,
107 } => (old_commit, new_commit),
108 };
109
110 let config = AppConfig::load().unwrap_or_default();
111
112 let emits = std::sync::atomic::AtomicUsize::new(0);
113 let counted_cb = |processed: usize, total: usize| {
114 emits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
115 if let Some(cb) = progress_callback {
116 cb(processed, total);
117 }
118 };
119
120 crate::serve::incremental_sync_blocking(
121 sync_root,
122 old_commit.as_deref(),
123 &branch_id.0,
124 db,
125 branch_id,
126 &config.scan,
127 &config.detection,
128 Some(&counted_cb),
129 );
130
131 ReviewSyncOutcome::Synced {
132 old_commit,
133 new_commit,
134 progress_emits: emits.load(std::sync::atomic::Ordering::Relaxed),
135 }
136}
137
138fn tty_progress_printer(head_short: String) -> impl Fn(usize, usize) {
145 let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
146 move |processed: usize, total: usize| {
147 let mut guard = match last_emit.lock() {
148 Ok(g) => g,
149 Err(p) => p.into_inner(),
150 };
151 if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
152 return;
153 }
154 *guard = Instant::now();
155 drop(guard);
156 let mut stderr = std::io::stderr().lock();
157 let _ = write!(
158 stderr,
159 "\rSyncing project state to {head_short}... Files: {processed} / {total} "
160 );
161 let _ = stderr.flush();
162 }
163}
164
165fn piped_progress_printer(head_short: String) -> impl Fn(usize, usize) {
171 let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
172 move |processed: usize, total: usize| {
173 let mut guard = match last_emit.lock() {
174 Ok(g) => g,
175 Err(p) => p.into_inner(),
176 };
177 if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
178 return;
179 }
180 *guard = Instant::now();
181 drop(guard);
182 eprintln!("Syncing project state to {head_short}: {processed} / {total} files");
183 }
184}
185
186pub fn run_review(project_path: Option<PathBuf>, no_sync: bool) -> Result<(), CliError> {
194 let explicit = project_path.as_deref();
196 let resolved = crate::db::resolve_project(explicit, "review")?;
197
198 if !resolved.db_path.exists() {
200 return Err(CliError::CommandFailed {
201 command: "review".to_owned(),
202 reason: "No database found. Run `seshat scan` first.".to_owned(),
203 });
204 }
205
206 let branch_id_str =
208 crate::db::get_current_branch(&resolved.project_root).unwrap_or_else(|| {
209 tracing::debug!(
210 path = %resolved.project_root.display(),
211 "Could not detect git branch, defaulting to 'main'"
212 );
213 "main".to_string()
214 });
215 let branch_id = BranchId::from(branch_id_str.as_str());
216
217 let db = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
220 command: "review".to_owned(),
221 reason: format!("failed to open database: {e}"),
222 })?;
223
224 if no_sync {
226 prepare_review_sync(&db, &resolved.project_root, &branch_id, true, None);
227 } else {
228 let sync_root = resolved.sync_root().to_path_buf();
235 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
236 let freshness = check_branch_freshness(&branch_repo, &sync_root, &branch_id);
237 match &freshness {
238 FreshnessCheck::UpToDate => {
239 tracing::debug!(branch = %branch_id.0, "review: DB is up to date with HEAD");
240 }
241 FreshnessCheck::GitUnavailable => {
242 tracing::debug!(
243 root = %sync_root.display(),
244 "review: git unavailable, skipping freshness check"
245 );
246 }
247 FreshnessCheck::Stale { new_commit, .. } => {
248 let head_short: String = new_commit.chars().take(7).collect();
249 let is_stdout_tty = std::io::stdout().is_terminal();
258 if is_stdout_tty {
259 print!("Syncing project state to {head_short}... ");
260 let _ = std::io::stdout().lock().flush();
261 } else {
262 println!("Syncing project state to {head_short}...");
263 }
264 if is_stdout_tty {
265 let printer = tty_progress_printer(head_short.clone());
266 run_review_sync_with_freshness(
267 &db,
268 &sync_root,
269 &branch_id,
270 freshness.clone(),
271 Some(&printer),
272 );
273 println!("\rSyncing project state to {head_short}... done. ");
277 let _ = std::io::stdout().lock().flush();
278 } else {
279 let printer = piped_progress_printer(head_short.clone());
280 run_review_sync_with_freshness(
281 &db,
282 &sync_root,
283 &branch_id,
284 freshness.clone(),
285 Some(&printer),
286 );
287 println!("Sync complete.");
288 let _ = std::io::stdout().lock().flush();
289 }
290 }
291 }
292 }
293
294 let conn = db.connection().clone();
295 crate::tui::run_review_tui_with_conn(&branch_id_str, &conn)?;
296
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use tempfile::tempdir;
304
305 #[test]
306 fn run_review_nonexistent_project_returns_error() {
307 let result = run_review(
308 Some(PathBuf::from("/tmp/seshat-nonexistent-review-test-xyz")),
309 false,
310 );
311 assert!(result.is_err());
312 }
313
314 #[test]
315 fn run_review_with_some_path_sets_deref() {
316 let tmp = tempdir().unwrap();
317 let db_path = tmp.path().join("seshat.db");
318
319 std::fs::write(&db_path, "fake db").unwrap();
320
321 let result = run_review(Some(tmp.path().to_path_buf()), false);
322 assert!(result.is_err());
323 }
324
325 #[test]
326 fn run_review_file_instead_of_directory_error() {
327 let tmp = tempdir().unwrap();
328 let file_path = tmp.path().join("just_a_file");
329 std::fs::write(&file_path, "hello").unwrap();
330 let result = run_review(Some(file_path), false);
331 assert!(result.is_err());
332 }
333
334 #[test]
337 fn prepare_review_sync_returns_skipped_when_no_sync_passed() {
338 let dir = tempdir().expect("tempdir");
339 let db = Database::open(":memory:").expect("open db");
340 let branch = BranchId::from("main");
341
342 let outcome = prepare_review_sync(&db, dir.path(), &branch, true, None);
343 assert_eq!(outcome, ReviewSyncOutcome::Skipped);
344 }
345
346 #[test]
347 fn prepare_review_sync_returns_git_unavailable_for_non_git_directory() {
348 let dir = tempdir().expect("tempdir");
349 let db = Database::open(":memory:").expect("open db");
350 let branch = BranchId::from("main");
351
352 let outcome = prepare_review_sync(&db, dir.path(), &branch, false, None);
353 assert_eq!(outcome, ReviewSyncOutcome::GitUnavailable);
354 }
355}