git_topic_stage/
stager.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use std::collections::HashSet;
8use std::fmt::{self, Debug, Display};
9use std::num;
10use std::result::Result;
11
12use chrono::{DateTime, Utc};
13use git_workarea::{
14    CommitId, Conflict, GitContext, GitError, Identity, MergeResult, MergeStatus, WorkAreaError,
15};
16use log::{debug, info, warn};
17use thiserror::Error;
18
19/// Why a commit is not a valid staging branch commit.
20///
21/// The staging branch format is such that its first-parent history consists solely of two-parent
22/// merge commits. It must also have the base commit as an ancestor.
23#[derive(Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum InvalidCommitReason {
26    /// A non-merge commit was found.
27    NonMergeCommit,
28    /// An octopus merge commit was found.
29    OctopusMerge,
30    /// The integration branch is not related to the base.
31    NotRelated,
32    /// The integration branch does not point to a commit.
33    NotACommit,
34    /// A merge commit has an invalid commit subject.
35    InvalidSubject(String),
36    /// A merge commit is missing an ID.
37    MissingId,
38    /// A merge commit is missing a URL.
39    MissingUrl,
40    /// A topic has in ID of `0`, which is reserved for the base branch.
41    ZeroId,
42    /// A topic has in ID that is not an unsigned integer.
43    UnparseableId(String),
44}
45
46impl Display for InvalidCommitReason {
47    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
48        let reason = match self {
49            InvalidCommitReason::NonMergeCommit => "non-merge commit",
50            InvalidCommitReason::OctopusMerge => "octopus merge",
51            InvalidCommitReason::NotRelated => "not related",
52            InvalidCommitReason::NotACommit => "not a commit",
53            InvalidCommitReason::InvalidSubject(_) => "invalid subject",
54            InvalidCommitReason::MissingId => "missing id",
55            InvalidCommitReason::MissingUrl => "missing url",
56            InvalidCommitReason::ZeroId => "invalid id (0)",
57            InvalidCommitReason::UnparseableId(_) => "unparseable id",
58        };
59
60        write!(f, "{}", reason)
61    }
62}
63
64/// Errors which may occur while managing a topic stage branch.
65#[derive(Debug, Error)]
66#[non_exhaustive]
67pub enum StagerError {
68    /// An error occurred when working with Git itself.
69    #[error("git error: {}", source)]
70    Git {
71        /// The cause of the error.
72        #[from]
73        source: GitError,
74    },
75    /// An error occurred when working with the workarea.
76    #[error("workarea error: {}", source)]
77    WorkArea {
78        /// The cause of the error.
79        #[from]
80        source: WorkAreaError,
81    },
82    /// The integration branch is invalid.
83    #[error("invalid integration branch: {}: {}", commit, reason)]
84    InvalidIntegrationBranch {
85        /// The merge commit into the integration branch.
86        commit: CommitId,
87        /// Why the branch is invalid.
88        reason: InvalidCommitReason,
89    },
90    /// A topic with the given ID is already on the stage.
91    #[error("duplicate stage id: {}", id)]
92    DuplicateTopicId {
93        /// The topic ID which has duplicate merges.
94        id: u64,
95    },
96    /// An error occurred when creating a stage ref.
97    #[error("failed to update the {} ref to {}: {}", stage, base, output)]
98    CreateStageRef {
99        /// The ref that could not be made.
100        stage: CommitId,
101        /// What it should have been updated to.
102        base: CommitId,
103        /// Git's error output.
104        output: String,
105    },
106    /// An error occurred when listing the stage's history.
107    #[error(
108        "failed to list the first parent history of the stage branch: {}",
109        output
110    )]
111    ListStageHistory {
112        /// Git's error output.
113        output: String,
114    },
115    /// An error occurred when extracting topic information from a merge.
116    #[error(
117        "failed to get information about a merge on the topic stage: {}",
118        output
119    )]
120    ExtractMergeInfo {
121        /// Git's error output.
122        output: String,
123    },
124    /// An invalid merge into the stage was found.
125    #[error("invalid stage merge commit {} found: {}", commit, log_info)]
126    InvalidStageMerge {
127        /// The invalid merge commit.
128        commit: CommitId,
129        /// The log information extracted from the commit.
130        log_info: String,
131    },
132    /// The base commit cannot be unstaged.
133    #[error("cannot unstage base")]
134    CannotUnstageBase,
135    /// An invalid commit date was found.
136    #[error("failed to parse date: {}", source)]
137    DateParse {
138        /// The date parse error.
139        #[source]
140        source: chrono::ParseError,
141    },
142    /// An invalid topic ID was found.
143    #[deprecated(since = "4.1.0", note = "No longer used.")]
144    #[error("failed to parse topic ID: {}", source)]
145    IdParse {
146        /// The integer parse error.
147        #[source]
148        source: num::ParseIntError,
149    },
150}
151
152impl StagerError {
153    fn invalid_branch(commit: CommitId, reason: InvalidCommitReason) -> Self {
154        StagerError::InvalidIntegrationBranch {
155            commit,
156            reason,
157        }
158    }
159
160    fn duplicate_topic_id(id: u64) -> Self {
161        StagerError::DuplicateTopicId {
162            id,
163        }
164    }
165
166    fn create_stage_ref(stage: CommitId, base: CommitId, output: &[u8]) -> Self {
167        StagerError::CreateStageRef {
168            stage,
169            base,
170            output: String::from_utf8_lossy(output).into(),
171        }
172    }
173
174    fn list_stage_history(output: &[u8]) -> Self {
175        StagerError::ListStageHistory {
176            output: String::from_utf8_lossy(output).into(),
177        }
178    }
179
180    fn extract_merge_info(output: &[u8]) -> Self {
181        StagerError::ExtractMergeInfo {
182            output: String::from_utf8_lossy(output).into(),
183        }
184    }
185
186    fn invalid_stage_merge(commit: &str, log_info: &[u8]) -> Self {
187        StagerError::InvalidStageMerge {
188            commit: CommitId::new(commit),
189            log_info: String::from_utf8_lossy(log_info).into(),
190        }
191    }
192
193    fn date_parse(source: chrono::ParseError) -> Self {
194        StagerError::DateParse {
195            source,
196        }
197    }
198}
199
200type StagerResult<T> = Result<T, StagerError>;
201
202/// A branch for the stager.
203///
204/// Topics contain additional information so that they may be easily identified and so that the
205/// commit messages are useful to humans as well.
206#[derive(Debug, Clone)]
207pub struct Topic {
208    /// The `HEAD` commit of the topic branch.
209    pub commit: CommitId,
210    /// The author of the stage request.
211    pub author: Identity,
212    /// When the stage request occurred.
213    pub stamp: DateTime<Utc>,
214    /// An ID for the topic.
215    pub id: u64,
216    /// The name of the topic.
217    pub name: String,
218    /// The URL of the topic.
219    pub url: String,
220}
221
222impl Topic {
223    /// Create a topic.
224    ///
225    /// The ID must be unique across all topics.
226    pub fn new<N, U>(
227        commit: CommitId,
228        author: Identity,
229        stamp: DateTime<Utc>,
230        id: u64,
231        name: N,
232        url: U,
233    ) -> Self
234    where
235        N: Into<String>,
236        U: Into<String>,
237    {
238        Self {
239            commit,
240            author,
241            stamp,
242            id,
243            name: name.into(),
244            url: url.into(),
245        }
246    }
247}
248
249impl Display for Topic {
250    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
251        write!(
252            f,
253            "topic {} at {}, staged by {} at {}",
254            self.name, self.commit, self.author, self.stamp
255        )
256    }
257}
258
259impl PartialEq for Topic {
260    fn eq(&self, rhs: &Self) -> bool {
261        // The author and stamp time are explicitly not considered since they are not important to
262        // the actual topic comparison once on the stage.
263        self.commit == rhs.commit && self.id == rhs.id
264    }
265}
266
267/// A topic branch which should be integrated into the stage.
268#[derive(Debug, Clone, PartialEq)]
269pub struct CandidateTopic {
270    /// The old revision for the topic (if available).
271    pub old_id: Option<Topic>,
272    /// The new revision for the topic.
273    pub new_id: Topic,
274}
275
276impl CandidateTopic {
277    fn is_self_consistent(&self) -> bool {
278        self.old_id
279            .as_ref()
280            .map_or(true, |old| old.id == self.new_id.id)
281    }
282}
283
284impl Display for CandidateTopic {
285    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
286        if let Some(ref old_topic) = self.old_id {
287            write!(f, "candidate {}, replacing {}", self.new_id, old_topic)
288        } else {
289            write!(f, "candidate {}, new", self.new_id)
290        }
291    }
292}
293
294/// A topic branch which has been staged.
295#[derive(Debug, Clone, PartialEq)]
296pub struct StagedTopic {
297    /// The commit where the topic branch has been merged into the staging branch.
298    pub merge: CommitId,
299    /// The topic branch.
300    pub topic: Topic,
301}
302
303impl StagedTopic {
304    /// The HEAD commit of the topic branch.
305    pub fn commit(&self) -> &CommitId {
306        &self.topic.commit
307    }
308}
309
310impl Display for StagedTopic {
311    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
312        write!(f, "staged {}, via {}", self.topic, self.merge)
313    }
314}
315
316/// Reasons for which a branch can be unstaged.
317#[non_exhaustive]
318pub enum UnstageReason {
319    /// Conflicts occurred while merging the topic.
320    MergeConflict(Vec<Conflict>),
321}
322
323impl Debug for UnstageReason {
324    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
325        match *self {
326            UnstageReason::MergeConflict(ref conflicts) => {
327                write!(f, "MergeConflict ( {} conflicts )", conflicts.len())
328            },
329        }
330    }
331}
332
333/// Reasons an old topic was removed from the stage.
334#[derive(Debug)]
335#[non_exhaustive]
336pub enum OldTopicRemoval {
337    /// The topic branch has been obsoleted by an update.
338    Obsoleted {
339        /// The old topic, as staged.
340        old_merge: StagedTopic,
341        /// The staged topic branch which has replaced the old topic branch.
342        replacement: Option<StagedTopic>,
343    },
344    /// The topic branch has been removed, without replacement, from the stage.
345    Removed(StagedTopic),
346}
347
348impl OldTopicRemoval {
349    /// The topic branch which was removed.
350    pub fn topic(&self) -> &Topic {
351        match *self {
352            OldTopicRemoval::Obsoleted {
353                old_merge: ref ob, ..
354            } => &ob.topic,
355            OldTopicRemoval::Removed(ref rb) => &rb.topic,
356        }
357    }
358}
359
360/// Results from integrating a topic.
361#[derive(Debug)]
362#[non_exhaustive]
363pub enum IntegrationResult {
364    /// The topic is successfully staged into the integration branch.
365    Staged(StagedTopic),
366    /// The topic is kicked out of the integration branch.
367    Unstaged(Topic, UnstageReason),
368    /// The topic has already been merged to the integration branch.
369    AlreadyMerged(Topic),
370    /// The topic has already been merged to the stage.
371    #[deprecated(since = "4.2.0", note = "Unnecessary and unused.")]
372    AlreadyStaged(Topic),
373    /// The topic is not mergeable.
374    Unmerged(Topic, MergeStatus),
375}
376
377impl IntegrationResult {
378    /// The topic branch.
379    pub fn topic(&self) -> &Topic {
380        #[allow(deprecated)]
381        match *self {
382            IntegrationResult::Staged(ref sb) => &sb.topic,
383            IntegrationResult::Unstaged(ref u, _) => u,
384            IntegrationResult::AlreadyMerged(ref t) => t,
385            IntegrationResult::AlreadyStaged(ref t) => t,
386            IntegrationResult::Unmerged(ref t, _) => t,
387        }
388    }
389
390    /// Whether the topic branch is currently staged or not.
391    pub fn on_stage(&self) -> bool {
392        #[allow(deprecated)]
393        match *self {
394            IntegrationResult::Staged(_)
395            | IntegrationResult::AlreadyMerged(_)
396            | IntegrationResult::AlreadyStaged(_) => true,
397            IntegrationResult::Unstaged(_, _) | IntegrationResult::Unmerged(_, _) => false,
398        }
399    }
400}
401
402/// The result of rewinding the stage back to a point where new commits may be applied.
403///
404/// The order is such that the first pair represents the old state of the staged topics while the
405/// last pair represents the topics which need to be re-integrated into the staging branch.
406type RewoundStage = (Option<StagedTopic>, Vec<StagedTopic>, Option<Topic>);
407
408/// The results of a stage operation.
409#[derive(Debug)]
410pub struct StageResult {
411    /// The branch which the operation removed from the stage.
412    pub old_topic: Option<OldTopicRemoval>,
413    /// Results from reintegrating the other staged topics.
414    ///
415    /// Other topics may need to be merged into the integration result again and may fail to merge
416    /// once another topic is removed from the branch.
417    pub results: Vec<IntegrationResult>,
418}
419
420/// A manager for an integration branch.
421///
422/// This stores the state of a staging branch and the representative topics.
423#[derive(Debug)]
424pub struct Stager {
425    /// The git context for the stager to work in.
426    ctx: GitContext,
427    /// The ordered set of topics currently merged into the topic stage.
428    topics: Vec<StagedTopic>,
429}
430
431/// The summary prefix to use when staging topics.
432const STAGE_TOPIC_PREFIX: &str = "Stage topic '";
433/// The summary suffix to use when staging topics.
434const STAGE_TOPIC_SUFFIX: &str = "'";
435/// The trailer to use for the ID of the topic.
436const TOPIC_ID_TRAILER: &str = "Topic-id: ";
437/// The trailer to use for the URL of the topic.
438const TOPIC_URL_TRAILER: &str = "Topic-url: ";
439
440impl Stager {
441    /// Create a new stage from the given commit.
442    pub fn new(ctx: &GitContext, base: CommitId) -> Self {
443        Self {
444            ctx: ctx.clone(),
445            topics: vec![Self::make_base_staged_topic(base)],
446        }
447    }
448
449    /// Create a new stage, discovering topics which have been merged into an integration branch.
450    ///
451    /// This constructor takes a base branch and the name of the stage branch. It queries the given
452    /// Git context for the history of the stage branch from the base and constructs its state from
453    /// its first parent history.
454    ///
455    /// If the stage does not exist, it is created with the base commit as its start.
456    ///
457    /// Fails if the stage branch history does not appear to be a proper stage branch. A proper
458    /// stage branch's first parent history from the base consists only of merge commits with
459    /// exactly two parents with required information in its commit message.
460    pub fn from_branch(ctx: &GitContext, base: CommitId, stage: CommitId) -> StagerResult<Self> {
461        let cat_file = ctx
462            .git()
463            .arg("cat-file")
464            .arg("-t")
465            .arg(stage.as_str())
466            .output()
467            .map_err(|err| GitError::subcommand("cat-file", err))?;
468        if cat_file.status.success() {
469            // Ensure that the stage ref is a commit.
470            let stage_type = String::from_utf8_lossy(&cat_file.stdout);
471            if stage_type.trim() != "commit" {
472                return Err(StagerError::invalid_branch(
473                    stage,
474                    InvalidCommitReason::NotACommit,
475                ));
476            }
477        } else {
478            // Create the stage ref.
479            let update_ref = ctx
480                .git()
481                .arg("update-ref")
482                .arg(stage.as_str())
483                .arg(base.as_str())
484                .output()
485                .map_err(|err| GitError::subcommand("update-ref", err))?;
486            if !update_ref.status.success() {
487                return Err(StagerError::create_stage_ref(
488                    stage,
489                    base,
490                    &update_ref.stderr,
491                ));
492            }
493        }
494
495        // Check if the stage ref needs updated for the new base.
496        let is_ancestor = ctx
497            .git()
498            .arg("merge-base")
499            .arg("--is-ancestor")
500            .arg(base.as_str())
501            .arg(stage.as_str())
502            .status()
503            .map_err(|err| GitError::subcommand("merge-base --is-ancestor", err))?;
504        let needs_base_update = !is_ancestor.success();
505
506        debug!(target: "git-topic-stage", "creating a stage branch from {} -> {}", base, stage);
507
508        // Find the merge base to create the base topic.
509        let merge_base = ctx
510            .git()
511            .arg("merge-base")
512            .arg(base.as_str())
513            .arg(stage.as_str())
514            .output()
515            .map_err(|err| GitError::subcommand("merge-base", err))?;
516        if !merge_base.status.success() {
517            return Err(StagerError::invalid_branch(
518                stage,
519                InvalidCommitReason::NotRelated,
520            ));
521        }
522        let merge_base = String::from_utf8_lossy(&merge_base.stdout);
523
524        let mut topics = vec![Self::make_base_staged_topic(CommitId::new(
525            merge_base.trim(),
526        ))];
527
528        // Get the first parent history of the stage ref that are not in the base. Also include the
529        // parents of the commits so the topic set can be reconstructed.
530        let rev_list = ctx
531            .git()
532            .arg("rev-list")
533            .arg("--first-parent")
534            .arg("--reverse")
535            .arg("--parents")
536            .arg(stage.as_str())
537            .arg(format!("^{}", base))
538            .output()
539            .map_err(|err| GitError::subcommand("rev-list --first-parent", err))?;
540        if !rev_list.status.success() {
541            return Err(StagerError::list_stage_history(&rev_list.stderr));
542        }
543        let merges = String::from_utf8_lossy(&rev_list.stdout);
544
545        // Parse out our merge history.
546        let new_topics = merges
547            .lines()
548            .map(|merge| {
549                // Get the parents of the merge commit.
550                let revs = merge.split_whitespace().collect::<Vec<_>>();
551                let (rev, parents) = revs[..].split_first().expect("invalid rev-list format");
552                let rev_commit = CommitId::new(*rev);
553
554                // The stage should be constructed only of merge commits.
555                //
556                // This may happen if the target branch has been rewound while the stage has the
557                // original history.
558                if parents.len() == 1 {
559                    return Err(StagerError::invalid_branch(
560                        rev_commit,
561                        InvalidCommitReason::NonMergeCommit,
562                    ));
563                }
564
565                // Though octopus merges are also not allowed.
566                if parents.len() > 2 {
567                    return Err(StagerError::invalid_branch(
568                        rev_commit,
569                        InvalidCommitReason::OctopusMerge,
570                    ));
571                }
572
573                // Extract all of the information from a commit in order to recreate the
574                // `StageTopic` structure.
575                let commit_info = ctx
576                    .git()
577                    .arg("log")
578                    .arg("--max-count=1")
579                    .arg("--pretty=%an%n%ae%n%aI%n%B")
580                    .arg(rev)
581                    .output()
582                    .map_err(|err| GitError::subcommand("log", err))?;
583                if !commit_info.status.success() {
584                    return Err(StagerError::extract_merge_info(&commit_info.stderr));
585                }
586                let info = String::from_utf8_lossy(&commit_info.stdout);
587                let info = info.lines().collect::<Vec<_>>();
588
589                // Sanity check that we got the information we want.
590                if info.len() <= 6 {
591                    return Err(StagerError::invalid_stage_merge(rev, &commit_info.stdout));
592                }
593
594                // Extract out the name of the topic from the merge commit message summary.
595                let subject = info[3];
596                if !subject.starts_with(STAGE_TOPIC_PREFIX)
597                    || !subject.ends_with(STAGE_TOPIC_SUFFIX)
598                {
599                    let reason = InvalidCommitReason::InvalidSubject(subject.into());
600                    return Err(StagerError::invalid_branch(rev_commit, reason));
601                }
602                let name =
603                    &subject[STAGE_TOPIC_PREFIX.len()..subject.len() - STAGE_TOPIC_SUFFIX.len()];
604
605                let mut id = None;
606                let mut url = None;
607
608                // Parse trailers from the commit message.
609                for line in info[5..info.len() - 1].iter().rev() {
610                    if line.is_empty() {
611                        break;
612                    } else if let Some(topic_id) = line.strip_prefix(TOPIC_ID_TRAILER) {
613                        id = Some(topic_id.parse().map_err(|err| {
614                            let reason = InvalidCommitReason::UnparseableId(format!("{}", err));
615                            StagerError::invalid_branch(rev_commit.clone(), reason)
616                        })?);
617                    } else if let Some(topic_url) = line.strip_prefix(TOPIC_URL_TRAILER) {
618                        url = Some(topic_url.to_string());
619                    } else {
620                        // Not a real issue, but this is unexpected.
621                        warn!(
622                            target: "git-topic-stage",
623                            "unrecognized trailer in {}: {}",
624                            parents[1],
625                            line,
626                        );
627                    }
628                }
629
630                // Check that we have the information we need to create a `StagedTopic`.
631                let url = if let Some(url) = url {
632                    url
633                } else {
634                    return Err(StagerError::invalid_branch(
635                        rev_commit,
636                        InvalidCommitReason::MissingUrl,
637                    ));
638                };
639
640                let id = match id {
641                    Some(0) => {
642                        return Err(StagerError::invalid_branch(
643                            rev_commit,
644                            InvalidCommitReason::ZeroId,
645                        ));
646                    },
647                    Some(id) => id,
648                    None => {
649                        return Err(StagerError::invalid_branch(
650                            rev_commit,
651                            InvalidCommitReason::MissingId,
652                        ));
653                    },
654                };
655
656                info!(
657                    target: "git-topic-stage",
658                    "found staged topic '{}' from {}",
659                    name, url,
660                );
661
662                Ok(StagedTopic {
663                    merge: rev_commit,
664                    topic: Topic::new(
665                        CommitId::new(parents[1]),
666                        Identity::new(info[0], info[1]),
667                        info[2].parse().map_err(StagerError::date_parse)?,
668                        id,
669                        name,
670                        url,
671                    ),
672                })
673            })
674            .collect::<StagerResult<Vec<_>>>()?;
675
676        topics.extend(new_topics);
677
678        // Verify that there are no duplicate topic IDs.
679        let mut ids = HashSet::new();
680        for topic in &topics {
681            if !ids.insert(topic.topic.id) {
682                return Err(StagerError::duplicate_topic_id(topic.topic.id));
683            }
684        }
685
686        let mut stager = Self {
687            ctx: ctx.clone(),
688            topics,
689        };
690
691        // If the target branch for the stage branch has been updated, perform a stage of a topic
692        // which updates to the current state of the branch.
693        if needs_base_update {
694            let base_update = CandidateTopic {
695                old_id: Some(Self::make_base_topic(CommitId::new(merge_base.trim()))),
696                new_id: Self::make_base_topic(base),
697            };
698
699            stager.stage(base_update)?;
700        }
701
702        Ok(stager)
703    }
704
705    /// Returns the git context the stager uses for operations.
706    pub fn git_context(&self) -> &GitContext {
707        &self.ctx
708    }
709
710    /// Returns the base branch for the integration branch.
711    pub fn base(&self) -> &CommitId {
712        self.topics[0].commit()
713    }
714
715    /// The topics which have been merged into the stage.
716    pub fn topics(&self) -> &[StagedTopic] {
717        &self.topics[1..]
718    }
719
720    /// The a topic on the stage by its ID.
721    pub fn find_topic_by_id(&self, id: u64) -> Option<&StagedTopic> {
722        self.topics()
723            .iter()
724            .find(|staged_topic| id == staged_topic.topic.id)
725    }
726
727    /// Find where a topic has been merged into the integration branch.
728    pub fn find_topic(&self, topic: &Topic) -> Option<&StagedTopic> {
729        self.topics()
730            .iter()
731            .find(|staged_topic| topic == &staged_topic.topic)
732    }
733
734    /// Returns the newest commit in the integration branch.
735    pub fn head(&self) -> &CommitId {
736        &self
737            .topics
738            .iter()
739            .last()
740            .expect("expected there to be a HEAD topic on the stage")
741            .merge
742    }
743
744    /// Make a new staged topic structure from a commit, assuming it is the base of the stage.
745    fn make_base_staged_topic(base: CommitId) -> StagedTopic {
746        StagedTopic {
747            merge: base.clone(),
748            topic: Self::make_base_topic(base),
749        }
750    }
751
752    /// Make a topic structure from a commit, assuming it is the base of the stage.
753    fn make_base_topic(base: CommitId) -> Topic {
754        Topic {
755            commit: base,
756            author: Identity::new("stager", "stager@example.com"),
757            stamp: Utc::now(),
758            id: 0,
759            name: "base".into(),
760            url: "url".into(),
761        }
762    }
763
764    /// Rewind a stage to the topic right before a topic was merged.
765    fn rewind_stage(&mut self, topic: CandidateTopic) -> RewoundStage {
766        // Find the topic branch's old commit in the current stage.
767        let root = topic
768            .old_id
769            .and_then(|old| {
770                self.topics
771                    .iter()
772                    .position(|staged_topic| old == staged_topic.topic)
773            })
774            .unwrap_or(self.topics.len());
775
776        // Grab the branches which must be restaged.
777        let mut old_stage = self.topics.drain(root..).collect::<Vec<_>>();
778
779        if self.topics.is_empty() {
780            debug!(target: "git-topic-stage", "rewinding the stage to its base");
781        } else {
782            debug!(target: "git-topic-stage", "rewinding the stage to {}", self.head());
783        }
784
785        let (old_topic, new_topic) = if self.topics.is_empty() {
786            // The base is being replaced.
787            self.topics
788                .push(Self::make_base_staged_topic(topic.new_id.commit));
789
790            // The old topic branch is the first one in the list of branches that need to be
791            // restaged, so remove it from the vector.
792            (Some(old_stage.remove(0)), None)
793        } else if old_stage.is_empty() {
794            // This topic is brand new.
795            (None, Some(topic.new_id))
796        } else {
797            // The old topic branch is the first one in the list of branches that need to be
798            // restaged, so remove it from the vector.
799            (Some(old_stage.remove(0)), Some(topic.new_id))
800        };
801
802        (old_topic, old_stage, new_topic)
803    }
804
805    /// Replay a set of topics onto the stage.
806    fn replay_stage(
807        &mut self,
808        old_stage: Vec<StagedTopic>,
809    ) -> StagerResult<Vec<IntegrationResult>> {
810        debug!(target: "git-topic-stage", "replaying {} branches into the stage", old_stage.len());
811
812        // Restage old branches.
813        old_stage
814            .into_iter()
815            .map(|old_staged_topic| {
816                let (staged, res) = self.merge_to_stage(old_staged_topic.topic)?;
817                if let Some(t) = staged {
818                    self.topics.push(t);
819                }
820                Ok(res)
821            })
822            .collect()
823    }
824
825    /// Remove a topic from the stage.
826    pub fn unstage(&mut self, topic: StagedTopic) -> StagerResult<StageResult> {
827        info!(target: "git-topic-stage", "unstaging a topic: {}", topic);
828
829        if topic.commit() == self.base() {
830            debug!(target: "git-topic-stage", "ignoring a request to unstage the base");
831
832            return Err(StagerError::CannotUnstageBase);
833        }
834
835        let (old_topic, old_stage, new_topic) = self.rewind_stage(CandidateTopic {
836            old_id: Some(topic.topic.clone()),
837            new_id: topic.topic,
838        });
839
840        let results = self.replay_stage(old_stage)?;
841
842        if new_topic.is_none() {
843            // Requested the unstaging of the base branch.
844            warn!(target: "git-topic-stage", "unstage called on the base branch");
845        }
846
847        if let Some(ref old_topic) = old_topic {
848            debug!(target: "git-topic-stage", "unstaged {}", old_topic);
849        }
850
851        // Give some information about the old topic branch (if it existed).
852        let old_branch_result = old_topic.map(OldTopicRemoval::Removed);
853
854        Ok(StageResult {
855            old_topic: old_branch_result,
856            results,
857        })
858    }
859
860    /// Add a topic branch to the stage.
861    ///
862    /// If the branch already existed on the staging branch, it is first removed from the stage and
863    /// then any topics which were merged after it are merged again, in order. The updated topic is
864    /// then merged as the last operation.
865    pub fn stage(&mut self, topic: CandidateTopic) -> StagerResult<StageResult> {
866        info!(target: "git-topic-stage", "staging a topic: {}", topic);
867
868        let self_consistent = topic.is_self_consistent();
869        if !self_consistent || topic.old_id.is_none() {
870            if !self_consistent {
871                warn!(
872                    target: "git-topic-stage",
873                    "inconsistent candidate topic submission: {}",
874                    topic,
875                );
876            }
877
878            if self
879                .topics
880                .iter()
881                .any(|staged| staged.topic.id == topic.new_id.id)
882            {
883                return Err(StagerError::duplicate_topic_id(topic.new_id.id));
884            }
885        }
886
887        // Rewind the stage.
888        let (old_topic, old_stage, new_topic) = self.rewind_stage(topic);
889
890        let mut results = self.replay_stage(old_stage)?;
891
892        let old_branch_result = if let Some(topic) = new_topic {
893            // Apply the new topic branch.
894            let (staged, res) = self.merge_to_stage(topic)?;
895
896            if let Some(sb) = staged.clone() {
897                self.topics.push(sb);
898            }
899            results.push(res);
900
901            // Give some information about the old topic branch (if it existed).
902            old_topic.map(|topic| {
903                OldTopicRemoval::Obsoleted {
904                    old_merge: topic,
905                    replacement: staged,
906                }
907            })
908        } else {
909            // The base of the branch was replaced; return the old stage base.
910            old_topic.map(|topic| {
911                OldTopicRemoval::Obsoleted {
912                    old_merge: topic,
913                    replacement: Some(self.topics[0].clone()),
914                }
915            })
916        };
917
918        Ok(StageResult {
919            old_topic: old_branch_result,
920            results,
921        })
922    }
923
924    /// Remove all staged topics from the staging branch.
925    ///
926    /// Previously staged topics are returned.
927    pub fn clear(&mut self) -> Vec<StagedTopic> {
928        let new_id = Self::make_base_staged_topic(self.base().clone()).topic;
929        let old_id = Some(new_id.clone());
930
931        info!(target: "git-topic-stage", "clearing the stage");
932
933        self.rewind_stage(CandidateTopic {
934            old_id,
935            new_id,
936        })
937        .1
938    }
939
940    /// Merge a topic into the stage.
941    fn merge_to_stage(
942        &self,
943        topic: Topic,
944    ) -> StagerResult<(Option<StagedTopic>, IntegrationResult)> {
945        let base_commit = self.base();
946        let head_commit = self.head();
947        let topic_commit = topic.commit.clone();
948
949        debug!(target: "git-topic-stage", "merging {} into the stage", topic);
950
951        // Check that the topic is mergeable against the *base* commit. New root commits may have
952        // appeared in topic branches that have since been merged. Checking against the base commit
953        // ensures that it at least has a chance if it becomes the first branch on the stage again.
954        let merge_status = self.ctx.mergeable(base_commit, &topic_commit)?;
955        match merge_status {
956            MergeStatus::Mergeable(_) => (),
957            MergeStatus::AlreadyMerged => {
958                // Reject already-merged topics.
959                debug!(target: "git-topic-stage", "rejecting: already merged");
960                return Ok((None, IntegrationResult::AlreadyMerged(topic)));
961            },
962            status @ MergeStatus::NoCommonHistory => {
963                // Reject topics which do not share history.
964                debug!(target: "git-topic-stage", "rejecting: no common history");
965                return Ok((None, IntegrationResult::Unmerged(topic, status)));
966            },
967        };
968
969        // Check that the topic is mergeable against the *base* commit. New root commits may have
970        // appeared in topic branches that have since been merged. Checking against the base commit
971        // ensures that it at least has a chance if it becomes the first branch on the stage again.
972        let bases = match self.ctx.mergeable(head_commit, &topic_commit)? {
973            MergeStatus::Mergeable(bases) => Some(bases),
974            MergeStatus::AlreadyMerged => None,
975            status @ MergeStatus::NoCommonHistory => {
976                // Reject topics which do not share history.
977                debug!(target: "git-topic-stage", "rejecting: no common history with the stage");
978                return Ok((None, IntegrationResult::Unmerged(topic, status)));
979            },
980        };
981
982        // Prepare a workarea in which to perform the merge.
983        let workarea = self.ctx.prepare(head_commit)?;
984
985        // Prepare the merged tree.
986        let merge_result = if let Some(bases) = bases {
987            workarea.setup_merge(&bases, head_commit, &topic_commit)?
988        } else {
989            workarea.setup_update_merge(head_commit, &topic_commit)?
990        };
991
992        let mut merge_command = match merge_result {
993            MergeResult::Conflict(conflicts) => {
994                debug!(target: "git-topic-stage", "rejecting: conflicts: {}", conflicts.len());
995
996                return Ok((
997                    None,
998                    IntegrationResult::Unstaged(topic, UnstageReason::MergeConflict(conflicts)),
999                ));
1000            },
1001            MergeResult::Ready(command) => command,
1002        };
1003
1004        // Add metadata about the stage request to the commit.
1005        merge_command.author(&topic.author).author_date(topic.stamp);
1006
1007        // Commit the merged tree.
1008        let merge_commit = merge_command.commit(self.create_message(self.base(), &topic))?;
1009        let staged_topic = StagedTopic {
1010            merge: merge_commit,
1011            topic,
1012        };
1013
1014        debug!(target: "git-topic-stage", "successfully staged as {}", staged_topic);
1015
1016        Ok((
1017            Some(staged_topic.clone()),
1018            IntegrationResult::Staged(staged_topic),
1019        ))
1020    }
1021
1022    /// Create the merge commit message for a topic.
1023    fn create_message(&self, _: &CommitId, topic: &Topic) -> String {
1024        format!(
1025            "{}{}{}\n\n{}{}\n{}{}\n",
1026            STAGE_TOPIC_PREFIX,
1027            topic.name,
1028            STAGE_TOPIC_SUFFIX,
1029            TOPIC_ID_TRAILER,
1030            topic.id,
1031            TOPIC_URL_TRAILER,
1032            topic.url,
1033        )
1034    }
1035}