Skip to content

Instantly share code, notes, and snippets.

@alpaca-tc
Created June 29, 2023 12:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alpaca-tc/412ec9d2c10044bd4500eadbe40459de to your computer and use it in GitHub Desktop.
Save alpaca-tc/412ec9d2c10044bd4500eadbe40459de to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
# Usage
# diffs = Dir["./patches/*.patch"].map { GitDiff.parse(File.read(_1)) }
class GitDiff
class File
include ActiveModel::Model
include ActiveModel::Attributes
attribute :old_path, :string
attribute :new_path, :string
attribute :old_index, :string
attribute :new_index, :string
attribute :old_mode, :string
attribute :new_mode, :string
attribute :text_fragments, default: -> { [] }
validates :old_path, presence: true
validates :new_path, presence: true
validates :old_index, presence: true
validates :new_index, presence: true
validates :old_mode, presence: true
validates :new_mode, presence: true
validate do
errors.add(:text_fragments) unless text_fragments.map(&:valid?).all?
end
end
class TextFragment
include ActiveModel::Model
include ActiveModel::Attributes
attribute :comment, :string
attribute :old_position, :integer
attribute :old_lines, :integer
attribute :new_position, :integer
attribute :new_lines, :integer
attribute :lines, default: -> { [] }
validates :old_position, presence: true
validates :old_lines, presence: true
validates :new_position, presence: true
validates :new_lines, presence: true
validate do
errors.add(:lines) unless lines.map(&:valid?).all?
end
# @return [Array<GitDiff::TextFragment>]
def by_lines
if old_lines == 0 && new_lines == 0
[self]
else
lines.group_by(&:index).map do |index, index_lines|
self.class.new(
comment:,
old_position: old_position + index,
old_lines: 0,
new_position: new_position + index,
new_lines: 0,
lines: index_lines,
)
end
end
end
# @param line [GitDiff::Line]
def add_line(line)
index = lines.count { _1.operator == line.operator }
line.index = index
lines << line
end
# @return [Array<Array<GitDiff::Line>, Array<GitDiff::Line>>] [added, deleted]の配列
def added_deleted
lines.partition { _1.operator == "+" }.map { _1.sort_by(&:index) }
end
def added_deleted_text
added, deleted = added_deleted
added_text = added.map(&:line).join("\n")
deleted_text = deleted.map(&:line).join("\n")
[added_text, deleted_text]
end
def to_s
buf = +""
buf << "@@ -#{old_position}"
buf << ",#{old_lines}" if old_lines > 0
buf << " "
buf << "+#{new_position}"
buf << ",#{new_lines}" if new_lines > 0
buf << " @@"
buf << comment
buf << "\n"
buf << lines.map(&:to_s).join("\n")
end
alias_method :inspect, :to_s
end
class Line
include ActiveModel::Model
include ActiveModel::Attributes
attribute :operator, :string
attribute :line, :string
attribute :index, :integer
validates :operator, inclusion: { in: %w(+ -) }
validates :line, presence: true
validates :index, presence: true
def deleted?
operator == "-"
end
def to_s
"#{operator}#{line}"
end
alias_method :inspect, :to_s
end
class Parser
DIFF_GIT_RE = %r{^diff --git (?:a|i|w|c|o|1|2)/(?<old_path>.*) (?:b|i|w|c|o|1|2)/(?<new_path>.*)$}
DIFF_INDEX_RE = /^index (?<old_index>[0-9a-f]+)\.\.(?<new_index>[0-9a-f]+)(?: (?<new_mode>\d+))?$/
DIFF_OLD_MODE_RE = /^old mode (?<old_mode>\d+)$/
DIFF_NEW_MODE_RE = /^new mode (?<new_mode>\d+)$/
DIFF_OLD_RE = %r{^--- (?:a|i|w|c|o|1|2)/.*$}
DIFF_NEW_RE = %r{^\+\+\+ (?:b|i|w|c|o|1|2)/.*$}
DIFF_TO_RE = /---/
TEXT_FLAGMENT_BEG_RE = /^@@ -(?<old_position>\d+)(?:,(?<old_lines>\d+))? \+(?<new_position>\d+)(?:,(?<new_lines>\d+))? @@(?<comment>.*)$/
TEXT_FLAGMENT_RE = /^(?<operator>\+|-)(?<line>.*)$/
EOF_RE = /^\\ No newline at end of file$/
class OutsideTextFragmentError < StandardError; end
def initialize(content)
@content = content
end
def to_files
files = []
file = nil
within_text_fragment = false
text_fragment = nil
@content.lines.each_with_index do |line, _count|
case line
when DIFF_GIT_RE
within_text_fragment = false
text_fragment = nil
file&.validate!
file = GitDiff::File.new
files << file
file.old_path = Regexp.last_match[:old_path]
file.new_path = Regexp.last_match[:new_path]
when DIFF_OLD_MODE_RE
file.old_mode = Regexp.last_match[:old_mode]
when DIFF_NEW_MODE_RE
file.new_mode = Regexp.last_match[:new_mode]
when DIFF_INDEX_RE
file.old_index = Regexp.last_match[:old_index]
file.new_index = Regexp.last_match[:new_index]
file.new_mode = Regexp.last_match[:new_mode]
when DIFF_OLD_RE
# skip
when DIFF_NEW_RE
within_text_fragment = true
when TEXT_FLAGMENT_BEG_RE
text_fragment = GitDiff::TextFragment.new(
comment: Regexp.last_match[:comment],
old_position: Regexp.last_match[:old_position].to_i,
old_lines: Regexp.last_match[:old_lines].to_i,
new_position: Regexp.last_match[:new_position].to_i,
new_lines: Regexp.last_match[:new_lines].to_i,
)
file.text_fragments << text_fragment
when TEXT_FLAGMENT_RE
raise OutsideTextFragmentError unless within_text_fragment
text_fragment.add_line(GitDiff::Line.new(operator: Regexp.last_match[:operator], line: Regexp.last_match[:line]))
when EOF_RE
# skip
else
raise "unknown line: #{line}"
end
end
files
end
end
attr_reader :files
def self.parse(content)
files = Parser.new(content).to_files
new(files:)
end
# @param files [GitDiff::File]
def initialize(files: [])
raise ArgumentError, "files is blank" if files.empty?
@files = files
end
def to_s
buf = +""
files.each do |file|
buf << "diff --git a/#{file.old_path} b/#{file.new_path}\n"
# NOTE: changeだけ対応している
buf << "index #{file.old_index}..#{file.new_index} #{file.new_mode}\n"
buf << "--- a/#{file.old_path}\n"
buf << "+++ b/#{file.new_path}\n"
buf << file.text_fragments.map(&:to_s).join("\n")
buf << "\n"
end
buf
end
alias_method :inspect, :to_s
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment