use std::fmt::{self, Debug, Display};
use std::num;
use std::result::Result;
use crates::chrono::{self, DateTime, Utc};
use crates::git_workarea::{
CommitId, Conflict, GitContext, GitError, Identity, MergeResult, MergeStatus, WorkAreaError,
};
use crates::thiserror::Error;
#[derive(Debug, PartialEq, Eq)]
pub enum InvalidCommitReason {
NonMergeCommit,
OctopusMerge,
NotRelated,
NotACommit,
InvalidSubject(String),
MissingId,
MissingUrl,
ZeroId,
#[doc(hidden)]
_NonExhaustive,
}
impl Display for InvalidCommitReason {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let reason = match self {
InvalidCommitReason::NonMergeCommit => "non-merge commit",
InvalidCommitReason::OctopusMerge => "octopus merge",
InvalidCommitReason::NotRelated => "not related",
InvalidCommitReason::NotACommit => "not a commit",
InvalidCommitReason::InvalidSubject(_) => "invalid subject",
InvalidCommitReason::MissingId => "missing id",
InvalidCommitReason::MissingUrl => "missing url",
InvalidCommitReason::ZeroId => "invalid id (0)",
nonexhaustive => unreachable!("unhandled invalid commit reason: {:?}", nonexhaustive),
};
write!(f, "{}", reason)
}
}
#[derive(Debug, Error)]
pub enum StagerError {
#[error("git error: {}", source)]
Git {
#[from]
source: GitError,
},
#[error("workarea error: {}", source)]
WorkArea {
#[from]
source: WorkAreaError,
},
#[error("invalid integration branch: {}: {}", commit, reason)]
InvalidIntegrationBranch {
commit: CommitId,
reason: InvalidCommitReason,
},
#[error("failed to update the {} ref to {}: {}", stage, base, output)]
CreateStageRef {
stage: CommitId,
base: CommitId,
output: String,
},
#[error("failed to list the first parent history of the stage branch: {}", output)]
ListStageHistory {
output: String,
},
#[error("failed to get information about a merge on the topic stage: {}", output)]
ExtractMergeInfo {
output: String,
},
#[error("invalid stage merge commit {} found: {}", commit, log_info)]
InvalidStageMerge {
commit: CommitId,
log_info: String,
},
#[error("cannot unstage base")]
CannotUnstageBase,
#[error("failed to parse date: {}", source)]
DateParse {
#[source]
source: chrono::ParseError,
},
#[error("failed to parse topic ID: {}", source)]
IdParse {
#[source]
source: num::ParseIntError,
},
#[doc(hidden)]
#[error("unreachable...")]
_NonExhaustive,
}
impl StagerError {
fn invalid_branch(commit: CommitId, reason: InvalidCommitReason) -> Self {
StagerError::InvalidIntegrationBranch {
commit,
reason,
}
}
fn create_stage_ref(stage: CommitId, base: CommitId, output: &[u8]) -> Self {
StagerError::CreateStageRef {
stage,
base,
output: String::from_utf8_lossy(output).into(),
}
}
fn list_stage_history(output: &[u8]) -> Self {
StagerError::ListStageHistory {
output: String::from_utf8_lossy(output).into(),
}
}
fn extract_merge_info(output: &[u8]) -> Self {
StagerError::ExtractMergeInfo {
output: String::from_utf8_lossy(output).into(),
}
}
fn invalid_stage_merge(commit: &str, log_info: &[u8]) -> Self {
StagerError::InvalidStageMerge {
commit: CommitId::new(commit),
log_info: String::from_utf8_lossy(log_info).into(),
}
}
fn date_parse(source: chrono::ParseError) -> Self {
StagerError::DateParse {
source,
}
}
fn id_parse(source: num::ParseIntError) -> Self {
StagerError::IdParse {
source,
}
}
}
type StagerResult<T> = Result<T, StagerError>;
#[derive(Debug, Clone)]
pub struct Topic {
pub commit: CommitId,
pub author: Identity,
pub stamp: DateTime<Utc>,
pub id: u64,
pub name: String,
pub url: String,
}
impl Topic {
pub fn new<N, U>(
commit: CommitId,
author: Identity,
stamp: DateTime<Utc>,
id: u64,
name: N,
url: U,
) -> Self
where
N: Into<String>,
U: Into<String>,
{
Self {
commit,
author,
stamp,
id,
name: name.into(),
url: url.into(),
}
}
}
impl Display for Topic {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"topic {} at {}, staged by {} at {}",
self.name, self.commit, self.author, self.stamp
)
}
}
impl PartialEq for Topic {
fn eq(&self, rhs: &Self) -> bool {
self.commit == rhs.commit && self.id == rhs.id
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CandidateTopic {
pub old_id: Option<Topic>,
pub new_id: Topic,
}
impl Display for CandidateTopic {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if let Some(ref old_topic) = self.old_id {
write!(f, "candidate {}, replacing {}", self.new_id, old_topic)
} else {
write!(f, "candidate {}, new", self.new_id)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StagedTopic {
pub merge: CommitId,
pub topic: Topic,
}
impl StagedTopic {
pub fn commit(&self) -> &CommitId {
&self.topic.commit
}
}
impl Display for StagedTopic {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "staged {}, via {}", self.topic, self.merge)
}
}
pub enum UnstageReason {
MergeConflict(Vec<Conflict>),
}
impl Debug for UnstageReason {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
UnstageReason::MergeConflict(ref conflicts) => {
write!(f, "MergeConflict ( {} conflicts )", conflicts.len())
},
}
}
}
#[derive(Debug)]
pub enum OldTopicRemoval {
Obsoleted {
old_merge: StagedTopic,
replacement: Option<StagedTopic>,
},
Removed(StagedTopic),
}
impl OldTopicRemoval {
pub fn topic(&self) -> &Topic {
match *self {
OldTopicRemoval::Obsoleted {
old_merge: ref ob, ..
} => &ob.topic,
OldTopicRemoval::Removed(ref rb) => &rb.topic,
}
}
}
#[derive(Debug)]
pub enum IntegrationResult {
Staged(StagedTopic),
Unstaged(Topic, UnstageReason),
Unmerged(Topic, MergeStatus),
}
impl IntegrationResult {
pub fn topic(&self) -> &Topic {
match *self {
IntegrationResult::Staged(ref sb) => &sb.topic,
IntegrationResult::Unstaged(ref u, _) => u,
IntegrationResult::Unmerged(ref t, _) => t,
}
}
pub fn on_stage(&self) -> bool {
match *self {
IntegrationResult::Staged(_) => true,
IntegrationResult::Unstaged(_, _) | IntegrationResult::Unmerged(_, _) => false,
}
}
}
type RewoundStage = (Option<StagedTopic>, Vec<StagedTopic>, Option<Topic>);
#[derive(Debug)]
pub struct StageResult {
pub old_topic: Option<OldTopicRemoval>,
pub results: Vec<IntegrationResult>,
}
#[derive(Debug)]
pub struct Stager {
ctx: GitContext,
topics: Vec<StagedTopic>,
}
const STAGE_TOPIC_PREFIX: &str = "Stage topic '";
const STAGE_TOPIC_SUFFIX: &str = "'";
const TOPIC_ID_TRAILER: &str = "Topic-id: ";
const TOPIC_URL_TRAILER: &str = "Topic-url: ";
impl Stager {
pub fn new(ctx: &GitContext, base: CommitId) -> Self {
Self {
ctx: ctx.clone(),
topics: vec![Self::make_base_staged_topic(base)],
}
}
pub fn from_branch(ctx: &GitContext, base: CommitId, stage: CommitId) -> StagerResult<Self> {
let cat_file = ctx
.git()
.arg("cat-file")
.arg("-t")
.arg(stage.as_str())
.output()
.map_err(|err| GitError::subcommand("cat-file", err))?;
if cat_file.status.success() {
let stage_type = String::from_utf8_lossy(&cat_file.stdout);
if stage_type.trim() != "commit" {
return Err(StagerError::invalid_branch(
stage,
InvalidCommitReason::NotACommit,
));
}
} else {
let update_ref = ctx
.git()
.arg("update-ref")
.arg(stage.as_str())
.arg(base.as_str())
.output()
.map_err(|err| GitError::subcommand("update-ref", err))?;
if !update_ref.status.success() {
return Err(StagerError::create_stage_ref(
stage,
base,
&update_ref.stderr,
));
}
}
let is_ancestor = ctx
.git()
.arg("merge-base")
.arg("--is-ancestor")
.arg(base.as_str())
.arg(stage.as_str())
.status()
.map_err(|err| GitError::subcommand("merge-base --is-ancestor", err))?;
let needs_base_update = !is_ancestor.success();
debug!(target: "git-topic-stage", "creating a stage branch from {} -> {}", base, stage);
let merge_base = ctx
.git()
.arg("merge-base")
.arg(base.as_str())
.arg(stage.as_str())
.output()
.map_err(|err| GitError::subcommand("merge-base", err))?;
if !merge_base.status.success() {
return Err(StagerError::invalid_branch(
stage,
InvalidCommitReason::NotRelated,
));
}
let merge_base = String::from_utf8_lossy(&merge_base.stdout);
let mut topics = vec![Self::make_base_staged_topic(CommitId::new(
merge_base.trim(),
))];
let rev_list = ctx
.git()
.arg("rev-list")
.arg("--first-parent")
.arg("--reverse")
.arg("--parents")
.arg(stage.as_str())
.arg(&format!("^{}", base))
.output()
.map_err(|err| GitError::subcommand("rev-list --first-parent", err))?;
if !rev_list.status.success() {
return Err(StagerError::list_stage_history(&rev_list.stderr));
}
let merges = String::from_utf8_lossy(&rev_list.stdout);
let new_topics = merges
.lines()
.map(|merge| {
let revs = merge.split_whitespace().collect::<Vec<_>>();
let (rev, parents) = revs[..].split_first().expect("invalid rev-list format");
let rev_commit = CommitId::new(*rev);
if parents.len() == 1 {
return Err(StagerError::invalid_branch(
rev_commit,
InvalidCommitReason::NonMergeCommit,
));
}
if parents.len() > 2 {
return Err(StagerError::invalid_branch(
rev_commit,
InvalidCommitReason::OctopusMerge,
));
}
let commit_info = ctx
.git()
.arg("log")
.arg("--max-count=1")
.arg("--pretty=%an%n%ae%n%aI%n%B")
.arg(rev)
.output()
.map_err(|err| GitError::subcommand("log", err))?;
if !commit_info.status.success() {
return Err(StagerError::extract_merge_info(&commit_info.stderr));
}
let info = String::from_utf8_lossy(&commit_info.stdout);
let info = info.lines().collect::<Vec<_>>();
if info.len() <= 6 {
return Err(StagerError::invalid_stage_merge(rev, &commit_info.stdout));
}
let subject = info[3];
if !subject.starts_with(STAGE_TOPIC_PREFIX)
|| !subject.ends_with(STAGE_TOPIC_SUFFIX)
{
let reason = InvalidCommitReason::InvalidSubject(subject.into());
return Err(StagerError::invalid_branch(rev_commit, reason));
}
let name =
&subject[STAGE_TOPIC_PREFIX.len()..subject.len() - STAGE_TOPIC_SUFFIX.len()];
let mut id = None;
let mut url = None;
for line in info[5..info.len() - 1].iter().rev() {
if line.is_empty() {
break;
} else if line.starts_with(TOPIC_ID_TRAILER) {
id = Some(
line[TOPIC_ID_TRAILER.len()..]
.parse()
.map_err(StagerError::id_parse)?,
);
} else if line.starts_with(TOPIC_URL_TRAILER) {
url = Some(line[TOPIC_URL_TRAILER.len()..].to_string());
} else {
warn!(target: "git-topic-stage",
"unrecognized trailer in {}: {}",
parents[1],
line);
}
}
let url = if let Some(url) = url {
url
} else {
return Err(StagerError::invalid_branch(
rev_commit,
InvalidCommitReason::MissingUrl,
));
};
let id = match id {
Some(0) => {
return Err(StagerError::invalid_branch(
rev_commit,
InvalidCommitReason::ZeroId,
));
},
Some(id) => id,
None => {
return Err(StagerError::invalid_branch(
rev_commit,
InvalidCommitReason::MissingId,
));
},
};
info!(target: "git-topic-stage",
"found staged topic '{}' from {}",
name, url);
Ok(StagedTopic {
merge: rev_commit,
topic: Topic::new(
CommitId::new(parents[1]),
Identity::new(info[0], info[1]),
info[2].parse().map_err(StagerError::date_parse)?,
id,
name,
url,
),
})
})
.collect::<StagerResult<Vec<_>>>()?;
topics.extend(new_topics.into_iter());
let mut stager = Self {
ctx: ctx.clone(),
topics,
};
if needs_base_update {
let base_update = CandidateTopic {
old_id: Some(Self::make_base_topic(CommitId::new(merge_base.trim()))),
new_id: Self::make_base_topic(base),
};
stager.stage(base_update)?;
}
Ok(stager)
}
pub fn git_context(&self) -> &GitContext {
&self.ctx
}
pub fn base(&self) -> &CommitId {
self.topics[0].commit()
}
pub fn topics(&self) -> &[StagedTopic] {
&self.topics[1..]
}
pub fn find_topic_by_id(&self, id: u64) -> Option<&StagedTopic> {
self.topics()
.iter()
.find(|staged_topic| id == staged_topic.topic.id)
}
pub fn find_topic(&self, topic: &Topic) -> Option<&StagedTopic> {
self.topics()
.iter()
.find(|staged_topic| topic == &staged_topic.topic)
}
pub fn head(&self) -> &CommitId {
&self
.topics
.iter()
.last()
.expect("expected there to be a HEAD topic on the stage")
.merge
}
fn make_base_staged_topic(base: CommitId) -> StagedTopic {
StagedTopic {
merge: base.clone(),
topic: Self::make_base_topic(base),
}
}
fn make_base_topic(base: CommitId) -> Topic {
Topic {
commit: base,
author: Identity::new("stager", "stager@example.com"),
stamp: Utc::now(),
id: 0,
name: "base".into(),
url: "url".into(),
}
}
fn rewind_stage(&mut self, topic: CandidateTopic) -> RewoundStage {
let root = topic
.old_id
.and_then(|old| {
self.topics
.iter()
.position(|staged_topic| old == staged_topic.topic)
})
.unwrap_or_else(|| self.topics.len());
let mut old_stage = self.topics.drain(root..).collect::<Vec<_>>();
if self.topics.is_empty() {
debug!(target: "git-topic-stage", "rewinding the stage to its base");
} else {
debug!(target: "git-topic-stage", "rewinding the stage to {}", self.head());
}
let (old_topic, new_topic) = if self.topics.is_empty() {
self.topics
.push(Self::make_base_staged_topic(topic.new_id.commit));
(Some(old_stage.remove(0)), None)
} else if old_stage.is_empty() {
(None, Some(topic.new_id))
} else {
(Some(old_stage.remove(0)), Some(topic.new_id))
};
(old_topic, old_stage, new_topic)
}
fn replay_stage(
&mut self,
old_stage: Vec<StagedTopic>,
) -> StagerResult<Vec<IntegrationResult>> {
debug!(target: "git-topic-stage", "replaying {} branches into the stage", old_stage.len());
old_stage
.into_iter()
.map(|old_staged_topic| {
let (staged, res) = self.merge_to_stage(old_staged_topic.topic)?;
if let Some(t) = staged {
self.topics.push(t);
}
Ok(res)
})
.collect()
}
pub fn unstage(&mut self, topic: StagedTopic) -> StagerResult<StageResult> {
info!(target: "git-topic-stage", "unstaging a topic: {}", topic);
if topic.commit() == self.base() {
debug!(target: "git-topic-stage", "ignoring a request to unstage the base");
return Err(StagerError::CannotUnstageBase);
}
let (old_topic, old_stage, new_topic) = self.rewind_stage(CandidateTopic {
old_id: Some(topic.topic.clone()),
new_id: topic.topic,
});
let results = self.replay_stage(old_stage)?;
if new_topic.is_none() {
warn!(target: "git-topic-stage", "unstage called on the base branch");
}
if let Some(ref old_topic) = old_topic {
debug!(target: "git-topic-stage", "unstaged {}", old_topic);
}
let old_branch_result = old_topic.map(OldTopicRemoval::Removed);
Ok(StageResult {
old_topic: old_branch_result,
results,
})
}
pub fn stage(&mut self, topic: CandidateTopic) -> StagerResult<StageResult> {
info!(target: "git-topic-stage", "staging a topic: {}", topic);
let (old_topic, old_stage, new_topic) = self.rewind_stage(topic);
let mut results = self.replay_stage(old_stage)?;
let old_branch_result = if let Some(topic) = new_topic {
let (staged, res) = self.merge_to_stage(topic)?;
if let Some(sb) = staged.clone() {
self.topics.push(sb);
}
results.push(res);
old_topic.map(|topic| {
OldTopicRemoval::Obsoleted {
old_merge: topic,
replacement: staged,
}
})
} else {
old_topic.map(|topic| {
OldTopicRemoval::Obsoleted {
old_merge: topic,
replacement: Some(self.topics[0].clone()),
}
})
};
Ok(StageResult {
old_topic: old_branch_result,
results,
})
}
pub fn clear(&mut self) -> Vec<StagedTopic> {
let new_id = Self::make_base_staged_topic(self.base().clone()).topic;
let old_id = Some(new_id.clone());
info!(target: "git-topic-stage", "clearing the stage");
self.rewind_stage(CandidateTopic {
old_id,
new_id,
})
.1
}
fn merge_to_stage(
&self,
topic: Topic,
) -> StagerResult<(Option<StagedTopic>, IntegrationResult)> {
let base_commit = self.base();
let head_commit = self.head();
let topic_commit = topic.commit.clone();
debug!(target: "git-topic-stage", "merging {} into the stage", topic);
let merge_status = self.ctx.mergeable(base_commit, &topic_commit)?;
let bases = if let MergeStatus::Mergeable(bases) = merge_status {
bases
} else {
debug!(target: "git-topic-stage", "rejecting: unmergeable: {}", merge_status);
return Ok((
None,
IntegrationResult::Unmerged(topic.clone(), merge_status),
));
};
let workarea = self.ctx.prepare(head_commit)?;
let merge_result = workarea.setup_merge(&bases, head_commit, &topic_commit)?;
let mut merge_command = match merge_result {
MergeResult::Conflict(conflicts) => {
debug!(target: "git-topic-stage", "rejecting: conflicts: {}", conflicts.len());
return Ok((
None,
IntegrationResult::Unstaged(topic, UnstageReason::MergeConflict(conflicts)),
));
},
MergeResult::Ready(command) => command,
};
merge_command.author(&topic.author).author_date(topic.stamp);
let merge_commit = merge_command.commit(self.create_message(self.base(), &topic))?;
let staged_topic = StagedTopic {
merge: merge_commit,
topic,
};
debug!(target: "git-topic-stage", "successfully staged as {}", staged_topic);
Ok((
Some(staged_topic.clone()),
IntegrationResult::Staged(staged_topic),
))
}
fn create_message(&self, _: &CommitId, topic: &Topic) -> String {
format!(
"{}{}{}\n\n{}{}\n{}{}\n",
STAGE_TOPIC_PREFIX,
topic.name,
STAGE_TOPIC_SUFFIX,
TOPIC_ID_TRAILER,
topic.id,
TOPIC_URL_TRAILER,
topic.url,
)
}
}