Skip to content

Commit

Permalink
Support for combining diffs
Browse files Browse the repository at this point in the history
Now that we can handle merge conflicts and merge within files, we can
produce merge commits where a file is not equal to its version in either
of the parent commits. In this case we'd like `log --cc` to print a
"combined diff" of the merge commit's version against each of its
parents.

Similarly, during a merge conflict, we'd like the `diff` command to
display the combined diff of the current workspace file against the
"ours" and "theirs" versions in the index.

Both these scenarios involve diffing a single post-image (the version in
the workspace, or in the merge commit) with multiple pre-images (the
versions in the index or parent commits).

For example, consider the following scenario. The table below shows the
contents of a file in the merged commits' common ancestor (Base), the
HEAD commit (Ours), the target commit (Theirs) and the merge commit
(Merged).

      Base          Ours          Theirs        Merged
    -----------------------------------------------------------
      alfa          alfa          echo          echo
      bravo         bravo         bravo         bravo
      charlie       delta         charlie       delta
                                                foxtrot

When displaying the merge commit, we'd like to show the diff of Merged
version against both the Ours and Theirs versions, which are:

                    Ours          Theirs
                  --------------------------------
                   -alfa          echo
                   +echo          bravo
                    bravo        -charlie
                    delta        +delta
                   +foxtrot      +foxtrot

Git has a way of combining these diffs such that when it displays a
merge commit, we'll see:

    - alfa
    + echo
      bravo
     -charlie
     +delta
    ++foxtrot

The first column of +/- mirrors the diff against Ours, while the second
column shows the diff against Theirs. Edits from the two diffs are
combined into a single row when their text matches, i.e. when the edits
relate to the same line in the post-image, the Merged version.

The `Diff::Combined` class performs this grouping. It takes an array of
diffs, which are arrays of `Diff::Edit` objects, and returns an array of
`Row` objects that each contains an array of edits that are aligned.
Recall that diffs use the following structs:

    Line = Struct.new(:number, :text)
    Edit = Struct.new(:type, :a_line, :b_line)

This commit introduces the structure:

    Row = Struct.new(:edits)

where `edits` is an array of `Edit` values. All the edits in a single
row have the same `b_line`; `Diff::Combined` essentially aligns a set of
diffs with the same post-image on their `b_line` values. If the row is a
deletion it will contain a single `Edit` with no `b_line` and all its
other columns will be `nil`. If the row contains additions/unchanged
lines, then all its columns should be full.

The `Diff::Combined::Row#to_s` method produces the line-by-line combined
diff format shown above, and works for any number of input diffs.
  • Loading branch information
jcoglan committed Jul 28, 2018
1 parent f9719f0 commit 97822cb
Show file tree
Hide file tree
Showing 2 changed files with 65 additions and 0 deletions.
6 changes: 6 additions & 0 deletions lib/diff.rb
@@ -1,3 +1,4 @@
require_relative "./diff/combined"
require_relative "./diff/hunk"
require_relative "./diff/myers"

Expand Down Expand Up @@ -29,4 +30,9 @@ def self.diff(a, b)
def self.diff_hunks(a, b)
Hunk.filter(diff(a, b))
end

def self.combined(as, b)
diffs = as.map { |a| diff(a, b) }
Combined.new(diffs).to_a
end
end
59 changes: 59 additions & 0 deletions lib/diff/combined.rb
@@ -0,0 +1,59 @@
module Diff
class Combined

include Enumerable

Row = Struct.new(:edits) do
def to_s
symbols = edits.map { |edit| SYMBOLS.fetch(edit&.type, " ") }

del = edits.find { |edit| edit&.type == :del }
line = del ? del.a_line : edits.first.b_line

symbols.join("") + line.text
end
end

def initialize(diffs)
@diffs = diffs
end

def each
@offsets = @diffs.map { 0 }

loop do
@diffs.each_with_index do |diff, i|
consume_deletions(diff, i) { |row| yield row }
end

return if complete?

edits = offset_diffs.map { |offset, diff| diff[offset] }
@offsets.map! { |offset| offset + 1 }

yield Row.new(edits)
end
end

private

def complete?
offset_diffs.all? { |offset, diff| offset == diff.size }
end

def offset_diffs
@offsets.zip(@diffs)
end

def consume_deletions(diff, i)
while @offsets[i] < diff.size and diff[@offsets[i]].type == :del
edits = Array.new(@diffs.size)
edits[i] = diff[@offsets[i]]
@offsets[i] += 1

yield Row.new(edits)
end
end

end
end

0 comments on commit 97822cb

Please sign in to comment.