use crate::commands::review::{run as run_review, HookMode, ReviewOptions};
use crate::exit::{CliError, Exit};
use quorum_core::git::DiffSource;
use quorum_lippa_client::keyring::Storage;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
pub const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushTuple {
pub local_ref: String,
pub local_sha: String,
pub remote_ref: String,
pub remote_sha: String,
}
impl PushTuple {
pub fn classify(&self) -> TupleAction {
if self.local_ref == "(delete)" {
return TupleAction::SkipDeletion;
}
if self.local_ref.starts_with("refs/tags/") {
return TupleAction::SkipTag;
}
if self.remote_sha == ZERO_SHA {
return TupleAction::NewBranch;
}
TupleAction::Range {
base: self.remote_sha.clone(),
head: self.local_sha.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TupleAction {
SkipDeletion,
SkipTag,
NewBranch,
Range { base: String, head: String },
}
pub fn parse_tuples<R: Read>(reader: R) -> Vec<PushTuple> {
let mut out = Vec::new();
let buf = BufReader::new(reader);
for line in buf.lines().map_while(Result::ok) {
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
let fields: Vec<&str> = trimmed.split_whitespace().collect();
if fields.len() < 4 {
eprintln!(
"quorum: skipping malformed pre-push stdin line ({} fields): {trimmed}",
fields.len()
);
continue;
}
out.push(PushTuple {
local_ref: fields[0].to_string(),
local_sha: fields[1].to_string(),
remote_ref: fields[2].to_string(),
remote_sha: fields[3].to_string(),
});
}
out
}
pub fn push_start_iso(now: time::OffsetDateTime) -> String {
let iso = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".into());
iso.replace(':', "-")
}
pub async fn run_pre_commit(
repo_start: &Path,
json: bool,
no_keyring_storage: Option<Storage>,
) -> Result<Exit, CliError> {
run_review(
repo_start,
ReviewOptions {
json_to_stdout: json,
no_keyring_storage,
diff_source: DiffSource::StagedIndex,
no_expire: false,
tui: false,
hook_mode: HookMode::PreCommit,
archive_filename_override: None,
},
)
.await
}
pub async fn run_pre_push<R: Read>(
repo_start: &Path,
stdin: R,
no_keyring_storage: Option<Storage>,
) -> Result<Exit, CliError> {
let tuples = parse_tuples(stdin);
if tuples.is_empty() {
eprintln!("quorum: pre-push received no ref tuples on stdin; nothing to review");
return Ok(Exit::Ok);
}
let push_iso = push_start_iso(time::OffsetDateTime::now_utc());
let mut max_exit = Exit::Ok;
for (i, t) in tuples.iter().enumerate() {
let tuple_n = i + 1;
let archive_name = format!("{push_iso}.tuple-{tuple_n}.json");
let diff_source = match t.classify() {
TupleAction::SkipDeletion => {
eprintln!(
"quorum: branch deletion detected for {}; skipping review",
t.remote_ref
);
continue;
}
TupleAction::SkipTag => {
eprintln!(
"quorum: tag push detected; skipping review (code review not applicable to refs/tags/*)"
);
continue;
}
TupleAction::NewBranch => {
match resolve_new_branch_base(repo_start, &t.local_sha) {
Ok(Some(base)) => DiffSource::CommitRange {
base,
head: t.local_sha.clone(),
},
Ok(None) => {
eprintln!(
"quorum: new-branch push for {}: no upstream HEAD; reviewing the tip commit only",
t.remote_ref
);
DiffSource::CommitRange {
base: format!("{}^", t.local_sha),
head: t.local_sha.clone(),
}
}
Err(e) => {
eprintln!(
"quorum: new-branch base resolution failed for {}: {e}; skipping",
t.remote_ref
);
continue;
}
}
}
TupleAction::Range { base, head } => DiffSource::CommitRange { base, head },
};
let exit = run_review(
repo_start,
ReviewOptions {
json_to_stdout: false,
no_keyring_storage: no_keyring_storage.clone(),
diff_source,
no_expire: false,
tui: false,
hook_mode: HookMode::PrePush,
archive_filename_override: Some(archive_name),
},
)
.await?;
max_exit = max_exit.max(exit);
}
Ok(max_exit)
}
fn resolve_new_branch_base(repo_start: &Path, local_sha: &str) -> Result<Option<String>, String> {
let repo = git2::Repository::discover(repo_start).map_err(|e| format!("repo discover: {e}"))?;
let head_ref = match repo.find_reference("refs/remotes/origin/HEAD") {
Ok(r) => r,
Err(_) => return Ok(None),
};
let head_oid = head_ref
.resolve()
.map_err(|e| format!("resolve origin/HEAD: {e}"))?
.target()
.ok_or_else(|| "origin/HEAD has no target oid".to_string())?;
let local_oid = git2::Oid::from_str(local_sha).map_err(|e| format!("oid parse: {e}"))?;
let base = repo
.merge_base(local_oid, head_oid)
.map_err(|e| format!("merge_base: {e}"))?;
Ok(Some(base.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_tuples_handles_standard_push() {
let stdin = "refs/heads/main abc123 refs/heads/main 0000def\n";
let v = parse_tuples(stdin.as_bytes());
assert_eq!(v.len(), 1);
let t = &v[0];
assert_eq!(t.local_ref, "refs/heads/main");
assert_eq!(t.local_sha, "abc123");
assert_eq!(t.remote_ref, "refs/heads/main");
assert_eq!(t.remote_sha, "0000def");
}
#[test]
fn parse_tuples_skips_blank_lines() {
let stdin = "\n\nrefs/heads/x sha refs/heads/x sha\n\n";
let v = parse_tuples(stdin.as_bytes());
assert_eq!(v.len(), 1);
}
#[test]
fn parse_tuples_handles_multi_ref_atomic_push() {
let stdin = "refs/heads/a sha1 refs/heads/a 0000000000000000000000000000000000000000\n\
refs/heads/b sha2 refs/heads/b 0000000000000000000000000000000000000000\n";
let v = parse_tuples(stdin.as_bytes());
assert_eq!(v.len(), 2);
}
#[test]
fn parse_tuples_skips_malformed_lines() {
let stdin = "refs/heads/main abc only-three-fields\nrefs/heads/x sha refs/heads/x sha\n";
let v = parse_tuples(stdin.as_bytes());
assert_eq!(v.len(), 1);
assert_eq!(v[0].local_ref, "refs/heads/x");
}
#[test]
fn classify_deletion_uses_literal_delete_marker() {
let t = PushTuple {
local_ref: "(delete)".into(),
local_sha: ZERO_SHA.into(),
remote_ref: "refs/heads/feature/x".into(),
remote_sha: "abc1234".into(),
};
assert_eq!(t.classify(), TupleAction::SkipDeletion);
}
#[test]
fn classify_tag_push_is_skipped() {
let t = PushTuple {
local_ref: "refs/tags/v1.0".into(),
local_sha: "abc1234".into(),
remote_ref: "refs/tags/v1.0".into(),
remote_sha: ZERO_SHA.into(),
};
assert_eq!(t.classify(), TupleAction::SkipTag);
}
#[test]
fn classify_new_branch_uses_zero_remote_sha() {
let t = PushTuple {
local_ref: "refs/heads/feature/x".into(),
local_sha: "abc1234".into(),
remote_ref: "refs/heads/feature/x".into(),
remote_sha: ZERO_SHA.into(),
};
assert_eq!(t.classify(), TupleAction::NewBranch);
}
#[test]
fn classify_standard_push_uses_range() {
let t = PushTuple {
local_ref: "refs/heads/main".into(),
local_sha: "new_sha".into(),
remote_ref: "refs/heads/main".into(),
remote_sha: "old_sha".into(),
};
assert_eq!(
t.classify(),
TupleAction::Range {
base: "old_sha".into(),
head: "new_sha".into(),
}
);
}
#[test]
fn classify_force_push_has_no_special_marker_uses_range() {
let t = PushTuple {
local_ref: "refs/heads/main".into(),
local_sha: "rewound_sha".into(),
remote_ref: "refs/heads/main".into(),
remote_sha: "old_tip_sha".into(),
};
assert!(matches!(t.classify(), TupleAction::Range { .. }));
}
#[test]
fn push_start_iso_strips_colons() {
let t = time::OffsetDateTime::from_unix_timestamp(1_715_350_981).unwrap();
let s = push_start_iso(t);
assert!(!s.contains(':'));
}
}