pointbreak 0.10.0

Durable terminal code review for changes humans and coding agents collaborate on together
Documentation
use std::io::Write;
use std::path::PathBuf;

use clap::{Args, ValueEnum};
use pointbreak::model::{ChangeIdentityDescriptorV1, RevisionId, RevisionRefV1};
use pointbreak::session::{
    CaptureOptions, ChangeAdvanceV1, ChangeCaptureOptions, CommitRangeSpec, RootCommitSpec,
    StagedSpec, UnstagedSpec, WorktreeSpec, capture_change_revision,
};
use sha2::{Digest, Sha256};

#[cfg(test)]
use crate::cli::common::endpoint_label;
use crate::cli::output;
use crate::cli_tracing::TracingArgs;

const CHANGE_CAPTURE_RECEIPT_SCHEMA: &str = "pointbreak.change-capture-receipt.v1";

/// Capture a revision from the working tree or a committed commit range.
#[derive(Debug, Args)]
pub(super) struct CaptureArgs {
    #[arg(long, default_value = ".")]
    repo: PathBuf,

    /// Retry identity. Generated and retained locally when omitted.
    #[arg(long)]
    operation_id: Option<String>,

    /// Advance the exact Revision selected by this cursor within its stable Change.
    #[arg(long, requires = "advance")]
    review_cursor: Option<String>,

    /// Whether the new Revision replaces the selected Revision or remains parallel.
    #[arg(long, value_enum, requires = "review_cursor")]
    advance: Option<CaptureAdvanceArg>,

    /// Additional exact `REVISION_ID@OBJECT_ARTIFACT_SHA256` predecessors for consolidation.
    #[arg(long, requires = "review_cursor")]
    also_supersedes: Vec<String>,

    /// Capture the committed range from this rev (resolved to a commit, peeling
    /// annotated tags) to --target instead of the HEAD -> working-tree diff.
    /// The working tree and untracked files are not read.
    #[arg(long)]
    base: Option<String>,

    /// Capture the target commit against Git's empty tree.
    #[arg(long)]
    root: bool,

    /// Capture staged changes only.
    #[arg(long)]
    staged: bool,

    /// Capture unstaged tracked changes only.
    #[arg(long)]
    unstaged: bool,

    /// Include untracked files with worktree or unstaged capture.
    #[arg(long)]
    include_untracked: bool,

    /// Record a revision even when the selected source has no changed files.
    #[arg(long)]
    allow_empty: bool,

    /// Target rev (resolved to a commit). Defaults to HEAD with --base or --root.
    #[arg(long)]
    target: Option<String>,

    /// Unsupported legacy flag. Use --review-cursor --advance replace.
    #[arg(long = "supersedes")]
    supersedes: Vec<String>,

    /// Short human-readable label shown by revision discovery surfaces.
    #[arg(long)]
    summary: Option<String>,

    /// Scope the capture to the given git pathspec(s): both the tracked diff
    /// and untracked-file synthesis include only matching files. May be
    /// repeated; the recorded set is order-independent. Pathspecs are
    /// interpreted relative to the repository root (native git pathspec
    /// syntax, including magic like ":(exclude)..."). A scope that matches no
    /// changed files is an error.
    #[arg(long = "path", value_name = "PATHSPEC")]
    paths: Vec<String>,

    /// Sign this write with a specific key: a keystore key name or a path to a
    /// key file. Overrides POINTBREAK_SIGNING_KEY. A key that cannot be loaded leaves
    /// the write unsigned (exit 0) with an advisory diagnostic — signing never
    /// blocks.
    #[arg(long)]
    sign_key: Option<String>,

    #[command(flatten)]
    format_args: output::FormatArgs,
}

#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum CaptureAdvanceArg {
    Replace,
    Parallel,
}

