summaryrefslogtreecommitdiff
path: root/lib/gitlab/diff/position_tracer.rb
blob: b68a163681471aed545f67e1db16a11cee4651c2 (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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# Finds the diff position in the new diff that corresponds to the same location
# specified by the provided position in the old diff.
module Gitlab
  module Diff
    class PositionTracer
      attr_accessor :project
      attr_accessor :old_diff_refs
      attr_accessor :new_diff_refs
      attr_accessor :paths

      def initialize(project:, old_diff_refs:, new_diff_refs:, paths: nil)
        @project = project
        @old_diff_refs = old_diff_refs
        @new_diff_refs = new_diff_refs
        @paths = paths
      end

      def trace(ab_position)
        return unless old_diff_refs&.complete? && new_diff_refs&.complete?
        return unless ab_position.diff_refs == old_diff_refs

        # Suppose we have an MR with source branch `feature` and target branch `master`.
        # When the MR was created, the head of `master` was commit A, and the
        # head of `feature` was commit B, resulting in the original diff A->B.
        # Since creation, `master` was updated to C.
        # Now `feature` is being updated to D, and the newly generated MR diff is C->D.
        # It is possible that C and D are direct decendants of A and B respectively,
        # but this isn't necessarily the case as rebases and merges come into play.
        #
        # Suppose we have a diff note on the original diff A->B. Now that the MR
        # is updated, we need to find out what line in C->D corresponds to the
        # line the note was originally created on, so that we can update the diff note's
        # records and continue to display it in the right place in the diffs.
        # If we cannot find this line in the new diff, this means the diff note is now
        # outdated, and we will display that fact to the user.
        #
        # In the new diff, the file the diff note was originally created on may
        # have been renamed, deleted or even created, if the file existed in A and B,
        # but was removed in C, and restored in D.
        #
        # Every diff note stores a Position object that defines a specific location,
        # identified by paths and line numbers, within a specific diff, identified
        # by start, head and base commit ids.
        #
        # For diff notes for diff A->B, the position looks like this:
        # Position
        #   start_sha - ID of commit A
        #   head_sha - ID of commit B
        #   base_sha - ID of base commit of A and B
        #   old_path - path as of A (nil if file was newly created)
        #   new_path - path as of B (nil if file was deleted)
        #   old_line - line number as of A (nil if file was newly created)
        #   new_line - line number as of B (nil if file was deleted)
        #
        # We can easily update `start_sha` and `head_sha` to hold the IDs of
        # commits C and D, and can trivially determine `base_sha` based on those,
        # but need to find the paths and line numbers as of C and D.
        #
        # If the file was unchanged or newly created in A->B, the path as of D can be found
        # by generating diff B->D ("head to head"), finding the diff file with
        # `diff_file.old_path == position.new_path`, and taking `diff_file.new_path`.
        # The path as of C can be found by taking diff C->D, finding the diff file
        # with that same `new_path` and taking `diff_file.old_path`.
        # The line number as of D can be found by using the LineMapper on diff B->D
        # and providing the line number as of B.
        # The line number as of C can be found by using the LineMapper on diff C->D
        # and providing the line number as of D.
        #
        # If the file was deleted in A->B, the path as of C can be found
        # by generating diff A->C ("base to base"), finding the diff file with
        # `diff_file.old_path == position.old_path`, and taking `diff_file.new_path`.
        # The path as of D can be found by taking diff C->D, finding the diff file
        # with `old_path` set to that `diff_file.new_path` and taking `diff_file.new_path`.
        # The line number as of C can be found by using the LineMapper on diff A->C
        # and providing the line number as of A.
        # The line number as of D can be found by using the LineMapper on diff C->D
        # and providing the line number as of C.

        if ab_position.added?
          trace_added_line(ab_position)
        elsif ab_position.removed?
          trace_removed_line(ab_position)
        else # unchanged
          trace_unchanged_line(ab_position)
        end
      end

      private

      def trace_added_line(ab_position)
        b_path = ab_position.new_path
        b_line = ab_position.new_line

        bd_diff = bd_diffs.diff_file_with_old_path(b_path)

        d_path = bd_diff&.new_path || b_path
        d_line = LineMapper.new(bd_diff).old_to_new(b_line)

        if d_line
          cd_diff = cd_diffs.diff_file_with_new_path(d_path)

          c_path = cd_diff&.old_path || d_path
          c_line = LineMapper.new(cd_diff).new_to_old(d_line)

          if c_line
            # If the line is still in D but also in C, it has turned from an
            # added line into an unchanged one.
            new_position = position(cd_diff, c_line, d_line)
            if valid_position?(new_position)
              # If the line is still in the MR, we don't treat this as outdated.
              { position: new_position, outdated: false }
            else
              # If the line is no longer in the MR, we unfortunately cannot show
              # the current state on the CD diff, so we treat it as outdated.
              ac_diff = ac_diffs.diff_file_with_new_path(c_path)

              { position: position(ac_diff, nil, c_line), outdated: true }
            end
          else
            # If the line is still in D and not in C, it is still added.
            { position: position(cd_diff, nil, d_line), outdated: false }
          end
        else
          # If the line is no longer in D, it has been removed from the MR.
          { position: position(bd_diff, b_line, nil), outdated: true }
        end
      end

      def trace_removed_line(ab_position)
        a_path = ab_position.old_path
        a_line = ab_position.old_line

        ac_diff = ac_diffs.diff_file_with_old_path(a_path)

        c_path = ac_diff&.new_path || a_path
        c_line = LineMapper.new(ac_diff).old_to_new(a_line)

        if c_line
          cd_diff = cd_diffs.diff_file_with_old_path(c_path)

          d_path = cd_diff&.new_path || c_path
          d_line = LineMapper.new(cd_diff).old_to_new(c_line)

          if d_line
            # If the line is still in C but also in D, it has turned from a
            # removed line into an unchanged one.
            bd_diff = bd_diffs.diff_file_with_new_path(d_path)

            { position: position(bd_diff, nil, d_line), outdated: true }
          else
            # If the line is still in C and not in D, it is still removed.
            { position: position(cd_diff, c_line, nil), outdated: false }
          end
        else
          # If the line is no longer in C, it has been removed outside of the MR.
          { position: position(ac_diff, a_line, nil), outdated: true }
        end
      end

      def trace_unchanged_line(ab_position)
        a_path = ab_position.old_path
        a_line = ab_position.old_line
        b_path = ab_position.new_path
        b_line = ab_position.new_line

        ac_diff = ac_diffs.diff_file_with_old_path(a_path)

        c_path = ac_diff&.new_path || a_path
        c_line = LineMapper.new(ac_diff).old_to_new(a_line)

        bd_diff = bd_diffs.diff_file_with_old_path(b_path)

        d_line = LineMapper.new(bd_diff).old_to_new(b_line)

        cd_diff = cd_diffs.diff_file_with_old_path(c_path)

        if c_line && d_line
          # If the line is still in C and D, it is still unchanged.
          new_position = position(cd_diff, c_line, d_line)
          if valid_position?(new_position)
            # If the line is still in the MR, we don't treat this as outdated.
            { position: new_position, outdated: false }
          else
            # If the line is no longer in the MR, we unfortunately cannot show
            # the current state on the CD diff or any change on the BD diff,
            # so we treat it as outdated.
            { position: nil, outdated: true }
          end
        elsif d_line # && !c_line
          # If the line is still in D but no longer in C, it has turned from
          # an unchanged line into an added one.
          # We don't treat this as outdated since the line is still in the MR.
          { position: position(cd_diff, nil, d_line), outdated: false }
        else # !d_line && (c_line || !c_line)
          # If the line is no longer in D, it has turned from an unchanged line
          # into a removed one.
          { position: position(bd_diff, b_line, nil), outdated: true }
        end
      end

      def ac_diffs
        @ac_diffs ||= compare(
          old_diff_refs.base_sha || old_diff_refs.start_sha,
          new_diff_refs.base_sha || new_diff_refs.start_sha,
          straight: true
        )
      end

      def bd_diffs
        @bd_diffs ||= compare(old_diff_refs.head_sha, new_diff_refs.head_sha, straight: true)
      end

      def cd_diffs
        @cd_diffs ||= compare(new_diff_refs.start_sha, new_diff_refs.head_sha)
      end

      def compare(start_sha, head_sha, straight: false)
        compare = CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
        compare.diffs(paths: paths, expanded: true)
      end

      def position(diff_file, old_line, new_line)
        Position.new(diff_file: diff_file, old_line: old_line, new_line: new_line)
      end

      def valid_position?(position)
        !!position.diff_line(project.repository)
      end
    end
  end
end