purdypatch 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +18 -0
- data/Rakefile +1 -0
- data/bin/purdypatch +5 -0
- data/lib/prettypatch/PrettyPatch.rb +818 -0
- data/lib/prettypatch/PrettyPatch_test.rb +99 -0
- data/lib/prettypatch/diff.rb +164 -0
- data/lib/prettypatch/prettify.rb +37 -0
- data/lib/purdypatch/version.rb +3 -0
- data/lib/purdypatch.rb +44 -0
- data/purdypatch.gemspec +20 -0
- metadata +77 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013, Nathan de Vries
|
2
|
+
All rights reserved.
|
3
|
+
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
6
|
+
|
7
|
+
* Redistributions of source code must retain the above copyright notice,
|
8
|
+
this list of conditions and the following disclaimer.
|
9
|
+
* Redistributions in binary form must reproduce the above copyright
|
10
|
+
notice, this list of conditions and the following disclaimer in the
|
11
|
+
documentation and/or other materials provided with the distribution.
|
12
|
+
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
14
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
15
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
17
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
18
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
20
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
21
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
22
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Purdy Patch
|
2
|
+
===========
|
3
|
+
|
4
|
+
This gem provides a `purdypatch` tool for improving the format of `git-format-patch` emails, making them easier for reviewers to read and reply to in a decentralized, email-based review system.
|
5
|
+
|
6
|
+
It uses the incredibly useful [PrettyPatch formatter](http://svn.webkit.org/repository/webkit/trunk/Websites/bugs.webkit.org/PrettyPatch/), which is used by the patch review system on [bugs.webkit.org](https://bugs.webkit.org).
|
7
|
+
|
8
|
+
To get started, simply run `gem install purdypatch` to install the tool.
|
9
|
+
|
10
|
+
Once you have `purdypatch` installed, here's a simple workflow for sending out a patch for review:
|
11
|
+
|
12
|
+
$ git format-patch --to="Doge <sohip@suchclass.com>" --attach HEAD~1
|
13
|
+
$ purdypatch *.patch
|
14
|
+
$ git send-email *.purdypatch
|
15
|
+
|
16
|
+
According to [Devil’s Dictionary of Programming](http://programmingisterrible.com/post/65781074112/devils-dictionary-of-programming), `purdypatch` is simple, opinionated *and* lightweight.
|
17
|
+
|
18
|
+
Enjoy.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/purdypatch
ADDED
@@ -0,0 +1,818 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'diff'
|
3
|
+
require 'open3'
|
4
|
+
require 'open-uri'
|
5
|
+
require 'pp'
|
6
|
+
require 'set'
|
7
|
+
require 'tempfile'
|
8
|
+
|
9
|
+
module PrettyPatch
|
10
|
+
|
11
|
+
public
|
12
|
+
|
13
|
+
GIT_PATH = "git"
|
14
|
+
|
15
|
+
def self.prettify(string)
|
16
|
+
$last_prettify_file_count = -1
|
17
|
+
$last_prettify_part_count = { "remove" => 0, "add" => 0, "shared" => 0, "binary" => 0, "extract-error" => 0 }
|
18
|
+
string = normalize_line_ending(string)
|
19
|
+
str = "#{HEADER}<body>\n"
|
20
|
+
|
21
|
+
# Just look at the first line to see if it is an SVN revision number as added
|
22
|
+
# by webkit-patch for git checkouts.
|
23
|
+
$svn_revision = 0
|
24
|
+
string.each_line do |line|
|
25
|
+
match = /^Subversion\ Revision: (\d*)$/.match(line)
|
26
|
+
unless match.nil?
|
27
|
+
str << "<span class='revision'>#{match[1]}</span>\n"
|
28
|
+
$svn_revision = match[1].to_i;
|
29
|
+
end
|
30
|
+
break
|
31
|
+
end
|
32
|
+
|
33
|
+
fileDiffs = FileDiff.parse(string)
|
34
|
+
|
35
|
+
$last_prettify_file_count = fileDiffs.length
|
36
|
+
str << fileDiffs.collect{ |diff| diff.to_html }.join
|
37
|
+
str << "</body></html>"
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.filename_from_diff_header(line)
|
41
|
+
DIFF_HEADER_FORMATS.each do |format|
|
42
|
+
match = format.match(line)
|
43
|
+
return match[1] unless match.nil?
|
44
|
+
end
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.diff_header?(line)
|
49
|
+
RELAXED_DIFF_HEADER_FORMATS.any? { |format| line =~ format }
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
DIFF_HEADER_FORMATS = [
|
54
|
+
/^Index: (.*)\r?$/,
|
55
|
+
/^diff --git "?a\/.+"? "?b\/(.+)"?\r?$/,
|
56
|
+
/^\+\+\+ ([^\t]+)(\t.*)?\r?$/
|
57
|
+
]
|
58
|
+
|
59
|
+
RELAXED_DIFF_HEADER_FORMATS = [
|
60
|
+
/^Index:/,
|
61
|
+
/^diff/
|
62
|
+
]
|
63
|
+
|
64
|
+
BINARY_FILE_MARKER_FORMAT = /^Cannot display: file marked as a binary type.$/
|
65
|
+
|
66
|
+
IMAGE_FILE_MARKER_FORMAT = /^svn:mime-type = image\/png$/
|
67
|
+
|
68
|
+
GIT_INDEX_MARKER_FORMAT = /^index ([0-9a-f]{40})\.\.([0-9a-f]{40})/
|
69
|
+
|
70
|
+
GIT_BINARY_FILE_MARKER_FORMAT = /^GIT binary patch$/
|
71
|
+
|
72
|
+
GIT_BINARY_PATCH_FORMAT = /^(literal|delta) \d+$/
|
73
|
+
|
74
|
+
GIT_LITERAL_FORMAT = /^literal \d+$/
|
75
|
+
|
76
|
+
GIT_DELTA_FORMAT = /^delta \d+$/
|
77
|
+
|
78
|
+
START_OF_BINARY_DATA_FORMAT = /^[0-9a-zA-Z\+\/=]{20,}/ # Assume 20 chars without a space is base64 binary data.
|
79
|
+
|
80
|
+
START_OF_SECTION_FORMAT = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@\s*(.*)/
|
81
|
+
|
82
|
+
START_OF_EXTENT_STRING = "%c" % 0
|
83
|
+
END_OF_EXTENT_STRING = "%c" % 1
|
84
|
+
|
85
|
+
# We won't search for intra-line diffs in lines longer than this length, to avoid hangs. See <http://webkit.org/b/56109>.
|
86
|
+
MAXIMUM_INTRALINE_DIFF_LINE_LENGTH = 10000
|
87
|
+
|
88
|
+
SMALLEST_EQUAL_OPERATION = 3
|
89
|
+
|
90
|
+
OPENSOURCE_TRAC_URL = "http://trac.webkit.org/"
|
91
|
+
|
92
|
+
OPENSOURCE_DIRS = Set.new %w[
|
93
|
+
Examples
|
94
|
+
LayoutTests
|
95
|
+
PerformanceTests
|
96
|
+
Source
|
97
|
+
Tools
|
98
|
+
WebKitLibraries
|
99
|
+
Websites
|
100
|
+
]
|
101
|
+
|
102
|
+
IMAGE_CHECKSUM_ERROR = "INVALID: Image lacks a checksum. This will fail with a MISSING error in run-webkit-tests. Always generate new png files using run-webkit-tests."
|
103
|
+
|
104
|
+
def self.normalize_line_ending(s)
|
105
|
+
if RUBY_VERSION >= "1.9"
|
106
|
+
# Transliteration table from http://stackoverflow.com/a/6609998
|
107
|
+
transliteration_table = { '\xc2\x82' => ',', # High code comma
|
108
|
+
'\xc2\x84' => ',,', # High code double comma
|
109
|
+
'\xc2\x85' => '...', # Tripple dot
|
110
|
+
'\xc2\x88' => '^', # High carat
|
111
|
+
'\xc2\x91' => '\x27', # Forward single quote
|
112
|
+
'\xc2\x92' => '\x27', # Reverse single quote
|
113
|
+
'\xc2\x93' => '\x22', # Forward double quote
|
114
|
+
'\xc2\x94' => '\x22', # Reverse double quote
|
115
|
+
'\xc2\x95' => ' ',
|
116
|
+
'\xc2\x96' => '-', # High hyphen
|
117
|
+
'\xc2\x97' => '--', # Double hyphen
|
118
|
+
'\xc2\x99' => ' ',
|
119
|
+
'\xc2\xa0' => ' ',
|
120
|
+
'\xc2\xa6' => '|', # Split vertical bar
|
121
|
+
'\xc2\xab' => '<<', # Double less than
|
122
|
+
'\xc2\xbb' => '>>', # Double greater than
|
123
|
+
'\xc2\xbc' => '1/4', # one quarter
|
124
|
+
'\xc2\xbd' => '1/2', # one half
|
125
|
+
'\xc2\xbe' => '3/4', # three quarters
|
126
|
+
'\xca\xbf' => '\x27', # c-single quote
|
127
|
+
'\xcc\xa8' => '', # modifier - under curve
|
128
|
+
'\xcc\xb1' => '' # modifier - under line
|
129
|
+
}
|
130
|
+
encoded_string = s.force_encoding('UTF-8').encode('UTF-16', :invalid => :replace, :replace => '', :fallback => transliteration_table).encode('UTF-8')
|
131
|
+
encoded_string.gsub /\r\n?/, "\n"
|
132
|
+
else
|
133
|
+
s.gsub /\r\n?/, "\n"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def self.find_url_and_path(file_path)
|
138
|
+
# Search file_path from the bottom up, at each level checking whether
|
139
|
+
# we've found a directory we know exists in the source tree.
|
140
|
+
|
141
|
+
dirname, basename = File.split(file_path)
|
142
|
+
dirname.split(/\//).reverse.inject(basename) do |path, directory|
|
143
|
+
path = directory + "/" + path
|
144
|
+
|
145
|
+
return [OPENSOURCE_TRAC_URL, path] if OPENSOURCE_DIRS.include?(directory)
|
146
|
+
|
147
|
+
path
|
148
|
+
end
|
149
|
+
|
150
|
+
[nil, file_path]
|
151
|
+
end
|
152
|
+
|
153
|
+
def self.linkifyFilename(filename)
|
154
|
+
url, pathBeneathTrunk = find_url_and_path(filename)
|
155
|
+
|
156
|
+
url.nil? ? filename : "<a href='#{url}browser/trunk/#{pathBeneathTrunk}'>#{filename}</a>"
|
157
|
+
end
|
158
|
+
|
159
|
+
|
160
|
+
HEADER =<<EOF
|
161
|
+
<html>
|
162
|
+
<head>
|
163
|
+
<meta charset='utf-8'>
|
164
|
+
<style>
|
165
|
+
:link, :visited {
|
166
|
+
text-decoration: none;
|
167
|
+
border-bottom: 1px dotted;
|
168
|
+
}
|
169
|
+
|
170
|
+
:link {
|
171
|
+
color: #039;
|
172
|
+
}
|
173
|
+
|
174
|
+
.FileDiff {
|
175
|
+
background-color: #f8f8f8;
|
176
|
+
border: 1px solid #ddd;
|
177
|
+
font-family: monospace;
|
178
|
+
margin: 1em 0;
|
179
|
+
position: relative;
|
180
|
+
}
|
181
|
+
|
182
|
+
h1 {
|
183
|
+
color: #333;
|
184
|
+
font-family: sans-serif;
|
185
|
+
font-size: 1em;
|
186
|
+
margin-left: 0.5em;
|
187
|
+
display: table-cell;
|
188
|
+
width: 100%;
|
189
|
+
padding: 0.5em;
|
190
|
+
}
|
191
|
+
|
192
|
+
h1 :link, h1 :visited {
|
193
|
+
color: inherit;
|
194
|
+
}
|
195
|
+
|
196
|
+
h1 :hover {
|
197
|
+
color: #555;
|
198
|
+
background-color: #eee;
|
199
|
+
}
|
200
|
+
|
201
|
+
.DiffLinks {
|
202
|
+
float: right;
|
203
|
+
}
|
204
|
+
|
205
|
+
.FileDiffLinkContainer {
|
206
|
+
opacity: 0;
|
207
|
+
display: table-cell;
|
208
|
+
padding-right: 0.5em;
|
209
|
+
white-space: nowrap;
|
210
|
+
}
|
211
|
+
|
212
|
+
.DiffSection {
|
213
|
+
background-color: white;
|
214
|
+
border: solid #ddd;
|
215
|
+
border-width: 1px 0px;
|
216
|
+
}
|
217
|
+
|
218
|
+
.ExpansionLine, .LineContainer {
|
219
|
+
white-space: nowrap;
|
220
|
+
}
|
221
|
+
|
222
|
+
.sidebyside .DiffBlockPart.add:first-child {
|
223
|
+
float: right;
|
224
|
+
}
|
225
|
+
|
226
|
+
.LineSide,
|
227
|
+
.sidebyside .DiffBlockPart.remove,
|
228
|
+
.sidebyside .DiffBlockPart.add {
|
229
|
+
display:inline-block;
|
230
|
+
width: 50%;
|
231
|
+
vertical-align: top;
|
232
|
+
}
|
233
|
+
|
234
|
+
.sidebyside .resizeHandle {
|
235
|
+
width: 5px;
|
236
|
+
height: 100%;
|
237
|
+
cursor: move;
|
238
|
+
position: absolute;
|
239
|
+
top: 0;
|
240
|
+
left: 50%;
|
241
|
+
}
|
242
|
+
|
243
|
+
.sidebyside .resizeHandle:hover {
|
244
|
+
background-color: grey;
|
245
|
+
opacity: 0.5;
|
246
|
+
}
|
247
|
+
|
248
|
+
.sidebyside .DiffBlockPart.remove .to,
|
249
|
+
.sidebyside .DiffBlockPart.add .from {
|
250
|
+
display: none;
|
251
|
+
}
|
252
|
+
|
253
|
+
.lineNumber, .expansionLineNumber {
|
254
|
+
border-bottom: 1px solid #998;
|
255
|
+
border-right: 1px solid #ddd;
|
256
|
+
color: #444;
|
257
|
+
display: inline-block;
|
258
|
+
padding: 1px 5px 0px 0px;
|
259
|
+
text-align: right;
|
260
|
+
vertical-align: bottom;
|
261
|
+
width: 3em;
|
262
|
+
}
|
263
|
+
|
264
|
+
.lineNumber {
|
265
|
+
background-color: #eed;
|
266
|
+
}
|
267
|
+
|
268
|
+
.expansionLineNumber {
|
269
|
+
background-color: #eee;
|
270
|
+
}
|
271
|
+
|
272
|
+
pre, .text {
|
273
|
+
padding-left: 5px;
|
274
|
+
white-space: pre-wrap;
|
275
|
+
word-wrap: break-word;
|
276
|
+
}
|
277
|
+
|
278
|
+
.image {
|
279
|
+
border: 2px solid black;
|
280
|
+
}
|
281
|
+
|
282
|
+
.context, .context .lineNumber {
|
283
|
+
color: #849;
|
284
|
+
background-color: #fef;
|
285
|
+
}
|
286
|
+
|
287
|
+
.Line.add, .FileDiff .add {
|
288
|
+
background-color: #dfd;
|
289
|
+
}
|
290
|
+
|
291
|
+
.Line.add ins {
|
292
|
+
background-color: #9e9;
|
293
|
+
text-decoration: none;
|
294
|
+
}
|
295
|
+
|
296
|
+
.Line.remove, .FileDiff .remove {
|
297
|
+
background-color: #fdd;
|
298
|
+
}
|
299
|
+
|
300
|
+
.Line.remove del {
|
301
|
+
background-color: #e99;
|
302
|
+
text-decoration: none;
|
303
|
+
}
|
304
|
+
|
305
|
+
div:focus {
|
306
|
+
outline: 1px solid blue;
|
307
|
+
outline-offset: -1px;
|
308
|
+
}
|
309
|
+
|
310
|
+
.revision {
|
311
|
+
display: none;
|
312
|
+
}
|
313
|
+
|
314
|
+
.clear_float {
|
315
|
+
clear: both;
|
316
|
+
}
|
317
|
+
</style>
|
318
|
+
</head>
|
319
|
+
EOF
|
320
|
+
|
321
|
+
def self.revisionOrDescription(string)
|
322
|
+
case string
|
323
|
+
when /\(revision \d+\)/
|
324
|
+
/\(revision (\d+)\)/.match(string)[1]
|
325
|
+
when /\(.*\)/
|
326
|
+
/\((.*)\)/.match(string)[1]
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def self.has_image_suffix(filename)
|
331
|
+
filename =~ /\.(png|jpg|gif)$/
|
332
|
+
end
|
333
|
+
|
334
|
+
class FileDiff
|
335
|
+
def initialize(lines)
|
336
|
+
@filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
|
337
|
+
startOfSections = 1
|
338
|
+
for i in 0...lines.length
|
339
|
+
case lines[i]
|
340
|
+
when /^--- /
|
341
|
+
@from = PrettyPatch.revisionOrDescription(lines[i])
|
342
|
+
when /^\+\+\+ /
|
343
|
+
@filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
|
344
|
+
@to = PrettyPatch.revisionOrDescription(lines[i])
|
345
|
+
startOfSections = i + 1
|
346
|
+
break
|
347
|
+
when BINARY_FILE_MARKER_FORMAT
|
348
|
+
@binary = true
|
349
|
+
if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
|
350
|
+
@image = true
|
351
|
+
startOfSections = i + 2
|
352
|
+
for x in startOfSections...lines.length
|
353
|
+
# Binary diffs often have property changes listed before the actual binary data. Skip them.
|
354
|
+
if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
|
355
|
+
startOfSections = x
|
356
|
+
break
|
357
|
+
end
|
358
|
+
end
|
359
|
+
end
|
360
|
+
break
|
361
|
+
when GIT_INDEX_MARKER_FORMAT
|
362
|
+
@git_indexes = [$1, $2]
|
363
|
+
when GIT_BINARY_FILE_MARKER_FORMAT
|
364
|
+
@binary = true
|
365
|
+
if (GIT_BINARY_PATCH_FORMAT.match(lines[i + 1]) and PrettyPatch.has_image_suffix(@filename)) then
|
366
|
+
@git_image = true
|
367
|
+
startOfSections = i + 1
|
368
|
+
end
|
369
|
+
break
|
370
|
+
end
|
371
|
+
end
|
372
|
+
lines_with_contents = lines[startOfSections...lines.length]
|
373
|
+
@sections = DiffSection.parse(lines_with_contents) unless @binary
|
374
|
+
if @image and not lines_with_contents.empty?
|
375
|
+
@image_url = "data:image/png;base64," + lines_with_contents.join
|
376
|
+
@image_checksum = FileDiff.read_checksum_from_png(lines_with_contents.join.unpack("m").join)
|
377
|
+
elsif @git_image
|
378
|
+
begin
|
379
|
+
raise "index line is missing" unless @git_indexes
|
380
|
+
|
381
|
+
chunks = nil
|
382
|
+
for i in 0...lines_with_contents.length
|
383
|
+
if lines_with_contents[i] =~ /^$/
|
384
|
+
chunks = [lines_with_contents[i + 1 .. -1], lines_with_contents[0 .. i]]
|
385
|
+
break
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
raise "no binary chunks" unless chunks
|
390
|
+
|
391
|
+
from_filepath = FileDiff.extract_contents_of_from_revision(@filename, chunks[0], @git_indexes[0])
|
392
|
+
to_filepath = FileDiff.extract_contents_of_to_revision(@filename, chunks[1], @git_indexes[1], from_filepath, @git_indexes[0])
|
393
|
+
filepaths = from_filepath, to_filepath
|
394
|
+
|
395
|
+
binary_contents = filepaths.collect { |filepath| File.exists?(filepath) ? File.read(filepath) : nil }
|
396
|
+
@image_urls = binary_contents.collect { |content| (content and not content.empty?) ? "data:image/png;base64," + [content].pack("m") : nil }
|
397
|
+
@image_checksums = binary_contents.collect { |content| FileDiff.read_checksum_from_png(content) }
|
398
|
+
rescue
|
399
|
+
$last_prettify_part_count["extract-error"] += 1
|
400
|
+
@image_error = "Exception raised during decoding git binary patch:<pre>#{CGI.escapeHTML($!.to_s + "\n" + $!.backtrace.join("\n"))}</pre>"
|
401
|
+
ensure
|
402
|
+
File.unlink(from_filepath) if (from_filepath and File.exists?(from_filepath))
|
403
|
+
File.unlink(to_filepath) if (to_filepath and File.exists?(to_filepath))
|
404
|
+
end
|
405
|
+
end
|
406
|
+
nil
|
407
|
+
end
|
408
|
+
|
409
|
+
def image_to_html
|
410
|
+
if not @image_url then
|
411
|
+
return "<span class='text'>Image file removed</span>"
|
412
|
+
end
|
413
|
+
|
414
|
+
image_checksum = ""
|
415
|
+
if @image_checksum
|
416
|
+
image_checksum = @image_checksum
|
417
|
+
elsif @filename.include? "-expected.png" and @image_url
|
418
|
+
image_checksum = IMAGE_CHECKSUM_ERROR
|
419
|
+
end
|
420
|
+
|
421
|
+
return "<p>" + image_checksum + "</p><img class='image' src='" + @image_url + "' />"
|
422
|
+
end
|
423
|
+
|
424
|
+
def to_html
|
425
|
+
str = "<div class='FileDiff'>\n"
|
426
|
+
str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
|
427
|
+
if @image then
|
428
|
+
str += self.image_to_html
|
429
|
+
elsif @git_image then
|
430
|
+
if @image_error
|
431
|
+
str += @image_error
|
432
|
+
else
|
433
|
+
for i in (0...2)
|
434
|
+
image_url = @image_urls[i]
|
435
|
+
image_checksum = @image_checksums[i]
|
436
|
+
|
437
|
+
style = ["remove", "add"][i]
|
438
|
+
str += "<p class=\"#{style}\">"
|
439
|
+
|
440
|
+
if image_checksum
|
441
|
+
str += image_checksum
|
442
|
+
elsif @filename.include? "-expected.png" and image_url
|
443
|
+
str += IMAGE_CHECKSUM_ERROR
|
444
|
+
end
|
445
|
+
|
446
|
+
str += "<br>"
|
447
|
+
|
448
|
+
if image_url
|
449
|
+
str += "<img class='image' src='" + image_url + "' />"
|
450
|
+
else
|
451
|
+
str += ["</p>Added", "</p>Removed"][i]
|
452
|
+
end
|
453
|
+
end
|
454
|
+
end
|
455
|
+
elsif @binary then
|
456
|
+
$last_prettify_part_count["binary"] += 1
|
457
|
+
str += "<span class='text'>Binary file, nothing to see here</span>"
|
458
|
+
else
|
459
|
+
str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
|
460
|
+
end
|
461
|
+
|
462
|
+
if @from then
|
463
|
+
str += "<span class='revision'>" + @from + "</span>"
|
464
|
+
end
|
465
|
+
|
466
|
+
str += "</div>\n"
|
467
|
+
end
|
468
|
+
|
469
|
+
def self.parse(string)
|
470
|
+
haveSeenDiffHeader = false
|
471
|
+
linesForDiffs = []
|
472
|
+
string.each_line do |line|
|
473
|
+
if (PrettyPatch.diff_header?(line))
|
474
|
+
linesForDiffs << []
|
475
|
+
haveSeenDiffHeader = true
|
476
|
+
elsif (!haveSeenDiffHeader && line =~ /^--- /)
|
477
|
+
linesForDiffs << []
|
478
|
+
haveSeenDiffHeader = false
|
479
|
+
end
|
480
|
+
linesForDiffs.last << line unless linesForDiffs.last.nil?
|
481
|
+
end
|
482
|
+
|
483
|
+
linesForDiffs.collect { |lines| FileDiff.new(lines) }
|
484
|
+
end
|
485
|
+
|
486
|
+
def self.read_checksum_from_png(png_bytes)
|
487
|
+
# Ruby 1.9 added the concept of string encodings, so to avoid treating binary data as UTF-8,
|
488
|
+
# we can force the encoding to binary at this point.
|
489
|
+
if RUBY_VERSION >= "1.9"
|
490
|
+
png_bytes.force_encoding('binary')
|
491
|
+
end
|
492
|
+
match = png_bytes && png_bytes.match(/tEXtchecksum\0([a-fA-F0-9]{32})/)
|
493
|
+
match ? match[1] : nil
|
494
|
+
end
|
495
|
+
|
496
|
+
def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
|
497
|
+
return <<END
|
498
|
+
diff --git a/#{filename} b/#{filename}
|
499
|
+
new file mode 100644
|
500
|
+
index 0000000000000000000000000000000000000000..#{git_index}
|
501
|
+
GIT binary patch
|
502
|
+
#{encoded_chunk.join("")}literal 0
|
503
|
+
HcmV?d00001
|
504
|
+
|
505
|
+
END
|
506
|
+
end
|
507
|
+
|
508
|
+
def self.git_changed_file_binary_patch(to_filename, from_filename, encoded_chunk, to_git_index, from_git_index)
|
509
|
+
return <<END
|
510
|
+
diff --git a/#{from_filename} b/#{to_filename}
|
511
|
+
copy from #{from_filename}
|
512
|
+
+++ b/#{to_filename}
|
513
|
+
index #{from_git_index}..#{to_git_index}
|
514
|
+
GIT binary patch
|
515
|
+
#{encoded_chunk.join("")}literal 0
|
516
|
+
HcmV?d00001
|
517
|
+
|
518
|
+
END
|
519
|
+
end
|
520
|
+
|
521
|
+
def self.get_svn_uri(repository_path)
|
522
|
+
"http://svn.webkit.org/repository/webkit/!svn/bc/" + $svn_revision.to_s + "/trunk/" + (repository_path)
|
523
|
+
end
|
524
|
+
|
525
|
+
def self.get_new_temp_filepath_and_name
|
526
|
+
tempfile = Tempfile.new("PrettyPatch")
|
527
|
+
filepath = tempfile.path + '.bin'
|
528
|
+
filename = File.basename(filepath)
|
529
|
+
return filepath, filename
|
530
|
+
end
|
531
|
+
|
532
|
+
def self.download_from_revision_from_svn(repository_path)
|
533
|
+
filepath, filename = get_new_temp_filepath_and_name
|
534
|
+
svn_uri = get_svn_uri(repository_path)
|
535
|
+
open(filepath, 'wb') do |to_file|
|
536
|
+
to_file << open(svn_uri) { |from_file| from_file.read }
|
537
|
+
end
|
538
|
+
return filepath
|
539
|
+
end
|
540
|
+
|
541
|
+
def self.run_git_apply_on_patch(output_filepath, patch)
|
542
|
+
# Apply the git binary patch using git-apply.
|
543
|
+
cmd = GIT_PATH + " apply --directory=" + File.dirname(output_filepath)
|
544
|
+
stdin, stdout, stderr = *Open3.popen3(cmd)
|
545
|
+
begin
|
546
|
+
stdin.puts(patch)
|
547
|
+
stdin.close
|
548
|
+
|
549
|
+
error = stderr.read
|
550
|
+
if error != ""
|
551
|
+
error = "Error running " + cmd + "\n" + "with patch:\n" + patch[0..500] + "...\n" + error
|
552
|
+
end
|
553
|
+
raise error if error != ""
|
554
|
+
ensure
|
555
|
+
stdin.close unless stdin.closed?
|
556
|
+
stdout.close
|
557
|
+
stderr.close
|
558
|
+
end
|
559
|
+
end
|
560
|
+
|
561
|
+
def self.extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
|
562
|
+
filepath, filename = get_new_temp_filepath_and_name
|
563
|
+
patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
|
564
|
+
run_git_apply_on_patch(filepath, patch)
|
565
|
+
return filepath
|
566
|
+
end
|
567
|
+
|
568
|
+
def self.extract_contents_from_git_binary_delta_chunk(from_filepath, from_git_index, encoded_chunk, to_git_index)
|
569
|
+
to_filepath, to_filename = get_new_temp_filepath_and_name
|
570
|
+
from_filename = File.basename(from_filepath)
|
571
|
+
patch = FileDiff.git_changed_file_binary_patch(to_filename, from_filename, encoded_chunk, to_git_index, from_git_index)
|
572
|
+
run_git_apply_on_patch(to_filepath, patch)
|
573
|
+
return to_filepath
|
574
|
+
end
|
575
|
+
|
576
|
+
def self.extract_contents_of_from_revision(repository_path, encoded_chunk, git_index)
|
577
|
+
# For literal encoded, simply reconstruct.
|
578
|
+
if GIT_LITERAL_FORMAT.match(encoded_chunk[0])
|
579
|
+
return extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
|
580
|
+
end
|
581
|
+
# For delta encoded, download from svn.
|
582
|
+
if GIT_DELTA_FORMAT.match(encoded_chunk[0])
|
583
|
+
return download_from_revision_from_svn(repository_path)
|
584
|
+
end
|
585
|
+
raise "Error: unknown git patch encoding"
|
586
|
+
end
|
587
|
+
|
588
|
+
def self.extract_contents_of_to_revision(repository_path, encoded_chunk, git_index, from_filepath, from_git_index)
|
589
|
+
# For literal encoded, simply reconstruct.
|
590
|
+
if GIT_LITERAL_FORMAT.match(encoded_chunk[0])
|
591
|
+
return extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
|
592
|
+
end
|
593
|
+
# For delta encoded, reconstruct using delta and previously constructed 'from' revision.
|
594
|
+
if GIT_DELTA_FORMAT.match(encoded_chunk[0])
|
595
|
+
return extract_contents_from_git_binary_delta_chunk(from_filepath, from_git_index, encoded_chunk, git_index)
|
596
|
+
end
|
597
|
+
raise "Error: unknown git patch encoding"
|
598
|
+
end
|
599
|
+
end
|
600
|
+
|
601
|
+
class DiffBlock
|
602
|
+
attr_accessor :parts
|
603
|
+
|
604
|
+
def initialize(container)
|
605
|
+
@parts = []
|
606
|
+
container << self
|
607
|
+
end
|
608
|
+
|
609
|
+
def to_html
|
610
|
+
str = "<div class='DiffBlock'>\n"
|
611
|
+
str += @parts.collect{ |part| part.to_html }.join
|
612
|
+
str += "<div class='clear_float'></div></div>\n"
|
613
|
+
end
|
614
|
+
end
|
615
|
+
|
616
|
+
class DiffBlockPart
|
617
|
+
attr_reader :className
|
618
|
+
attr :lines
|
619
|
+
|
620
|
+
def initialize(className, container)
|
621
|
+
$last_prettify_part_count[className] += 1
|
622
|
+
@className = className
|
623
|
+
@lines = []
|
624
|
+
container.parts << self
|
625
|
+
end
|
626
|
+
|
627
|
+
def to_html
|
628
|
+
str = "<div class='DiffBlockPart %s'>\n" % @className
|
629
|
+
str += @lines.collect{ |line| line.to_html }.join
|
630
|
+
# Don't put white-space after this so adjacent inline-block DiffBlockParts will not wrap.
|
631
|
+
str += "</div>"
|
632
|
+
end
|
633
|
+
end
|
634
|
+
|
635
|
+
class DiffSection
|
636
|
+
def initialize(lines)
|
637
|
+
lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
|
638
|
+
|
639
|
+
matches = START_OF_SECTION_FORMAT.match(lines[0])
|
640
|
+
|
641
|
+
if matches
|
642
|
+
from, to = [matches[1].to_i, matches[3].to_i]
|
643
|
+
if matches[2] and matches[4]
|
644
|
+
from_end = from + matches[2].to_i
|
645
|
+
to_end = to + matches[4].to_i
|
646
|
+
end
|
647
|
+
end
|
648
|
+
|
649
|
+
@blocks = []
|
650
|
+
diff_block = nil
|
651
|
+
diff_block_part = nil
|
652
|
+
|
653
|
+
for line in lines[1...lines.length]
|
654
|
+
startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
|
655
|
+
text = line[startOfLine...line.length].chomp
|
656
|
+
case line[0]
|
657
|
+
when ?-
|
658
|
+
if (diff_block_part.nil? or diff_block_part.className != 'remove')
|
659
|
+
diff_block = DiffBlock.new(@blocks)
|
660
|
+
diff_block_part = DiffBlockPart.new('remove', diff_block)
|
661
|
+
end
|
662
|
+
|
663
|
+
diff_block_part.lines << CodeLine.new(from, nil, text)
|
664
|
+
from += 1 unless from.nil?
|
665
|
+
when ?+
|
666
|
+
if (diff_block_part.nil? or diff_block_part.className != 'add')
|
667
|
+
# Put add lines that immediately follow remove lines into the same DiffBlock.
|
668
|
+
if (diff_block.nil? or diff_block_part.className != 'remove')
|
669
|
+
diff_block = DiffBlock.new(@blocks)
|
670
|
+
end
|
671
|
+
|
672
|
+
diff_block_part = DiffBlockPart.new('add', diff_block)
|
673
|
+
end
|
674
|
+
|
675
|
+
diff_block_part.lines << CodeLine.new(nil, to, text)
|
676
|
+
to += 1 unless to.nil?
|
677
|
+
else
|
678
|
+
if (diff_block_part.nil? or diff_block_part.className != 'shared')
|
679
|
+
diff_block = DiffBlock.new(@blocks)
|
680
|
+
diff_block_part = DiffBlockPart.new('shared', diff_block)
|
681
|
+
end
|
682
|
+
|
683
|
+
diff_block_part.lines << CodeLine.new(from, to, text)
|
684
|
+
from += 1 unless from.nil?
|
685
|
+
to += 1 unless to.nil?
|
686
|
+
end
|
687
|
+
|
688
|
+
break if from_end and to_end and from == from_end and to == to_end
|
689
|
+
end
|
690
|
+
|
691
|
+
changes = [ [ [], [] ] ]
|
692
|
+
for block in @blocks
|
693
|
+
for block_part in block.parts
|
694
|
+
for line in block_part.lines
|
695
|
+
if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
|
696
|
+
changes << [ [], [] ]
|
697
|
+
next
|
698
|
+
end
|
699
|
+
changes.last.first << line if line.toLineNumber.nil?
|
700
|
+
changes.last.last << line if line.fromLineNumber.nil?
|
701
|
+
end
|
702
|
+
end
|
703
|
+
end
|
704
|
+
|
705
|
+
for change in changes
|
706
|
+
next unless change.first.length == change.last.length
|
707
|
+
for i in (0...change.first.length)
|
708
|
+
from_text = change.first[i].text
|
709
|
+
to_text = change.last[i].text
|
710
|
+
next if from_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH or to_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH
|
711
|
+
raw_operations = HTMLDiff::DiffBuilder.new(from_text, to_text).operations
|
712
|
+
operations = []
|
713
|
+
back = 0
|
714
|
+
raw_operations.each_with_index do |operation, j|
|
715
|
+
if operation.action == :equal and j < raw_operations.length - 1
|
716
|
+
length = operation.end_in_new - operation.start_in_new
|
717
|
+
if length < SMALLEST_EQUAL_OPERATION
|
718
|
+
back = length
|
719
|
+
next
|
720
|
+
end
|
721
|
+
end
|
722
|
+
operation.start_in_old -= back
|
723
|
+
operation.start_in_new -= back
|
724
|
+
back = 0
|
725
|
+
operations << operation
|
726
|
+
end
|
727
|
+
change.first[i].operations = operations
|
728
|
+
change.last[i].operations = operations
|
729
|
+
end
|
730
|
+
end
|
731
|
+
|
732
|
+
@blocks.unshift(ContextLine.new(matches[5])) unless matches.nil? || matches[5].empty?
|
733
|
+
end
|
734
|
+
|
735
|
+
def to_html
|
736
|
+
str = "<div class='DiffSection'>\n"
|
737
|
+
str += @blocks.collect{ |block| block.to_html }.join
|
738
|
+
str += "</div>\n"
|
739
|
+
end
|
740
|
+
|
741
|
+
def self.parse(lines)
|
742
|
+
linesForSections = lines.inject([[]]) do |sections, line|
|
743
|
+
sections << [] if line =~ /^@@/
|
744
|
+
sections.last << line
|
745
|
+
sections
|
746
|
+
end
|
747
|
+
|
748
|
+
linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
|
749
|
+
linesForSections.collect { |lines| DiffSection.new(lines) }
|
750
|
+
end
|
751
|
+
end
|
752
|
+
|
753
|
+
class Line
|
754
|
+
attr_reader :fromLineNumber
|
755
|
+
attr_reader :toLineNumber
|
756
|
+
attr_reader :text
|
757
|
+
|
758
|
+
def initialize(from, to, text)
|
759
|
+
@fromLineNumber = from
|
760
|
+
@toLineNumber = to
|
761
|
+
@text = text
|
762
|
+
end
|
763
|
+
|
764
|
+
def text_as_html
|
765
|
+
CGI.escapeHTML(text)
|
766
|
+
end
|
767
|
+
|
768
|
+
def classes
|
769
|
+
lineClasses = ["Line", "LineContainer"]
|
770
|
+
lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
|
771
|
+
lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
|
772
|
+
lineClasses
|
773
|
+
end
|
774
|
+
|
775
|
+
def to_html
|
776
|
+
markedUpText = self.text_as_html
|
777
|
+
str = "<div class='%s'>\n" % self.classes.join(' ')
|
778
|
+
str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>" %
|
779
|
+
[@fromLineNumber.nil? ? ' ' : @fromLineNumber,
|
780
|
+
@toLineNumber.nil? ? ' ' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
|
781
|
+
str += "<span class='text'>%s</span>\n" % markedUpText
|
782
|
+
str += "</div>\n"
|
783
|
+
end
|
784
|
+
end
|
785
|
+
|
786
|
+
class CodeLine < Line
|
787
|
+
attr :operations, true
|
788
|
+
|
789
|
+
def text_as_html
|
790
|
+
html = []
|
791
|
+
tag = @fromLineNumber.nil? ? "ins" : "del"
|
792
|
+
if @operations.nil? or @operations.empty?
|
793
|
+
return CGI.escapeHTML(@text)
|
794
|
+
end
|
795
|
+
@operations.each do |operation|
|
796
|
+
start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
|
797
|
+
eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
|
798
|
+
escaped_text = CGI.escapeHTML(@text[start...eend])
|
799
|
+
if eend - start === 0 or operation.action === :equal
|
800
|
+
html << escaped_text
|
801
|
+
else
|
802
|
+
html << "<#{tag}>#{escaped_text}</#{tag}>"
|
803
|
+
end
|
804
|
+
end
|
805
|
+
html.join
|
806
|
+
end
|
807
|
+
end
|
808
|
+
|
809
|
+
class ContextLine < Line
|
810
|
+
def initialize(context)
|
811
|
+
super("@", "@", context)
|
812
|
+
end
|
813
|
+
|
814
|
+
def classes
|
815
|
+
super << "context"
|
816
|
+
end
|
817
|
+
end
|
818
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
|
3
|
+
require 'test/unit'
|
4
|
+
require 'open-uri'
|
5
|
+
require 'PrettyPatch'
|
6
|
+
|
7
|
+
# Note: internet connection is needed to run this test suite.
|
8
|
+
|
9
|
+
class PrettyPatch_test < Test::Unit::TestCase
|
10
|
+
class Info
|
11
|
+
TITLE = 0
|
12
|
+
FILE = 1
|
13
|
+
ADD = 2
|
14
|
+
REMOVE = 3
|
15
|
+
SHARED = 4
|
16
|
+
end
|
17
|
+
|
18
|
+
PATCHES = {
|
19
|
+
20510 => ["Single change", 1, 1, 0, 2],
|
20
|
+
20528 => ["No 'Index' or 'diff' in patch header", 1, 4, 3, 7],
|
21
|
+
21151 => ["Leading '/' in the path of files", 4, 9, 1, 16],
|
22
|
+
# Binary files use shared blocks, there are three in 30488.
|
23
|
+
30488 => ["Quoted filenames in git diff", 23, 28, 25, 64 + 3],
|
24
|
+
23920 => ["Mac line ending", 3, 3, 0, 5],
|
25
|
+
39615 => ["Git signature", 2, 2, 0, 3],
|
26
|
+
80852 => ["Changes one line plus ChangeLog", 2, 2, 1, 4],
|
27
|
+
83127 => ["Only add stuff", 2, 2, 0, 3],
|
28
|
+
85071 => ["Adds and removes from a file plus git signature", 2, 5, 3, 9],
|
29
|
+
106368 => ["Images with git delta binary patch", 69, 8, 23, 10],
|
30
|
+
}
|
31
|
+
|
32
|
+
def get_patch_uri(id)
|
33
|
+
"https://bugs.webkit.org/attachment.cgi?id=" + id.to_s
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_patch(id)
|
37
|
+
result = nil
|
38
|
+
patch_uri = get_patch_uri(id)
|
39
|
+
begin
|
40
|
+
result = open(patch_uri) { |f| result = f.read }
|
41
|
+
rescue => exception
|
42
|
+
assert(false, "Fail to get patch " + patch_uri)
|
43
|
+
end
|
44
|
+
result
|
45
|
+
end
|
46
|
+
|
47
|
+
def check_one_patch(id, info)
|
48
|
+
patch = get_patch(id)
|
49
|
+
description = get_patch_uri(id)
|
50
|
+
description += " (" + info[Info::TITLE] + ")" unless info[Info::TITLE].nil?
|
51
|
+
puts "Testing " + description
|
52
|
+
pretty = nil
|
53
|
+
assert_nothing_raised("Crash while prettifying " + description) {
|
54
|
+
pretty = PrettyPatch.prettify(patch)
|
55
|
+
}
|
56
|
+
assert(pretty, "Empty result while prettifying " + description)
|
57
|
+
assert_equal(info[Info::FILE], $last_prettify_file_count, "Wrong number of files changed in " + description)
|
58
|
+
assert_equal(info[Info::ADD], $last_prettify_part_count["add"], "Wrong number of 'add' parts in " + description)
|
59
|
+
assert_equal(info[Info::REMOVE], $last_prettify_part_count["remove"], "Wrong number of 'remove' parts in " + description)
|
60
|
+
assert_equal(info[Info::SHARED], $last_prettify_part_count["shared"], "Wrong number of 'shared' parts in " + description)
|
61
|
+
assert_equal(0, $last_prettify_part_count["binary"], "Wrong number of 'binary' parts in " + description)
|
62
|
+
assert_equal(0, $last_prettify_part_count["extract-error"], "Wrong number of 'extract-error' parts in " + description)
|
63
|
+
return pretty
|
64
|
+
end
|
65
|
+
|
66
|
+
def test_patches
|
67
|
+
PATCHES.each { |id, info| check_one_patch(id, info) }
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_images_without_checksum
|
71
|
+
pretty = check_one_patch(144064, ["Images without checksums", 10, 5, 4, 8])
|
72
|
+
matches = pretty.match("INVALID: Image lacks a checksum.")
|
73
|
+
# FIXME: This should match, but there's a bug when running the tests where the image data
|
74
|
+
# doesn't get properly written out to the temp files, so there is no image and we don't print
|
75
|
+
# the warning that the image is missing its checksum.
|
76
|
+
assert(!matches, "Should have invalid checksums")
|
77
|
+
# FIXME: This should only have 4 invalid images, but due to the above tempfile issue, there are 0.
|
78
|
+
assert_equal(0, pretty.scan(/INVALID\: Image lacks a checksum\./).size)
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_new_image
|
82
|
+
pretty = check_one_patch(145881, ["New image", 19, 36, 19, 56])
|
83
|
+
matches = pretty.match("INVALID: Image lacks a checksum.")
|
84
|
+
assert(!matches, "Should not have invalid checksums")
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_images_correctly_without_checksum_git
|
88
|
+
pretty = check_one_patch(101620, ["Images correctly without checksums git", 7, 15, 10, 26])
|
89
|
+
matches = pretty.match("INVALID: Image lacks a checksum.")
|
90
|
+
assert(!matches, "Png should lack a checksum without an error.")
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_images_correctly_without_checksum_svn
|
94
|
+
pretty = check_one_patch(31202, ["Images correctly without checksums svn", 4, 4, 1, 4])
|
95
|
+
matches = pretty.match("INVALID: Image lacks a checksum.")
|
96
|
+
assert(!matches, "Png should lack a checksum without an error.")
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module HTMLDiff
|
2
|
+
|
3
|
+
Match = Struct.new(:start_in_old, :start_in_new, :size)
|
4
|
+
class Match
|
5
|
+
def end_in_old
|
6
|
+
self.start_in_old + self.size
|
7
|
+
end
|
8
|
+
|
9
|
+
def end_in_new
|
10
|
+
self.start_in_new + self.size
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
Operation = Struct.new(:action, :start_in_old, :end_in_old, :start_in_new, :end_in_new)
|
15
|
+
|
16
|
+
class DiffBuilder
|
17
|
+
|
18
|
+
def initialize(old_version, new_version)
|
19
|
+
@old_version, @new_version = old_version, new_version
|
20
|
+
split_inputs_to_words
|
21
|
+
index_new_words
|
22
|
+
end
|
23
|
+
|
24
|
+
def split_inputs_to_words
|
25
|
+
@old_words = explode(@old_version)
|
26
|
+
@new_words = explode(@new_version)
|
27
|
+
end
|
28
|
+
|
29
|
+
def index_new_words
|
30
|
+
@word_indices = Hash.new { |h, word| h[word] = [] }
|
31
|
+
@new_words.each_with_index { |word, i| @word_indices[word] << i }
|
32
|
+
end
|
33
|
+
|
34
|
+
def operations
|
35
|
+
position_in_old = position_in_new = 0
|
36
|
+
operations = []
|
37
|
+
|
38
|
+
matches = matching_blocks
|
39
|
+
# an empty match at the end forces the loop below to handle the unmatched tails
|
40
|
+
# I'm sure it can be done more gracefully, but not at 23:52
|
41
|
+
matches << Match.new(@old_words.length, @new_words.length, 0)
|
42
|
+
|
43
|
+
matches.each_with_index do |match, i|
|
44
|
+
match_starts_at_current_position_in_old = (position_in_old == match.start_in_old)
|
45
|
+
match_starts_at_current_position_in_new = (position_in_new == match.start_in_new)
|
46
|
+
|
47
|
+
action_upto_match_positions =
|
48
|
+
case [match_starts_at_current_position_in_old, match_starts_at_current_position_in_new]
|
49
|
+
when [false, false]
|
50
|
+
:replace
|
51
|
+
when [true, false]
|
52
|
+
:insert
|
53
|
+
when [false, true]
|
54
|
+
:delete
|
55
|
+
else
|
56
|
+
# this happens if the first few words are same in both versions
|
57
|
+
:none
|
58
|
+
end
|
59
|
+
|
60
|
+
if action_upto_match_positions != :none
|
61
|
+
operation_upto_match_positions =
|
62
|
+
Operation.new(action_upto_match_positions,
|
63
|
+
position_in_old, match.start_in_old,
|
64
|
+
position_in_new, match.start_in_new)
|
65
|
+
operations << operation_upto_match_positions
|
66
|
+
end
|
67
|
+
if match.size != 0
|
68
|
+
match_operation = Operation.new(:equal,
|
69
|
+
match.start_in_old, match.end_in_old,
|
70
|
+
match.start_in_new, match.end_in_new)
|
71
|
+
operations << match_operation
|
72
|
+
end
|
73
|
+
|
74
|
+
position_in_old = match.end_in_old
|
75
|
+
position_in_new = match.end_in_new
|
76
|
+
end
|
77
|
+
|
78
|
+
operations
|
79
|
+
end
|
80
|
+
|
81
|
+
def matching_blocks
|
82
|
+
matching_blocks = []
|
83
|
+
recursively_find_matching_blocks(0, @old_words.size, 0, @new_words.size, matching_blocks)
|
84
|
+
matching_blocks
|
85
|
+
end
|
86
|
+
|
87
|
+
def recursively_find_matching_blocks(start_in_old, end_in_old, start_in_new, end_in_new, matching_blocks)
|
88
|
+
match = find_match(start_in_old, end_in_old, start_in_new, end_in_new)
|
89
|
+
if match
|
90
|
+
if start_in_old < match.start_in_old and start_in_new < match.start_in_new
|
91
|
+
recursively_find_matching_blocks(
|
92
|
+
start_in_old, match.start_in_old, start_in_new, match.start_in_new, matching_blocks)
|
93
|
+
end
|
94
|
+
matching_blocks << match
|
95
|
+
if match.end_in_old < end_in_old and match.end_in_new < end_in_new
|
96
|
+
recursively_find_matching_blocks(
|
97
|
+
match.end_in_old, end_in_old, match.end_in_new, end_in_new, matching_blocks)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def find_match(start_in_old, end_in_old, start_in_new, end_in_new)
|
103
|
+
|
104
|
+
best_match_in_old = start_in_old
|
105
|
+
best_match_in_new = start_in_new
|
106
|
+
best_match_size = 0
|
107
|
+
|
108
|
+
match_length_at = Hash.new { |h, index| h[index] = 0 }
|
109
|
+
|
110
|
+
start_in_old.upto(end_in_old - 1) do |index_in_old|
|
111
|
+
|
112
|
+
new_match_length_at = Hash.new { |h, index| h[index] = 0 }
|
113
|
+
|
114
|
+
@word_indices[@old_words[index_in_old]].each do |index_in_new|
|
115
|
+
next if index_in_new < start_in_new
|
116
|
+
break if index_in_new >= end_in_new
|
117
|
+
|
118
|
+
new_match_length = match_length_at[index_in_new - 1] + 1
|
119
|
+
new_match_length_at[index_in_new] = new_match_length
|
120
|
+
|
121
|
+
if new_match_length > best_match_size
|
122
|
+
best_match_in_old = index_in_old - new_match_length + 1
|
123
|
+
best_match_in_new = index_in_new - new_match_length + 1
|
124
|
+
best_match_size = new_match_length
|
125
|
+
end
|
126
|
+
end
|
127
|
+
match_length_at = new_match_length_at
|
128
|
+
end
|
129
|
+
|
130
|
+
# best_match_in_old, best_match_in_new, best_match_size = add_matching_words_left(
|
131
|
+
# best_match_in_old, best_match_in_new, best_match_size, start_in_old, start_in_new)
|
132
|
+
# best_match_in_old, best_match_in_new, match_size = add_matching_words_right(
|
133
|
+
# best_match_in_old, best_match_in_new, best_match_size, end_in_old, end_in_new)
|
134
|
+
|
135
|
+
return (best_match_size != 0 ? Match.new(best_match_in_old, best_match_in_new, best_match_size) : nil)
|
136
|
+
end
|
137
|
+
|
138
|
+
def add_matching_words_left(match_in_old, match_in_new, match_size, start_in_old, start_in_new)
|
139
|
+
while match_in_old > start_in_old and
|
140
|
+
match_in_new > start_in_new and
|
141
|
+
@old_words[match_in_old - 1] == @new_words[match_in_new - 1]
|
142
|
+
match_in_old -= 1
|
143
|
+
match_in_new -= 1
|
144
|
+
match_size += 1
|
145
|
+
end
|
146
|
+
[match_in_old, match_in_new, match_size]
|
147
|
+
end
|
148
|
+
|
149
|
+
def add_matching_words_right(match_in_old, match_in_new, match_size, end_in_old, end_in_new)
|
150
|
+
while match_in_old + match_size < end_in_old and
|
151
|
+
match_in_new + match_size < end_in_new and
|
152
|
+
@old_words[match_in_old + match_size] == @new_words[match_in_new + match_size]
|
153
|
+
match_size += 1
|
154
|
+
end
|
155
|
+
[match_in_old, match_in_new, match_size]
|
156
|
+
end
|
157
|
+
|
158
|
+
def explode(sequence)
|
159
|
+
sequence.is_a?(String) ? sequence.split(//) : sequence
|
160
|
+
end
|
161
|
+
|
162
|
+
end # of class Diff Builder
|
163
|
+
|
164
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'pathname'
|
5
|
+
require 'webrick/htmlutils'
|
6
|
+
|
7
|
+
$LOAD_PATH << Pathname.new(__FILE__).dirname.realpath.to_s
|
8
|
+
|
9
|
+
require 'PrettyPatch'
|
10
|
+
|
11
|
+
BACKTRACE_SEPARATOR = "\n\tfrom "
|
12
|
+
|
13
|
+
options = { :html_exceptions => false }
|
14
|
+
OptionParser.new do |opts|
|
15
|
+
opts.banner = "Usage: #{File.basename($0)} [options] [patch-file]"
|
16
|
+
|
17
|
+
opts.separator ""
|
18
|
+
|
19
|
+
opts.on("--html-exceptions", "Print exceptions to stdout as HTML") { |h| options[:html_exceptions] = h }
|
20
|
+
end.parse!
|
21
|
+
|
22
|
+
patch_data = nil
|
23
|
+
if ARGV.length == 0 || ARGV[0] == '-' then
|
24
|
+
patch_data = $stdin.read
|
25
|
+
else
|
26
|
+
File.open(ARGV[0]) { |file| patch_data = file.read }
|
27
|
+
end
|
28
|
+
|
29
|
+
begin
|
30
|
+
puts PrettyPatch.prettify(patch_data)
|
31
|
+
rescue => exception
|
32
|
+
raise unless options[:html_exceptions]
|
33
|
+
|
34
|
+
backtrace = exception.backtrace
|
35
|
+
backtrace[0] += ": " + exception + " (" + exception.class.to_s + ")"
|
36
|
+
print "<pre>\n", WEBrick::HTMLUtils::escape(backtrace.join(BACKTRACE_SEPARATOR)), "\n</pre>\n"
|
37
|
+
end
|
data/lib/purdypatch.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'mail'
|
2
|
+
require 'pathname'
|
3
|
+
require 'purdypatch/version'
|
4
|
+
require 'prettypatch/PrettyPatch'
|
5
|
+
|
6
|
+
module PurdyPatch
|
7
|
+
class CLI
|
8
|
+
def self.start(*args)
|
9
|
+
if args.empty?
|
10
|
+
puts "purdypatch expects one or more files created via `git format-patch --attach [ <since> | <revision range> ]`."
|
11
|
+
exit
|
12
|
+
end
|
13
|
+
|
14
|
+
args.each do |arg|
|
15
|
+
patch_mail_path = Pathname.new(arg)
|
16
|
+
next unless patch_mail_path.exist?
|
17
|
+
|
18
|
+
mail = Mail.read(patch_mail_path)
|
19
|
+
parts = mail.parts.dup
|
20
|
+
purdified = false
|
21
|
+
parts.each_with_index do |part, i|
|
22
|
+
next unless part.attachment? && part.content_type =~ %r(text/x-patch)
|
23
|
+
|
24
|
+
purdy_part = Mail::Part.new do
|
25
|
+
content_type 'text/html; charset=UTF-8'
|
26
|
+
body PrettyPatch.prettify(part.body.decoded)
|
27
|
+
end
|
28
|
+
mail.parts.insert(i, purdy_part)
|
29
|
+
mail.header['X-Formatter'] = "PurdyPatch-#{PurdyPatch::VERSION}"
|
30
|
+
purdified = true
|
31
|
+
end
|
32
|
+
|
33
|
+
if purdified
|
34
|
+
purdy_patch_mail_path = patch_mail_path.sub_ext('.purdypatch')
|
35
|
+
File.open(purdy_patch_mail_path, File::CREAT | File::TRUNC | File::RDWR) do |f|
|
36
|
+
f.write mail.to_s
|
37
|
+
end
|
38
|
+
puts %Q(Purdified patch email saved to "#{purdy_patch_mail_path}".)
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/purdypatch.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'purdypatch/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "purdypatch"
|
8
|
+
gem.version = PurdyPatch::VERSION
|
9
|
+
gem.authors = ["Nathan de Vries"]
|
10
|
+
gem.email = ["nathan@atnan.com"]
|
11
|
+
gem.description = %q{A tool for improving the format of git-format-patch emails, making them easier to review.}
|
12
|
+
gem.summary = %q{Make your git-format-patch emails all purdy like.}
|
13
|
+
gem.homepage = "https://github.com/atnan/purdypatch"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.require_paths = ["lib", "lib/prettypatch"]
|
18
|
+
|
19
|
+
gem.add_dependency "mail"
|
20
|
+
end
|
metadata
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: purdypatch
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Nathan de Vries
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-11-07 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: mail
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
description: A tool for improving the format of git-format-patch emails, making them
|
31
|
+
easier to review.
|
32
|
+
email:
|
33
|
+
- nathan@atnan.com
|
34
|
+
executables:
|
35
|
+
- purdypatch
|
36
|
+
extensions: []
|
37
|
+
extra_rdoc_files: []
|
38
|
+
files:
|
39
|
+
- .gitignore
|
40
|
+
- Gemfile
|
41
|
+
- LICENSE.txt
|
42
|
+
- README.md
|
43
|
+
- Rakefile
|
44
|
+
- bin/purdypatch
|
45
|
+
- lib/prettypatch/PrettyPatch.rb
|
46
|
+
- lib/prettypatch/PrettyPatch_test.rb
|
47
|
+
- lib/prettypatch/diff.rb
|
48
|
+
- lib/prettypatch/prettify.rb
|
49
|
+
- lib/purdypatch.rb
|
50
|
+
- lib/purdypatch/version.rb
|
51
|
+
- purdypatch.gemspec
|
52
|
+
homepage: https://github.com/atnan/purdypatch
|
53
|
+
licenses: []
|
54
|
+
post_install_message:
|
55
|
+
rdoc_options: []
|
56
|
+
require_paths:
|
57
|
+
- lib
|
58
|
+
- lib/prettypatch
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
60
|
+
none: false
|
61
|
+
requirements:
|
62
|
+
- - ! '>='
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - ! '>='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
requirements: []
|
72
|
+
rubyforge_project:
|
73
|
+
rubygems_version: 1.8.23
|
74
|
+
signing_key:
|
75
|
+
specification_version: 3
|
76
|
+
summary: Make your git-format-patch emails all purdy like.
|
77
|
+
test_files: []
|