From 97822cbbeaa84a658cd7bbfc7d4020363107ff96 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Sat, 28 Jul 2018 14:53:49 +0100 Subject: [PATCH] Support for combining diffs 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. --- lib/diff.rb | 6 +++++ lib/diff/combined.rb | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 lib/diff/combined.rb diff --git a/lib/diff.rb b/lib/diff.rb index 59510bed..14fa0511 100644 --- a/lib/diff.rb +++ b/lib/diff.rb @@ -1,3 +1,4 @@ +require_relative "./diff/combined" require_relative "./diff/hunk" require_relative "./diff/myers" @@ -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 diff --git a/lib/diff/combined.rb b/lib/diff/combined.rb new file mode 100644 index 00000000..d6f6b7ae --- /dev/null +++ b/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