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";
#[derive(Debug, Args)]
pub(super) struct CaptureArgs {
#[arg(long, default_value = ".")]
repo: PathBuf,
#[arg(long)]
operation_id: Option<String>,
#[arg(long, requires = "advance")]
review_cursor: Option<String>,
#[arg(long, value_enum, requires = "review_cursor")]
advance: Option<CaptureAdvanceArg>,
#[arg(long, requires = "review_cursor")]
also_supersedes: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
root: bool,
#[arg(long)]
staged: bool,
#[arg(long)]
unstaged: bool,
#[arg(long)]
include_untracked: bool,
#[arg(long)]
allow_empty: bool,
#[arg(long)]
target: Option<String>,
#[arg(long = "supersedes")]
supersedes: Vec<String>,
#[arg(long)]
summary: Option<String>,
#[arg(long = "path", value_name = "PATHSPEC")]
paths: Vec<String>,
#[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);
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(),
)?)
}
#[cfg(test)]
fn capture_receipt_text(result: &pointbreak::session::CaptureResult) -> String {
crate::cli::common::with_advisory_lines(render_capture_text(result), &result.diagnostics)
}
#[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))
}
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::*;
#[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}"
);
}
}