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,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module PatchUtil
|
|
7
|
+
module Git
|
|
8
|
+
class Rewriter
|
|
9
|
+
Result = RewriteSessionManager::Result
|
|
10
|
+
AbortResult = RewriteSessionManager::AbortResult
|
|
11
|
+
RestoreResult = RewriteSessionManager::RestoreResult
|
|
12
|
+
ResolveResult = RewriteSessionManager::ResolveResult
|
|
13
|
+
ResolveBlockResult = RewriteSessionManager::ResolveBlockResult
|
|
14
|
+
ConflictBlocksResult = RewriteSessionManager::ConflictBlocksResult
|
|
15
|
+
ExportBlockResult = RewriteSessionManager::ExportBlockResult
|
|
16
|
+
ApplyBlockEditResult = RewriteSessionManager::ApplyBlockEditResult
|
|
17
|
+
ExportBlockSessionResult = RewriteSessionManager::ExportBlockSessionResult
|
|
18
|
+
ApplyBlockSessionEditResult = RewriteSessionManager::ApplyBlockSessionEditResult
|
|
19
|
+
SessionSummaryResult = RewriteSessionManager::SessionSummaryResult
|
|
20
|
+
Status = RewriteSessionManager::Status
|
|
21
|
+
|
|
22
|
+
def initialize(git_cli: Cli.new, applier: PatchUtil::Split::Applier.new, clock: -> { Time.now.utc },
|
|
23
|
+
session_manager: nil)
|
|
24
|
+
@git_cli = git_cli
|
|
25
|
+
@applier = applier
|
|
26
|
+
@clock = clock
|
|
27
|
+
@session_manager = session_manager || RewriteSessionManager.new(git_cli: git_cli, clock: clock)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rewrite(source:, diff:, plan_entry:)
|
|
31
|
+
repo_path = source.repo_path
|
|
32
|
+
raise PatchUtil::ValidationError, 'git rewrite apply requires a git commit source' unless source.git?
|
|
33
|
+
|
|
34
|
+
unless @git_cli.worktree_clean?(repo_path)
|
|
35
|
+
raise PatchUtil::ValidationError,
|
|
36
|
+
'git rewrite apply requires a clean worktree'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
branch = @git_cli.current_branch(repo_path)
|
|
40
|
+
raise PatchUtil::ValidationError, 'git rewrite apply requires a checked out branch' if branch.empty?
|
|
41
|
+
|
|
42
|
+
head = @git_cli.head_sha(repo_path)
|
|
43
|
+
target = source.commit_sha
|
|
44
|
+
unless @git_cli.ancestor?(repo_path, target, head)
|
|
45
|
+
raise PatchUtil::ValidationError, "target commit #{target} is not an ancestor of HEAD"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
parent = @git_cli.rev_parse(repo_path, "#{target}^")
|
|
49
|
+
descendants = @git_cli.rev_list(repo_path, "#{target}..#{head}")
|
|
50
|
+
merge_descendant = descendants.find { |revision| @git_cli.merge_commit?(repo_path, revision) }
|
|
51
|
+
if merge_descendant
|
|
52
|
+
raise PatchUtil::ValidationError,
|
|
53
|
+
"git rewrite apply does not support descendant merge commits yet: #{merge_descendant} is a merge commit in #{target}..#{head}"
|
|
54
|
+
end
|
|
55
|
+
original_commit = @git_cli.show_commit_metadata(repo_path, target)
|
|
56
|
+
backup_ref = "refs/patch_util-backups/#{branch}/#{timestamp_token}"
|
|
57
|
+
@git_cli.update_ref(repo_path, backup_ref, head)
|
|
58
|
+
worktree = build_worktree_path(repo_path, branch, target)
|
|
59
|
+
emitted_output_dir = File.join(worktree, '.patch_util_emitted')
|
|
60
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
61
|
+
pending_revisions = descendants.dup
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
@git_cli.worktree_add(repo_path, worktree, parent)
|
|
65
|
+
|
|
66
|
+
emitted = @applier.apply(diff: diff, plan_entry: plan_entry,
|
|
67
|
+
output_dir: emitted_output_dir)
|
|
68
|
+
emitted.each do |item|
|
|
69
|
+
@git_cli.apply_patch_text(worktree, item[:patch_text])
|
|
70
|
+
FileUtils.rm_rf(emitted_output_dir)
|
|
71
|
+
@git_cli.commit_all(worktree, build_commit_message(item[:name], target, original_commit),
|
|
72
|
+
env: split_commit_env(original_commit))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
until pending_revisions.empty?
|
|
76
|
+
revision = pending_revisions.first
|
|
77
|
+
@git_cli.cherry_pick(worktree, revision)
|
|
78
|
+
pending_revisions.shift
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
new_head = @git_cli.head_sha(worktree)
|
|
82
|
+
@git_cli.update_ref(repo_path, "refs/heads/#{branch}", new_head, head)
|
|
83
|
+
@git_cli.reset_hard(repo_path, new_head)
|
|
84
|
+
@git_cli.worktree_remove(repo_path, worktree)
|
|
85
|
+
state_store.clear_branch(branch)
|
|
86
|
+
|
|
87
|
+
Result.new(
|
|
88
|
+
branch: branch,
|
|
89
|
+
old_head: head,
|
|
90
|
+
new_head: new_head,
|
|
91
|
+
backup_ref: backup_ref,
|
|
92
|
+
commits: emitted.map { |item| item[:name] }
|
|
93
|
+
)
|
|
94
|
+
rescue PatchUtil::Error => e
|
|
95
|
+
state_store.record_failure(
|
|
96
|
+
RewriteStateStore::State.new(
|
|
97
|
+
branch: branch,
|
|
98
|
+
target_sha: target,
|
|
99
|
+
head_sha: head,
|
|
100
|
+
backup_ref: backup_ref,
|
|
101
|
+
worktree_path: worktree,
|
|
102
|
+
status: 'failed',
|
|
103
|
+
message: e.message,
|
|
104
|
+
created_at: @clock.call.iso8601,
|
|
105
|
+
pending_revisions: pending_revisions
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
raise PatchUtil::Error,
|
|
109
|
+
"#{e.message}\nbackup ref: #{backup_ref}\nretained worktree: #{worktree}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def abort_rewrite(repo_path:)
|
|
114
|
+
@session_manager.abort_rewrite(repo_path: repo_path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def continue_rewrite(repo_path:)
|
|
118
|
+
@session_manager.continue_rewrite(repo_path: repo_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def status(repo_path:)
|
|
122
|
+
@session_manager.status(repo_path: repo_path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def restore_rewrite(repo_path:)
|
|
126
|
+
@session_manager.restore_rewrite(repo_path: repo_path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_conflicts(repo_path:, side:, paths: nil, all_unresolved: false)
|
|
130
|
+
@session_manager.resolve_conflicts(
|
|
131
|
+
repo_path: repo_path,
|
|
132
|
+
side: side,
|
|
133
|
+
paths: paths,
|
|
134
|
+
all_unresolved: all_unresolved
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_conflict_block(repo_path:, path:, block_id:, side:)
|
|
139
|
+
@session_manager.resolve_conflict_block(
|
|
140
|
+
repo_path: repo_path,
|
|
141
|
+
path: path,
|
|
142
|
+
block_id: block_id,
|
|
143
|
+
side: side
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def conflict_blocks(repo_path:, paths: nil)
|
|
148
|
+
@session_manager.conflict_blocks(repo_path: repo_path, paths: paths)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def export_conflict_block(repo_path:, path:, block_id:, output_path:)
|
|
152
|
+
@session_manager.export_conflict_block(
|
|
153
|
+
repo_path: repo_path,
|
|
154
|
+
path: path,
|
|
155
|
+
block_id: block_id,
|
|
156
|
+
output_path: output_path
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def apply_conflict_block_edit(repo_path:, path:, block_id:, input_path:)
|
|
161
|
+
@session_manager.apply_conflict_block_edit(
|
|
162
|
+
repo_path: repo_path,
|
|
163
|
+
path: path,
|
|
164
|
+
block_id: block_id,
|
|
165
|
+
input_path: input_path
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def export_conflict_block_session(repo_path:, output_path:, paths: nil)
|
|
170
|
+
@session_manager.export_conflict_block_session(
|
|
171
|
+
repo_path: repo_path,
|
|
172
|
+
output_path: output_path,
|
|
173
|
+
paths: paths
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def apply_conflict_block_session_edit(repo_path:, input_path:)
|
|
178
|
+
@session_manager.apply_conflict_block_session_edit(repo_path: repo_path, input_path: input_path)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def conflict_block_session_summary(repo_path:, paths: nil)
|
|
182
|
+
@session_manager.conflict_block_session_summary(repo_path: repo_path, paths: paths)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def next_action(status)
|
|
186
|
+
@session_manager.next_action(status)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def build_commit_message(chunk_name, target_sha, original_commit)
|
|
192
|
+
sections = []
|
|
193
|
+
sections << chunk_name
|
|
194
|
+
sections << original_commit.body unless original_commit.body.empty?
|
|
195
|
+
sections << build_split_metadata_block(target_sha, original_commit.subject)
|
|
196
|
+
sections.join("\n\n")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_split_metadata_block(target_sha, original_subject)
|
|
200
|
+
[
|
|
201
|
+
"Split-from: #{target_sha}",
|
|
202
|
+
"Original-subject: #{original_subject}"
|
|
203
|
+
].join("\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def split_commit_env(original_commit)
|
|
207
|
+
{
|
|
208
|
+
'GIT_AUTHOR_NAME' => original_commit.author_name,
|
|
209
|
+
'GIT_AUTHOR_EMAIL' => original_commit.author_email,
|
|
210
|
+
'GIT_AUTHOR_DATE' => original_commit.author_date,
|
|
211
|
+
'GIT_COMMITTER_NAME' => original_commit.committer_name,
|
|
212
|
+
'GIT_COMMITTER_EMAIL' => original_commit.committer_email,
|
|
213
|
+
'GIT_COMMITTER_DATE' => original_commit.committer_date
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def timestamp_token
|
|
218
|
+
@clock.call.strftime('%Y%m%d%H%M%S')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_worktree_path(repo_path, branch, target)
|
|
222
|
+
git_dir = @git_cli.git_dir(repo_path)
|
|
223
|
+
root = File.join(git_dir, 'patch_util', 'rewrite-worktrees')
|
|
224
|
+
FileUtils.mkdir_p(root)
|
|
225
|
+
File.join(root, "#{timestamp_token}-#{sanitize(branch)}-#{target[0, 12]}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def sanitize(text)
|
|
229
|
+
text.gsub(/[^a-zA-Z0-9._-]+/, '-')
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
module Git
|
|
5
|
+
autoload :Cli, 'patch_util/git/cli'
|
|
6
|
+
autoload :RewriteStateStore, 'patch_util/git/rewrite_state_store'
|
|
7
|
+
autoload :RewriteSessionManager, 'patch_util/git/rewrite_session_manager'
|
|
8
|
+
autoload :Rewriter, 'patch_util/git/rewriter'
|
|
9
|
+
autoload :RewriteCLI, 'patch_util/git/rewrite_cli'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatchUtil
|
|
4
|
+
class Parser
|
|
5
|
+
HUNK_HEADER = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?\z/
|
|
6
|
+
|
|
7
|
+
def parse(source)
|
|
8
|
+
lines = source.diff_text.lines(chomp: true)
|
|
9
|
+
file_diffs = []
|
|
10
|
+
index = 0
|
|
11
|
+
hunk_index = 0
|
|
12
|
+
|
|
13
|
+
while index < lines.length
|
|
14
|
+
index += 1 while index < lines.length && lines[index].empty?
|
|
15
|
+
break if index >= lines.length
|
|
16
|
+
|
|
17
|
+
diff_git_line = nil
|
|
18
|
+
metadata_lines = []
|
|
19
|
+
|
|
20
|
+
if lines[index].start_with?('diff --git ')
|
|
21
|
+
diff_git_line = lines[index]
|
|
22
|
+
index += 1
|
|
23
|
+
|
|
24
|
+
while index < lines.length && metadata_line?(lines[index])
|
|
25
|
+
metadata_lines << lines[index]
|
|
26
|
+
index += 1
|
|
27
|
+
end
|
|
28
|
+
elsif !lines[index].start_with?('--- ')
|
|
29
|
+
raise ParseError, "unsupported diff prelude line: #{lines[index]}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if index < lines.length && lines[index].start_with?('Binary files ')
|
|
33
|
+
raise UnsupportedFeatureError,
|
|
34
|
+
'binary diff requires a GIT binary patch payload; plain Binary files differ output is not enough'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
old_path, new_path, index = parse_paths(lines, index, metadata_lines, diff_git_line)
|
|
38
|
+
hunks = []
|
|
39
|
+
|
|
40
|
+
if index < lines.length && lines[index] == 'GIT binary patch'
|
|
41
|
+
path_lines = path_metadata_lines(metadata_lines)
|
|
42
|
+
if path_lines.any?
|
|
43
|
+
operation_hunk, hunk_index = build_operation_hunk(
|
|
44
|
+
old_path: old_path,
|
|
45
|
+
new_path: new_path,
|
|
46
|
+
patch_lines: path_lines,
|
|
47
|
+
hunk_index: hunk_index
|
|
48
|
+
)
|
|
49
|
+
hunks << operation_hunk
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
binary_hunk, index, hunk_index = build_binary_hunk(
|
|
53
|
+
lines: lines,
|
|
54
|
+
index: index,
|
|
55
|
+
old_path: old_path,
|
|
56
|
+
new_path: new_path,
|
|
57
|
+
metadata_lines: binary_metadata_lines(metadata_lines),
|
|
58
|
+
hunk_index: hunk_index
|
|
59
|
+
)
|
|
60
|
+
hunks << binary_hunk
|
|
61
|
+
else
|
|
62
|
+
operation_lines = nonbinary_operation_lines(metadata_lines)
|
|
63
|
+
if operation_lines.any?
|
|
64
|
+
operation_hunk, hunk_index = build_operation_hunk(
|
|
65
|
+
old_path: old_path,
|
|
66
|
+
new_path: new_path,
|
|
67
|
+
patch_lines: operation_lines,
|
|
68
|
+
hunk_index: hunk_index
|
|
69
|
+
)
|
|
70
|
+
hunks << operation_hunk
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
while index < lines.length && lines[index].start_with?('@@ ')
|
|
74
|
+
hunk, index = parse_text_hunk(lines, index, hunk_index)
|
|
75
|
+
hunks << hunk
|
|
76
|
+
hunk_index += 1
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
raise UnsupportedFeatureError, "file diff for #{new_path} has no supported hunks" if hunks.empty?
|
|
81
|
+
|
|
82
|
+
file_diffs << FileDiff.new(
|
|
83
|
+
old_path: old_path,
|
|
84
|
+
new_path: new_path,
|
|
85
|
+
hunks: hunks,
|
|
86
|
+
diff_git_line: diff_git_line,
|
|
87
|
+
metadata_lines: metadata_lines
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Diff.new(source: source, file_diffs: file_diffs)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def parse_paths(lines, index, metadata_lines, diff_git_line)
|
|
97
|
+
if index < lines.length && lines[index].start_with?('--- ')
|
|
98
|
+
old_path = lines[index].delete_prefix('--- ')
|
|
99
|
+
index += 1
|
|
100
|
+
new_line = lines[index]
|
|
101
|
+
raise ParseError, "missing +++ header after #{old_path}" unless new_line&.start_with?('+++ ')
|
|
102
|
+
|
|
103
|
+
new_path = new_line.delete_prefix('+++ ')
|
|
104
|
+
index += 1
|
|
105
|
+
return [old_path, new_path, index]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
rename_from = metadata_value(metadata_lines, 'rename from ')
|
|
109
|
+
rename_to = metadata_value(metadata_lines, 'rename to ')
|
|
110
|
+
copy_from = metadata_value(metadata_lines, 'copy from ')
|
|
111
|
+
copy_to = metadata_value(metadata_lines, 'copy to ')
|
|
112
|
+
old_path_from_diff, new_path_from_diff = diff_git_line ? paths_from_diff_git_line(diff_git_line) : [nil, nil]
|
|
113
|
+
|
|
114
|
+
return ["a/#{rename_from}", "b/#{rename_to}", index] if rename_from && rename_to
|
|
115
|
+
return ["a/#{copy_from}", "b/#{copy_to}", index] if copy_from && copy_to
|
|
116
|
+
|
|
117
|
+
return ['/dev/null', new_path_from_diff, index] if metadata_value(metadata_lines, 'new file mode ')
|
|
118
|
+
|
|
119
|
+
return [old_path_from_diff, '/dev/null', index] if metadata_value(metadata_lines, 'deleted file mode ')
|
|
120
|
+
|
|
121
|
+
return [*paths_from_diff_git_line(diff_git_line), index] if diff_git_line
|
|
122
|
+
|
|
123
|
+
raise ParseError, 'missing path headers and no diff --git or rename/copy metadata to infer paths'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def metadata_line?(line)
|
|
127
|
+
line.start_with?('index ') ||
|
|
128
|
+
line.start_with?('old mode ') ||
|
|
129
|
+
line.start_with?('new mode ') ||
|
|
130
|
+
line.start_with?('deleted file mode ') ||
|
|
131
|
+
line.start_with?('new file mode ') ||
|
|
132
|
+
line.start_with?('similarity index ') ||
|
|
133
|
+
line.start_with?('rename from ') ||
|
|
134
|
+
line.start_with?('rename to ') ||
|
|
135
|
+
line.start_with?('copy from ') ||
|
|
136
|
+
line.start_with?('copy to ')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def path_metadata_lines(metadata_lines)
|
|
140
|
+
metadata_lines.select do |line|
|
|
141
|
+
line.start_with?('similarity index ') ||
|
|
142
|
+
line.start_with?('rename from ') ||
|
|
143
|
+
line.start_with?('rename to ') ||
|
|
144
|
+
line.start_with?('copy from ') ||
|
|
145
|
+
line.start_with?('copy to ')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def mode_metadata_lines(metadata_lines)
|
|
150
|
+
metadata_lines.select do |line|
|
|
151
|
+
line.start_with?('old mode ') ||
|
|
152
|
+
line.start_with?('new mode ')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def nonbinary_operation_lines(metadata_lines)
|
|
157
|
+
metadata_lines.select do |line|
|
|
158
|
+
line.start_with?('old mode ') ||
|
|
159
|
+
line.start_with?('new mode ') ||
|
|
160
|
+
line.start_with?('similarity index ') ||
|
|
161
|
+
line.start_with?('rename from ') ||
|
|
162
|
+
line.start_with?('rename to ') ||
|
|
163
|
+
line.start_with?('copy from ') ||
|
|
164
|
+
line.start_with?('copy to ')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def binary_metadata_lines(metadata_lines)
|
|
169
|
+
metadata_lines.reject do |line|
|
|
170
|
+
line.start_with?('similarity index ') ||
|
|
171
|
+
line.start_with?('rename from ') ||
|
|
172
|
+
line.start_with?('rename to ') ||
|
|
173
|
+
line.start_with?('copy from ') ||
|
|
174
|
+
line.start_with?('copy to ')
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_operation_hunk(old_path:, new_path:, patch_lines:, hunk_index:)
|
|
179
|
+
label = hunk_label_for(hunk_index)
|
|
180
|
+
row = Row.new(
|
|
181
|
+
id: "#{label}:0",
|
|
182
|
+
kind: :file_operation,
|
|
183
|
+
text: operation_summary(old_path: old_path, new_path: new_path, patch_lines: patch_lines),
|
|
184
|
+
old_lineno: nil,
|
|
185
|
+
new_lineno: nil,
|
|
186
|
+
change_label: "#{label}1",
|
|
187
|
+
change_ordinal: 1
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
[
|
|
191
|
+
Hunk.new(
|
|
192
|
+
label: label,
|
|
193
|
+
old_start: 0,
|
|
194
|
+
old_count: 0,
|
|
195
|
+
new_start: 0,
|
|
196
|
+
new_count: 0,
|
|
197
|
+
section: "file operation #{old_path} -> #{new_path}",
|
|
198
|
+
rows: [row],
|
|
199
|
+
kind: :file_operation,
|
|
200
|
+
patch_lines: patch_lines
|
|
201
|
+
),
|
|
202
|
+
hunk_index + 1
|
|
203
|
+
]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def build_binary_hunk(lines:, index:, old_path:, new_path:, metadata_lines:, hunk_index:)
|
|
207
|
+
label = hunk_label_for(hunk_index)
|
|
208
|
+
payload_lines = []
|
|
209
|
+
|
|
210
|
+
while index < lines.length
|
|
211
|
+
break if lines[index].start_with?('diff --git ')
|
|
212
|
+
|
|
213
|
+
payload_lines << lines[index]
|
|
214
|
+
index += 1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
patch_lines = metadata_lines + payload_lines
|
|
218
|
+
row = Row.new(
|
|
219
|
+
id: "#{label}:0",
|
|
220
|
+
kind: :binary,
|
|
221
|
+
text: binary_summary(old_path: old_path, new_path: new_path, patch_lines: patch_lines),
|
|
222
|
+
old_lineno: nil,
|
|
223
|
+
new_lineno: nil,
|
|
224
|
+
change_label: "#{label}1",
|
|
225
|
+
change_ordinal: 1
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
[
|
|
229
|
+
Hunk.new(
|
|
230
|
+
label: label,
|
|
231
|
+
old_start: 0,
|
|
232
|
+
old_count: 0,
|
|
233
|
+
new_start: 0,
|
|
234
|
+
new_count: 0,
|
|
235
|
+
section: "binary change #{old_path} -> #{new_path}",
|
|
236
|
+
rows: [row],
|
|
237
|
+
kind: :binary,
|
|
238
|
+
patch_lines: patch_lines
|
|
239
|
+
),
|
|
240
|
+
index,
|
|
241
|
+
hunk_index + 1
|
|
242
|
+
]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_text_hunk(lines, start_index, hunk_index)
|
|
246
|
+
header = lines[start_index]
|
|
247
|
+
match = HUNK_HEADER.match(header)
|
|
248
|
+
raise ParseError, "invalid hunk header: #{header}" unless match
|
|
249
|
+
|
|
250
|
+
old_start = Integer(match[1], 10)
|
|
251
|
+
old_count = match[2] ? Integer(match[2], 10) : 1
|
|
252
|
+
new_start = Integer(match[3], 10)
|
|
253
|
+
new_count = match[4] ? Integer(match[4], 10) : 1
|
|
254
|
+
section = match[5]
|
|
255
|
+
|
|
256
|
+
rows = []
|
|
257
|
+
index = start_index + 1
|
|
258
|
+
old_lineno = old_start
|
|
259
|
+
new_lineno = new_start
|
|
260
|
+
change_ordinal = 0
|
|
261
|
+
row_index = 0
|
|
262
|
+
hunk_label = hunk_label_for(hunk_index)
|
|
263
|
+
|
|
264
|
+
while index < lines.length
|
|
265
|
+
line = lines[index]
|
|
266
|
+
break if line.start_with?('@@ ') || line.start_with?('diff --git ') || line.start_with?('--- ')
|
|
267
|
+
break if metadata_line?(line) || line == 'GIT binary patch' || line.start_with?('Binary files ')
|
|
268
|
+
|
|
269
|
+
if line == ''
|
|
270
|
+
index += 1
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
prefix = line[0]
|
|
275
|
+
text = line[1..] || ''
|
|
276
|
+
row_id = "#{hunk_label}:#{row_index}"
|
|
277
|
+
|
|
278
|
+
case prefix
|
|
279
|
+
when ' '
|
|
280
|
+
rows << Row.new(
|
|
281
|
+
id: row_id,
|
|
282
|
+
kind: :context,
|
|
283
|
+
text: text,
|
|
284
|
+
old_lineno: old_lineno,
|
|
285
|
+
new_lineno: new_lineno,
|
|
286
|
+
change_label: nil,
|
|
287
|
+
change_ordinal: nil
|
|
288
|
+
)
|
|
289
|
+
old_lineno += 1
|
|
290
|
+
new_lineno += 1
|
|
291
|
+
when '-'
|
|
292
|
+
change_ordinal += 1
|
|
293
|
+
rows << Row.new(
|
|
294
|
+
id: row_id,
|
|
295
|
+
kind: :deletion,
|
|
296
|
+
text: text,
|
|
297
|
+
old_lineno: old_lineno,
|
|
298
|
+
new_lineno: nil,
|
|
299
|
+
change_label: "#{hunk_label}#{change_ordinal}",
|
|
300
|
+
change_ordinal: change_ordinal
|
|
301
|
+
)
|
|
302
|
+
old_lineno += 1
|
|
303
|
+
when '+'
|
|
304
|
+
change_ordinal += 1
|
|
305
|
+
rows << Row.new(
|
|
306
|
+
id: row_id,
|
|
307
|
+
kind: :addition,
|
|
308
|
+
text: text,
|
|
309
|
+
old_lineno: nil,
|
|
310
|
+
new_lineno: new_lineno,
|
|
311
|
+
change_label: "#{hunk_label}#{change_ordinal}",
|
|
312
|
+
change_ordinal: change_ordinal
|
|
313
|
+
)
|
|
314
|
+
new_lineno += 1
|
|
315
|
+
else
|
|
316
|
+
raise ParseError, "unsupported hunk row: #{line}"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
row_index += 1
|
|
320
|
+
index += 1
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
[
|
|
324
|
+
Hunk.new(
|
|
325
|
+
label: hunk_label,
|
|
326
|
+
old_start: old_start,
|
|
327
|
+
old_count: old_count,
|
|
328
|
+
new_start: new_start,
|
|
329
|
+
new_count: new_count,
|
|
330
|
+
section: section,
|
|
331
|
+
rows: rows,
|
|
332
|
+
kind: :text,
|
|
333
|
+
patch_lines: []
|
|
334
|
+
),
|
|
335
|
+
index
|
|
336
|
+
]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def metadata_value(metadata_lines, prefix)
|
|
340
|
+
line = metadata_lines.find { |item| item.start_with?(prefix) }
|
|
341
|
+
return nil unless line
|
|
342
|
+
|
|
343
|
+
line.delete_prefix(prefix)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def operation_summary(old_path:, new_path:, patch_lines:)
|
|
347
|
+
old_mode = metadata_value(patch_lines, 'old mode ')
|
|
348
|
+
new_mode = metadata_value(patch_lines, 'new mode ')
|
|
349
|
+
rename_from = metadata_value(patch_lines, 'rename from ')
|
|
350
|
+
copy_from = metadata_value(patch_lines, 'copy from ')
|
|
351
|
+
similarity = metadata_value(patch_lines, 'similarity index ')
|
|
352
|
+
|
|
353
|
+
operation_parts = []
|
|
354
|
+
if rename_from
|
|
355
|
+
text = "rename #{display_path(old_path)} -> #{display_path(new_path)}"
|
|
356
|
+
text += " (#{similarity})" if similarity
|
|
357
|
+
operation_parts << text
|
|
358
|
+
elsif copy_from
|
|
359
|
+
text = "copy #{display_path(old_path)} -> #{display_path(new_path)}"
|
|
360
|
+
text += " (#{similarity})" if similarity
|
|
361
|
+
operation_parts << text
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
operation_parts << "mode #{display_path(new_path)} #{old_mode} -> #{new_mode}" if old_mode && new_mode
|
|
365
|
+
|
|
366
|
+
return operation_parts.join(', ') if operation_parts.any?
|
|
367
|
+
|
|
368
|
+
"file operation #{display_path(old_path)} -> #{display_path(new_path)}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def binary_summary(old_path:, new_path:, patch_lines:)
|
|
372
|
+
old_mode = metadata_value(patch_lines, 'old mode ')
|
|
373
|
+
new_mode = metadata_value(patch_lines, 'new mode ')
|
|
374
|
+
|
|
375
|
+
summary = if old_path == '/dev/null'
|
|
376
|
+
"binary add #{display_path(new_path)}"
|
|
377
|
+
elsif new_path == '/dev/null'
|
|
378
|
+
"binary delete #{display_path(old_path)}"
|
|
379
|
+
else
|
|
380
|
+
"binary #{display_path(new_path)}"
|
|
381
|
+
end
|
|
382
|
+
summary += " (mode #{old_mode} -> #{new_mode})" if old_mode && new_mode
|
|
383
|
+
summary
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def display_path(path)
|
|
387
|
+
return path if path == '/dev/null'
|
|
388
|
+
|
|
389
|
+
path.sub(%r{\A[ab]/}, '')
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def paths_from_diff_git_line(diff_git_line)
|
|
393
|
+
match = /\Adiff --git (\S+) (\S+)\z/.match(diff_git_line.to_s)
|
|
394
|
+
raise ParseError, 'missing diff --git header needed to infer diff paths' unless match
|
|
395
|
+
|
|
396
|
+
[match[1], match[2]]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def hunk_label_for(index)
|
|
400
|
+
current = index
|
|
401
|
+
label = +''
|
|
402
|
+
|
|
403
|
+
loop do
|
|
404
|
+
label.prepend((97 + (current % 26)).chr)
|
|
405
|
+
current = (current / 26) - 1
|
|
406
|
+
break if current.negative?
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
label
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|