use std::path::Path;
use crate::error::Result;
use crate::git;
use crate::session::Provenance;
pub const INCOMPLETE_TRAILER: &str = "Onevcs-Status: incomplete";
pub const CHANGE_BASE_TRAILER: &str = "Onevcs-Change-Base:";
pub const RECOVERED_TRAILER: &str = "Onevcs-Recovered-Incomplete:";
pub const ATTESTATION_SUBJECT: &str = "chore: attest verified recovery of preserved work";
pub const INCOMPLETE_SUFFIX: &str = "(incomplete step)";
pub fn is_incomplete(message: &str) -> bool {
message.contains(INCOMPLETE_TRAILER) || message.contains(INCOMPLETE_SUFFIX)
}
pub fn is_provenance(message: &str) -> bool {
is_incomplete(message) || message.contains(RECOVERED_TRAILER)
}
pub fn incomplete_message(summary: &str, change_base: Option<&str>) -> String {
let mut message = format!(
"chore: preserve {summary} {INCOMPLETE_SUFFIX}\n\n\
Preserved by onevcs after the session did not complete.\n\n{INCOMPLETE_TRAILER}"
);
if let Some(base) = change_base {
message.push_str(&format!("\n{CHANGE_BASE_TRAILER} {base}"));
}
message
}
pub fn unattested(repo: &Path, base: &str, branch: &str) -> Result<Vec<String>> {
let commits = git::log_messages(repo, base, branch)?;
let recovered = attested_shas(&commits);
Ok(commits
.iter()
.filter(|commit| is_incomplete(&commit.message) && !recovered.contains(&commit.sha))
.map(|commit| commit.sha.clone())
.collect())
}
pub fn attestation_trailers(repo: &Path, base: &str, branch: &str) -> Result<Vec<String>> {
let commits = git::log_messages(repo, base, branch)?;
let recovered = attested_shas(&commits);
Ok(commits
.iter()
.filter(|commit| is_incomplete(&commit.message) && recovered.contains(&commit.sha))
.map(|commit| format!("{RECOVERED_TRAILER} {}", commit.sha))
.collect())
}
fn attested_shas(commits: &[git::CommitMessage]) -> Vec<String> {
commits
.iter()
.flat_map(|commit| commit.message.lines())
.filter_map(|line| line.trim().strip_prefix(RECOVERED_TRAILER))
.map(|sha| sha.trim().to_owned())
.collect()
}
pub fn recorded_change_base(repo: &Path, base: &str, branch: &str) -> Result<Option<String>> {
let commits = git::log_messages(repo, base, branch)?;
for commit in commits.iter().rev() {
if !is_incomplete(&commit.message) {
continue;
}
let recorded: Vec<String> = commit
.message
.lines()
.filter_map(|line| line.trim().strip_prefix(CHANGE_BASE_TRAILER))
.map(|value| value.trim().to_owned())
.collect();
return Ok(recorded.into_iter().find(|value| !value.is_empty()));
}
Ok(None)
}
pub fn attest(repo: &Path, base: &str) -> Result<Option<String>> {
let mut missing = unattested(repo, base, "HEAD")?;
if missing.is_empty() {
return Ok(None);
}
missing.sort();
let trailers: Vec<String> = missing
.iter()
.map(|sha| format!("{RECOVERED_TRAILER} {sha}"))
.collect();
git::commit_empty(
repo,
&format!("{ATTESTATION_SUBJECT}\n\n{}", trailers.join("\n")),
)
.map(Some)
}
pub fn provenance_of(repo: &Path, base: &str, branch: &str) -> Result<Provenance> {
let commits = git::log_messages(repo, base, branch)?;
Ok(
if commits.iter().any(|commit| is_incomplete(&commit.message)) {
Provenance::IncompleteStep
} else {
Provenance::Complete
},
)
}
pub fn publication_subject(
repo: &Path,
base: &str,
branch: &str,
explicit: Option<&str>,
) -> Result<std::result::Result<String, String>> {
if let Some(title) = explicit {
let title = title.trim();
return Ok(if title.is_empty() {
Err("the explicit title is blank".to_owned())
} else if title.len() <= SUBJECT_LIMIT {
Ok(title.to_owned())
} else {
Err(format!(
"the explicit title is {} characters, over the {SUBJECT_LIMIT}-character limit",
title.len()
))
});
}
let commits = git::log_messages(repo, base, branch)?;
let describing: Vec<&git::CommitMessage> = commits
.iter()
.filter(|commit| !is_provenance(&commit.message))
.collect();
if describing.is_empty() {
return Ok(Err(format!(
"branch {branch:?} has no commit that describes a change"
)));
}
let mut ranked: Vec<(u8, &str)> = describing
.iter()
.filter_map(|commit| commit.message.lines().next())
.map(|subject| (significance(subject), subject))
.collect();
ranked.sort_by_key(|(rank, _)| std::cmp::Reverse(*rank));
match ranked
.iter()
.find(|(_, subject)| subject.len() <= SUBJECT_LIMIT)
{
Some((_, subject)) => Ok(Ok((*subject).to_owned())),
None => Ok(Err(format!(
"no commit subject on branch {branch:?} fits the {SUBJECT_LIMIT}-character limit; \
shorten one, or publish with --title"
))),
}
}
pub const SUBJECT_LIMIT: usize = 72;
fn significance(subject: &str) -> u8 {
let kind = subject.split_once(':').map(|(kind, _)| kind).unwrap_or("");
if kind.contains('!') {
return 6;
}
match kind.split('(').next().unwrap_or("") {
"feat" => 5,
"fix" => 4,
"perf" => 3,
"refactor" => 2,
"docs" | "test" | "build" | "ci" | "style" => 1,
_ => 0,
}
}