patch_util 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +8 -0
- data/SKILL.md +157 -0
- data/exe/patch_util +12 -0
- data/lib/patch_util/cli.rb +22 -0
- data/lib/patch_util/diff.rb +132 -0
- data/lib/patch_util/git/cli.rb +664 -0
- data/lib/patch_util/git/rewrite_cli.rb +393 -0
- data/lib/patch_util/git/rewrite_session_manager.rb +480 -0
- data/lib/patch_util/git/rewrite_state_store.rb +81 -0
- data/lib/patch_util/git/rewriter.rb +233 -0
- data/lib/patch_util/git.rb +11 -0
- data/lib/patch_util/parser.rb +412 -0
- data/lib/patch_util/selection.rb +98 -0
- data/lib/patch_util/source.rb +69 -0
- data/lib/patch_util/split/applier.rb +38 -0
- data/lib/patch_util/split/cli.rb +167 -0
- data/lib/patch_util/split/emitter.rb +52 -0
- data/lib/patch_util/split/inspector.rb +203 -0
- data/lib/patch_util/split/plan.rb +33 -0
- data/lib/patch_util/split/plan_store.rb +106 -0
- data/lib/patch_util/split/planner.rb +24 -0
- data/lib/patch_util/split/projector.rb +252 -0
- data/lib/patch_util/split/verifier.rb +133 -0
- data/lib/patch_util/split.rb +21 -0
- data/lib/patch_util/version.rb +5 -0
- data/lib/patch_util.rb +21 -0
- metadata +92 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
module Selection
|
|
5
|
+
WholeHunk = Data.define(:hunk_label) do
|
|
6
|
+
def whole_hunk?
|
|
7
|
+
true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
ChangedLineRange = Data.define(:hunk_label, :start_ordinal, :end_ordinal) do
|
|
12
|
+
def whole_hunk?
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ordinals
|
|
17
|
+
(start_ordinal..end_ordinal).to_a
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Parser
|
|
22
|
+
def parse(selector_text)
|
|
23
|
+
tokens = selector_text.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
24
|
+
selectors = []
|
|
25
|
+
|
|
26
|
+
tokens.each do |token|
|
|
27
|
+
selectors.concat(parse_token(token))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
selectors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse_token(token)
|
|
36
|
+
if (match = /\A([a-z]+)(\d+)-([a-z]+)(\d+)\z/.match(token))
|
|
37
|
+
start_hunk = match[1]
|
|
38
|
+
finish_hunk = match[3]
|
|
39
|
+
raise ValidationError, "cross-hunk ranges are not supported: #{token}" unless start_hunk == finish_hunk
|
|
40
|
+
|
|
41
|
+
start_ordinal = Integer(match[2], 10)
|
|
42
|
+
end_ordinal = Integer(match[4], 10)
|
|
43
|
+
raise ValidationError, "descending selector range: #{token}" if start_ordinal > end_ordinal
|
|
44
|
+
|
|
45
|
+
return [ChangedLineRange.new(
|
|
46
|
+
hunk_label: start_hunk,
|
|
47
|
+
start_ordinal: start_ordinal,
|
|
48
|
+
end_ordinal: end_ordinal
|
|
49
|
+
)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if (match = /\A([a-z]+)(\d+)\z/.match(token))
|
|
53
|
+
ordinal = Integer(match[2], 10)
|
|
54
|
+
return [ChangedLineRange.new(hunk_label: match[1], start_ordinal: ordinal, end_ordinal: ordinal)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if (match = /\A([a-z]+)-([a-z]+)\z/.match(token))
|
|
58
|
+
start_label = match[1]
|
|
59
|
+
end_label = match[2]
|
|
60
|
+
start_index = hunk_label_index(start_label)
|
|
61
|
+
end_index = hunk_label_index(end_label)
|
|
62
|
+
raise ValidationError, "descending hunk range: #{token}" if start_index > end_index
|
|
63
|
+
|
|
64
|
+
return (start_index..end_index).map do |index|
|
|
65
|
+
WholeHunk.new(hunk_label: hunk_label_for(index))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return [WholeHunk.new(hunk_label: token)] if /\A[a-z]+\z/.match?(token)
|
|
70
|
+
|
|
71
|
+
raise ValidationError, "invalid selector token: #{token}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def hunk_label_index(label)
|
|
75
|
+
value = 0
|
|
76
|
+
|
|
77
|
+
label.each_byte do |byte|
|
|
78
|
+
value = (value * 26) + (byte - 96)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
value - 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def hunk_label_for(index)
|
|
85
|
+
current = index
|
|
86
|
+
label = +''
|
|
87
|
+
|
|
88
|
+
loop do
|
|
89
|
+
label.prepend((97 + (current % 26)).chr)
|
|
90
|
+
current = (current / 26) - 1
|
|
91
|
+
break if current.negative?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
label
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module PatchUtil
|
|
6
|
+
class Source
|
|
7
|
+
attr_reader :diff_text, :fingerprint, :kind, :label, :path, :repo_path, :commit_sha, :parent_shas
|
|
8
|
+
|
|
9
|
+
def self.from_diff_text(diff_text, label: 'stdin')
|
|
10
|
+
bytes = diff_text.dup.force_encoding(Encoding::BINARY)
|
|
11
|
+
new(
|
|
12
|
+
kind: 'raw_diff',
|
|
13
|
+
label: label,
|
|
14
|
+
path: nil,
|
|
15
|
+
diff_text: bytes.dup.force_encoding(Encoding::UTF_8),
|
|
16
|
+
fingerprint: Digest::SHA256.hexdigest(bytes)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_patch_file(path)
|
|
21
|
+
expanded = File.expand_path(path)
|
|
22
|
+
bytes = File.binread(expanded)
|
|
23
|
+
new(
|
|
24
|
+
kind: 'patch_file',
|
|
25
|
+
label: path,
|
|
26
|
+
path: expanded,
|
|
27
|
+
diff_text: bytes.dup.force_encoding(Encoding::UTF_8),
|
|
28
|
+
fingerprint: Digest::SHA256.hexdigest(bytes)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_git_commit(repo_path:, revision: 'HEAD', git_cli: PatchUtil::Git::Cli.new)
|
|
33
|
+
root = git_cli.repo_root(repo_path)
|
|
34
|
+
sha = git_cli.rev_parse(root, revision)
|
|
35
|
+
parent_shas = git_cli.parent_shas(root, sha)
|
|
36
|
+
if parent_shas.length > 1
|
|
37
|
+
raise ValidationError,
|
|
38
|
+
"merge commits are not supported yet for inspect/plan/apply: #{sha} has #{parent_shas.length} parents"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
diff_text = git_cli.show_commit_patch(root, sha)
|
|
42
|
+
new(
|
|
43
|
+
kind: 'git_commit',
|
|
44
|
+
label: "#{root}@#{sha}",
|
|
45
|
+
path: nil,
|
|
46
|
+
diff_text: diff_text,
|
|
47
|
+
fingerprint: sha,
|
|
48
|
+
repo_path: root,
|
|
49
|
+
commit_sha: sha,
|
|
50
|
+
parent_shas: parent_shas
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(kind:, label:, path:, diff_text:, fingerprint:, repo_path: nil, commit_sha: nil, parent_shas: [])
|
|
55
|
+
@kind = kind
|
|
56
|
+
@label = label
|
|
57
|
+
@path = path
|
|
58
|
+
@diff_text = diff_text
|
|
59
|
+
@fingerprint = fingerprint
|
|
60
|
+
@repo_path = repo_path
|
|
61
|
+
@commit_sha = commit_sha
|
|
62
|
+
@parent_shas = parent_shas
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def git?
|
|
66
|
+
kind.start_with?('git_')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module PatchUtil
|
|
6
|
+
module Split
|
|
7
|
+
class Applier
|
|
8
|
+
def initialize(emitter: Emitter.new)
|
|
9
|
+
@emitter = emitter
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply(diff:, plan_entry:, output_dir:)
|
|
13
|
+
projector = Projector.new(diff: diff, plan_entry: plan_entry)
|
|
14
|
+
target_dir = File.expand_path(output_dir)
|
|
15
|
+
FileUtils.mkdir_p(target_dir)
|
|
16
|
+
|
|
17
|
+
emitted = []
|
|
18
|
+
plan_entry.chunks.each_with_index do |chunk, chunk_index|
|
|
19
|
+
patch_text = @emitter.emit(projector.project_chunk(chunk_index))
|
|
20
|
+
raise ValidationError, "chunk #{chunk.name} did not produce a patch" if patch_text.empty?
|
|
21
|
+
|
|
22
|
+
path = File.join(target_dir, format('%04d-%s.patch', chunk_index + 1, slug(chunk.name)))
|
|
23
|
+
File.write(path, patch_text)
|
|
24
|
+
emitted << { name: chunk.name, path: path, patch_text: patch_text }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
emitted
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def slug(name)
|
|
33
|
+
normalized = name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
|
|
34
|
+
normalized.empty? ? 'chunk' : normalized
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'thor'
|
|
5
|
+
|
|
6
|
+
module PatchUtil
|
|
7
|
+
module Split
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
desc 'inspect', 'Display annotated diff lines and any saved plan overlay'
|
|
10
|
+
option :patch, type: :string, aliases: '-p', banner: 'PATH'
|
|
11
|
+
option :commit, type: :string, aliases: '-c', banner: 'REV'
|
|
12
|
+
option :repo, type: :string, aliases: '-r', banner: 'PATH'
|
|
13
|
+
option :plan, type: :string, aliases: '-P', banner: 'PATH'
|
|
14
|
+
option :compact, type: :boolean, default: false, banner: 'BOOL'
|
|
15
|
+
option :expand, type: :string, banner: 'HUNKS'
|
|
16
|
+
def inspect
|
|
17
|
+
source = load_source
|
|
18
|
+
diff = PatchUtil::Parser.new.parse(source)
|
|
19
|
+
plan_entry = load_plan_entry(source)
|
|
20
|
+
expand_hunks = parse_expanded_hunks(diff)
|
|
21
|
+
puts Inspector.new.render(diff: diff,
|
|
22
|
+
plan_entry: plan_entry,
|
|
23
|
+
compact: options[:compact],
|
|
24
|
+
expand_hunks: expand_hunks)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc 'plan [NAME SELECTORS]... [LEFTOVERS_NAME]', 'Persist a split plan'
|
|
28
|
+
option :patch, type: :string, aliases: '-p', banner: 'PATH'
|
|
29
|
+
option :commit, type: :string, aliases: '-c', banner: 'REV'
|
|
30
|
+
option :repo, type: :string, aliases: '-r', banner: 'PATH'
|
|
31
|
+
option :plan, type: :string, aliases: '-P', banner: 'PATH'
|
|
32
|
+
def plan(*args)
|
|
33
|
+
source = load_source
|
|
34
|
+
diff = PatchUtil::Parser.new.parse(source)
|
|
35
|
+
chunk_requests = build_chunk_requests(args)
|
|
36
|
+
plan_entry = Planner.new.build(source: source, diff: diff, chunk_requests: chunk_requests)
|
|
37
|
+
|
|
38
|
+
store = PlanStore.new(path: resolve_plan_path(source))
|
|
39
|
+
plan_set = store.upsert(store.load, plan_entry)
|
|
40
|
+
store.save(plan_set)
|
|
41
|
+
|
|
42
|
+
puts "saved #{plan_entry.chunks.length} chunks to #{store.path}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc 'apply', 'Emit one patch file per saved chunk'
|
|
46
|
+
option :patch, type: :string, aliases: '-p', banner: 'PATH'
|
|
47
|
+
option :commit, type: :string, aliases: '-c', banner: 'REV'
|
|
48
|
+
option :repo, type: :string, aliases: '-r', banner: 'PATH'
|
|
49
|
+
option :plan, type: :string, aliases: '-P', banner: 'PATH'
|
|
50
|
+
option :output_dir, type: :string, aliases: '-o', banner: 'DIR'
|
|
51
|
+
option :rewrite, type: :boolean, default: false, banner: 'BOOL'
|
|
52
|
+
def apply
|
|
53
|
+
source = load_source
|
|
54
|
+
diff = PatchUtil::Parser.new.parse(source)
|
|
55
|
+
plan_entry = load_plan_entry(source)
|
|
56
|
+
raise ValidationError, "no saved plan matches #{source.label}" unless plan_entry
|
|
57
|
+
|
|
58
|
+
if options[:rewrite]
|
|
59
|
+
raise ValidationError, '--rewrite is only supported for git commit sources' unless source.git?
|
|
60
|
+
|
|
61
|
+
result = PatchUtil::Git::Rewriter.new.rewrite(source: source, diff: diff, plan_entry: plan_entry)
|
|
62
|
+
puts "rewrote #{result.branch}: #{result.old_head} -> #{result.new_head}"
|
|
63
|
+
puts "backup ref: #{result.backup_ref}"
|
|
64
|
+
result.commits.each { |name| puts "created #{name}" }
|
|
65
|
+
else
|
|
66
|
+
raise ValidationError, '--output-dir is required unless --rewrite is used' unless options[:output_dir]
|
|
67
|
+
|
|
68
|
+
emitted = Applier.new.apply(diff: diff, plan_entry: plan_entry, output_dir: options[:output_dir])
|
|
69
|
+
emitted.each do |item|
|
|
70
|
+
puts "#{item[:name]} -> #{item[:path]}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
no_commands do
|
|
76
|
+
def load_source
|
|
77
|
+
validate_source_options!
|
|
78
|
+
|
|
79
|
+
if options[:patch]
|
|
80
|
+
PatchUtil::Source.from_patch_file(options[:patch])
|
|
81
|
+
else
|
|
82
|
+
PatchUtil::Source.from_git_commit(repo_path: options[:repo] || Dir.pwd,
|
|
83
|
+
revision: options[:commit] || 'HEAD')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_chunk_requests(args)
|
|
88
|
+
raise ValidationError, 'plan requires at least one chunk name and selector' if args.empty?
|
|
89
|
+
|
|
90
|
+
requests = []
|
|
91
|
+
pairable_count = args.length.even? ? args.length : args.length - 1
|
|
92
|
+
index = 0
|
|
93
|
+
|
|
94
|
+
while index < pairable_count
|
|
95
|
+
name = args[index]
|
|
96
|
+
selector_text = args[index + 1]
|
|
97
|
+
raise ValidationError, "missing selector for chunk #{name}" if selector_text.nil?
|
|
98
|
+
|
|
99
|
+
requests << ChunkRequest.new(name: name, selector_text: selector_text, leftovers: false)
|
|
100
|
+
index += 2
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
requests << ChunkRequest.new(name: args[-1], selector_text: nil, leftovers: true) if args.length.odd?
|
|
104
|
+
|
|
105
|
+
requests
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_expanded_hunks(diff)
|
|
109
|
+
selector_text = options[:expand]
|
|
110
|
+
return [] unless selector_text
|
|
111
|
+
|
|
112
|
+
raise ValidationError, '--expand requires --compact' unless options[:compact]
|
|
113
|
+
|
|
114
|
+
selectors = PatchUtil::Selection::Parser.new.parse(selector_text)
|
|
115
|
+
raise ValidationError, '--expand requires at least one hunk label' if selectors.empty?
|
|
116
|
+
|
|
117
|
+
labels = []
|
|
118
|
+
selectors.each do |selector|
|
|
119
|
+
unless selector.whole_hunk?
|
|
120
|
+
raise ValidationError,
|
|
121
|
+
'--expand only accepts whole-hunk labels or hunk ranges (for example: a,b,c or a-c)'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless diff.hunk_by_label(selector.hunk_label)
|
|
125
|
+
raise ValidationError,
|
|
126
|
+
"unknown hunk label for --expand: #{selector.hunk_label}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
labels << selector.hunk_label
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
labels.uniq
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def load_plan_entry(source)
|
|
136
|
+
store = PlanStore.new(path: resolve_plan_path(source))
|
|
137
|
+
store.load.find_entry(source)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resolve_plan_path(source)
|
|
141
|
+
return File.expand_path(options[:plan]) if options[:plan]
|
|
142
|
+
|
|
143
|
+
if source.git?
|
|
144
|
+
git_dir = PatchUtil::Git::Cli.new.git_dir(source.repo_path)
|
|
145
|
+
return File.join(git_dir, 'patch_util', 'plans.json')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise ValidationError, "--plan is required for #{source.kind} sources outside a git repository"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_source_options!
|
|
152
|
+
if options[:patch] && (options[:commit] || options[:repo])
|
|
153
|
+
raise ValidationError, 'use either --patch or --commit/--repo source options, not both'
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
raise ValidationError, '--repo requires --commit when used explicitly' if options[:repo] && !options[:commit]
|
|
157
|
+
|
|
158
|
+
return if options[:patch]
|
|
159
|
+
return if options[:commit]
|
|
160
|
+
return if PatchUtil::Git::Cli.new.inside_repo?(options[:repo] || Dir.pwd)
|
|
161
|
+
|
|
162
|
+
raise ValidationError, 'provide --patch, or run inside a git repository, or pass --commit with --repo'
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
module Split
|
|
5
|
+
class Emitter
|
|
6
|
+
def emit(projected_files)
|
|
7
|
+
lines = []
|
|
8
|
+
|
|
9
|
+
projected_files.each do |file_patch|
|
|
10
|
+
lines << file_patch.diff_git_line if file_patch.diff_git_line
|
|
11
|
+
lines.concat(file_patch.metadata_lines)
|
|
12
|
+
if file_patch.emit_text_headers
|
|
13
|
+
lines << "--- #{format_old_path(file_patch.old_path)}"
|
|
14
|
+
lines << "+++ #{format_new_path(file_patch.new_path)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
file_patch.hunks.each do |hunk|
|
|
18
|
+
if hunk.kind == :text
|
|
19
|
+
lines << "@@ -#{format_range(hunk.old_start,
|
|
20
|
+
hunk.old_count)} +#{format_range(hunk.new_start, hunk.new_count)} @@"
|
|
21
|
+
lines.concat(hunk.lines)
|
|
22
|
+
else
|
|
23
|
+
lines.concat(hunk.patch_lines)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return '' if lines.empty?
|
|
29
|
+
|
|
30
|
+
lines.join("\n") + "\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def format_range(start, count)
|
|
36
|
+
"#{start},#{count}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_old_path(path)
|
|
40
|
+
return '/dev/null' if path == '/dev/null'
|
|
41
|
+
|
|
42
|
+
"a/#{path.sub(%r{\A[ab]/}, '')}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_new_path(path)
|
|
46
|
+
return '/dev/null' if path == '/dev/null'
|
|
47
|
+
|
|
48
|
+
"b/#{path.sub(%r{\A[ab]/}, '')}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
module Split
|
|
5
|
+
class Inspector
|
|
6
|
+
COLUMN_WIDTH = 28
|
|
7
|
+
|
|
8
|
+
def render(diff:, plan_entry: nil, compact: false, expand_hunks: [])
|
|
9
|
+
assignments = assignment_map(plan_entry)
|
|
10
|
+
return render_compact(diff, assignments, expand_hunks) if compact
|
|
11
|
+
|
|
12
|
+
render_full(diff, assignments)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def render_full(diff, assignments)
|
|
18
|
+
lines = []
|
|
19
|
+
|
|
20
|
+
diff.file_diffs.each do |file_diff|
|
|
21
|
+
lines << "--- #{file_diff.old_path}"
|
|
22
|
+
lines << "+++ #{file_diff.new_path}"
|
|
23
|
+
|
|
24
|
+
file_diff.hunks.each do |hunk|
|
|
25
|
+
lines << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@"
|
|
26
|
+
hunk.rows.each do |row|
|
|
27
|
+
if row.change?
|
|
28
|
+
label = row.change_label
|
|
29
|
+
chunk_name = assignments[row.id]
|
|
30
|
+
marker = chunk_name ? "#{label} [#{chunk_name}]" : label
|
|
31
|
+
lines << format("%-#{COLUMN_WIDTH}s %s%s", marker, row.display_prefix, row.text)
|
|
32
|
+
else
|
|
33
|
+
lines << format("%-#{COLUMN_WIDTH}s %s%s", '', row.display_prefix, row.text)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
lines.join("\n") + "\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_compact(diff, assignments, expand_hunks)
|
|
43
|
+
lines = []
|
|
44
|
+
expanded_labels = expand_hunks.to_set
|
|
45
|
+
|
|
46
|
+
lines << '== Compact Inspect =='
|
|
47
|
+
lines << 'legend: text=unified hunk, operation=rename/copy/mode metadata, binary=git binary payload'
|
|
48
|
+
lines << 'label spans show selectable changed-line ranges with optional [chunk name] overlay'
|
|
49
|
+
lines << "expanded hunks: #{expanded_labels.to_a.join(', ')}" unless expanded_labels.empty?
|
|
50
|
+
lines << ''
|
|
51
|
+
lines << '== File Index =='
|
|
52
|
+
|
|
53
|
+
diff.file_diffs.each do |file_diff|
|
|
54
|
+
lines << compact_file_index_line(file_diff, assignments)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
lines << ''
|
|
58
|
+
lines << '== Details =='
|
|
59
|
+
|
|
60
|
+
diff.file_diffs.each do |file_diff|
|
|
61
|
+
lines << "--- #{file_diff.old_path}"
|
|
62
|
+
lines << "+++ #{file_diff.new_path}"
|
|
63
|
+
|
|
64
|
+
file_diff.hunks.each do |hunk|
|
|
65
|
+
if expanded_labels.include?(hunk.label)
|
|
66
|
+
lines.concat(full_hunk_lines(hunk, assignments))
|
|
67
|
+
else
|
|
68
|
+
lines << compact_hunk_line(hunk, assignments)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines.join("\n") + "\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def compact_file_index_line(file_diff, assignments)
|
|
77
|
+
path = compact_file_path(file_diff)
|
|
78
|
+
hunks = compact_index_hunks(file_diff).map { |hunk| compact_file_index_hunk(hunk, assignments) }
|
|
79
|
+
change_count = 0
|
|
80
|
+
file_diff.hunks.each do |hunk|
|
|
81
|
+
change_count += hunk.change_rows.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
"#{path} (#{count_label(file_diff.hunks.length,
|
|
85
|
+
'hunk')}, #{count_label(change_count, 'change')}): #{hunks.join('; ')}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def compact_file_path(file_diff)
|
|
89
|
+
return file_diff.new_path if file_diff.addition?
|
|
90
|
+
return file_diff.old_path if file_diff.deletion?
|
|
91
|
+
|
|
92
|
+
file_diff.new_path
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compact_index_hunks(file_diff)
|
|
96
|
+
indexed = []
|
|
97
|
+
file_diff.hunks.each_with_index do |hunk, index|
|
|
98
|
+
indexed << [hunk, index]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
indexed.sort_by { |hunk, index| [-hunk.change_rows.length, index] }.map(&:first)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compact_file_index_hunk(hunk, assignments)
|
|
105
|
+
summary = compact_change_segments(hunk.change_rows, assignments)
|
|
106
|
+
"#{hunk.label}(#{compact_kind(hunk)}, #{count_label(hunk.change_rows.length, 'change')}: #{summary})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def compact_hunk_line(hunk, assignments)
|
|
110
|
+
if hunk.text?
|
|
111
|
+
"#{hunk.label} text @@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@: " \
|
|
112
|
+
"#{compact_change_segments(hunk.change_rows, assignments)}"
|
|
113
|
+
else
|
|
114
|
+
row = hunk.change_rows.first
|
|
115
|
+
"#{hunk.label} #{compact_kind(hunk)}: #{compact_change_segments([row],
|
|
116
|
+
assignments)} #{row.display_prefix}#{row.text}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def full_hunk_lines(hunk, assignments)
|
|
121
|
+
lines = []
|
|
122
|
+
lines << "#{compact_hunk_line(hunk, assignments)} [expanded]"
|
|
123
|
+
|
|
124
|
+
lines << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@" if hunk.text?
|
|
125
|
+
|
|
126
|
+
hunk.rows.each do |row|
|
|
127
|
+
if row.change?
|
|
128
|
+
label = row.change_label
|
|
129
|
+
chunk_name = assignments[row.id]
|
|
130
|
+
marker = chunk_name ? "#{label} [#{chunk_name}]" : label
|
|
131
|
+
lines << format("%-#{COLUMN_WIDTH}s %s%s", marker, row.display_prefix, row.text)
|
|
132
|
+
else
|
|
133
|
+
lines << format("%-#{COLUMN_WIDTH}s %s%s", '', row.display_prefix, row.text)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
lines
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compact_change_segments(rows, assignments)
|
|
141
|
+
segments = []
|
|
142
|
+
range_start = nil
|
|
143
|
+
range_end = nil
|
|
144
|
+
current_chunk_name = nil
|
|
145
|
+
|
|
146
|
+
rows.each do |row|
|
|
147
|
+
chunk_name = assignments[row.id]
|
|
148
|
+
|
|
149
|
+
if range_start && contiguous_segment?(range_end, row) && current_chunk_name == chunk_name
|
|
150
|
+
range_end = row
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
segments << compact_segment_label(range_start, range_end, current_chunk_name) if range_start
|
|
155
|
+
range_start = row
|
|
156
|
+
range_end = row
|
|
157
|
+
current_chunk_name = chunk_name
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
segments << compact_segment_label(range_start, range_end, current_chunk_name) if range_start
|
|
161
|
+
segments.join(', ')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def compact_segment_label(range_start, range_end, chunk_name)
|
|
165
|
+
marker = if range_start.change_label == range_end.change_label
|
|
166
|
+
range_start.change_label
|
|
167
|
+
else
|
|
168
|
+
"#{range_start.change_label}-#{range_end.change_label}"
|
|
169
|
+
end
|
|
170
|
+
chunk_name ? "#{marker} [#{chunk_name}]" : marker
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def contiguous_segment?(left, right)
|
|
174
|
+
left.change_ordinal + 1 == right.change_ordinal
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def compact_kind(hunk)
|
|
178
|
+
return 'text' if hunk.text?
|
|
179
|
+
return 'operation' if hunk.operation?
|
|
180
|
+
return 'binary' if hunk.binary?
|
|
181
|
+
|
|
182
|
+
raise PatchUtil::UnsupportedFeatureError, "unknown hunk kind: #{hunk.kind.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def count_label(count, singular)
|
|
186
|
+
noun = count == 1 ? singular : "#{singular}s"
|
|
187
|
+
"#{count} #{noun}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def assignment_map(plan_entry)
|
|
191
|
+
return {} unless plan_entry
|
|
192
|
+
|
|
193
|
+
map = {}
|
|
194
|
+
plan_entry.chunks.each do |chunk|
|
|
195
|
+
chunk.row_ids.each do |row_id|
|
|
196
|
+
map[row_id] = chunk.name
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
map
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
module Split
|
|
5
|
+
ChunkRequest = Data.define(:name, :selector_text, :leftovers) do
|
|
6
|
+
def leftovers?
|
|
7
|
+
leftovers
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Chunk = Data.define(:name, :selector_text, :row_ids, :change_labels, :leftovers) do
|
|
12
|
+
def leftovers?
|
|
13
|
+
leftovers
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
PlanEntry = Data.define(:source_kind, :source_label, :source_fingerprint, :chunks, :created_at) do
|
|
18
|
+
def matches_source?(source)
|
|
19
|
+
source_kind == source.kind && source_fingerprint == source.fingerprint
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chunk_for_row_id(row_id)
|
|
23
|
+
chunks.find { |chunk| chunk.row_ids.include?(row_id) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
PlanSet = Data.define(:version, :entries) do
|
|
28
|
+
def find_entry(source)
|
|
29
|
+
entries.find { |entry| entry.matches_source?(source) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|