Skip to main content

seshat_cli/
review.rs

1//! Implementation of the `seshat review` command.
2//!
3//! Resolves the project DB, reads the active branch's `last_scanned_commit`,
4//! and — unless `--no-sync` is passed — performs a blocking incremental sync
5//! to the current `git rev-parse HEAD` before opening the TUI. The TUI then
6//! reads conventions that reflect the on-disk state, not a potentially stale
7//! snapshot from a previous scan.
8
9use 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
21/// Minimum delay between progress callback emits when stderr is a TTY.
22///
23/// Per US-011 AC: the progress UI updates "Files: X / Y on the same line at
24/// 1Hz" — so the throttle MUST allow 1 emit per second. Using exactly 1 second
25/// flickers under load when calls fall on either side of the boundary; 950 ms
26/// gives a stable ~1 Hz cadence without crossing into the "more than 1 Hz" zone.
27const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(950);
28
29/// Outcome returned by [`prepare_review_sync`] so tests can drive the freshness
30/// gate without launching the (interactive) TUI.
31///
32/// Mirrors the variants of [`FreshnessCheck`] one-to-one for the gate cases,
33/// plus a `Synced` variant that carries the file counts the AC requires the
34/// progress callback to surface. `Skipped` covers `--no-sync`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewSyncOutcome {
37    /// `--no-sync` was passed; sync gate was not consulted.
38    Skipped,
39    /// Freshness gate said up-to-date; no sync was run.
40    UpToDate,
41    /// Git was unavailable for the project root; sync was skipped silently.
42    GitUnavailable,
43    /// A blocking sync ran. `progress_emits` is the number of times the user-
44    /// facing progress callback fired (independent of internal upsert events,
45    /// which the throttle may have collapsed).
46    Synced {
47        old_commit: Option<String>,
48        new_commit: String,
49        progress_emits: usize,
50    },
51}
52
53/// Run the freshness gate and (when stale) the blocking incremental sync.
54///
55/// Extracted from [`run_review`] so integration tests can lock the gate at the
56/// same layer the CLI uses without spawning the TUI. The returned
57/// [`ReviewSyncOutcome`] is precisely what determines whether `run_review` calls
58/// `incremental_sync_blocking` before opening the TUI.
59///
60/// `progress_callback` lets tests inject a counting callback. In production,
61/// `run_review` passes a stderr-printing throttled callback (see
62/// `tty_progress_printer` / `piped_progress_printer`).
63pub 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    // The freshness check + sync need two roots:
79    // - `sync_root` for git ops (gix opens the repo here; for worktrees this
80    //   resolves up to the shared common-dir).
81    // - `project_root` for actual file reads (the worktree's checkout dir)
82    //   AND for HEAD lookup — the worktree's HEAD is what we want to sync
83    //   *to*, not the main worktree's.
84    // For plain repos both paths are identical; for git worktrees they
85    // differ and `sync_root_for` would point at a sibling worktree.
86    let sync_root = crate::db::sync_root_for(project_root);
87
88    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
89    let freshness = check_branch_freshness(&branch_repo, project_root, branch_id);
90    run_review_sync_with_freshness(
91        db,
92        project_root,
93        &sync_root,
94        branch_id,
95        freshness,
96        progress_callback,
97    )
98}
99
100/// Run the blocking incremental sync given an already-computed
101/// [`FreshnessCheck`]. Lets `run_review` consult the gate ONCE and then
102/// drive both its banner output and the actual sync from the same
103/// reading — pre-fix it ran the gate, then [`prepare_review_sync`] ran
104/// it AGAIN, opening a TOCTOU window where HEAD could move between the
105/// two reads (P23).
106#[allow(clippy::too_many_arguments)]
107pub fn run_review_sync_with_freshness(
108    db: &Database,
109    project_root: &std::path::Path,
110    sync_root: &std::path::Path,
111    branch_id: &BranchId,
112    freshness: FreshnessCheck,
113    progress_callback: Option<&dyn Fn(usize, usize)>,
114) -> ReviewSyncOutcome {
115    let (old_commit, new_commit) = match freshness {
116        FreshnessCheck::UpToDate => return ReviewSyncOutcome::UpToDate,
117        FreshnessCheck::GitUnavailable => return ReviewSyncOutcome::GitUnavailable,
118        FreshnessCheck::Stale {
119            old_commit,
120            new_commit,
121        } => (old_commit, new_commit),
122    };
123
124    let config = AppConfig::load().unwrap_or_default();
125
126    let emits = std::sync::atomic::AtomicUsize::new(0);
127    let counted_cb = |processed: usize, total: usize| {
128        emits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
129        if let Some(cb) = progress_callback {
130            cb(processed, total);
131        }
132    };
133
134    crate::serve::incremental_sync_blocking(
135        project_root,
136        sync_root,
137        old_commit.as_deref(),
138        &branch_id.0,
139        db,
140        branch_id,
141        &config.scan,
142        &config.detection,
143        Some(&counted_cb),
144    );
145
146    ReviewSyncOutcome::Synced {
147        old_commit,
148        new_commit,
149        progress_emits: emits.load(std::sync::atomic::Ordering::Relaxed),
150    }
151}
152
153/// Build a TTY-aware throttled progress printer.
154///
155/// Emits `Syncing project state to <head[..7]>... Files: X / Y` to stderr,
156/// rewriting the same line at most once per [`TTY_PROGRESS_INTERVAL`] (≈1 Hz).
157/// The final `(total, total)` tick is always emitted (the throttle gates only
158/// the intermediate updates) so the user sees the completion line.
159fn tty_progress_printer(head_short: String) -> impl Fn(usize, usize) {
160    let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
161    move |processed: usize, total: usize| {
162        let mut guard = match last_emit.lock() {
163            Ok(g) => g,
164            Err(p) => p.into_inner(),
165        };
166        if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
167            return;
168        }
169        *guard = Instant::now();
170        drop(guard);
171        let mut stderr = std::io::stderr().lock();
172        let _ = write!(
173            stderr,
174            "\rSyncing project state to {head_short}... Files: {processed} / {total}    "
175        );
176        let _ = stderr.flush();
177    }
178}
179
180/// Build a non-TTY (piped) progress printer.
181///
182/// Emits one line per throttled update: `Syncing files: X / Y`. Used when
183/// stderr is not a terminal (CI logs, tee, redirected output) so that lines
184/// are preserved instead of being overwritten with carriage returns.
185fn piped_progress_printer(head_short: String) -> impl Fn(usize, usize) {
186    let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
187    move |processed: usize, total: usize| {
188        let mut guard = match last_emit.lock() {
189            Ok(g) => g,
190            Err(p) => p.into_inner(),
191        };
192        if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
193            return;
194        }
195        *guard = Instant::now();
196        drop(guard);
197        eprintln!("Syncing project state to {head_short}: {processed} / {total} files");
198    }
199}
200
201/// Run the `seshat review` command end-to-end.
202///
203/// 1. Resolves the project DB and active branch.
204/// 2. Unless `no_sync` is set, runs the freshness gate; on `Stale`, runs an
205///    incremental blocking sync to HEAD with a stderr progress UI (TTY: same-
206///    line `\r` rewrite at 1 Hz; piped: one line per throttled update).
207/// 3. Launches the TUI with the (now-fresh) connection.
208pub fn run_review(project_path: Option<PathBuf>, no_sync: bool) -> Result<(), CliError> {
209    // Resolve the project — shared resolver also used by serve/status.
210    let explicit = project_path.as_deref();
211    let resolved = crate::db::resolve_project(explicit, "review")?;
212
213    // Check that the database actually exists.
214    if !resolved.db_path.exists() {
215        return Err(CliError::CommandFailed {
216            command: "review".to_owned(),
217            reason: "No database found. Run `seshat scan` first.".to_owned(),
218        });
219    }
220
221    // Determine branch once and pass it through to all downstream calls.
222    let branch_id_str =
223        crate::db::get_current_branch(&resolved.project_root).unwrap_or_else(|| {
224            tracing::debug!(
225               path = %resolved.project_root.display(),
226                "Could not detect git branch, defaulting to 'main'"
227            );
228            "main".to_string()
229        });
230    let branch_id = BranchId::from(branch_id_str.as_str());
231
232    // Open via Database so the freshness check, the blocking sync, and the TUI
233    // can all share one Arc<Mutex<Connection>> handle.
234    let db = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
235        command: "review".to_owned(),
236        reason: format!("failed to open database: {e}"),
237    })?;
238
239    // -- Freshness gate + blocking sync ---------------------------------
240    if no_sync {
241        prepare_review_sync(&db, &resolved.project_root, &branch_id, true, None);
242    } else {
243        // P23: consult the freshness gate ONCE and pass the result down.
244        // Pre-fix this ran the gate here, then prepare_review_sync ran
245        // it AGAIN — a TOCTOU window where HEAD could move between the
246        // two reads (banner announcing commit X, sync against commit Y).
247        // The resolver already walked to the git common-dir, so we just
248        // borrow it (or fall back to project_root for non-git).
249        let sync_root = resolved.sync_root().to_path_buf();
250        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
251        // HEAD lookup must hit the project root (worktree), not sync_root.
252        let freshness = check_branch_freshness(&branch_repo, &resolved.project_root, &branch_id);
253        match &freshness {
254            FreshnessCheck::UpToDate => {
255                tracing::debug!(branch = %branch_id.0, "review: DB is up to date with HEAD");
256            }
257            FreshnessCheck::GitUnavailable => {
258                tracing::debug!(
259                    root = %sync_root.display(),
260                    "review: git unavailable, skipping freshness check"
261                );
262            }
263            FreshnessCheck::Stale { new_commit, .. } => {
264                let head_short: String = new_commit.chars().take(7).collect();
265                // P22: PRD US-011 specifies stdout for the user-facing
266                // banner ("Syncing project state to ..."). The progress
267                // printer keeps writing to stderr — that's the standard
268                // place for transient progress info that should not
269                // pollute redirected stdout, and the printers handle TTY
270                // detection on stderr internally. The TTY check here
271                // gates the banner format only, against stdout, since
272                // that's what the spec wires to it.
273                let is_stdout_tty = std::io::stdout().is_terminal();
274                if is_stdout_tty {
275                    print!("Syncing project state to {head_short}... ");
276                    let _ = std::io::stdout().lock().flush();
277                } else {
278                    println!("Syncing project state to {head_short}...");
279                }
280                if is_stdout_tty {
281                    let printer = tty_progress_printer(head_short.clone());
282                    run_review_sync_with_freshness(
283                        &db,
284                        &resolved.project_root,
285                        &sync_root,
286                        &branch_id,
287                        freshness.clone(),
288                        Some(&printer),
289                    );
290                    // Newline after the in-place progress line, plus a "done"
291                    // marker so the user knows the sync finished before the
292                    // TUI takes over the screen.
293                    println!("\rSyncing project state to {head_short}... done.            ");
294                    let _ = std::io::stdout().lock().flush();
295                } else {
296                    let printer = piped_progress_printer(head_short.clone());
297                    run_review_sync_with_freshness(
298                        &db,
299                        &resolved.project_root,
300                        &sync_root,
301                        &branch_id,
302                        freshness.clone(),
303                        Some(&printer),
304                    );
305                    println!("Sync complete.");
306                    let _ = std::io::stdout().lock().flush();
307                }
308            }
309        }
310    }
311
312    let conn = db.connection().clone();
313    crate::tui::run_review_tui_with_conn(&branch_id_str, &conn)?;
314
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use tempfile::tempdir;
322
323    #[test]
324    fn run_review_nonexistent_project_returns_error() {
325        let result = run_review(
326            Some(PathBuf::from("/tmp/seshat-nonexistent-review-test-xyz")),
327            false,
328        );
329        assert!(result.is_err());
330    }
331
332    #[test]
333    fn run_review_with_some_path_sets_deref() {
334        let tmp = tempdir().unwrap();
335        let db_path = tmp.path().join("seshat.db");
336
337        std::fs::write(&db_path, "fake db").unwrap();
338
339        let result = run_review(Some(tmp.path().to_path_buf()), false);
340        assert!(result.is_err());
341    }
342
343    #[test]
344    fn run_review_file_instead_of_directory_error() {
345        let tmp = tempdir().unwrap();
346        let file_path = tmp.path().join("just_a_file");
347        std::fs::write(&file_path, "hello").unwrap();
348        let result = run_review(Some(file_path), false);
349        assert!(result.is_err());
350    }
351
352    // ── prepare_review_sync ─────────────────────────────────────────────
353
354    #[test]
355    fn prepare_review_sync_returns_skipped_when_no_sync_passed() {
356        let dir = tempdir().expect("tempdir");
357        let db = Database::open(":memory:").expect("open db");
358        let branch = BranchId::from("main");
359
360        let outcome = prepare_review_sync(&db, dir.path(), &branch, true, None);
361        assert_eq!(outcome, ReviewSyncOutcome::Skipped);
362    }
363
364    #[test]
365    fn prepare_review_sync_returns_git_unavailable_for_non_git_directory() {
366        let dir = tempdir().expect("tempdir");
367        let db = Database::open(":memory:").expect("open db");
368        let branch = BranchId::from("main");
369
370        let outcome = prepare_review_sync(&db, dir.path(), &branch, false, None);
371        assert_eq!(outcome, ReviewSyncOutcome::GitUnavailable);
372    }
373}