pub(super) fn run(
    args: CaptureArgs,
    tracing: &TracingArgs,
    stdout: &mut dyn Write,
    stderr: &mut dyn Write,
) -> Result<(), Box<dyn std::error::Error>> {
    let span = tracing::info_span!("shore.review.capture");
    let _entered = span.enter();
    tracing::debug!(command = "review.capture", "command_start");
    let explicit_sources = [args.base.is_some(), args.root, args.staged, args.unstaged]
        .into_iter()
        .filter(|selected| *selected)
        .count();
    if explicit_sources > 1 {
        return Err("--base, --root, --staged, and --unstaged are mutually exclusive".into());
    }
    if args.target.is_some() && args.base.is_none() && !args.root {
        return Err("--target requires --base or --root".into());
    }
    if args.include_untracked && (args.base.is_some() || args.root || args.staged) {
        return Err(
            "--include-untracked can only be used with worktree or unstaged capture".into(),
        );
    }
    if !args.supersedes.is_empty() {
        return Err("proposal-borne --supersedes is no longer supported; use --review-cursor --advance replace and exact --also-supersedes references".into());
    }
    let (options, skip) = capture_options(&args, tracing, stderr)?;
    let operation_id = match args.operation_id.clone() {
        Some(operation_id) => operation_id,
        None => random_operation_id()?,
    };
    let mut change_options = if let Some(cursor) = &args.review_cursor {
        let advance = match args.advance.expect("clap requires advance with cursor") {
            CaptureAdvanceArg::Replace => ChangeAdvanceV1::Replace,
            CaptureAdvanceArg::Parallel => ChangeAdvanceV1::Parallel,
        };
        ChangeCaptureOptions::advance(operation_id, options, cursor, advance)
    } else {
        let identity_nonce = nonce_for_operation(&operation_id);
        ChangeCaptureOptions::initial(
            operation_id,
            options,
            ChangeIdentityDescriptorV1::opaque_nonce(identity_nonce),
        )
    };
    for predecessor in &args.also_supersedes {
        change_options =
            change_options.with_additional_predecessor(parse_revision_ref(predecessor)?);
    }
    let capture = capture_change_revision(change_options)?;
    debug_assert_eq!(capture.schema, CHANGE_CAPTURE_RECEIPT_SCHEMA);
    crate::cli::common::surface_best_effort_skip(&skip, stderr);
    // Best-effort: if this worktree is splitting off from a family store a sibling
    // worktree is linked to, say so on stderr. Never fails the capture.
    if let Ok(Some(advisory)) = pointbreak::session::family_link_advisory(&args.repo) {
        let _ = writeln!(stderr, "{advisory}");
    }
    let format = output::resolve_format(args.format_args.explicit(), output::OutputFormat::Json)?;
    let text = render_change_capture_text(&capture);
    output::write_document(stdout, format, &capture, || text)
}

fn render_change_capture_text(capture: &pointbreak::session::ChangeCaptureReceiptV1) -> String {
    let file_word = if capture.diffstat.file_count == 1 {
        "file"
    } else {
        "files"
    };
    let mut lines = vec![format!(
        "captured {} in {} · operation {}",
        output::short_ref(capture.revision.revision_id.as_str()),
        output::short_ref(capture.change_id.as_str()),
        capture.operation_id,
    )];
    if let Some(summary) = &capture.revision.summary {
        lines.push(format!("summary: {summary}"));
    }
    lines.push(format!(
        "{} {file_word} · +{}/−{}",
        capture.diffstat.file_count, capture.diffstat.added_lines, capture.diffstat.removed_lines
    ));
    lines.join("\n")
}

fn random_operation_id() -> Result<String, Box<dyn std::error::Error>> {
    let mut nonce = [0_u8; 32];
    getrandom::fill(&mut nonce)?;
    let encoded = nonce
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect::<String>();
    Ok(format!("change-operation:{encoded}"))
}

fn nonce_for_operation(operation_id: &str) -> [u8; 32] {
    Sha256::digest(operation_id.as_bytes()).into()
}

fn parse_revision_ref(value: &str) -> Result<RevisionRefV1, Box<dyn std::error::Error>> {
    let (revision, artifact_hash) = value
        .split_once('@')
        .ok_or("--also-supersedes must be REVISION_ID@OBJECT_ARTIFACT_SHA256")?;
    Ok(RevisionRefV1::new(
        RevisionId::new(revision),
        artifact_hash.to_owned(),
    )?)
}

/// The full capture receipt for the text lane: the rendered ack plus one
/// `advisory:` line per projection diagnostic the write document carries.
#[cfg(test)]
fn capture_receipt_text(result: &pointbreak::session::CaptureResult) -> String {
    crate::cli::common::with_advisory_lines(render_capture_text(result), &result.diagnostics)
}

/// Text capture ack: a few-line confirmation shaped on the inspector's
/// revision-page header — revision short ref, base -> target, diffstat, event
/// counts. Renders from the public `CaptureResult`; wording is disposable.
#[cfg(test)]
fn render_capture_text(result: &pointbreak::session::CaptureResult) -> String {
    let stat = &result.diffstat;

    let statuses: Vec<String> = [
        (stat.added_files, "added"),
        (stat.modified_files, "modified"),
        (stat.deleted_files, "deleted"),
        (stat.renamed_files, "renamed"),
        (stat.copied_files, "copied"),
    ]
    .into_iter()
    .filter(|(count, _)| *count > 0)
    .map(|(count, label)| format!("{count} {label}"))
    .collect();

    let file_word = if stat.file_count == 1 {
        "file"
    } else {
        "files"
    };
    let mut diff_line = format!("{} {file_word}", stat.file_count);
    if !statuses.is_empty() {
        diff_line.push_str(&format!(" ({})", statuses.join(", ")));
    }
    diff_line.push_str(&format!(" · +{}/−{}", stat.added_lines, stat.removed_lines));
    if stat.binary_files > 0 {
        diff_line.push_str(&format!(" · {} binary", stat.binary_files));
    }
    if stat.mode_only_files > 0 {
        diff_line.push_str(&format!(" · {} mode-only", stat.mode_only_files));
    }

    let mut lines = vec![format!(
        "captured {} · base {} → {}",
        output::short_ref(result.revision_id.as_str()),
        endpoint_label(&result.base),
        endpoint_label(&result.target),
    )];
    if let Some(summary) = &result.summary {
        lines.push(format!(
            "summary: {}",
            crate::cli::common::clamp_title(summary)
        ));
    }
    lines.extend([
        diff_line,
        format!(
            "events: {} created, {} existing",
            result.events_created, result.events_existing
        ),
    ]);
    lines.join("\n")
}

