purdypatch 0.0.1
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.
- 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: []
|