app/models/diff.rb
class Diff
# Source can be :git or :interdiff
def initialize(diff, source: :git)
@raw = diff
@files = {}
@message_lines = []
@file_metadata_reader = source == :git ? :start_reading_git_file_metadata : :start_reading_interdiff_file_metadata
process_patch(diff)
process_subject
each_file do |file|
file.changes = Zlib::Inflate.inflate(file.changes.pack('C*')) if file.binary? && !file.delta?
end
@commit_message = @message_lines.join
end
def each_file
@files.each do |_name, value|
yield value
end
end
def empty?
@files.empty?
end
attr_reader :files
attr_reader :subject
attr_reader :commit_message
attr_reader :raw
private
def process_subject
return '' if @subject.nil?
# Subject can be Q-Encoded.
@subject[0] = @subject[0].sub('[PATCH] ', '')
@subject.map! do |line|
if line.start_with?('=?UTF-8?q?') && line.end_with?('?=')
line.byteslice(10, line.size - 12).unpack1('M').force_encoding('utf-8')
else
line
end.strip
end
@subject = @subject.join(' ')
end
class File
attr_accessor :name
attr_accessor :changes
attr_accessor :renamed_from
attr_accessor :old_chmod
attr_accessor :new_chmod
attr_accessor :index
attr_accessor :similarity
attr_accessor :binary
alias binary? binary
attr_accessor :delta
alias delta? delta
attr_accessor :size
attr_accessor :interdiff_tag
attr_writer :status
def initialize
@changes = []
end
def image?
return false unless binary? # This fails for SVG, so SVG is show as text
ext = @name.downcase.scan(/\.([^.]\w*)\z/).first.first
%w(png jpg jpeg gif tif tiff eps jif jpx).include?(ext)
end
def new?
@status == :new
end
def deleted?
@status == :deleted
end
def renamed?
@status == :renamed
end
def chmod_changed?
!@new_chmod.nil?
end
def label
return @name unless renamed?
@label ||= path_diff(@renamed_from, @name)
end
# Call this on binary files is wrong
def each_change
old_ln = old_ln_cache = 0
new_ln = new_ln_cache = 0
@changes.each_with_index do |line, i|
if line =~ /@ -(\d+),\d+ \+(\d+),\d+/
old_ln_cache = $1.to_i
new_ln_cache = $2.to_i
end
type = LINE_TYPES[line[0]]
case type
when :add
old_ln = ''
new_ln = new_ln_cache
new_ln_cache += 1
when :del
old_ln = old_ln_cache
old_ln_cache += 1
new_ln = ''
when :info
old_ln = new_ln = '...'
else
new_ln = new_ln_cache
old_ln = old_ln_cache
old_ln_cache += 1
new_ln_cache += 1
end
yield(line, type, @index + i, old_ln, new_ln)
end
end
private
LINE_TYPES = {
'@' => :info,
'-' => :del,
'+' => :add,
' ' => :nil
}
def path_diff(from, to)
old_parts = from.split('/')
new_parts = to.split('/')
output = []
old_changed = []
new_changed = []
change_blocks = 0
i = -1
old_parts.reverse_each do |old_dir|
new_dir = new_parts[i]
if old_dir == new_dir
if old_changed.any?
output << "{#{old_changed.reverse.join('/')} → #{new_changed.reverse.join('/')}}" if new_changed.any?
change_blocks += 1
end
output << old_dir
old_changed = []
new_changed = []
elsif change_blocks == 1
change_blocks += 1
break
else
old_changed << old_dir
new_changed << new_dir
end
i -= 1
end
missing_path_parts = -i <= new_parts.length
output.clear if old_changed.empty? && missing_path_parts
return "#{from} => #{to}" if change_blocks > 1 || output.empty?
if missing_path_parts
range = new_parts.length + i
new_changed += new_parts[0..range].reverse
end
output << "{#{old_changed.reverse.join('/')} → #{new_changed.reverse.join('/')}}" if new_changed.any?
output.reverse.join('/')
end
end
def process_patch(diff)
@state = :state_idle
@index = -1
diff.each_line do |line|
@index += 1
next if send(@file_metadata_reader, line)
send(@state, line)
end
end
def state_idle(line)
if line =~ /^Subject: (.*)/
@subject = [$1]
@state = :state_reading_subject
end
@state = :state_reading_commit_message if line.blank?
end
def state_reading_subject(line)
@subject << $1[1..-1] if line =~ /^( .+)/
@state = :state_reading_commit_message if line.blank?
end
def state_reading_commit_message(line)
@message_lines << line
end
def start_reading_git_file_metadata(line)
return false unless line.start_with?('diff --git ')
@file = File.new
line =~ %r{^diff --git a/(.*) b/(.*)}
@file.name = $2 == '/dev/null' ? $1 : $2
@files[@file.name] = @file
@state = :state_reading_file_metadata
end
def start_reading_interdiff_file_metadata(line)
new_file_line = line =~ /^(diff -u|---|\+\+\+)/
interdiff_tag = line =~ /^(reverted|unchanged|only in patch2|only in patch1)/
return false if !new_file_line && !interdiff_tag
new_file_found = !interdiff_tag && (line.start_with?('diff') || @next_interdiff_tag)
if interdiff_tag
@next_interdiff_tag = $1.capitalize
elsif new_file_found
if line.start_with?('diff')
line =~ %r{^diff -u .?/(.*) .?/(.*)}
file_name = $2 == '/dev/null' ? $1 : $2
else
line =~ %r{^... ./(.*)}
return true if $1.nil?
file_name ||= $1
end
@file = File.new
@file.name = file_name
@file.interdiff_tag = @next_interdiff_tag
@next_interdiff_tag = nil
@files[@file.name] = @file
if line.start_with?('+++')
@file.index = 0 # Don't care about index on interdiffs
@state = :state_reading_file_changes
else
@state = :state_reading_file_metadata
end
end
end
def state_reading_file_metadata(line)
case line
when /^new file mode (.+)/
@file.status = :new
@file.new_chmod = $1
when /^deleted file mode/
@file.status = :deleted
when /^rename from (.+)/
@file.status = :renamed
@file.renamed_from = $1
when /^rename to (.+)/
@file.name = $1
when /^similarity index (.+)/
@file.similarity = $1
when /^old mode (.+)/
@file.old_chmod = $1
when /^new mode (.+)/
@file.new_chmod = $1
when /^\+\+\+ /
@state = :state_reading_file_changes
@file.index = @index + 1
when /^GIT binary patch$/
@file.binary = true
@file.index = @index + 1
@file.size = 0
@state = :state_reading_file_changes
nil
end
end
def state_reading_file_changes(line)
if line == "-- \n"
@state = :state_idle
elsif @file.binary?
read_binary(line)
else
line.chop! if line.end_with?("\n")
@file.changes << line
end
end
def read_binary(line)
return if @file.delta?
if line.start_with?('delta ')
@file.delta = true # We don't support deltas in binary patches.
elsif line.start_with?('literal ')
size = line.scan(/\d+/).first.to_i
@file.size += size
elsif line != "\n" && !line.start_with?('HcmV')
@file.changes.push(*Base85.decode(line))
end
end
end