use crate::auto_push_single_flight::{PushGit, git_command};
use crate::manual_push_cause::ManualPushCause;
use crate::manual_push_probe::PushUpdate;
use crate::start_retry::RetryStart;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io::Write;
use std::process::Stdio;
const TRACKING_NAMESPACE: &str = "refs/remotes/";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManualPushRangeError {
pub message: String,
pub cause: ManualPushCause,
}
impl std::fmt::Display for ManualPushRangeError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(&self.message);
}
}
impl std::error::Error for ManualPushRangeError {}
fn range_error(cause: ManualPushCause, message: String) -> ManualPushRangeError {
return ManualPushRangeError { message, cause };
}
pub fn run_git_bytes(
git: &PushGit,
arguments: &[OsString],
input: Option<&str>,
) -> Result<Vec<u8>, ManualPushRangeError> {
let mut command = git_command(git, arguments);
command
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn_retrying().map_err(|error| {
return range_error(ManualPushCause::GitNotStarted, format!("git could not be started: {error}"));
})?;
if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
let _written = stdin.write_all(text.as_bytes());
}
let output = child.wait_with_output().map_err(|error| {
return range_error(ManualPushCause::GitSilent, format!("git did not finish: {error}"));
})?;
if !output.status.success() {
return Err(range_error(ManualPushCause::ContentMissing, format!(
"git {} failed: {}",
arguments
.iter()
.map(|argument| {
return argument.to_string_lossy().into_owned();
})
.collect::<Vec<String>>()
.join(" "),
String::from_utf8_lossy(&output.stderr).trim_end()
)));
}
return Ok(output.stdout);
}
fn lines_of(bytes: &[u8]) -> Vec<String> {
return String::from_utf8_lossy(bytes)
.split('\n')
.filter(|line| {
return !line.is_empty();
})
.map(str::to_owned)
.collect();
}
pub fn peel_commits(
git: &PushGit,
oids: &[String],
) -> Result<BTreeMap<String, String>, ManualPushRangeError> {
let mut unique: Vec<String> = oids.to_vec();
unique.sort();
unique.dedup();
if unique.is_empty() {
return Ok(BTreeMap::new());
}
let input: String = unique
.iter()
.map(|oid| {
return format!("{oid}^{{}}\n");
})
.collect();
let arguments: Vec<OsString> = ["cat-file", "--batch-check=%(objectname) %(objecttype)"]
.iter()
.map(OsString::from)
.collect();
let output = run_git_bytes(git, &arguments, Some(&input))?;
let records = lines_of(&output);
if records.len() != unique.len() {
return Err(range_error(
ManualPushCause::UnexpectedOutput,
"Git object batch-check returned an unexpected record count.".to_owned(),
));
}
let mut commits: BTreeMap<String, String> = BTreeMap::new();
for (oid, record) in unique.iter().zip(records.iter()) {
if record == &format!("{oid}^{{}} missing") {
continue;
}
let fields: Vec<&str> = record.split(' ').collect();
if fields.len() != 2 {
return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
"Git object batch-check returned malformed record: {record}"
)));
}
if fields[1] == "commit" {
commits.insert(oid.clone(), fields[0].to_owned());
}
}
return Ok(commits);
}
pub fn tracking_commits(
git: &PushGit,
remote_names: &[String],
) -> Result<BTreeMap<String, Vec<String>>, ManualPushRangeError> {
if remote_names.is_empty() {
return Ok(BTreeMap::new());
}
let arguments: Vec<OsString> = [
"for-each-ref",
"--format=%(objectname) %(objecttype) %(refname)",
TRACKING_NAMESPACE,
]
.iter()
.map(OsString::from)
.collect();
let refs = lines_of(&run_git_bytes(git, &arguments, None)?);
let mut by_remote: BTreeMap<String, Vec<String>> = BTreeMap::new();
for remote_name in remote_names {
let prefix = format!("{TRACKING_NAMESPACE}{remote_name}/");
let mut commits: Vec<String> = Vec::new();
for record in &refs {
let fields: Vec<&str> = record.split(' ').collect();
if fields.len() != 3 {
return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
"Git for-each-ref returned malformed record: {record}"
)));
}
if fields[1] == "commit" && fields[2].starts_with(&prefix) {
commits.push(fields[0].to_owned());
}
}
by_remote.insert(remote_name.clone(), commits);
}
return Ok(by_remote);
}
pub fn published_tips(
updates: &[PushUpdate],
prior_commits: &BTreeMap<String, String>,
tracking: &BTreeMap<String, Vec<String>>,
) -> BTreeMap<String, Vec<String>> {
let mut tips: BTreeMap<String, Vec<String>> = BTreeMap::new();
for update in updates {
let entry = tips.entry(update.remote_name.clone()).or_insert_with(|| {
return tracking
.get(&update.remote_name)
.cloned()
.unwrap_or_default();
});
if let Some(prior) = update.remote_oid.as_ref()
&& let Some(commit) = prior_commits.get(prior)
&& !entry.contains(commit)
{
entry.push(commit.clone());
}
}
return tips;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateScope {
None,
Commits(Vec<String>),
FinalTree(String),
}
pub fn update_scope(
git: &PushGit,
update: &PushUpdate,
published: &[String],
) -> Result<UpdateScope, ManualPushRangeError> {
let Some(local) = update.local_oid.as_ref() else {
return Ok(UpdateScope::None);
};
let commit_check = peel_commits(git, std::slice::from_ref(local))?;
if !commit_check.contains_key(local) {
return Err(range_error(ManualPushCause::UnsupportedObject, format!(
"pushed object {local} is not a commit, and the manual-push policies read commits only"
)));
}
if published.is_empty() {
return Ok(UpdateScope::FinalTree(local.clone()));
}
let mut arguments: Vec<OsString> = vec![
OsString::from("rev-list"),
OsString::from(local),
OsString::from("--not"),
];
arguments.extend(published.iter().map(OsString::from));
let commits = lines_of(&run_git_bytes(git, &arguments, None)?);
return Ok(UpdateScope::Commits(commits));
}
#[cfg(test)]
#[path = "manual_push_range_tests.rs"]
mod tests;