##// END OF EJS Templates
mdiff: add a "blocksinrange" function to filter diff blocks by line range...
Denis Laxalde -
r30717:3eeb8e13 default
parent child Browse files
Show More
@@ -0,0 +1,232 b''
1 from __future__ import absolute_import
2
3 import unittest
4 from mercurial import error, mdiff
5
6 # for readability, line numbers are 0-origin
7 text1 = '''
8 00 at OLD
9 01 at OLD
10 02 at OLD
11 02 at NEW, 03 at OLD
12 03 at NEW, 04 at OLD
13 04 at NEW, 05 at OLD
14 05 at NEW, 06 at OLD
15 07 at OLD
16 08 at OLD
17 09 at OLD
18 10 at OLD
19 11 at OLD
20 '''[1:] # strip initial LF
21
22 text2 = '''
23 00 at NEW
24 01 at NEW
25 02 at NEW, 03 at OLD
26 03 at NEW, 04 at OLD
27 04 at NEW, 05 at OLD
28 05 at NEW, 06 at OLD
29 06 at NEW
30 07 at NEW
31 08 at NEW
32 09 at NEW
33 10 at NEW
34 11 at NEW
35 '''[1:] # strip initial LF
36
37 def filteredblocks(blocks, rangeb):
38 """return `rangea` extracted from `blocks` coming from
39 `mdiff.blocksinrange` along with the mask of blocks within rangeb.
40 """
41 filtered, rangea = mdiff.blocksinrange(blocks, rangeb)
42 skipped = [b not in filtered for b in blocks]
43 return rangea, skipped
44
45 class blocksinrangetests(unittest.TestCase):
46
47 def setUp(self):
48 self.blocks = list(mdiff.allblocks(text1, text2))
49 assert self.blocks == [
50 ([0, 3, 0, 2], '!'),
51 ((3, 7, 2, 6), '='),
52 ([7, 12, 6, 12], '!'),
53 ((12, 12, 12, 12), '='),
54 ], self.blocks
55
56 def testWithinEqual(self):
57 """linerange within an "=" block"""
58 # IDX 0 1
59 # 012345678901
60 # SRC NNOOOONNNNNN (New/Old)
61 # ^^
62 linerange2 = (3, 5)
63 linerange1, skipped = filteredblocks(self.blocks, linerange2)
64 self.assertEqual(linerange1, (4, 6))
65 self.assertEqual(skipped, [True, False, True, True])
66
67 def testWithinEqualStrictly(self):
68 """linerange matching exactly an "=" block"""
69 # IDX 0 1
70 # 012345678901
71 # SRC NNOOOONNNNNN (New/Old)
72 # ^^^^
73 linerange2 = (2, 6)
74 linerange1, skipped = filteredblocks(self.blocks, linerange2)
75 self.assertEqual(linerange1, (3, 7))
76 self.assertEqual(skipped, [True, False, True, True])
77
78 def testWithinEqualLowerbound(self):
79 """linerange at beginning of an "=" block"""
80 # IDX 0 1
81 # 012345678901
82 # SRC NNOOOONNNNNN (New/Old)
83 # ^^
84 linerange2 = (2, 4)
85 linerange1, skipped = filteredblocks(self.blocks, linerange2)
86 self.assertEqual(linerange1, (3, 5))
87 self.assertEqual(skipped, [True, False, True, True])
88
89 def testWithinEqualLowerboundOneline(self):
90 """oneline-linerange at beginning of an "=" block"""
91 # IDX 0 1
92 # 012345678901
93 # SRC NNOOOONNNNNN (New/Old)
94 # ^
95 linerange2 = (2, 3)
96 linerange1, skipped = filteredblocks(self.blocks, linerange2)
97 self.assertEqual(linerange1, (3, 4))
98 self.assertEqual(skipped, [True, False, True, True])
99
100 def testWithinEqualUpperbound(self):
101 """linerange at end of an "=" block"""
102 # IDX 0 1
103 # 012345678901
104 # SRC NNOOOONNNNNN (New/Old)
105 # ^^^
106 linerange2 = (3, 6)
107 linerange1, skipped = filteredblocks(self.blocks, linerange2)
108 self.assertEqual(linerange1, (4, 7))
109 self.assertEqual(skipped, [True, False, True, True])
110
111 def testWithinEqualUpperboundOneLine(self):
112 """oneline-linerange at end of an "=" block"""
113 # IDX 0 1
114 # 012345678901
115 # SRC NNOOOONNNNNN (New/Old)
116 # ^
117 linerange2 = (5, 6)
118 linerange1, skipped = filteredblocks(self.blocks, linerange2)
119 self.assertEqual(linerange1, (6, 7))
120 self.assertEqual(skipped, [True, False, True, True])
121
122 def testWithinFirstBlockNeq(self):
123 """linerange within the first "!" block"""
124 # IDX 0 1
125 # 012345678901
126 # SRC NNOOOONNNNNN (New/Old)
127 # ^
128 # | (empty)
129 # ^
130 # ^^
131 for linerange2 in [
132 (0, 1),
133 (1, 1),
134 (1, 2),
135 (0, 2),
136 ]:
137 linerange1, skipped = filteredblocks(self.blocks, linerange2)
138 self.assertEqual(linerange1, (0, 3))
139 self.assertEqual(skipped, [False, True, True, True])
140
141 def testWithinLastBlockNeq(self):
142 """linerange within the last "!" block"""
143 # IDX 0 1
144 # 012345678901
145 # SRC NNOOOONNNNNN (New/Old)
146 # ^
147 # ^
148 # | (empty)
149 # ^^^^^^
150 # ^
151 for linerange2 in [
152 (6, 7),
153 (7, 8),
154 (7, 7),
155 (6, 12),
156 (11, 12),
157 ]:
158 linerange1, skipped = filteredblocks(self.blocks, linerange2)
159 self.assertEqual(linerange1, (7, 12))
160 self.assertEqual(skipped, [True, True, False, True])
161
162 def testAccrossTwoBlocks(self):
163 """linerange accross two blocks"""
164 # IDX 0 1
165 # 012345678901
166 # SRC NNOOOONNNNNN (New/Old)
167 # ^^^^
168 linerange2 = (1, 5)
169 linerange1, skipped = filteredblocks(self.blocks, linerange2)
170 self.assertEqual(linerange1, (0, 6))
171 self.assertEqual(skipped, [False, False, True, True])
172
173 def testCrossingSeveralBlocks(self):
174 """linerange accross three blocks"""
175 # IDX 0 1
176 # 012345678901
177 # SRC NNOOOONNNNNN (New/Old)
178 # ^^^^^^^
179 linerange2 = (1, 8)
180 linerange1, skipped = filteredblocks(self.blocks, linerange2)
181 self.assertEqual(linerange1, (0, 12))
182 self.assertEqual(skipped, [False, False, False, True])
183
184 def testStartInEqBlock(self):
185 """linerange starting in an "=" block"""
186 # IDX 0 1
187 # 012345678901
188 # SRC NNOOOONNNNNN (New/Old)
189 # ^^^^
190 # ^^^^^^^
191 for linerange2, expectedlinerange1 in [
192 ((5, 9), (6, 12)),
193 ((4, 11), (5, 12)),
194 ]:
195 linerange1, skipped = filteredblocks(self.blocks, linerange2)
196 self.assertEqual(linerange1, expectedlinerange1)
197 self.assertEqual(skipped, [True, False, False, True])
198
199 def testEndInEqBlock(self):
200 """linerange ending in an "=" block"""
201 # IDX 0 1
202 # 012345678901
203 # SRC NNOOOONNNNNN (New/Old)
204 # ^^
205 # ^^^^^
206 for linerange2, expectedlinerange1 in [
207 ((1, 3), (0, 4)),
208 ((0, 4), (0, 5)),
209 ]:
210 linerange1, skipped = filteredblocks(self.blocks, linerange2)
211 self.assertEqual(linerange1, expectedlinerange1)
212 self.assertEqual(skipped, [False, False, True, True])
213
214 def testOutOfRange(self):
215 """linerange exceeding file size"""
216 exctype = error.Abort
217 for linerange2 in [
218 (0, 34),
219 (15, 12),
220 ]:
221 # Could be `with self.assertRaises(error.Abort)` but python2.6
222 # does not have assertRaises context manager.
223 try:
224 mdiff.blocksinrange(self.blocks, linerange2)
225 except exctype as exc:
226 self.assertTrue('line range exceeds file size' in str(exc))
227 else:
228 self.fail('%s not raised' % exctype.__name__)
229
230 if __name__ == '__main__':
231 import silenttestrunner
232 silenttestrunner.main(__name__)
@@ -1,383 +1,422 b''
1 1 # mdiff.py - diff and patch routines for mercurial
2 2 #
3 3 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import re
11 11 import struct
12 12 import zlib
13 13
14 14 from .i18n import _
15 15 from . import (
16 16 base85,
17 17 bdiff,
18 18 error,
19 19 mpatch,
20 20 util,
21 21 )
22 22
23 23 def splitnewlines(text):
24 24 '''like str.splitlines, but only split on newlines.'''
25 25 lines = [l + '\n' for l in text.split('\n')]
26 26 if lines:
27 27 if lines[-1] == '\n':
28 28 lines.pop()
29 29 else:
30 30 lines[-1] = lines[-1][:-1]
31 31 return lines
32 32
33 33 class diffopts(object):
34 34 '''context is the number of context lines
35 35 text treats all files as text
36 36 showfunc enables diff -p output
37 37 git enables the git extended patch format
38 38 nodates removes dates from diff headers
39 39 nobinary ignores binary files
40 40 noprefix disables the 'a/' and 'b/' prefixes (ignored in plain mode)
41 41 ignorews ignores all whitespace changes in the diff
42 42 ignorewsamount ignores changes in the amount of whitespace
43 43 ignoreblanklines ignores changes whose lines are all blank
44 44 upgrade generates git diffs to avoid data loss
45 45 '''
46 46
47 47 defaults = {
48 48 'context': 3,
49 49 'text': False,
50 50 'showfunc': False,
51 51 'git': False,
52 52 'nodates': False,
53 53 'nobinary': False,
54 54 'noprefix': False,
55 55 'ignorews': False,
56 56 'ignorewsamount': False,
57 57 'ignoreblanklines': False,
58 58 'upgrade': False,
59 59 }
60 60
61 61 def __init__(self, **opts):
62 62 for k in self.defaults.keys():
63 63 v = opts.get(k)
64 64 if v is None:
65 65 v = self.defaults[k]
66 66 setattr(self, k, v)
67 67
68 68 try:
69 69 self.context = int(self.context)
70 70 except ValueError:
71 71 raise error.Abort(_('diff context lines count must be '
72 72 'an integer, not %r') % self.context)
73 73
74 74 def copy(self, **kwargs):
75 75 opts = dict((k, getattr(self, k)) for k in self.defaults)
76 76 opts.update(kwargs)
77 77 return diffopts(**opts)
78 78
79 79 defaultopts = diffopts()
80 80
81 81 def wsclean(opts, text, blank=True):
82 82 if opts.ignorews:
83 83 text = bdiff.fixws(text, 1)
84 84 elif opts.ignorewsamount:
85 85 text = bdiff.fixws(text, 0)
86 86 if blank and opts.ignoreblanklines:
87 87 text = re.sub('\n+', '\n', text).strip('\n')
88 88 return text
89 89
90 90 def splitblock(base1, lines1, base2, lines2, opts):
91 91 # The input lines matches except for interwoven blank lines. We
92 92 # transform it into a sequence of matching blocks and blank blocks.
93 93 lines1 = [(wsclean(opts, l) and 1 or 0) for l in lines1]
94 94 lines2 = [(wsclean(opts, l) and 1 or 0) for l in lines2]
95 95 s1, e1 = 0, len(lines1)
96 96 s2, e2 = 0, len(lines2)
97 97 while s1 < e1 or s2 < e2:
98 98 i1, i2, btype = s1, s2, '='
99 99 if (i1 >= e1 or lines1[i1] == 0
100 100 or i2 >= e2 or lines2[i2] == 0):
101 101 # Consume the block of blank lines
102 102 btype = '~'
103 103 while i1 < e1 and lines1[i1] == 0:
104 104 i1 += 1
105 105 while i2 < e2 and lines2[i2] == 0:
106 106 i2 += 1
107 107 else:
108 108 # Consume the matching lines
109 109 while i1 < e1 and lines1[i1] == 1 and lines2[i2] == 1:
110 110 i1 += 1
111 111 i2 += 1
112 112 yield [base1 + s1, base1 + i1, base2 + s2, base2 + i2], btype
113 113 s1 = i1
114 114 s2 = i2
115 115
116 def blocksinrange(blocks, rangeb):
117 """filter `blocks` like (a1, a2, b1, b2) from items outside line range
118 `rangeb` from ``(b1, b2)`` point of view.
119
120 Return `filteredblocks, rangea` where:
121
122 * `filteredblocks` is list of ``block = (a1, a2, b1, b2), stype`` items of
123 `blocks` that are inside `rangeb` from ``(b1, b2)`` point of view; a
124 block ``(b1, b2)`` being inside `rangeb` if
125 ``rangeb[0] < b2 and b1 < rangeb[1]``;
126 * `rangea` is the line range w.r.t. to ``(a1, a2)`` parts of `blocks`.
127 """
128 lbb, ubb = rangeb
129 lba, uba = None, None
130 filteredblocks = []
131 for block in blocks:
132 (a1, a2, b1, b2), stype = block
133 if lbb >= b1 and ubb <= b2 and stype == '=':
134 # rangeb is within a single "=" hunk, restrict back linerange1
135 # by offsetting rangeb
136 lba = lbb - b1 + a1
137 uba = ubb - b1 + a1
138 else:
139 if b1 <= lbb < b2:
140 if stype == '=':
141 lba = a2 - (b2 - lbb)
142 else:
143 lba = a1
144 if b1 < ubb <= b2:
145 if stype == '=':
146 uba = a1 + (ubb - b1)
147 else:
148 uba = a2
149 if lbb < b2 and b1 < ubb:
150 filteredblocks.append(block)
151 if lba is None or uba is None or uba < lba:
152 raise error.Abort(_('line range exceeds file size'))
153 return filteredblocks, (lba, uba)
154
116 155 def allblocks(text1, text2, opts=None, lines1=None, lines2=None):
117 156 """Return (block, type) tuples, where block is an mdiff.blocks
118 157 line entry. type is '=' for blocks matching exactly one another
119 158 (bdiff blocks), '!' for non-matching blocks and '~' for blocks
120 159 matching only after having filtered blank lines.
121 160 line1 and line2 are text1 and text2 split with splitnewlines() if
122 161 they are already available.
123 162 """
124 163 if opts is None:
125 164 opts = defaultopts
126 165 if opts.ignorews or opts.ignorewsamount:
127 166 text1 = wsclean(opts, text1, False)
128 167 text2 = wsclean(opts, text2, False)
129 168 diff = bdiff.blocks(text1, text2)
130 169 for i, s1 in enumerate(diff):
131 170 # The first match is special.
132 171 # we've either found a match starting at line 0 or a match later
133 172 # in the file. If it starts later, old and new below will both be
134 173 # empty and we'll continue to the next match.
135 174 if i > 0:
136 175 s = diff[i - 1]
137 176 else:
138 177 s = [0, 0, 0, 0]
139 178 s = [s[1], s1[0], s[3], s1[2]]
140 179
141 180 # bdiff sometimes gives huge matches past eof, this check eats them,
142 181 # and deals with the special first match case described above
143 182 if s[0] != s[1] or s[2] != s[3]:
144 183 type = '!'
145 184 if opts.ignoreblanklines:
146 185 if lines1 is None:
147 186 lines1 = splitnewlines(text1)
148 187 if lines2 is None:
149 188 lines2 = splitnewlines(text2)
150 189 old = wsclean(opts, "".join(lines1[s[0]:s[1]]))
151 190 new = wsclean(opts, "".join(lines2[s[2]:s[3]]))
152 191 if old == new:
153 192 type = '~'
154 193 yield s, type
155 194 yield s1, '='
156 195
157 196 def unidiff(a, ad, b, bd, fn1, fn2, opts=defaultopts):
158 197 def datetag(date, fn=None):
159 198 if not opts.git and not opts.nodates:
160 199 return '\t%s\n' % date
161 200 if fn and ' ' in fn:
162 201 return '\t\n'
163 202 return '\n'
164 203
165 204 if not a and not b:
166 205 return ""
167 206
168 207 if opts.noprefix:
169 208 aprefix = bprefix = ''
170 209 else:
171 210 aprefix = 'a/'
172 211 bprefix = 'b/'
173 212
174 213 epoch = util.datestr((0, 0))
175 214
176 215 fn1 = util.pconvert(fn1)
177 216 fn2 = util.pconvert(fn2)
178 217
179 218 if not opts.text and (util.binary(a) or util.binary(b)):
180 219 if a and b and len(a) == len(b) and a == b:
181 220 return ""
182 221 l = ['Binary file %s has changed\n' % fn1]
183 222 elif not a:
184 223 b = splitnewlines(b)
185 224 if a is None:
186 225 l1 = '--- /dev/null%s' % datetag(epoch)
187 226 else:
188 227 l1 = "--- %s%s%s" % (aprefix, fn1, datetag(ad, fn1))
189 228 l2 = "+++ %s%s" % (bprefix + fn2, datetag(bd, fn2))
190 229 l3 = "@@ -0,0 +1,%d @@\n" % len(b)
191 230 l = [l1, l2, l3] + ["+" + e for e in b]
192 231 elif not b:
193 232 a = splitnewlines(a)
194 233 l1 = "--- %s%s%s" % (aprefix, fn1, datetag(ad, fn1))
195 234 if b is None:
196 235 l2 = '+++ /dev/null%s' % datetag(epoch)
197 236 else:
198 237 l2 = "+++ %s%s%s" % (bprefix, fn2, datetag(bd, fn2))
199 238 l3 = "@@ -1,%d +0,0 @@\n" % len(a)
200 239 l = [l1, l2, l3] + ["-" + e for e in a]
201 240 else:
202 241 al = splitnewlines(a)
203 242 bl = splitnewlines(b)
204 243 l = list(_unidiff(a, b, al, bl, opts=opts))
205 244 if not l:
206 245 return ""
207 246
208 247 l.insert(0, "--- %s%s%s" % (aprefix, fn1, datetag(ad, fn1)))
209 248 l.insert(1, "+++ %s%s%s" % (bprefix, fn2, datetag(bd, fn2)))
210 249
211 250 for ln in xrange(len(l)):
212 251 if l[ln][-1] != '\n':
213 252 l[ln] += "\n\ No newline at end of file\n"
214 253
215 254 return "".join(l)
216 255
217 256 # creates a headerless unified diff
218 257 # t1 and t2 are the text to be diffed
219 258 # l1 and l2 are the text broken up into lines
220 259 def _unidiff(t1, t2, l1, l2, opts=defaultopts):
221 260 def contextend(l, len):
222 261 ret = l + opts.context
223 262 if ret > len:
224 263 ret = len
225 264 return ret
226 265
227 266 def contextstart(l):
228 267 ret = l - opts.context
229 268 if ret < 0:
230 269 return 0
231 270 return ret
232 271
233 272 lastfunc = [0, '']
234 273 def yieldhunk(hunk):
235 274 (astart, a2, bstart, b2, delta) = hunk
236 275 aend = contextend(a2, len(l1))
237 276 alen = aend - astart
238 277 blen = b2 - bstart + aend - a2
239 278
240 279 func = ""
241 280 if opts.showfunc:
242 281 lastpos, func = lastfunc
243 282 # walk backwards from the start of the context up to the start of
244 283 # the previous hunk context until we find a line starting with an
245 284 # alphanumeric char.
246 285 for i in xrange(astart - 1, lastpos - 1, -1):
247 286 if l1[i][0].isalnum():
248 287 func = ' ' + l1[i].rstrip()[:40]
249 288 lastfunc[1] = func
250 289 break
251 290 # by recording this hunk's starting point as the next place to
252 291 # start looking for function lines, we avoid reading any line in
253 292 # the file more than once.
254 293 lastfunc[0] = astart
255 294
256 295 # zero-length hunk ranges report their start line as one less
257 296 if alen:
258 297 astart += 1
259 298 if blen:
260 299 bstart += 1
261 300
262 301 yield "@@ -%d,%d +%d,%d @@%s\n" % (astart, alen,
263 302 bstart, blen, func)
264 303 for x in delta:
265 304 yield x
266 305 for x in xrange(a2, aend):
267 306 yield ' ' + l1[x]
268 307
269 308 # bdiff.blocks gives us the matching sequences in the files. The loop
270 309 # below finds the spaces between those matching sequences and translates
271 310 # them into diff output.
272 311 #
273 312 hunk = None
274 313 ignoredlines = 0
275 314 for s, stype in allblocks(t1, t2, opts, l1, l2):
276 315 a1, a2, b1, b2 = s
277 316 if stype != '!':
278 317 if stype == '~':
279 318 # The diff context lines are based on t1 content. When
280 319 # blank lines are ignored, the new lines offsets must
281 320 # be adjusted as if equivalent blocks ('~') had the
282 321 # same sizes on both sides.
283 322 ignoredlines += (b2 - b1) - (a2 - a1)
284 323 continue
285 324 delta = []
286 325 old = l1[a1:a2]
287 326 new = l2[b1:b2]
288 327
289 328 b1 -= ignoredlines
290 329 b2 -= ignoredlines
291 330 astart = contextstart(a1)
292 331 bstart = contextstart(b1)
293 332 prev = None
294 333 if hunk:
295 334 # join with the previous hunk if it falls inside the context
296 335 if astart < hunk[1] + opts.context + 1:
297 336 prev = hunk
298 337 astart = hunk[1]
299 338 bstart = hunk[3]
300 339 else:
301 340 for x in yieldhunk(hunk):
302 341 yield x
303 342 if prev:
304 343 # we've joined the previous hunk, record the new ending points.
305 344 hunk[1] = a2
306 345 hunk[3] = b2
307 346 delta = hunk[4]
308 347 else:
309 348 # create a new hunk
310 349 hunk = [astart, a2, bstart, b2, delta]
311 350
312 351 delta[len(delta):] = [' ' + x for x in l1[astart:a1]]
313 352 delta[len(delta):] = ['-' + x for x in old]
314 353 delta[len(delta):] = ['+' + x for x in new]
315 354
316 355 if hunk:
317 356 for x in yieldhunk(hunk):
318 357 yield x
319 358
320 359 def b85diff(to, tn):
321 360 '''print base85-encoded binary diff'''
322 361 def fmtline(line):
323 362 l = len(line)
324 363 if l <= 26:
325 364 l = chr(ord('A') + l - 1)
326 365 else:
327 366 l = chr(l - 26 + ord('a') - 1)
328 367 return '%c%s\n' % (l, base85.b85encode(line, True))
329 368
330 369 def chunk(text, csize=52):
331 370 l = len(text)
332 371 i = 0
333 372 while i < l:
334 373 yield text[i:i + csize]
335 374 i += csize
336 375
337 376 if to is None:
338 377 to = ''
339 378 if tn is None:
340 379 tn = ''
341 380
342 381 if to == tn:
343 382 return ''
344 383
345 384 # TODO: deltas
346 385 ret = []
347 386 ret.append('GIT binary patch\n')
348 387 ret.append('literal %s\n' % len(tn))
349 388 for l in chunk(zlib.compress(tn)):
350 389 ret.append(fmtline(l))
351 390 ret.append('\n')
352 391
353 392 return ''.join(ret)
354 393
355 394 def patchtext(bin):
356 395 pos = 0
357 396 t = []
358 397 while pos < len(bin):
359 398 p1, p2, l = struct.unpack(">lll", bin[pos:pos + 12])
360 399 pos += 12
361 400 t.append(bin[pos:pos + l])
362 401 pos += l
363 402 return "".join(t)
364 403
365 404 def patch(a, bin):
366 405 if len(a) == 0:
367 406 # skip over trivial delta header
368 407 return util.buffer(bin, 12)
369 408 return mpatch.patches(a, [bin])
370 409
371 410 # similar to difflib.SequenceMatcher.get_matching_blocks
372 411 def get_matching_blocks(a, b):
373 412 return [(d[0], d[2], d[1] - d[0]) for d in bdiff.blocks(a, b)]
374 413
375 414 def trivialdiffheader(length):
376 415 return struct.pack(">lll", 0, 0, length) if length else ''
377 416
378 417 def replacediffheader(oldlen, newlen):
379 418 return struct.pack(">lll", 0, oldlen, newlen)
380 419
381 420 patches = mpatch.patches
382 421 patchedsize = mpatch.patchedsize
383 422 textdiff = bdiff.bdiff
General Comments 0
You need to be logged in to leave comments. Login now