fn capture_options(
    args: &CaptureArgs,
    tracing: &TracingArgs,
    stderr: &mut dyn Write,
) -> Result<(CaptureOptions, crate::cli::common::SigningSkip), Box<dyn std::error::Error>> {
    let mut options = CaptureOptions::new(&args.repo);
    if args.root {
        options = options.with_root_commit(root_commit_spec(args));
    } else if args.staged {
        options = options.with_staged(StagedSpec::new());
    } else if args.unstaged {
        options = options.with_unstaged(unstaged_spec(args));
    } else if let Some(range) = commit_range_spec(args) {
        options = options.with_commit_range(range);
    } else if args.include_untracked {
        options = options.with_worktree(WorktreeSpec::new().with_include_untracked());
    }
    if let Some(summary) = &args.summary {
        options = options.with_summary(summary.clone());
    }
    if !args.paths.is_empty() {
        options = options.with_pathspecs(args.paths.clone());
    }
    if args.allow_empty {
        options = options.with_allow_empty();
    }
    if let Some(log_file) = &tracing.log_file {
        options = options.with_excluded_helper_path(log_file);
    }
    let mut skip = None;
    if let Some(resolved) =
        crate::cli::common::resolve_and_surface_signer(&args.repo, args.sign_key.as_deref(), stderr)
    {
        let (signed, signer_skip) = crate::cli::common::apply_resolved_signer(options, resolved);
        options = signed;
        skip = signer_skip;
    }
    Ok((options, skip))
}

/// Build the commit-range spec from `--base`/`--target`. `None` keeps the
/// default worktree capture. `--target` without `--base` or `--root` is rejected
/// in `run` before this point.
fn commit_range_spec(args: &CaptureArgs) -> Option<CommitRangeSpec> {
    let base = args.base.as_ref()?;
    let mut range = CommitRangeSpec::new(base.clone());
    if let Some(target) = &args.target {
        range = range.with_target_rev(target.clone());
    }
    Some(range)
}

fn root_commit_spec(args: &CaptureArgs) -> RootCommitSpec {
    let mut root = RootCommitSpec::new();
    if let Some(target) = &args.target {
        root = root.with_target_rev(target.clone());
    }
    root
}

fn unstaged_spec(args: &CaptureArgs) -> UnstagedSpec {
    let mut unstaged = UnstagedSpec::new();
    if args.include_untracked {
        unstaged = unstaged.with_include_untracked();
    }
    unstaged
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use pointbreak::model::{
        EngagementId, JournalId, ObjectId, ReviewEndpoint, RevisionId, RevisionSource,
        WorktreeCaptureMode,
    };
    use pointbreak::session::{CaptureDiffstat, CaptureResult, ProjectionDiagnostic};

    use super::*;

    /// The capture receipt must not silently drop what the JSON write document
    /// carries: every projection diagnostic surfaces as an `advisory:` line.
    #[test]
    fn capture_receipt_surfaces_projection_diagnostics() {
        let result = CaptureResult {
            journal_id: JournalId::new("journal:default"),
            revision_id: RevisionId::new(format!("rev:sha256:{}", "ab".repeat(32))),
            object_id: ObjectId::new(format!("obj:sha256:{}", "ab".repeat(32))),
            engagement_id: EngagementId::new(format!("engagement:sha256:{}", "ab".repeat(32))),
            summary: None,
            source: RevisionSource::GitWorktree {
                mode: WorktreeCaptureMode::CombinedHeadToWorkingTree,
                include_untracked: false,
                pathspecs: Vec::new(),
            },
            base: ReviewEndpoint::GitCommit {
                commit_oid: "ab".repeat(20),
                tree_oid: "cd".repeat(20),
            },
            target: ReviewEndpoint::GitWorkingTree {
                worktree_root: "/repo".to_owned(),
            },
            object_artifact_content_hash: format!("sha256:{}", "ab".repeat(32)),
            events_created: 1,
            events_existing: 0,
            events_created_by_type: BTreeMap::new(),
            diagnostics: vec![ProjectionDiagnostic {
                code: "ref_association_auto_record_skipped".to_owned(),
                message: "capture-time ref association was not recorded: boom".to_owned(),
            }],
            diffstat: CaptureDiffstat::default(),
        };
        let receipt = capture_receipt_text(&result);
        assert!(
            receipt.contains("advisory: capture-time ref association was not recorded"),
            "diagnostics surface on the receipt: {receipt}"
        );
    }
}