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    AlreadyStaged(Topic),
372    /// The topic is not mergeable.
373    Unmerged(Topic, MergeStatus),
374}
375
376impl IntegrationResult {
377    /// The topic branch.
378    pub fn topic(&self) -> &Topic {
379        match *self {
380            IntegrationResult::Staged(ref sb) => &sb.topic,
381            IntegrationResult::Unstaged(ref u, _) => u,
382            IntegrationResult::AlreadyMerged(ref t) => t,
383            IntegrationResult::AlreadyStaged(ref t) => t,
384            IntegrationResult::Unmerged(ref t, _) => t,
385        }
386    }
387
388    /// Whether the topic branch is currently staged or not.
389    pub fn on_stage(&self) -> bool {
390        match *self {
391            IntegrationResult::Staged(_)
392            | IntegrationResult::AlreadyMerged(_)
393            | IntegrationResult::AlreadyStaged(_) => true,
394            IntegrationResult::Unstaged(_, _) | IntegrationResult::Unmerged(_, _) => false,
395        }
396    }
397}
398
399/// The result of rewinding the stage back to a point where new commits may be applied.
400///
401/// The order is such that the first pair represents the old state of the staged topics while the
402/// last pair represents the topics which need to be re-integrated into the staging branch.
403type RewoundStage = (Option<StagedTopic>, Vec<StagedTopic>, Option<Topic>);
404
405/// The results of a stage operation.
406#[derive(Debug)]
407pub struct StageResult {
408    /// The branch which the operation removed from the stage.
409    pub old_topic: Option<OldTopicRemoval>,
410    /// Results from reintegrating the other staged topics.
411    ///
412    /// Other topics may need to be merged into the integration result again and may fail to merge
413    /// once another topic is removed from the branch.
414    pub results: Vec<IntegrationResult>,
415}
416
417/// A manager for an integration branch.
418///
419/// This stores the state of a staging branch and the representative topics.
420#[derive(Debug)]
421pub struct Stager {
422    /// The git context for the stager to work in.
423    ctx: GitContext,
424    /// The ordered set of topics currently merged into the topic stage.
425    topics: Vec<StagedTopic>,
426}
427
428/// The summary prefix to use when staging topics.
429const STAGE_TOPIC_PREFIX: &str = "Stage topic '";
430/// The summary suffix to use when staging topics.
431const STAGE_TOPIC_SUFFIX: &str = "'";
432/// The trailer to use for the ID of the topic.
433const TOPIC_ID_TRAILER: &str = "Topic-id: ";
434/// The trailer to use for the URL of the topic.
435const TOPIC_URL_TRAILER: &str = "Topic-url: ";
436
437impl Stager {
438    /// Create a new stage from the given commit.
439    pub fn new(ctx: &GitContext, base: CommitId) -> Self {
440        Self {
441            ctx: ctx.clone(),
442            topics: vec![Self::make_base_staged_topic(base)],
443        }
444    }
445
446    /// Create a new stage, discovering topics which have been merged into an integration branch.
447    ///
448    /// This constructor takes a base branch and the name of the stage branch. It queries the given
449    /// Git context for the history of the stage branch from the base and constructs its state from
450    /// its first parent history.
451    ///
452    /// If the stage does not exist, it is created with the base commit as its start.
453    ///
454    /// Fails if the stage branch history does not appear to be a proper stage branch. A proper
455    /// stage branch's first parent history from the base consists only of merge commits with
456    /// exactly two parents with required information in its commit message.
457    pub fn from_branch(ctx: &GitContext, base: CommitId, stage: CommitId) -> StagerResult<Self> {
458        let cat_file = ctx
459            .git()
460            .arg("cat-file")
461            .arg("-t")
462            .arg(stage.as_str())
463            .output()
464            .map_err(|err| GitError::subcommand("cat-file", err))?;
465        if cat_file.status.success() {
466            // Ensure that the stage ref is a commit.
467            let stage_type = String::from_utf8_lossy(&cat_file.stdout);
468            if stage_type.trim() != "commit" {
469                return Err(StagerError::invalid_branch(
470                    stage,
471                    InvalidCommitReason::NotACommit,
472                ));
473            }
474        } else {
475            // Create the stage ref.
476            let update_ref = ctx
477                .git()
478                .arg("update-ref")
479                .arg(stage.as_str())
480                .arg(base.as_str())
481                .output()
482                .map_err(|err| GitError::subcommand("update-ref", err))?;
483            if !update_ref.status.success() {
484                return Err(StagerError::create_stage_ref(
485                    stage,
486                    base,
487                    &update_ref.stderr,
488                ));
489            }
490        }
491
492        // Check if the stage ref needs updated for the new base.
493        let is_ancestor = ctx
494            .git()
495            .arg("merge-base")
496            .arg("--is-ancestor")
497            .arg(base.as_str())
498            .arg(stage.as_str())
499            .status()
500            .map_err(|err| GitError::subcommand("merge-base --is-ancestor", err))?;
501        let needs_base_update = !is_ancestor.success();
502
503        debug!(target: "git-topic-stage", "creating a stage branch from {} -> {}", base, stage);
504
505        // Find the merge base to create the base topic.
506        let merge_base = ctx
507            .git()
508            .arg("merge-base")
509            .arg(base.as_str())
510            .arg(stage.as_str())
511            .output()
512            .map_err(|err| GitError::subcommand("merge-base", err))?;
513        if !merge_base.status.success() {
514            return Err(StagerError::invalid_branch(
515                stage,
516                InvalidCommitReason::NotRelated,
517            ));
518        }
519        let merge_base = String::from_utf8_lossy(&merge_base.stdout);
520
521        let mut topics = vec![Self::make_base_staged_topic(CommitId::new(
522            merge_base.trim(),
523        ))];
524
525        // Get the first parent history of the stage ref that are not in the base. Also include the
526        // parents of the commits so the topic set can be reconstructed.
527        let rev_list = ctx
528            .git()
529            .arg("rev-list")
530            .arg("--first-parent")
531            .arg("--reverse")
532            .arg("--parents")
533            .arg(stage.as_str())
534            .arg(format!("^{}", base))
535            .output()
536            .map_err(|err| GitError::subcommand("rev-list --first-parent", err))?;
537        if !rev_list.status.success() {
538            return Err(StagerError::list_stage_history(&rev_list.stderr));
539        }
540        let merges = String::from_utf8_lossy(&rev_list.stdout);
541
542        // Parse out our merge history.
543        let new_topics = merges
544            .lines()
545            .map(|merge| {
546                // Get the parents of the merge commit.
547                let revs = merge.split_whitespace().collect::<Vec<_>>();
548                let (rev, parents) = revs[..].split_first().expect("invalid rev-list format");
549                let rev_commit = CommitId::new(*rev);
550
551                // The stage should be constructed only of merge commits.
552                //
553                // This may happen if the target branch has been rewound while the stage has the
554                // original history.
555                if parents.len() == 1 {
556                    return Err(StagerError::invalid_branch(
557                        rev_commit,
558                        InvalidCommitReason::NonMergeCommit,
559                    ));
560                }
561
562                // Though octopus merges are also not allowed.
563                if parents.len() > 2 {
564                    return Err(StagerError::invalid_branch(
565                        rev_commit,
566                        InvalidCommitReason::OctopusMerge,
567                    ));
568                }
569
570                // Extract all of the information from a commit in order to recreate the
571                // `StageTopic` structure.
572                let commit_info = ctx
573                    .git()
574                    .arg("log")
575                    .arg("--max-count=1")
576                    .arg("--pretty=%an%n%ae%n%aI%n%B")
577                    .arg(rev)
578                    .output()
579                    .map_err(|err| GitError::subcommand("log", err))?;
580                if !commit_info.status.success() {
581                    return Err(StagerError::extract_merge_info(&commit_info.stderr));
582                }
583                let info = String::from_utf8_lossy(&commit_info.stdout);
584                let info = info.lines().collect::<Vec<_>>();
585
586                // Sanity check that we got the information we want.
587                if info.len() <= 6 {
588                    return Err(StagerError::invalid_stage_merge(rev, &commit_info.stdout));
589                }
590
591                // Extract out the name of the topic from the merge commit message summary.
592                let subject = info[3];
593                if !subject.starts_with(STAGE_TOPIC_PREFIX)
594                    || !subject.ends_with(STAGE_TOPIC_SUFFIX)
595                {
596                    let reason = InvalidCommitReason::InvalidSubject(subject.into());
597                    return Err(StagerError::invalid_branch(rev_commit, reason));
598                }
599                let name =
600                    &subject[STAGE_TOPIC_PREFIX.len()..subject.len() - STAGE_TOPIC_SUFFIX.len()];
601
602                let mut id = None;
603                let mut url = None;
604
605                // Parse trailers from the commit message.
606                for line in info[5..info.len() - 1].iter().rev() {
607                    if line.is_empty() {
608                        break;
609                    } else if let Some(topic_id) = line.strip_prefix(TOPIC_ID_TRAILER) {
610                        id = Some(topic_id.parse().map_err(|err| {
611                            let reason = InvalidCommitReason::UnparseableId(format!("{}", err));
612                            StagerError::invalid_branch(rev_commit.clone(), reason)
613                        })?);
614                    } else if let Some(topic_url) = line.strip_prefix(TOPIC_URL_TRAILER) {
615                        url = Some(topic_url.to_string());
616                    } else {
617                        // Not a real issue, but this is unexpected.
618                        warn!(
619                            target: "git-topic-stage",
620                            "unrecognized trailer in {}: {}",
621                            parents[1],
622                            line,
623                        );
624                    }
625                }
626
627                // Check that we have the information we need to create a `StagedTopic`.
628                let url = if let Some(url) = url {
629                    url
630                } else {
631                    return Err(StagerError::invalid_branch(
632                        rev_commit,
633                        InvalidCommitReason::MissingUrl,
634                    ));
635                };
636
637                let id = match id {
638                    Some(0) => {
639                        return Err(StagerError::invalid_branch(
640                            rev_commit,
641                            InvalidCommitReason::ZeroId,
642                        ));
643                    },
644                    Some(id) => id,
645                    None => {
646                        return Err(StagerError::invalid_branch(
647                            rev_commit,
648                            InvalidCommitReason::MissingId,
649                        ));
650                    },
651                };
652
653                info!(
654                    target: "git-topic-stage",
655                    "found staged topic '{}' from {}",
656                    name, url,
657                );
658
659                Ok(StagedTopic {
660                    merge: rev_commit,
661                    topic: Topic::new(
662                        CommitId::new(parents[1]),
663                        Identity::new(info[0], info[1]),
664                        info[2].parse().map_err(StagerError::date_parse)?,
665                        id,
666                        name,
667                        url,
668                    ),
669                })
670            })
671            .collect::<StagerResult<Vec<_>>>()?;
672
673        topics.extend(new_topics);
674
675        // Verify that there are no duplicate topic IDs.
676        let mut ids = HashSet::new();
677        for topic in &topics {
678            if !ids.insert(topic.topic.id) {
679                return Err(StagerError::duplicate_topic_id(topic.topic.id));
680            }
681        }
682
683        let mut stager = Self {
684            ctx: ctx.clone(),
685            topics,
686        };
687
688        // If the target branch for the stage branch has been updated, perform a stage of a topic
689        // which updates to the current state of the branch.
690        if needs_base_update {
691            let base_update = CandidateTopic {
692                old_id: Some(Self::make_base_topic(CommitId::new(merge_base.trim()))),
693                new_id: Self::make_base_topic(base),
694            };
695
696            stager.stage(base_update)?;
697        }
698
699        Ok(stager)
700    }
701
702    /// Returns the git context the stager uses for operations.
703    pub fn git_context(&self) -> &GitContext {
704        &self.ctx
705    }
706
707    /// Returns the base branch for the integration branch.
708    pub fn base(&self) -> &CommitId {
709        self.topics[0].commit()
710    }
711
712    /// The topics which have been merged into the stage.
713    pub fn topics(&self) -> &[StagedTopic] {
714        &self.topics[1..]
715    }
716
717    /// The a topic on the stage by its ID.
718    pub fn find_topic_by_id(&self, id: u64) -> Option<&StagedTopic> {
719        self.topics()
720            .iter()
721            .find(|staged_topic| id == staged_topic.topic.id)
722    }
723
724    /// Find where a topic has been merged into the integration branch.
725    pub fn find_topic(&self, topic: &Topic) -> Option<&StagedTopic> {
726        self.topics()
727            .iter()
728            .find(|staged_topic| topic == &staged_topic.topic)
729    }
730
731    /// Returns the newest commit in the integration branch.
732    pub fn head(&self) -> &CommitId {
733        &self
734            .topics
735            .iter()
736            .last()
737            .expect("expected there to be a HEAD topic on the stage")
738            .merge
739    }
740
741    /// Make a new staged topic structure from a commit, assuming it is the base of the stage.
742    fn make_base_staged_topic(base: CommitId) -> StagedTopic {
743        StagedTopic {
744            merge: base.clone(),
745            topic: Self::make_base_topic(base),
746        }
747    }
748
749    /// Make a topic structure from a commit, assuming it is the base of the stage.
750    fn make_base_topic(base: CommitId) -> Topic {
751        Topic {
752            commit: base,
753            author: Identity::new("stager", "stager@example.com"),
754            stamp: Utc::now(),
755            id: 0,
756            name: "base".into(),
757            url: "url".into(),
758        }
759    }
760
761    /// Rewind a stage to the topic right before a topic was merged.
762    fn rewind_stage(&mut self, topic: CandidateTopic) -> RewoundStage {
763        // Find the topic branch's old commit in the current stage.
764        let root = topic
765            .old_id
766            .and_then(|old| {
767                self.topics
768                    .iter()
769                    .position(|staged_topic| old == staged_topic.topic)
770            })
771            .unwrap_or(self.topics.len());
772
773        // Grab the branches which must be restaged.
774        let mut old_stage = self.topics.drain(root..).collect::<Vec<_>>();
775
776        if self.topics.is_empty() {
777            debug!(target: "git-topic-stage", "rewinding the stage to its base");
778        } else {
779            debug!(target: "git-topic-stage", "rewinding the stage to {}", self.head());
780        }
781
782        let (old_topic, new_topic) = if self.topics.is_empty() {
783            // The base is being replaced.
784            self.topics
785                .push(Self::make_base_staged_topic(topic.new_id.commit));
786
787            // The old topic branch is the first one in the list of branches that need to be
788            // restaged, so remove it from the vector.
789            (Some(old_stage.remove(0)), None)
790        } else if old_stage.is_empty() {
791            // This topic is brand new.
792            (None, Some(topic.new_id))
793        } else {
794            // The old topic branch is the first one in the list of branches that need to be
795            // restaged, so remove it from the vector.
796            (Some(old_stage.remove(0)), Some(topic.new_id))
797        };
798
799        (old_topic, old_stage, new_topic)
800    }
801
802    /// Replay a set of topics onto the stage.
803    fn replay_stage(
804        &mut self,
805        old_stage: Vec<StagedTopic>,
806    ) -> StagerResult<Vec<IntegrationResult>> {
807        debug!(target: "git-topic-stage", "replaying {} branches into the stage", old_stage.len());
808
809        // Restage old branches.
810        old_stage
811            .into_iter()
812            .map(|old_staged_topic| {
813                let (staged, res) = self.merge_to_stage(old_staged_topic.topic)?;
814                if let Some(t) = staged {
815                    self.topics.push(t);
816                }
817                Ok(res)
818            })
819            .collect()
820    }
821
822    /// Remove a topic from the stage.
823    pub fn unstage(&mut self, topic: StagedTopic) -> StagerResult<StageResult> {
824        info!(target: "git-topic-stage", "unstaging a topic: {}", topic);
825
826        if topic.commit() == self.base() {
827            debug!(target: "git-topic-stage", "ignoring a request to unstage the base");
828
829            return Err(StagerError::CannotUnstageBase);
830        }
831
832        let (old_topic, old_stage, new_topic) = self.rewind_stage(CandidateTopic {
833            old_id: Some(topic.topic.clone()),
834            new_id: topic.topic,
835        });
836
837        let results = self.replay_stage(old_stage)?;
838
839        if new_topic.is_none() {
840            // Requested the unstaging of the base branch.
841            warn!(target: "git-topic-stage", "unstage called on the base branch");
842        }
843
844        if let Some(ref old_topic) = old_topic {
845            debug!(target: "git-topic-stage", "unstaged {}", old_topic);
846        }
847
848        // Give some information about the old topic branch (if it existed).
849        let old_branch_result = old_topic.map(OldTopicRemoval::Removed);
850
851        Ok(StageResult {
852            old_topic: old_branch_result,
853            results,
854        })
855    }
856
857    /// Add a topic branch to the stage.
858    ///
859    /// If the branch already existed on the staging branch, it is first removed from the stage and
860    /// then any topics which were merged after it are merged again, in order. The updated topic is
861    /// then merged as the last operation.
862    pub fn stage(&mut self, topic: CandidateTopic) -> StagerResult<StageResult> {
863        info!(target: "git-topic-stage", "staging a topic: {}", topic);
864
865        let self_consistent = topic.is_self_consistent();
866        if !self_consistent || topic.old_id.is_none() {
867            if !self_consistent {
868                warn!(
869                    target: "git-topic-stage",
870                    "inconsistent candidate topic submission: {}",
871                    topic,
872                );
873            }
874
875            if self
876                .topics
877                .iter()
878                .any(|staged| staged.topic.id == topic.new_id.id)
879            {
880                return Err(StagerError::duplicate_topic_id(topic.new_id.id));
881            }
882        }
883
884        // Rewind the stage.
885        let (old_topic, old_stage, new_topic) = self.rewind_stage(topic);
886
887        let mut results = self.replay_stage(old_stage)?;
888
889        let old_branch_result = if let Some(topic) = new_topic {
890            // Apply the new topic branch.
891            let (staged, res) = self.merge_to_stage(topic)?;
892
893            if let Some(sb) = staged.clone() {
894                self.topics.push(sb);
895            }
896            results.push(res);
897
898            // Give some information about the old topic branch (if it existed).
899            old_topic.map(|topic| {
900                OldTopicRemoval::Obsoleted {
901                    old_merge: topic,
902                    replacement: staged,
903                }
904            })
905        } else {
906            // The base of the branch was replaced; return the old stage base.
907            old_topic.map(|topic| {
908                OldTopicRemoval::Obsoleted {
909                    old_merge: topic,
910                    replacement: Some(self.topics[0].clone()),
911                }
912            })
913        };
914
915        Ok(StageResult {
916            old_topic: old_branch_result,
917            results,
918        })
919    }
920
921    /// Remove all staged topics from the staging branch.
922    ///
923    /// Previously staged topics are returned.
924    pub fn clear(&mut self) -> Vec<StagedTopic> {
925        let new_id = Self::make_base_staged_topic(self.base().clone()).topic;
926        let old_id = Some(new_id.clone());
927
928        info!(target: "git-topic-stage", "clearing the stage");
929
930        self.rewind_stage(CandidateTopic {
931            old_id,
932            new_id,
933        })
934        .1
935    }
936
937    /// Merge a topic into the stage.
938    fn merge_to_stage(
939        &self,
940        topic: Topic,
941    ) -> StagerResult<(Option<StagedTopic>, IntegrationResult)> {
942        let base_commit = self.base();
943        let head_commit = self.head();
944        let topic_commit = topic.commit.clone();
945
946        debug!(target: "git-topic-stage", "merging {} into the stage", topic);
947
948        // Check that the topic is mergeable against the *base* commit. New root commits may have
949        // appeared in topic branches that have since been merged. Checking against the base commit
950        // ensures that it at least has a chance if it becomes the first branch on the stage again.
951        let merge_status = self.ctx.mergeable(base_commit, &topic_commit)?;
952        match merge_status {
953            MergeStatus::Mergeable(_) => (),
954            MergeStatus::AlreadyMerged => {
955                // Reject already-merged topics.
956                debug!(target: "git-topic-stage", "rejecting: already merged");
957                return Ok((None, IntegrationResult::AlreadyMerged(topic)));
958            },
959            status @ MergeStatus::NoCommonHistory => {
960                // Reject topics which do not share history.
961                debug!(target: "git-topic-stage", "rejecting: no common history");
962                return Ok((None, IntegrationResult::Unmerged(topic, status)));
963            },
964        };
965
966        // Check that the topic is mergeable against the *base* commit. New root commits may have
967        // appeared in topic branches that have since been merged. Checking against the base commit
968        // ensures that it at least has a chance if it becomes the first branch on the stage again.
969        let bases = match self.ctx.mergeable(head_commit, &topic_commit)? {
970            MergeStatus::Mergeable(bases) => bases,
971            MergeStatus::AlreadyMerged => {
972                // Reject already-staged topics.
973                debug!(target: "git-topic-stage", "rejecting: already staged");
974                return Ok((None, IntegrationResult::AlreadyStaged(topic)));
975            },
976            status @ MergeStatus::NoCommonHistory => {
977                // Reject topics which do not share history.
978                debug!(target: "git-topic-stage", "rejecting: no common history with the stage");
979                return Ok((None, IntegrationResult::Unmerged(topic, status)));
980            },
981        };
982
983        // Prepare a workarea in which to perform the merge.
984        let workarea = self.ctx.prepare(head_commit)?;
985
986        // Prepare the merged tree.
987        let merge_result = workarea.setup_merge(&bases, head_commit, &topic_commit)?;
988
989        let mut merge_command = match merge_result {
990            MergeResult::Conflict(conflicts) => {
991                debug!(target: "git-topic-stage", "rejecting: conflicts: {}", conflicts.len());
992
993                return Ok((
994                    None,
995                    IntegrationResult::Unstaged(topic, UnstageReason::MergeConflict(conflicts)),
996                ));
997            },
998            MergeResult::Ready(command) => command,
999        };
1000
1001        // Add metadata about the stage request to the commit.
1002        merge_command.author(&topic.author).author_date(topic.stamp);
1003
1004        // Commit the merged tree.
1005        let merge_commit = merge_command.commit(self.create_message(self.base(), &topic))?;
1006        let staged_topic = StagedTopic {
1007            merge: merge_commit,
1008            topic,
1009        };
1010
1011        debug!(target: "git-topic-stage", "successfully staged as {}", staged_topic);
1012
1013        Ok((
1014            Some(staged_topic.clone()),
1015            IntegrationResult::Staged(staged_topic),
1016        ))
1017    }
1018
1019    /// Create the merge commit message for a topic.
1020    fn create_message(&self, _: &CommitId, topic: &Topic) -> String {
1021        format!(
1022            "{}{}{}\n\n{}{}\n{}{}\n",
1023            STAGE_TOPIC_PREFIX,
1024            topic.name,
1025            STAGE_TOPIC_SUFFIX,
1026            TOPIC_ID_TRAILER,
1027            topic.id,
1028            TOPIC_URL_TRAILER,
1029            topic.url,
1030        )
1031    }
1032}