pointbreak 0.5.0

Durable terminal code review for changes humans and coding agents collaborate on together
Documentation
//! `shore history --watch` re-renders only when the store's liveness
//! token (`event_set_hash`) changes, never on a bare poll tick, and runs as a
//! pure client-side poll: no daemon, no filesystem watch. It is killed on drop.

mod support;

use std::io::Read;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

use support::git_repo::GitRepo;
use support::inspect::capture;
use support::shore;

/// A spawned `shore history --watch`, draining stdout into a shared
/// buffer in the background; killed on drop.
struct Watcher {
    child: Child,
    stdout: Arc<Mutex<String>>,
    _drain: thread::JoinHandle<()>,
}

impl Watcher {
    fn spawn(repo: &Path, poll_ms: u64) -> Self {
        let mut child = Command::new(env!("CARGO_BIN_EXE_shore"))
            .args([
                "history",
                "--repo",
                repo.to_str().unwrap(),
                "--watch",
                "--poll-ms",
                &poll_ms.to_string(),
            ])
            .env_remove("SHORE_LOG")
            .env_remove("RUST_LOG")
            .env_remove("SHORE_FORMAT")
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn shore review history --watch");

        let stdout = Arc::new(Mutex::new(String::new()));
        let mut child_stdout = child.stdout.take().expect("watcher stdout");
        let sink = Arc::clone(&stdout);
        let drain = thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match child_stdout.read(&mut buf) {
                    Ok(0) | Err(_) => break,
                    Ok(n) => {
                        if let Ok(mut guard) = sink.lock() {
                            guard.push_str(&String::from_utf8_lossy(&buf[..n]));
                        }
                    }
                }
            }
        });

        Self {
            child,
            stdout,
            _drain: drain,
        }
    }

    /// Each render is one compact JSON document on its own line, so a render
    /// count is the number of non-empty lines emitted so far.
    fn render_count(&self) -> usize {
        self.stdout
            .lock()
            .map(|guard| guard.lines().filter(|line| !line.trim().is_empty()).count())
            .unwrap_or(0)
    }

    /// Poll until at least `target` renders have appeared (or the timeout
    /// elapses), returning the final count.
    fn wait_for_renders(&self, target: usize, timeout: Duration) -> usize {
        let deadline = Instant::now() + timeout;
        loop {
            let count = self.render_count();
            if count >= target || Instant::now() >= deadline {
                return count;
            }
            thread::sleep(Duration::from_millis(25));
        }
    }
}

impl Drop for Watcher {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

#[test]
fn watch_reprints_only_when_event_set_hash_changes() {
    let repo = GitRepo::new();
    repo.write("src/lib.rs", "pub fn value() -> u32 { 1 }\n");
    repo.commit_all("base");
    repo.write("src/lib.rs", "pub fn value() -> u32 { 2 }\n");

    // Seed one captured review so the watcher starts from a non-empty store.
    capture(repo.path());

    let watcher = Watcher::spawn(repo.path(), 50);

    // 1) The initial state prints exactly once on startup.
    let after_initial = watcher.wait_for_renders(1, Duration::from_secs(10));
    assert_eq!(after_initial, 1, "watch prints once on startup");

    // 2) Several poll cycles pass with no store change: no reprint. This is the
    //    crux — reprints track content transitions, not wall-clock ticks.
    thread::sleep(Duration::from_millis(500));
    assert_eq!(
        watcher.render_count(),
        1,
        "watch must not reprint on a bare tick"
    );

    // 3) A real change — one new observation event — triggers exactly one
    //    reprint.
    let output = shore([
        "observation",
        "add",
        "--repo",
        repo.path().to_str().unwrap(),
        "--track",
        "agent:codex",
        "--title",
        "watched change",
        "--body",
        "this moves the event set hash",
    ]);
    assert!(
        output.status.success(),
        "observation add failed:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );

    let after_change = watcher.wait_for_renders(2, Duration::from_secs(10));
    assert_eq!(
        after_change, 2,
        "watch reprints once when the event set changes"
    );

    // 4) The change settles: no further reprint without another change.
    thread::sleep(Duration::from_millis(500));
    assert_eq!(
        watcher.render_count(),
        2,
        "watch must not reprint again once the change is rendered"
    );
}