summaryrefslogtreecommitdiff
path: root/lib/gitlab/branch_push_merge_commit_analyzer.rb
blob: ddf2086363c1c26f6410f4d9d7913181b7aaf59c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# frozen_string_literal: true

module Gitlab
  # Analyse a graph of commits from a push to a branch,
  # for each commit, analyze that if it is the head of a merge request,
  # then what should its merge_commit be, relative to the branch.
  #
  # A----->B----->C----->D   target branch
  # |             ^
  # |             |
  # +-->E----->F--+          merged branch
  #     |     ^
  #     |     |
  #     +->G--+
  #
  # (See merge-commit-analyze-after branch in gitlab-test)
  #
  # Assuming
  # - A is already in remote
  # - B~D are all in its own branch with its own merge request, targeting the target branch
  #
  # When D is finally pushed to the target branch,
  # what are the merge commits for all the other merge requests?
  #
  # We can walk backwards from the HEAD commit D,
  # and find status of its parents.
  # First we determine if commit belongs to the target branch (i.e. A, B, C, D),
  # and then determine its merge commit.
  #
  # +--------+-----------------+--------------+
  # | Commit | Direct ancestor | Merge commit |
  # +--------+-----------------+--------------+
  # | D      | Y               | D            |
  # +--------+-----------------+--------------+
  # | C      | Y               | C            |
  # +--------+-----------------+--------------+
  # | F      |                 | C            |
  # +--------+-----------------+--------------+
  # | B      | Y               | B            |
  # +--------+-----------------+--------------+
  # | E      |                 | C            |
  # +--------+-----------------+--------------+
  # | G      |                 | C            |
  # +--------+-----------------+--------------+
  #
  # By examining the result, it can be said that
  #
  # - If commit is direct ancestor of HEAD, its merge commit is itself.
  # - Otherwise, the merge commit is the same as its child's merge commit.
  #
  class BranchPushMergeCommitAnalyzer
    class CommitDecorator < SimpleDelegator
      attr_accessor :merge_commit
      attr_writer :direct_ancestor # boolean

      def direct_ancestor?
        @direct_ancestor
      end

      # @param child_commit [CommitDecorator]
      # @param first_parent [Boolean] whether `self` is the first parent of `child_commit`
      def set_merge_commit(child_commit:)
        @merge_commit ||= direct_ancestor? ? self : child_commit.merge_commit
      end
    end

    # @param commits [Array] list of commits, must be ordered from the child (tip) of the graph back to the ancestors
    def initialize(commits, relevant_commit_ids: nil)
      @commits = commits
      @id_to_commit = {}
      @commits.each do |commit|
        @id_to_commit[commit.id] = CommitDecorator.new(commit)

        if relevant_commit_ids
          relevant_commit_ids.delete(commit.id)
          break if relevant_commit_ids.empty? # Only limit the analyze up to relevant_commit_ids
        end
      end

      analyze
    end

    def get_merge_commit(id)
      get_commit(id).merge_commit.id
    end

    private

    def analyze
      head_commit = get_commit(@commits.first.id)
      head_commit.direct_ancestor = true
      head_commit.merge_commit = head_commit

      mark_all_direct_ancestors(head_commit)

      # Analyzing a commit requires its child commit be analyzed first,
      # which is the case here since commits are ordered from child to parent.
      @id_to_commit.each_value do |commit|
        analyze_parents(commit)
      end
    end

    def analyze_parents(commit)
      commit.parent_ids.each do |parent_commit_id|
        parent_commit = get_commit(parent_commit_id)

        next unless parent_commit # parent commit may not be part of new commits

        parent_commit.set_merge_commit(child_commit: commit)
      end
    end

    # Mark all direct ancestors.
    # If child commit is a direct ancestor, its first parent is also a direct ancestor.
    # We assume direct ancestors matches the trail of the target branch over time,
    # This assumption is correct most of the time, especially for gitlab managed merges,
    # but there are exception cases which can't be solved.
    def mark_all_direct_ancestors(commit)
      loop do
        commit = get_commit(commit.parent_ids.first)

        break unless commit

        commit.direct_ancestor = true
      end
    end

    def get_commit(id)
      @id_to_commit[id]
    end
  end
end