use std::num::NonZeroUsize;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::Result;
use crate::git::{self, ObjectId};
use crate::host::Sha;
use crate::provenance::{self, Trailers};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Landed {
Yes {
evidence: LandingEvidence,
},
InPart {
evidence: LandingEvidence,
unlanded: NonZeroUsize,
},
No,
#[default]
Unknown,
}
impl Landed {
pub fn is_landed(&self) -> bool {
matches!(self, Landed::Yes { .. })
}
pub fn tier(&self) -> &'static str {
match self {
Landed::Yes { evidence } | Landed::InPart { evidence, .. } => evidence.tier(),
Landed::No | Landed::Unknown => "content comparison",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "tier", rename_all = "kebab-case")]
pub enum LandingEvidence {
RecordedLanding {
commit: Sha,
},
ChangeRequest {
commit: Sha,
change_url: Url,
},
Trailer {
commit: Sha,
},
}
impl LandingEvidence {
fn tier(&self) -> &'static str {
match self {
LandingEvidence::RecordedLanding { .. } => "a recorded landing",
LandingEvidence::ChangeRequest { .. } => "the change request's number in the base",
LandingEvidence::Trailer { .. } => "a landing trailer on the base",
}
}
pub fn commit(&self) -> &str {
match self {
LandingEvidence::RecordedLanding { commit }
| LandingEvidence::ChangeRequest { commit, .. }
| LandingEvidence::Trailer { commit } => &commit.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Recorded {
pub landing: Option<ObjectId>,
pub change: Option<Url>,
}
pub(crate) fn decide(
repo: git::Asked<'_>,
compared: &str,
known: Option<&Sha>,
branch: &str,
recorded: &Recorded,
trailers: &Trailers,
) -> Result<Landed> {
let Some(fork) = git::merge_base(repo, compared, branch)? else {
return inferred(
repo,
compared,
known,
!git::trees_differ(repo, compared, branch)?,
);
};
let mut partial: Option<(LandingEvidence, String)> = None;
if let Some(commit) = recorded.landing.as_ref().map(ObjectId::as_str) {
if git::known_to_reach(repo, commit, compared)? {
let evidence = LandingEvidence::RecordedLanding {
commit: Sha(commit.to_owned()),
};
if landed_all_of(repo, branch, commit)? {
return Ok(landed(evidence));
}
partial.get_or_insert((evidence, commit.to_owned()));
}
}
let base_history = git::log_messages(repo, &fork, compared)?;
if let Some(url) = recorded.change.as_ref() {
if let Some(commit) = names_the_change(&base_history, url.as_str()) {
let evidence = LandingEvidence::ChangeRequest {
commit: Sha(commit.clone()),
change_url: url.clone(),
};
if landed_all_of(repo, branch, &commit)? {
return Ok(landed(evidence));
}
partial.get_or_insert((evidence, commit));
}
}
for commit in &base_history {
for carried in trailer_values(&commit.message, trailers.landed()) {
if !git::known_to_reach(repo, carried.as_str(), branch)? {
continue;
}
let evidence = LandingEvidence::Trailer {
commit: Sha(commit.sha.clone()),
};
if landed_all_of(repo, branch, &commit.sha)? {
return Ok(landed(evidence));
}
partial.get_or_insert((evidence, commit.sha.clone()));
}
}
if let Some((evidence, landing)) = partial {
return Ok(Landed::InPart {
unlanded: unlanded_above(repo, branch, &fork, &landing)?,
evidence,
});
}
if git::known_to_carry_changes(repo, compared, &fork, branch)? {
return Ok(Landed::Unknown);
}
if already_took_this_change(repo, &fork, branch, &base_history, trailers)? {
return Ok(Landed::Unknown);
}
unresolved(repo, compared, known)
}
fn unresolved(repo: git::Asked<'_>, compared: &str, known: Option<&Sha>) -> Result<Landed> {
let behind = match known {
Some(tip) => !git::known_to_reach(repo, &tip.0, compared)?,
None => false,
};
Ok(match behind {
true => Landed::Unknown,
false => Landed::No,
})
}
fn already_took_this_change(
repo: git::Asked<'_>,
fork: &str,
branch: &str,
base_history: &[git::CommitMessage],
trailers: &Trailers,
) -> Result<bool> {
let Ok(subject) = provenance::publication_subject(repo, fork, branch, None, trailers)? else {
return Ok(false);
};
Ok(base_history
.iter()
.filter_map(|commit| commit.message.lines().next())
.any(|landed| landed.trim() == subject))
}
fn landed_all_of(repo: git::Asked<'_>, branch: &str, landing: &str) -> Result<bool> {
git::already_integrates(repo, landing, branch)
}
fn unlanded_above(
repo: git::Asked<'_>,
branch: &str,
fork: &str,
landing: &str,
) -> Result<NonZeroUsize> {
let mut unlanded = NonZeroUsize::MIN;
for commit in git::log_messages(repo, fork, branch)?.iter().rev().skip(1) {
if git::already_integrates(repo, landing, &commit.sha)? {
break;
}
unlanded = unlanded.saturating_add(1);
}
Ok(unlanded)
}
fn landed(evidence: LandingEvidence) -> Landed {
Landed::Yes { evidence }
}
fn inferred(
repo: git::Asked<'_>,
compared: &str,
known: Option<&Sha>,
carried: bool,
) -> Result<Landed> {
match carried {
true => Ok(Landed::Unknown),
false => unresolved(repo, compared, known),
}
}
fn names_the_change(history: &[git::CommitMessage], url: &str) -> Option<String> {
let number = url
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|last| !last.is_empty() && last.chars().all(|c| c.is_ascii_digit()));
let mut spellings = vec![url.to_owned()];
if let Some(number) = number {
spellings.push(format!("(#{number})"));
spellings.push(format!("Merge pull request #{number} "));
}
history
.iter()
.find(|commit| {
spellings
.iter()
.any(|spelling| commit.message.contains(spelling))
})
.map(|commit| commit.sha.clone())
}
fn trailer_values(message: &str, key: &str) -> Vec<ObjectId> {
message
.lines()
.filter_map(|line| line.trim().strip_prefix(key))
.filter_map(|value| ObjectId::parse(value.trim()))
.collect()
}