#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MergeRepositoryState {
pub merge_head_present: bool,
pub has_unmerged_entries: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcomeClass {
Completed,
Conflicted,
CommandFailure,
}
pub fn classify_merge_outcome(
exit_success: bool,
state: MergeRepositoryState,
) -> MergeOutcomeClass {
if state.merge_head_present && state.has_unmerged_entries {
return MergeOutcomeClass::Conflicted;
}
if exit_success && !state.merge_head_present {
return MergeOutcomeClass::Completed;
}
MergeOutcomeClass::CommandFailure
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpstreamRevisionClass {
AlreadyIntegrated,
RemoteAhead,
Diverged,
}
pub fn classify_upstream_revision(
fetched_is_ancestor_of_head: bool,
head_is_ancestor_of_fetched: bool,
) -> UpstreamRevisionClass {
if fetched_is_ancestor_of_head {
UpstreamRevisionClass::AlreadyIntegrated
} else if head_is_ancestor_of_fetched {
UpstreamRevisionClass::RemoteAhead
} else {
UpstreamRevisionClass::Diverged
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushRefStatus {
pub flag: char,
pub refs: String,
pub summary: String,
pub reason: Option<String>,
}
impl PushRefStatus {
pub fn is_rejected(&self) -> bool {
self.flag == '!'
}
pub fn is_race_rejection(&self) -> bool {
if !self.is_rejected() {
return false;
}
let haystack = format!(
"{} {}",
self.summary,
self.reason.as_deref().unwrap_or_default()
)
.to_ascii_lowercase();
haystack.contains("non-fast-forward")
|| haystack.contains("fetch first")
|| haystack.contains("stale info")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PushPorcelainReport {
pub refs: Vec<PushRefStatus>,
}
impl PushPorcelainReport {
pub fn has_race_rejection(&self) -> bool {
self.refs.iter().any(PushRefStatus::is_race_rejection)
}
pub fn has_rejection(&self) -> bool {
self.refs.iter().any(PushRefStatus::is_rejected)
}
}
pub fn parse_push_porcelain(stdout: &str) -> PushPorcelainReport {
let mut refs = Vec::new();
for line in stdout.lines() {
if line.is_empty() || !line.contains('\t') {
continue;
}
let mut fields = line.split('\t');
let Some(flag_field) = fields.next() else {
continue;
};
let flag = match flag_field.chars().next() {
Some(c) if flag_field.chars().count() <= 1 => c,
None => ' ',
_ => continue,
};
let Some(refs_field) = fields.next() else {
continue;
};
if !refs_field.contains(':') {
continue;
}
let summary = fields.next().unwrap_or_default().to_string();
let reason = fields.next().map(|r| r.trim().to_string());
refs.push(PushRefStatus {
flag,
refs: refs_field.to_string(),
summary,
reason,
});
}
PushPorcelainReport { refs }
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PorcelainV2State {
pub tracked_mutation: bool,
pub unmerged_entries: bool,
}
impl PorcelainV2State {
pub fn is_repairable(&self) -> bool {
self.tracked_mutation || self.unmerged_entries
}
}
pub fn parse_porcelain_v2(status: &str) -> PorcelainV2State {
let mut state = PorcelainV2State::default();
for line in status.lines() {
if line.starts_with("1 ") || line.starts_with("2 ") {
state.tracked_mutation = true;
} else if line.starts_with("u ") {
state.unmerged_entries = true;
}
}
state
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushFailureClass {
Race,
RepositoryRepairable,
Stalled,
}
pub fn classify_push_failure(
report: &PushPorcelainReport,
post_failure_state: PorcelainV2State,
) -> PushFailureClass {
if report.has_race_rejection() {
return PushFailureClass::Race;
}
if post_failure_state.is_repairable() {
return PushFailureClass::RepositoryRepairable;
}
PushFailureClass::Stalled
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushConfirmation {
Confirmed,
ConfirmedAfterAdvance,
NotConfirmed,
}
pub fn classify_push_confirmation(
pushed_head: &str,
observed_remote_sha: &str,
pushed_head_is_ancestor_of_observed: bool,
) -> PushConfirmation {
if pushed_head == observed_remote_sha {
PushConfirmation::Confirmed
} else if pushed_head_is_ancestor_of_observed {
PushConfirmation::ConfirmedAfterAdvance
} else {
PushConfirmation::NotConfirmed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(merge_head: bool, unmerged: bool) -> MergeRepositoryState {
MergeRepositoryState {
merge_head_present: merge_head,
has_unmerged_entries: unmerged,
}
}
#[test]
fn upstream_integration_merge_success_is_repository_derived() {
assert_eq!(
classify_merge_outcome(true, state(false, false)),
MergeOutcomeClass::Completed
);
}
#[test]
fn upstream_integration_localized_conflict_output_still_enters_repair() {
assert_eq!(
classify_merge_outcome(false, state(true, true)),
MergeOutcomeClass::Conflicted
);
}
#[test]
fn upstream_integration_unrelated_merge_failure_is_command_failure() {
assert_eq!(
classify_merge_outcome(false, state(false, false)),
MergeOutcomeClass::CommandFailure
);
assert_eq!(
classify_merge_outcome(true, state(true, false)),
MergeOutcomeClass::CommandFailure
);
}
#[test]
fn upstream_integration_classifies_revision_ancestry() {
assert_eq!(
classify_upstream_revision(true, false),
UpstreamRevisionClass::AlreadyIntegrated
);
assert_eq!(
classify_upstream_revision(false, true),
UpstreamRevisionClass::RemoteAhead
);
assert_eq!(
classify_upstream_revision(false, false),
UpstreamRevisionClass::Diverged
);
assert_eq!(
classify_upstream_revision(true, true),
UpstreamRevisionClass::AlreadyIntegrated
);
}
#[test]
fn upstream_integration_parses_push_porcelain_race_rejection() {
let stdout = "To /tmp/remote.git\n!\trefs/heads/main:refs/heads/main\t[rejected]\t(fetch first)\nDone\n";
let report = parse_push_porcelain(stdout);
assert_eq!(report.refs.len(), 1);
assert!(report.has_race_rejection());
assert_eq!(
classify_push_failure(&report, PorcelainV2State::default()),
PushFailureClass::Race
);
}
#[test]
fn upstream_integration_parses_push_porcelain_success() {
let stdout = "To /tmp/remote.git\n \trefs/heads/main:refs/heads/main\t abc..def\nDone\n";
let report = parse_push_porcelain(stdout);
assert_eq!(report.refs.len(), 1);
assert!(!report.has_rejection());
}
#[test]
fn upstream_integration_non_race_rejection_without_mutation_stalls() {
let report = parse_push_porcelain(
"To /tmp/remote.git\n!\trefs/heads/main:refs/heads/main\t[remote rejected]\t(pre-receive hook declined)\nDone\n",
);
assert!(!report.has_race_rejection());
assert_eq!(
classify_push_failure(&report, PorcelainV2State::default()),
PushFailureClass::Stalled
);
}
#[test]
fn upstream_integration_push_failure_with_tracked_mutation_is_repairable() {
let report = parse_push_porcelain("To /tmp/remote.git\nDone\n");
let status = parse_porcelain_v2("1 .M N... 100644 100644 100644 aaa bbb src/lib.rs\n");
assert!(status.tracked_mutation);
assert_eq!(
classify_push_failure(&report, status),
PushFailureClass::RepositoryRepairable
);
}
#[test]
fn upstream_integration_push_failure_with_unmerged_entries_is_repairable() {
let status = parse_porcelain_v2("u UU N... 100644 100644 100644 100644 a b c d src/x.rs\n");
assert!(status.unmerged_entries);
assert_eq!(
classify_push_failure(&PushPorcelainReport::default(), status),
PushFailureClass::RepositoryRepairable
);
}
#[test]
fn upstream_integration_untracked_files_are_not_repairable_mutation() {
let status = parse_porcelain_v2("? build.log\n! target/\n");
assert!(!status.is_repairable());
assert_eq!(
classify_push_failure(&PushPorcelainReport::default(), status),
PushFailureClass::Stalled
);
}
#[test]
fn upstream_integration_stderr_text_cannot_change_push_routing() {
let report = parse_push_porcelain(
"error: failed to push some refs\nhint: Updates were rejected because the remote contains work\n",
);
assert!(report.refs.is_empty());
assert_eq!(
classify_push_failure(&report, PorcelainV2State::default()),
PushFailureClass::Stalled
);
}
#[test]
fn upstream_integration_confirms_push_from_remote_observation() {
assert_eq!(
classify_push_confirmation("abc", "abc", false),
PushConfirmation::Confirmed
);
assert_eq!(
classify_push_confirmation("abc", "def", true),
PushConfirmation::ConfirmedAfterAdvance
);
assert_eq!(
classify_push_confirmation("abc", "def", false),
PushConfirmation::NotConfirmed
);
}
}