##// END OF EJS Templates
patch: stop using the `pycompat.open()` shim
Matt Harbison -
r53276:c921c23a default
parent child Browse files
Show More
@@ -1,3256 +1,3255
1 1 # patch.py - patch file parsing routines
2 2 #
3 3 # Copyright 2006 Brendan Cully <brendan@kublai.com>
4 4 # Copyright 2007 Chris Mason <chris.mason@oracle.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import annotations
10 10
11 11 import collections
12 12 import contextlib
13 13 import copy
14 14 import os
15 15 import re
16 16 import shutil
17 17 import zlib
18 18
19 19 from .i18n import _
20 20 from .node import (
21 21 hex,
22 22 sha1nodeconstants,
23 23 short,
24 24 )
25 from .pycompat import open
26 25 from . import (
27 26 copies,
28 27 diffhelper,
29 28 diffutil,
30 29 encoding,
31 30 error,
32 31 mail,
33 32 mdiff,
34 33 pathutil,
35 34 pycompat,
36 35 scmutil,
37 36 similar,
38 37 util,
39 38 vfs as vfsmod,
40 39 )
41 40 from .utils import (
42 41 dateutil,
43 42 hashutil,
44 43 procutil,
45 44 stringutil,
46 45 )
47 46
48 47 stringio = util.stringio
49 48
50 49 gitre = re.compile(br'diff --git a/(.*) b/(.*)')
51 50 tabsplitter = re.compile(br'(\t+|[^\t]+)')
52 51 wordsplitter = re.compile(
53 52 br'(\t+| +|[a-zA-Z0-9_\x80-\xff]+|[^ \ta-zA-Z0-9_\x80-\xff])'
54 53 )
55 54
56 55 PatchError = error.PatchError
57 56 PatchParseError = error.PatchParseError
58 57 PatchApplicationError = error.PatchApplicationError
59 58
60 59 # public functions
61 60
62 61
63 62 def split(stream):
64 63 '''return an iterator of individual patches from a stream'''
65 64
66 65 def isheader(line, inheader):
67 66 if inheader and line.startswith((b' ', b'\t')):
68 67 # continuation
69 68 return True
70 69 if line.startswith((b' ', b'-', b'+')):
71 70 # diff line - don't check for header pattern in there
72 71 return False
73 72 l = line.split(b': ', 1)
74 73 return len(l) == 2 and b' ' not in l[0]
75 74
76 75 def chunk(lines):
77 76 return stringio(b''.join(lines))
78 77
79 78 def hgsplit(stream, cur):
80 79 inheader = True
81 80
82 81 for line in stream:
83 82 if not line.strip():
84 83 inheader = False
85 84 if not inheader and line.startswith(b'# HG changeset patch'):
86 85 yield chunk(cur)
87 86 cur = []
88 87 inheader = True
89 88
90 89 cur.append(line)
91 90
92 91 if cur:
93 92 yield chunk(cur)
94 93
95 94 def mboxsplit(stream, cur):
96 95 for line in stream:
97 96 if line.startswith(b'From '):
98 97 for c in split(chunk(cur[1:])):
99 98 yield c
100 99 cur = []
101 100
102 101 cur.append(line)
103 102
104 103 if cur:
105 104 for c in split(chunk(cur[1:])):
106 105 yield c
107 106
108 107 def mimesplit(stream, cur):
109 108 def msgfp(m):
110 109 fp = stringio()
111 110 # pytype: disable=wrong-arg-types
112 111 g = mail.Generator(fp, mangle_from_=False)
113 112 # pytype: enable=wrong-arg-types
114 113 g.flatten(m)
115 114 fp.seek(0)
116 115 return fp
117 116
118 117 for line in stream:
119 118 cur.append(line)
120 119 c = chunk(cur)
121 120
122 121 m = mail.parse(c)
123 122 if not m.is_multipart():
124 123 yield msgfp(m)
125 124 else:
126 125 ok_types = ('text/plain', 'text/x-diff', 'text/x-patch')
127 126 for part in m.walk():
128 127 ct = part.get_content_type()
129 128 if ct not in ok_types:
130 129 continue
131 130 yield msgfp(part)
132 131
133 132 def headersplit(stream, cur):
134 133 inheader = False
135 134
136 135 for line in stream:
137 136 if not inheader and isheader(line, inheader):
138 137 yield chunk(cur)
139 138 cur = []
140 139 inheader = True
141 140 if inheader and not isheader(line, inheader):
142 141 inheader = False
143 142
144 143 cur.append(line)
145 144
146 145 if cur:
147 146 yield chunk(cur)
148 147
149 148 def remainder(cur):
150 149 yield chunk(cur)
151 150
152 151 class fiter:
153 152 def __init__(self, fp):
154 153 self.fp = fp
155 154
156 155 def __iter__(self):
157 156 return self
158 157
159 158 def next(self):
160 159 l = self.fp.readline()
161 160 if not l:
162 161 raise StopIteration
163 162 return l
164 163
165 164 __next__ = next
166 165
167 166 inheader = False
168 167 cur = []
169 168
170 169 mimeheaders = [b'content-type']
171 170
172 171 if not hasattr(stream, 'next'):
173 172 # http responses, for example, have readline but not next
174 173 stream = fiter(stream)
175 174
176 175 for line in stream:
177 176 cur.append(line)
178 177 if line.startswith(b'# HG changeset patch'):
179 178 return hgsplit(stream, cur)
180 179 elif line.startswith(b'From '):
181 180 return mboxsplit(stream, cur)
182 181 elif isheader(line, inheader):
183 182 inheader = True
184 183 if line.split(b':', 1)[0].lower() in mimeheaders:
185 184 # let email parser handle this
186 185 return mimesplit(stream, cur)
187 186 elif line.startswith(b'--- ') and inheader:
188 187 # No evil headers seen by diff start, split by hand
189 188 return headersplit(stream, cur)
190 189 # Not enough info, keep reading
191 190
192 191 # if we are here, we have a very plain patch
193 192 return remainder(cur)
194 193
195 194
196 195 ## Some facility for extensible patch parsing:
197 196 # list of pairs ("header to match", "data key")
198 197 patchheadermap = [
199 198 (b'Date', b'date'),
200 199 (b'Branch', b'branch'),
201 200 (b'Node ID', b'nodeid'),
202 201 ]
203 202
204 203
205 204 @contextlib.contextmanager
206 205 def extract(ui, fileobj):
207 206 """extract patch from data read from fileobj.
208 207
209 208 patch can be a normal patch or contained in an email message.
210 209
211 210 return a dictionary. Standard keys are:
212 211 - filename,
213 212 - message,
214 213 - user,
215 214 - date,
216 215 - branch,
217 216 - node,
218 217 - p1,
219 218 - p2.
220 219 Any item can be missing from the dictionary. If filename is missing,
221 220 fileobj did not contain a patch. Caller must unlink filename when done."""
222 221
223 222 fd, tmpname = pycompat.mkstemp(prefix=b'hg-patch-')
224 223 tmpfp = os.fdopen(fd, 'wb')
225 224 try:
226 225 yield _extract(ui, fileobj, tmpname, tmpfp)
227 226 finally:
228 227 tmpfp.close()
229 228 os.unlink(tmpname)
230 229
231 230
232 231 def _extract(ui, fileobj, tmpname, tmpfp):
233 232 # attempt to detect the start of a patch
234 233 # (this heuristic is borrowed from quilt)
235 234 diffre = re.compile(
236 235 br'^(?:Index:[ \t]|diff[ \t]-|RCS file: |'
237 236 br'retrieving revision [0-9]+(\.[0-9]+)*$|'
238 237 br'---[ \t].*?^\+\+\+[ \t]|'
239 238 br'\*\*\*[ \t].*?^---[ \t])',
240 239 re.MULTILINE | re.DOTALL,
241 240 )
242 241
243 242 data = {}
244 243
245 244 msg = mail.parse(fileobj)
246 245
247 246 subject = msg['Subject'] and mail.headdecode(msg['Subject'])
248 247 data[b'user'] = msg['From'] and mail.headdecode(msg['From'])
249 248 if not subject and not data[b'user']:
250 249 # Not an email, restore parsed headers if any
251 250 subject = (
252 251 b'\n'.join(
253 252 b': '.join(map(encoding.strtolocal, h)) for h in msg.items()
254 253 )
255 254 + b'\n'
256 255 )
257 256
258 257 # should try to parse msg['Date']
259 258 parents = []
260 259
261 260 nodeid = msg['X-Mercurial-Node']
262 261 if nodeid:
263 262 data[b'nodeid'] = nodeid = mail.headdecode(nodeid)
264 263 ui.debug(b'Node ID: %s\n' % nodeid)
265 264
266 265 if subject:
267 266 if subject.startswith(b'[PATCH'):
268 267 pend = subject.find(b']')
269 268 if pend >= 0:
270 269 subject = subject[pend + 1 :].lstrip()
271 270 subject = re.sub(br'\n[ \t]+', b' ', subject)
272 271 ui.debug(b'Subject: %s\n' % subject)
273 272 if data[b'user']:
274 273 ui.debug(b'From: %s\n' % data[b'user'])
275 274 diffs_seen = 0
276 275 ok_types = (b'text/plain', b'text/x-diff', b'text/x-patch')
277 276 message = b''
278 277 for part in msg.walk():
279 278 content_type = pycompat.bytestr(part.get_content_type())
280 279 ui.debug(b'Content-Type: %s\n' % content_type)
281 280 if content_type not in ok_types:
282 281 continue
283 282 payload = part.get_payload(decode=True)
284 283 m = diffre.search(payload)
285 284 if m:
286 285 hgpatch = False
287 286 hgpatchheader = False
288 287 ignoretext = False
289 288
290 289 ui.debug(b'found patch at byte %d\n' % m.start(0))
291 290 diffs_seen += 1
292 291 cfp = stringio()
293 292 for line in payload[: m.start(0)].splitlines():
294 293 if line.startswith(b'# HG changeset patch') and not hgpatch:
295 294 ui.debug(b'patch generated by hg export\n')
296 295 hgpatch = True
297 296 hgpatchheader = True
298 297 # drop earlier commit message content
299 298 cfp.seek(0)
300 299 cfp.truncate()
301 300 subject = None
302 301 elif hgpatchheader:
303 302 if line.startswith(b'# User '):
304 303 data[b'user'] = line[7:]
305 304 ui.debug(b'From: %s\n' % data[b'user'])
306 305 elif line.startswith(b"# Parent "):
307 306 parents.append(line[9:].lstrip())
308 307 elif line.startswith(b"# "):
309 308 for header, key in patchheadermap:
310 309 prefix = b'# %s ' % header
311 310 if line.startswith(prefix):
312 311 data[key] = line[len(prefix) :]
313 312 ui.debug(b'%s: %s\n' % (header, data[key]))
314 313 else:
315 314 hgpatchheader = False
316 315 elif line == b'---':
317 316 ignoretext = True
318 317 if not hgpatchheader and not ignoretext:
319 318 cfp.write(line)
320 319 cfp.write(b'\n')
321 320 message = cfp.getvalue()
322 321 if tmpfp:
323 322 tmpfp.write(payload)
324 323 if not payload.endswith(b'\n'):
325 324 tmpfp.write(b'\n')
326 325 elif not diffs_seen and message and content_type == b'text/plain':
327 326 message += b'\n' + payload
328 327
329 328 if subject and not message.startswith(subject):
330 329 message = b'%s\n%s' % (subject, message)
331 330 data[b'message'] = message
332 331 tmpfp.close()
333 332 if parents:
334 333 data[b'p1'] = parents.pop(0)
335 334 if parents:
336 335 data[b'p2'] = parents.pop(0)
337 336
338 337 if diffs_seen:
339 338 data[b'filename'] = tmpname
340 339
341 340 return data
342 341
343 342
344 343 class patchmeta:
345 344 """Patched file metadata
346 345
347 346 'op' is the performed operation within ADD, DELETE, RENAME, MODIFY
348 347 or COPY. 'path' is patched file path. 'oldpath' is set to the
349 348 origin file when 'op' is either COPY or RENAME, None otherwise. If
350 349 file mode is changed, 'mode' is a tuple (islink, isexec) where
351 350 'islink' is True if the file is a symlink and 'isexec' is True if
352 351 the file is executable. Otherwise, 'mode' is None.
353 352 """
354 353
355 354 def __init__(self, path):
356 355 self.path = path
357 356 self.oldpath = None
358 357 self.mode = None
359 358 self.op = b'MODIFY'
360 359 self.binary = False
361 360
362 361 def setmode(self, mode):
363 362 islink = mode & 0o20000
364 363 isexec = mode & 0o100
365 364 self.mode = (islink, isexec)
366 365
367 366 def copy(self):
368 367 other = patchmeta(self.path)
369 368 other.oldpath = self.oldpath
370 369 other.mode = self.mode
371 370 other.op = self.op
372 371 other.binary = self.binary
373 372 return other
374 373
375 374 def _ispatchinga(self, afile):
376 375 if afile == b'/dev/null':
377 376 return self.op == b'ADD'
378 377 return afile == b'a/' + (self.oldpath or self.path)
379 378
380 379 def _ispatchingb(self, bfile):
381 380 if bfile == b'/dev/null':
382 381 return self.op == b'DELETE'
383 382 return bfile == b'b/' + self.path
384 383
385 384 def ispatching(self, afile, bfile):
386 385 return self._ispatchinga(afile) and self._ispatchingb(bfile)
387 386
388 387 def __repr__(self):
389 388 return "<patchmeta %s %r>" % (self.op, self.path)
390 389
391 390
392 391 def readgitpatch(lr):
393 392 """extract git-style metadata about patches from <patchname>"""
394 393
395 394 # Filter patch for git information
396 395 gp = None
397 396 gitpatches = []
398 397 for line in lr:
399 398 line = line.rstrip(b'\r\n')
400 399 if line.startswith(b'diff --git a/'):
401 400 m = gitre.match(line)
402 401 if m:
403 402 if gp:
404 403 gitpatches.append(gp)
405 404 dst = m.group(2)
406 405 gp = patchmeta(dst)
407 406 elif gp:
408 407 if line.startswith(b'--- '):
409 408 gitpatches.append(gp)
410 409 gp = None
411 410 continue
412 411 if line.startswith(b'rename from '):
413 412 gp.op = b'RENAME'
414 413 gp.oldpath = line[12:]
415 414 elif line.startswith(b'rename to '):
416 415 gp.path = line[10:]
417 416 elif line.startswith(b'copy from '):
418 417 gp.op = b'COPY'
419 418 gp.oldpath = line[10:]
420 419 elif line.startswith(b'copy to '):
421 420 gp.path = line[8:]
422 421 elif line.startswith(b'deleted file'):
423 422 gp.op = b'DELETE'
424 423 elif line.startswith(b'new file mode '):
425 424 gp.op = b'ADD'
426 425 gp.setmode(int(line[-6:], 8))
427 426 elif line.startswith(b'new mode '):
428 427 gp.setmode(int(line[-6:], 8))
429 428 elif line.startswith(b'GIT binary patch'):
430 429 gp.binary = True
431 430 if gp:
432 431 gitpatches.append(gp)
433 432
434 433 return gitpatches
435 434
436 435
437 436 class linereader:
438 437 # simple class to allow pushing lines back into the input stream
439 438 def __init__(self, fp):
440 439 self.fp = fp
441 440 self.buf = []
442 441
443 442 def push(self, line):
444 443 if line is not None:
445 444 self.buf.append(line)
446 445
447 446 def readline(self):
448 447 if self.buf:
449 448 l = self.buf[0]
450 449 del self.buf[0]
451 450 return l
452 451 return self.fp.readline()
453 452
454 453 def __iter__(self):
455 454 return iter(self.readline, b'')
456 455
457 456
458 457 class abstractbackend:
459 458 def __init__(self, ui):
460 459 self.ui = ui
461 460
462 461 def getfile(self, fname):
463 462 """Return target file data and flags as a (data, (islink,
464 463 isexec)) tuple. Data is None if file is missing/deleted.
465 464 """
466 465 raise NotImplementedError
467 466
468 467 def setfile(self, fname, data, mode, copysource):
469 468 """Write data to target file fname and set its mode. mode is a
470 469 (islink, isexec) tuple. If data is None, the file content should
471 470 be left unchanged. If the file is modified after being copied,
472 471 copysource is set to the original file name.
473 472 """
474 473 raise NotImplementedError
475 474
476 475 def unlink(self, fname):
477 476 """Unlink target file."""
478 477 raise NotImplementedError
479 478
480 479 def writerej(self, fname, failed, total, lines):
481 480 """Write rejected lines for fname. total is the number of hunks
482 481 which failed to apply and total the total number of hunks for this
483 482 files.
484 483 """
485 484
486 485 def exists(self, fname):
487 486 raise NotImplementedError
488 487
489 488 def close(self):
490 489 raise NotImplementedError
491 490
492 491
493 492 class fsbackend(abstractbackend):
494 493 def __init__(self, ui, basedir):
495 494 super(fsbackend, self).__init__(ui)
496 495 self.opener = vfsmod.vfs(basedir)
497 496
498 497 def getfile(self, fname):
499 498 if self.opener.islink(fname):
500 499 return (self.opener.readlink(fname), (True, False))
501 500
502 501 isexec = False
503 502 try:
504 503 isexec = self.opener.lstat(fname).st_mode & 0o100 != 0
505 504 except FileNotFoundError:
506 505 pass
507 506 try:
508 507 return (self.opener.read(fname), (False, isexec))
509 508 except FileNotFoundError:
510 509 return None, None
511 510
512 511 def setfile(self, fname, data, mode, copysource):
513 512 islink, isexec = mode
514 513 if data is None:
515 514 self.opener.setflags(fname, islink, isexec)
516 515 return
517 516 if islink:
518 517 self.opener.symlink(data, fname)
519 518 else:
520 519 self.opener.write(fname, data)
521 520 if isexec:
522 521 self.opener.setflags(fname, False, True)
523 522
524 523 def unlink(self, fname):
525 524 rmdir = self.ui.configbool(b'experimental', b'removeemptydirs')
526 525 self.opener.unlinkpath(fname, ignoremissing=True, rmdir=rmdir)
527 526
528 527 def writerej(self, fname, failed, total, lines):
529 528 fname = fname + b".rej"
530 529 self.ui.warn(
531 530 _(b"%d out of %d hunks FAILED -- saving rejects to file %s\n")
532 531 % (failed, total, fname)
533 532 )
534 533 fp = self.opener(fname, b'w')
535 534 fp.writelines(lines)
536 535 fp.close()
537 536
538 537 def exists(self, fname):
539 538 return self.opener.lexists(fname)
540 539
541 540
542 541 class workingbackend(fsbackend):
543 542 def __init__(self, ui, repo, similarity):
544 543 super(workingbackend, self).__init__(ui, repo.root)
545 544 self.repo = repo
546 545 self.similarity = similarity
547 546 self.removed = set()
548 547 self.changed = set()
549 548 self.copied = []
550 549
551 550 def _checkknown(self, fname):
552 551 if not self.repo.dirstate.get_entry(fname).any_tracked and self.exists(
553 552 fname
554 553 ):
555 554 raise PatchApplicationError(
556 555 _(b'cannot patch %s: file is not tracked') % fname
557 556 )
558 557
559 558 def setfile(self, fname, data, mode, copysource):
560 559 self._checkknown(fname)
561 560 super(workingbackend, self).setfile(fname, data, mode, copysource)
562 561 if copysource is not None:
563 562 self.copied.append((copysource, fname))
564 563 self.changed.add(fname)
565 564
566 565 def unlink(self, fname):
567 566 self._checkknown(fname)
568 567 super(workingbackend, self).unlink(fname)
569 568 self.removed.add(fname)
570 569 self.changed.add(fname)
571 570
572 571 def close(self):
573 572 with self.repo.dirstate.changing_files(self.repo):
574 573 wctx = self.repo[None]
575 574 changed = set(self.changed)
576 575 for src, dst in self.copied:
577 576 scmutil.dirstatecopy(self.ui, self.repo, wctx, src, dst)
578 577 if self.removed:
579 578 wctx.forget(sorted(self.removed))
580 579 for f in self.removed:
581 580 if f not in self.repo.dirstate:
582 581 # File was deleted and no longer belongs to the
583 582 # dirstate, it was probably marked added then
584 583 # deleted, and should not be considered by
585 584 # marktouched().
586 585 changed.discard(f)
587 586 if changed:
588 587 scmutil.marktouched(self.repo, changed, self.similarity)
589 588 return sorted(self.changed)
590 589
591 590
592 591 class filestore:
593 592 def __init__(self, maxsize=None):
594 593 self.opener = None
595 594 self.files = {}
596 595 self.created = 0
597 596 self.maxsize = maxsize
598 597 if self.maxsize is None:
599 598 self.maxsize = 4 * (2**20)
600 599 self.size = 0
601 600 self.data = {}
602 601
603 602 def setfile(self, fname, data, mode, copied=None):
604 603 if self.maxsize < 0 or (len(data) + self.size) <= self.maxsize:
605 604 self.data[fname] = (data, mode, copied)
606 605 self.size += len(data)
607 606 else:
608 607 if self.opener is None:
609 608 root = pycompat.mkdtemp(prefix=b'hg-patch-')
610 609 self.opener = vfsmod.vfs(root)
611 610 # Avoid filename issues with these simple names
612 611 fn = b'%d' % self.created
613 612 self.opener.write(fn, data)
614 613 self.created += 1
615 614 self.files[fname] = (fn, mode, copied)
616 615
617 616 def getfile(self, fname):
618 617 if fname in self.data:
619 618 return self.data[fname]
620 619 if not self.opener or fname not in self.files:
621 620 return None, None, None
622 621 fn, mode, copied = self.files[fname]
623 622 return self.opener.read(fn), mode, copied
624 623
625 624 def close(self):
626 625 if self.opener:
627 626 shutil.rmtree(self.opener.base)
628 627
629 628
630 629 class repobackend(abstractbackend):
631 630 def __init__(self, ui, repo, ctx, store):
632 631 super(repobackend, self).__init__(ui)
633 632 self.repo = repo
634 633 self.ctx = ctx
635 634 self.store = store
636 635 self.changed = set()
637 636 self.removed = set()
638 637 self.copied = {}
639 638
640 639 def _checkknown(self, fname):
641 640 if fname not in self.ctx:
642 641 raise PatchApplicationError(
643 642 _(b'cannot patch %s: file is not tracked') % fname
644 643 )
645 644
646 645 def getfile(self, fname):
647 646 try:
648 647 fctx = self.ctx[fname]
649 648 except error.LookupError:
650 649 return None, None
651 650 flags = fctx.flags()
652 651 return fctx.data(), (b'l' in flags, b'x' in flags)
653 652
654 653 def setfile(self, fname, data, mode, copysource):
655 654 if copysource:
656 655 self._checkknown(copysource)
657 656 if data is None:
658 657 data = self.ctx[fname].data()
659 658 self.store.setfile(fname, data, mode, copysource)
660 659 self.changed.add(fname)
661 660 if copysource:
662 661 self.copied[fname] = copysource
663 662
664 663 def unlink(self, fname):
665 664 self._checkknown(fname)
666 665 self.removed.add(fname)
667 666
668 667 def exists(self, fname):
669 668 return fname in self.ctx
670 669
671 670 def close(self):
672 671 return self.changed | self.removed
673 672
674 673
675 674 # @@ -start,len +start,len @@ or @@ -start +start @@ if len is 1
676 675 unidesc = re.compile(br'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
677 676 contextdesc = re.compile(br'(?:---|\*\*\*) (\d+)(?:,(\d+))? (?:---|\*\*\*)')
678 677 eolmodes = [b'strict', b'crlf', b'lf', b'auto']
679 678
680 679
681 680 class patchfile:
682 681 def __init__(self, ui, gp, backend, store, eolmode=b'strict'):
683 682 self.fname = gp.path
684 683 self.eolmode = eolmode
685 684 self.eol = None
686 685 self.backend = backend
687 686 self.ui = ui
688 687 self.lines = []
689 688 self.exists = False
690 689 self.missing = True
691 690 self.mode = gp.mode
692 691 self.copysource = gp.oldpath
693 692 self.create = gp.op in (b'ADD', b'COPY', b'RENAME')
694 693 self.remove = gp.op == b'DELETE'
695 694 if self.copysource is None:
696 695 data, mode = backend.getfile(self.fname)
697 696 else:
698 697 data, mode = store.getfile(self.copysource)[:2]
699 698 if data is not None:
700 699 self.exists = self.copysource is None or backend.exists(self.fname)
701 700 self.missing = False
702 701 if data:
703 702 self.lines = mdiff.splitnewlines(data)
704 703 if self.mode is None:
705 704 self.mode = mode
706 705 if self.lines:
707 706 # Normalize line endings
708 707 if self.lines[0].endswith(b'\r\n'):
709 708 self.eol = b'\r\n'
710 709 elif self.lines[0].endswith(b'\n'):
711 710 self.eol = b'\n'
712 711 if eolmode != b'strict':
713 712 nlines = []
714 713 for l in self.lines:
715 714 if l.endswith(b'\r\n'):
716 715 l = l[:-2] + b'\n'
717 716 nlines.append(l)
718 717 self.lines = nlines
719 718 else:
720 719 if self.create:
721 720 self.missing = False
722 721 if self.mode is None:
723 722 self.mode = (False, False)
724 723 if self.missing:
725 724 self.ui.warn(_(b"unable to find '%s' for patching\n") % self.fname)
726 725 self.ui.warn(
727 726 _(
728 727 b"(use '--prefix' to apply patch relative to the "
729 728 b"current directory)\n"
730 729 )
731 730 )
732 731
733 732 self.hash = {}
734 733 self.dirty = 0
735 734 self.offset = 0
736 735 self.skew = 0
737 736 self.rej = []
738 737 self.fileprinted = False
739 738 self.printfile(False)
740 739 self.hunks = 0
741 740
742 741 def writelines(self, fname, lines, mode):
743 742 if self.eolmode == b'auto':
744 743 eol = self.eol
745 744 elif self.eolmode == b'crlf':
746 745 eol = b'\r\n'
747 746 else:
748 747 eol = b'\n'
749 748
750 749 if self.eolmode != b'strict' and eol and eol != b'\n':
751 750 rawlines = []
752 751 for l in lines:
753 752 if l and l.endswith(b'\n'):
754 753 l = l[:-1] + eol
755 754 rawlines.append(l)
756 755 lines = rawlines
757 756
758 757 self.backend.setfile(fname, b''.join(lines), mode, self.copysource)
759 758
760 759 def printfile(self, warn):
761 760 if self.fileprinted:
762 761 return
763 762 if warn or self.ui.verbose:
764 763 self.fileprinted = True
765 764 s = _(b"patching file %s\n") % self.fname
766 765 if warn:
767 766 self.ui.warn(s)
768 767 else:
769 768 self.ui.note(s)
770 769
771 770 def findlines(self, l, linenum):
772 771 # looks through the hash and finds candidate lines. The
773 772 # result is a list of line numbers sorted based on distance
774 773 # from linenum
775 774
776 775 cand = self.hash.get(l, [])
777 776 if len(cand) > 1:
778 777 # resort our list of potentials forward then back.
779 778 cand.sort(key=lambda x: abs(x - linenum))
780 779 return cand
781 780
782 781 def write_rej(self):
783 782 # our rejects are a little different from patch(1). This always
784 783 # creates rejects in the same form as the original patch. A file
785 784 # header is inserted so that you can run the reject through patch again
786 785 # without having to type the filename.
787 786 if not self.rej:
788 787 return
789 788 base = os.path.basename(self.fname)
790 789 lines = [b"--- %s\n+++ %s\n" % (base, base)]
791 790 for x in self.rej:
792 791 for l in x.hunk:
793 792 lines.append(l)
794 793 if l[-1:] != b'\n':
795 794 lines.append(b'\n' + diffhelper.MISSING_NEWLINE_MARKER)
796 795 self.backend.writerej(self.fname, len(self.rej), self.hunks, lines)
797 796
798 797 def apply(self, h):
799 798 if not h.complete():
800 799 raise PatchParseError(
801 800 _(b"bad hunk #%d %s (%d %d %d %d)")
802 801 % (h.number, h.desc, len(h.a), h.lena, len(h.b), h.lenb)
803 802 )
804 803
805 804 self.hunks += 1
806 805
807 806 if self.missing:
808 807 self.rej.append(h)
809 808 return -1
810 809
811 810 if self.exists and self.create:
812 811 if self.copysource:
813 812 self.ui.warn(
814 813 _(b"cannot create %s: destination already exists\n")
815 814 % self.fname
816 815 )
817 816 else:
818 817 self.ui.warn(_(b"file %s already exists\n") % self.fname)
819 818 self.rej.append(h)
820 819 return -1
821 820
822 821 if isinstance(h, binhunk):
823 822 if self.remove:
824 823 self.backend.unlink(self.fname)
825 824 else:
826 825 l = h.new(self.lines)
827 826 self.lines[:] = l
828 827 self.offset += len(l)
829 828 self.dirty = True
830 829 return 0
831 830
832 831 horig = h
833 832 if (
834 833 self.eolmode in (b'crlf', b'lf')
835 834 or self.eolmode == b'auto'
836 835 and self.eol
837 836 ):
838 837 # If new eols are going to be normalized, then normalize
839 838 # hunk data before patching. Otherwise, preserve input
840 839 # line-endings.
841 840 h = h.getnormalized()
842 841
843 842 # fast case first, no offsets, no fuzz
844 843 old, oldstart, new, newstart = h.fuzzit(0, False)
845 844 oldstart += self.offset
846 845 orig_start = oldstart
847 846 # if there's skew we want to emit the "(offset %d lines)" even
848 847 # when the hunk cleanly applies at start + skew, so skip the
849 848 # fast case code
850 849 if self.skew == 0 and diffhelper.testhunk(old, self.lines, oldstart):
851 850 if self.remove:
852 851 self.backend.unlink(self.fname)
853 852 else:
854 853 self.lines[oldstart : oldstart + len(old)] = new
855 854 self.offset += len(new) - len(old)
856 855 self.dirty = True
857 856 return 0
858 857
859 858 # ok, we couldn't match the hunk. Lets look for offsets and fuzz it
860 859 self.hash = {}
861 860 for x, s in enumerate(self.lines):
862 861 self.hash.setdefault(s, []).append(x)
863 862
864 863 for fuzzlen in range(self.ui.configint(b"patch", b"fuzz") + 1):
865 864 for toponly in [True, False]:
866 865 old, oldstart, new, newstart = h.fuzzit(fuzzlen, toponly)
867 866 oldstart = oldstart + self.offset + self.skew
868 867 oldstart = min(oldstart, len(self.lines))
869 868 if old:
870 869 cand = self.findlines(old[0][1:], oldstart)
871 870 else:
872 871 # Only adding lines with no or fuzzed context, just
873 872 # take the skew in account
874 873 cand = [oldstart]
875 874
876 875 for l in cand:
877 876 if not old or diffhelper.testhunk(old, self.lines, l):
878 877 self.lines[l : l + len(old)] = new
879 878 self.offset += len(new) - len(old)
880 879 self.skew = l - orig_start
881 880 self.dirty = True
882 881 offset = l - orig_start - fuzzlen
883 882 if fuzzlen:
884 883 msg = _(
885 884 b"Hunk #%d succeeded at %d "
886 885 b"with fuzz %d "
887 886 b"(offset %d lines).\n"
888 887 )
889 888 self.printfile(True)
890 889 self.ui.warn(
891 890 msg % (h.number, l + 1, fuzzlen, offset)
892 891 )
893 892 else:
894 893 msg = _(
895 894 b"Hunk #%d succeeded at %d "
896 895 b"(offset %d lines).\n"
897 896 )
898 897 self.ui.note(msg % (h.number, l + 1, offset))
899 898 return fuzzlen
900 899 self.printfile(True)
901 900 self.ui.warn(_(b"Hunk #%d FAILED at %d\n") % (h.number, orig_start))
902 901 self.rej.append(horig)
903 902 return -1
904 903
905 904 def close(self):
906 905 if self.dirty:
907 906 self.writelines(self.fname, self.lines, self.mode)
908 907 self.write_rej()
909 908 return len(self.rej)
910 909
911 910
912 911 class header:
913 912 """patch header"""
914 913
915 914 diffgit_re = re.compile(b'diff --git a/(.*) b/(.*)$')
916 915 diff_re = re.compile(b'diff -r .* (.*)$')
917 916 allhunks_re = re.compile(b'(?:index|deleted file) ')
918 917 pretty_re = re.compile(b'(?:new file|deleted file) ')
919 918 special_re = re.compile(b'(?:index|deleted|copy|rename|new mode) ')
920 919 newfile_re = re.compile(b'(?:new file|copy to|rename to)')
921 920
922 921 def __init__(self, header):
923 922 self.header = header
924 923 self.hunks = []
925 924
926 925 def binary(self):
927 926 return any(h.startswith(b'index ') for h in self.header)
928 927
929 928 def pretty(self, fp):
930 929 for h in self.header:
931 930 if h.startswith(b'index '):
932 931 fp.write(_(b'this modifies a binary file (all or nothing)\n'))
933 932 break
934 933 if self.pretty_re.match(h):
935 934 fp.write(h)
936 935 if self.binary():
937 936 fp.write(_(b'this is a binary file\n'))
938 937 break
939 938 if h.startswith(b'---'):
940 939 fp.write(
941 940 _(b'%d hunks, %d lines changed\n')
942 941 % (
943 942 len(self.hunks),
944 943 sum([max(h.added, h.removed) for h in self.hunks]),
945 944 )
946 945 )
947 946 break
948 947 fp.write(h)
949 948
950 949 def write(self, fp):
951 950 fp.write(b''.join(self.header))
952 951
953 952 def allhunks(self):
954 953 return any(self.allhunks_re.match(h) for h in self.header)
955 954
956 955 def files(self):
957 956 match = self.diffgit_re.match(self.header[0])
958 957 if match:
959 958 fromfile, tofile = match.groups()
960 959 if fromfile == tofile:
961 960 return [fromfile]
962 961 return [fromfile, tofile]
963 962 else:
964 963 return self.diff_re.match(self.header[0]).groups()
965 964
966 965 def filename(self):
967 966 return self.files()[-1]
968 967
969 968 def __repr__(self):
970 969 return '<header %s>' % (
971 970 ' '.join(pycompat.rapply(pycompat.fsdecode, self.files()))
972 971 )
973 972
974 973 def isnewfile(self):
975 974 return any(self.newfile_re.match(h) for h in self.header)
976 975
977 976 def special(self):
978 977 # Special files are shown only at the header level and not at the hunk
979 978 # level for example a file that has been deleted is a special file.
980 979 # The user cannot change the content of the operation, in the case of
981 980 # the deleted file he has to take the deletion or not take it, he
982 981 # cannot take some of it.
983 982 # Newly added files are special if they are empty, they are not special
984 983 # if they have some content as we want to be able to change it
985 984 nocontent = len(self.header) == 2
986 985 emptynewfile = self.isnewfile() and nocontent
987 986 return emptynewfile or any(
988 987 self.special_re.match(h) for h in self.header
989 988 )
990 989
991 990
992 991 class recordhunk:
993 992 """patch hunk
994 993
995 994 XXX shouldn't we merge this with the other hunk class?
996 995 """
997 996
998 997 def __init__(
999 998 self,
1000 999 header,
1001 1000 fromline,
1002 1001 toline,
1003 1002 proc,
1004 1003 before,
1005 1004 hunk,
1006 1005 after,
1007 1006 maxcontext=None,
1008 1007 ):
1009 1008 def trimcontext(lines, reverse=False):
1010 1009 if maxcontext is not None:
1011 1010 delta = len(lines) - maxcontext
1012 1011 if delta > 0:
1013 1012 if reverse:
1014 1013 return delta, lines[delta:]
1015 1014 else:
1016 1015 return delta, lines[:maxcontext]
1017 1016 return 0, lines
1018 1017
1019 1018 self.header = header
1020 1019 trimedbefore, self.before = trimcontext(before, True)
1021 1020 self.fromline = fromline + trimedbefore
1022 1021 self.toline = toline + trimedbefore
1023 1022 _trimedafter, self.after = trimcontext(after, False)
1024 1023 self.proc = proc
1025 1024 self.hunk = hunk
1026 1025 self.added, self.removed = self.countchanges(self.hunk)
1027 1026
1028 1027 def __eq__(self, v):
1029 1028 if not isinstance(v, recordhunk):
1030 1029 return False
1031 1030
1032 1031 return (
1033 1032 (v.hunk == self.hunk)
1034 1033 and (v.proc == self.proc)
1035 1034 and (self.fromline == v.fromline)
1036 1035 and (self.header.files() == v.header.files())
1037 1036 )
1038 1037
1039 1038 def __hash__(self):
1040 1039 return hash(
1041 1040 (
1042 1041 tuple(self.hunk),
1043 1042 tuple(self.header.files()),
1044 1043 self.fromline,
1045 1044 self.proc,
1046 1045 )
1047 1046 )
1048 1047
1049 1048 def countchanges(self, hunk):
1050 1049 """hunk -> (n+,n-)"""
1051 1050 add = len([h for h in hunk if h.startswith(b'+')])
1052 1051 rem = len([h for h in hunk if h.startswith(b'-')])
1053 1052 return add, rem
1054 1053
1055 1054 def reversehunk(self):
1056 1055 """return another recordhunk which is the reverse of the hunk
1057 1056
1058 1057 If this hunk is diff(A, B), the returned hunk is diff(B, A). To do
1059 1058 that, swap fromline/toline and +/- signs while keep other things
1060 1059 unchanged.
1061 1060 """
1062 1061 m = {b'+': b'-', b'-': b'+', b'\\': b'\\'}
1063 1062 hunk = [b'%s%s' % (m[l[0:1]], l[1:]) for l in self.hunk]
1064 1063 return recordhunk(
1065 1064 self.header,
1066 1065 self.toline,
1067 1066 self.fromline,
1068 1067 self.proc,
1069 1068 self.before,
1070 1069 hunk,
1071 1070 self.after,
1072 1071 )
1073 1072
1074 1073 def write(self, fp):
1075 1074 delta = len(self.before) + len(self.after)
1076 1075 if self.after and self.after[-1] == diffhelper.MISSING_NEWLINE_MARKER:
1077 1076 delta -= 1
1078 1077 fromlen = delta + self.removed
1079 1078 tolen = delta + self.added
1080 1079 fp.write(
1081 1080 b'@@ -%d,%d +%d,%d @@%s\n'
1082 1081 % (
1083 1082 self.fromline,
1084 1083 fromlen,
1085 1084 self.toline,
1086 1085 tolen,
1087 1086 self.proc and (b' ' + self.proc),
1088 1087 )
1089 1088 )
1090 1089 fp.write(b''.join(self.before + self.hunk + self.after))
1091 1090
1092 1091 pretty = write
1093 1092
1094 1093 def filename(self):
1095 1094 return self.header.filename()
1096 1095
1097 1096 @encoding.strmethod
1098 1097 def __repr__(self):
1099 1098 return b'<hunk %r@%d>' % (self.filename(), self.fromline)
1100 1099
1101 1100
1102 1101 def getmessages():
1103 1102 return {
1104 1103 b'multiple': {
1105 1104 b'apply': _(b"apply change %d/%d to '%s'?"),
1106 1105 b'discard': _(b"discard change %d/%d to '%s'?"),
1107 1106 b'keep': _(b"keep change %d/%d to '%s'?"),
1108 1107 b'record': _(b"record change %d/%d to '%s'?"),
1109 1108 },
1110 1109 b'single': {
1111 1110 b'apply': _(b"apply this change to '%s'?"),
1112 1111 b'discard': _(b"discard this change to '%s'?"),
1113 1112 b'keep': _(b"keep this change to '%s'?"),
1114 1113 b'record': _(b"record this change to '%s'?"),
1115 1114 },
1116 1115 b'help': {
1117 1116 b'apply': _(
1118 1117 b'[Ynesfdaq?]'
1119 1118 b'$$ &Yes, apply this change'
1120 1119 b'$$ &No, skip this change'
1121 1120 b'$$ &Edit this change manually'
1122 1121 b'$$ &Skip remaining changes to this file'
1123 1122 b'$$ Apply remaining changes to this &file'
1124 1123 b'$$ &Done, skip remaining changes and files'
1125 1124 b'$$ Apply &all changes to all remaining files'
1126 1125 b'$$ &Quit, applying no changes'
1127 1126 b'$$ &? (display help)'
1128 1127 ),
1129 1128 b'discard': _(
1130 1129 b'[Ynesfdaq?]'
1131 1130 b'$$ &Yes, discard this change'
1132 1131 b'$$ &No, skip this change'
1133 1132 b'$$ &Edit this change manually'
1134 1133 b'$$ &Skip remaining changes to this file'
1135 1134 b'$$ Discard remaining changes to this &file'
1136 1135 b'$$ &Done, skip remaining changes and files'
1137 1136 b'$$ Discard &all changes to all remaining files'
1138 1137 b'$$ &Quit, discarding no changes'
1139 1138 b'$$ &? (display help)'
1140 1139 ),
1141 1140 b'keep': _(
1142 1141 b'[Ynesfdaq?]'
1143 1142 b'$$ &Yes, keep this change'
1144 1143 b'$$ &No, skip this change'
1145 1144 b'$$ &Edit this change manually'
1146 1145 b'$$ &Skip remaining changes to this file'
1147 1146 b'$$ Keep remaining changes to this &file'
1148 1147 b'$$ &Done, skip remaining changes and files'
1149 1148 b'$$ Keep &all changes to all remaining files'
1150 1149 b'$$ &Quit, keeping all changes'
1151 1150 b'$$ &? (display help)'
1152 1151 ),
1153 1152 b'record': _(
1154 1153 b'[Ynesfdaq?]'
1155 1154 b'$$ &Yes, record this change'
1156 1155 b'$$ &No, skip this change'
1157 1156 b'$$ &Edit this change manually'
1158 1157 b'$$ &Skip remaining changes to this file'
1159 1158 b'$$ Record remaining changes to this &file'
1160 1159 b'$$ &Done, skip remaining changes and files'
1161 1160 b'$$ Record &all changes to all remaining files'
1162 1161 b'$$ &Quit, recording no changes'
1163 1162 b'$$ &? (display help)'
1164 1163 ),
1165 1164 },
1166 1165 }
1167 1166
1168 1167
1169 1168 def filterpatch(ui, headers, match, operation=None):
1170 1169 """Interactively filter patch chunks into applied-only chunks"""
1171 1170 messages = getmessages()
1172 1171
1173 1172 if operation is None:
1174 1173 operation = b'record'
1175 1174
1176 1175 def prompt(skipfile, skipall, query, chunk):
1177 1176 """prompt query, and process base inputs
1178 1177
1179 1178 - y/n for the rest of file
1180 1179 - y/n for the rest
1181 1180 - ? (help)
1182 1181 - q (quit)
1183 1182
1184 1183 Return True/False and possibly updated skipfile and skipall.
1185 1184 """
1186 1185 newpatches = None
1187 1186 if skipall is not None:
1188 1187 return skipall, skipfile, skipall, newpatches
1189 1188 if skipfile is not None:
1190 1189 return skipfile, skipfile, skipall, newpatches
1191 1190 while True:
1192 1191 resps = messages[b'help'][operation]
1193 1192 # IMPORTANT: keep the last line of this prompt short (<40 english
1194 1193 # chars is a good target) because of issue6158.
1195 1194 r = ui.promptchoice(b"%s\n(enter ? for help) %s" % (query, resps))
1196 1195 ui.write(b"\n")
1197 1196 if r == 8: # ?
1198 1197 for c, t in ui.extractchoices(resps)[1]:
1199 1198 ui.write(b'%s - %s\n' % (c, encoding.lower(t)))
1200 1199 continue
1201 1200 elif r == 0: # yes
1202 1201 ret = True
1203 1202 elif r == 1: # no
1204 1203 ret = False
1205 1204 elif r == 2: # Edit patch
1206 1205 if chunk is None:
1207 1206 ui.write(_(b'cannot edit patch for whole file'))
1208 1207 ui.write(b"\n")
1209 1208 continue
1210 1209 if chunk.header.binary():
1211 1210 ui.write(_(b'cannot edit patch for binary file'))
1212 1211 ui.write(b"\n")
1213 1212 continue
1214 1213 # Patch comment based on the Git one (based on comment at end of
1215 1214 # https://mercurial-scm.org/wiki/RecordExtension)
1216 1215 phelp = b'---' + _(
1217 1216 b"""
1218 1217 To remove '-' lines, make them ' ' lines (context).
1219 1218 To remove '+' lines, delete them.
1220 1219 Lines starting with # will be removed from the patch.
1221 1220
1222 1221 If the patch applies cleanly, the edited hunk will immediately be
1223 1222 added to the record list. If it does not apply cleanly, a rejects
1224 1223 file will be generated: you can use that when you try again. If
1225 1224 all lines of the hunk are removed, then the edit is aborted and
1226 1225 the hunk is left unchanged.
1227 1226 """
1228 1227 )
1229 1228 (patchfd, patchfn) = pycompat.mkstemp(
1230 1229 prefix=b"hg-editor-", suffix=b".diff"
1231 1230 )
1232 1231 ncpatchfp = None
1233 1232 try:
1234 1233 # Write the initial patch
1235 1234 f = util.nativeeolwriter(os.fdopen(patchfd, 'wb'))
1236 1235 chunk.header.write(f)
1237 1236 chunk.write(f)
1238 1237 f.write(
1239 1238 b''.join(
1240 1239 [b'# ' + i + b'\n' for i in phelp.splitlines()]
1241 1240 )
1242 1241 )
1243 1242 f.close()
1244 1243 # Start the editor and wait for it to complete
1245 1244 editor = ui.geteditor()
1246 1245 ret = ui.system(
1247 1246 b"%s \"%s\"" % (editor, patchfn),
1248 1247 environ={b'HGUSER': ui.username()},
1249 1248 blockedtag=b'filterpatch',
1250 1249 )
1251 1250 if ret != 0:
1252 1251 ui.warn(_(b"editor exited with exit code %d\n") % ret)
1253 1252 continue
1254 1253 # Remove comment lines
1255 1254 patchfp = open(patchfn, 'rb')
1256 1255 ncpatchfp = stringio()
1257 1256 for line in patchfp:
1258 1257 line = util.fromnativeeol(line)
1259 1258 if not line.startswith(b'#'):
1260 1259 ncpatchfp.write(line)
1261 1260 patchfp.close()
1262 1261 ncpatchfp.seek(0)
1263 1262 newpatches = parsepatch(ncpatchfp)
1264 1263 finally:
1265 1264 os.unlink(patchfn)
1266 1265 del ncpatchfp
1267 1266 # Signal that the chunk shouldn't be applied as-is, but
1268 1267 # provide the new patch to be used instead.
1269 1268 ret = False
1270 1269 elif r == 3: # Skip
1271 1270 ret = skipfile = False
1272 1271 elif r == 4: # file (Record remaining)
1273 1272 ret = skipfile = True
1274 1273 elif r == 5: # done, skip remaining
1275 1274 ret = skipall = False
1276 1275 elif r == 6: # all
1277 1276 ret = skipall = True
1278 1277 elif r == 7: # quit
1279 1278 raise error.CanceledError(_(b'user quit'))
1280 1279 return ret, skipfile, skipall, newpatches
1281 1280
1282 1281 seen = set()
1283 1282 applied = {} # 'filename' -> [] of chunks
1284 1283 skipfile, skipall = None, None
1285 1284 pos, total = 1, sum(len(h.hunks) for h in headers)
1286 1285 for h in headers:
1287 1286 pos += len(h.hunks)
1288 1287 skipfile = None
1289 1288 fixoffset = 0
1290 1289 hdr = b''.join(h.header)
1291 1290 if hdr in seen:
1292 1291 continue
1293 1292 seen.add(hdr)
1294 1293 if skipall is None:
1295 1294 h.pretty(ui)
1296 1295 files = h.files()
1297 1296 msg = _(b'examine changes to %s?') % _(b' and ').join(
1298 1297 b"'%s'" % f for f in files
1299 1298 )
1300 1299 if all(match.exact(f) for f in files):
1301 1300 r, skipall, np = True, None, None
1302 1301 else:
1303 1302 r, skipfile, skipall, np = prompt(skipfile, skipall, msg, None)
1304 1303 if not r:
1305 1304 continue
1306 1305 applied[h.filename()] = [h]
1307 1306 if h.allhunks():
1308 1307 applied[h.filename()] += h.hunks
1309 1308 continue
1310 1309 for i, chunk in enumerate(h.hunks):
1311 1310 if skipfile is None and skipall is None:
1312 1311 chunk.pretty(ui)
1313 1312 if total == 1:
1314 1313 msg = messages[b'single'][operation] % chunk.filename()
1315 1314 else:
1316 1315 idx = pos - len(h.hunks) + i
1317 1316 msg = messages[b'multiple'][operation] % (
1318 1317 idx,
1319 1318 total,
1320 1319 chunk.filename(),
1321 1320 )
1322 1321 r, skipfile, skipall, newpatches = prompt(
1323 1322 skipfile, skipall, msg, chunk
1324 1323 )
1325 1324 if r:
1326 1325 if fixoffset:
1327 1326 chunk = copy.copy(chunk)
1328 1327 chunk.toline += fixoffset
1329 1328 applied[chunk.filename()].append(chunk)
1330 1329 elif newpatches is not None:
1331 1330 for newpatch in newpatches:
1332 1331 for newhunk in newpatch.hunks:
1333 1332 if fixoffset:
1334 1333 newhunk.toline += fixoffset
1335 1334 applied[newhunk.filename()].append(newhunk)
1336 1335 else:
1337 1336 fixoffset += chunk.removed - chunk.added
1338 1337 return (
1339 1338 sum(
1340 1339 [h for h in applied.values() if h[0].special() or len(h) > 1],
1341 1340 [],
1342 1341 ),
1343 1342 {},
1344 1343 )
1345 1344
1346 1345
1347 1346 class hunk:
1348 1347 def __init__(self, desc, num, lr, context):
1349 1348 self.number = num
1350 1349 self.desc = desc
1351 1350 self.hunk = [desc]
1352 1351 self.a = []
1353 1352 self.b = []
1354 1353 self.starta = self.lena = None
1355 1354 self.startb = self.lenb = None
1356 1355 if lr is not None:
1357 1356 if context:
1358 1357 self.read_context_hunk(lr)
1359 1358 else:
1360 1359 self.read_unified_hunk(lr)
1361 1360
1362 1361 def getnormalized(self):
1363 1362 """Return a copy with line endings normalized to LF."""
1364 1363
1365 1364 def normalize(lines):
1366 1365 nlines = []
1367 1366 for line in lines:
1368 1367 if line.endswith(b'\r\n'):
1369 1368 line = line[:-2] + b'\n'
1370 1369 nlines.append(line)
1371 1370 return nlines
1372 1371
1373 1372 # Dummy object, it is rebuilt manually
1374 1373 nh = hunk(self.desc, self.number, None, None)
1375 1374 nh.number = self.number
1376 1375 nh.desc = self.desc
1377 1376 nh.hunk = self.hunk
1378 1377 nh.a = normalize(self.a)
1379 1378 nh.b = normalize(self.b)
1380 1379 nh.starta = self.starta
1381 1380 nh.startb = self.startb
1382 1381 nh.lena = self.lena
1383 1382 nh.lenb = self.lenb
1384 1383 return nh
1385 1384
1386 1385 def read_unified_hunk(self, lr):
1387 1386 m = unidesc.match(self.desc)
1388 1387 if not m:
1389 1388 raise PatchParseError(_(b"bad hunk #%d") % self.number)
1390 1389 self.starta, self.lena, self.startb, self.lenb = m.groups()
1391 1390 if self.lena is None:
1392 1391 self.lena = 1
1393 1392 else:
1394 1393 self.lena = int(self.lena)
1395 1394 if self.lenb is None:
1396 1395 self.lenb = 1
1397 1396 else:
1398 1397 self.lenb = int(self.lenb)
1399 1398 self.starta = int(self.starta)
1400 1399 self.startb = int(self.startb)
1401 1400 try:
1402 1401 diffhelper.addlines(
1403 1402 lr, self.hunk, self.lena, self.lenb, self.a, self.b
1404 1403 )
1405 1404 except error.ParseError as e:
1406 1405 raise PatchParseError(_(b"bad hunk #%d: %s") % (self.number, e))
1407 1406 # if we hit eof before finishing out the hunk, the last line will
1408 1407 # be zero length. Lets try to fix it up.
1409 1408 while len(self.hunk[-1]) == 0:
1410 1409 del self.hunk[-1]
1411 1410 del self.a[-1]
1412 1411 del self.b[-1]
1413 1412 self.lena -= 1
1414 1413 self.lenb -= 1
1415 1414 self._fixnewline(lr)
1416 1415
1417 1416 def read_context_hunk(self, lr):
1418 1417 self.desc = lr.readline()
1419 1418 m = contextdesc.match(self.desc)
1420 1419 if not m:
1421 1420 raise PatchParseError(_(b"bad hunk #%d") % self.number)
1422 1421 self.starta, aend = m.groups()
1423 1422 self.starta = int(self.starta)
1424 1423 if aend is None:
1425 1424 aend = self.starta
1426 1425 self.lena = int(aend) - self.starta
1427 1426 if self.starta:
1428 1427 self.lena += 1
1429 1428 for x in range(self.lena):
1430 1429 l = lr.readline()
1431 1430 if l.startswith(b'---'):
1432 1431 # lines addition, old block is empty
1433 1432 lr.push(l)
1434 1433 break
1435 1434 s = l[2:]
1436 1435 if l.startswith(b'- ') or l.startswith(b'! '):
1437 1436 u = b'-' + s
1438 1437 elif l.startswith(b' '):
1439 1438 u = b' ' + s
1440 1439 else:
1441 1440 raise PatchParseError(
1442 1441 _(b"bad hunk #%d old text line %d") % (self.number, x)
1443 1442 )
1444 1443 self.a.append(u)
1445 1444 self.hunk.append(u)
1446 1445
1447 1446 l = lr.readline()
1448 1447 if l.startswith(br'\ '):
1449 1448 s = self.a[-1][:-1]
1450 1449 self.a[-1] = s
1451 1450 self.hunk[-1] = s
1452 1451 l = lr.readline()
1453 1452 m = contextdesc.match(l)
1454 1453 if not m:
1455 1454 raise PatchParseError(_(b"bad hunk #%d") % self.number)
1456 1455 self.startb, bend = m.groups()
1457 1456 self.startb = int(self.startb)
1458 1457 if bend is None:
1459 1458 bend = self.startb
1460 1459 self.lenb = int(bend) - self.startb
1461 1460 if self.startb:
1462 1461 self.lenb += 1
1463 1462 hunki = 1
1464 1463 for x in range(self.lenb):
1465 1464 l = lr.readline()
1466 1465 if l.startswith(br'\ '):
1467 1466 # XXX: the only way to hit this is with an invalid line range.
1468 1467 # The no-eol marker is not counted in the line range, but I
1469 1468 # guess there are diff(1) out there which behave differently.
1470 1469 s = self.b[-1][:-1]
1471 1470 self.b[-1] = s
1472 1471 self.hunk[hunki - 1] = s
1473 1472 continue
1474 1473 if not l:
1475 1474 # line deletions, new block is empty and we hit EOF
1476 1475 lr.push(l)
1477 1476 break
1478 1477 s = l[2:]
1479 1478 if l.startswith(b'+ ') or l.startswith(b'! '):
1480 1479 u = b'+' + s
1481 1480 elif l.startswith(b' '):
1482 1481 u = b' ' + s
1483 1482 elif len(self.b) == 0:
1484 1483 # line deletions, new block is empty
1485 1484 lr.push(l)
1486 1485 break
1487 1486 else:
1488 1487 raise PatchParseError(
1489 1488 _(b"bad hunk #%d old text line %d") % (self.number, x)
1490 1489 )
1491 1490 self.b.append(s)
1492 1491 while True:
1493 1492 if hunki >= len(self.hunk):
1494 1493 h = b""
1495 1494 else:
1496 1495 h = self.hunk[hunki]
1497 1496 hunki += 1
1498 1497 if h == u:
1499 1498 break
1500 1499 elif h.startswith(b'-'):
1501 1500 continue
1502 1501 else:
1503 1502 self.hunk.insert(hunki - 1, u)
1504 1503 break
1505 1504
1506 1505 if not self.a:
1507 1506 # this happens when lines were only added to the hunk
1508 1507 for x in self.hunk:
1509 1508 if x.startswith(b'-') or x.startswith(b' '):
1510 1509 self.a.append(x)
1511 1510 if not self.b:
1512 1511 # this happens when lines were only deleted from the hunk
1513 1512 for x in self.hunk:
1514 1513 if x.startswith(b'+') or x.startswith(b' '):
1515 1514 self.b.append(x[1:])
1516 1515 # @@ -start,len +start,len @@
1517 1516 self.desc = b"@@ -%d,%d +%d,%d @@\n" % (
1518 1517 self.starta,
1519 1518 self.lena,
1520 1519 self.startb,
1521 1520 self.lenb,
1522 1521 )
1523 1522 self.hunk[0] = self.desc
1524 1523 self._fixnewline(lr)
1525 1524
1526 1525 def _fixnewline(self, lr):
1527 1526 l = lr.readline()
1528 1527 if l.startswith(br'\ '):
1529 1528 diffhelper.fixnewline(self.hunk, self.a, self.b)
1530 1529 else:
1531 1530 lr.push(l)
1532 1531
1533 1532 def complete(self):
1534 1533 return len(self.a) == self.lena and len(self.b) == self.lenb
1535 1534
1536 1535 def _fuzzit(self, old, new, fuzz, toponly):
1537 1536 # this removes context lines from the top and bottom of list 'l'. It
1538 1537 # checks the hunk to make sure only context lines are removed, and then
1539 1538 # returns a new shortened list of lines.
1540 1539 fuzz = min(fuzz, len(old))
1541 1540 if fuzz:
1542 1541 top = 0
1543 1542 bot = 0
1544 1543 hlen = len(self.hunk)
1545 1544 for x in range(hlen - 1):
1546 1545 # the hunk starts with the @@ line, so use x+1
1547 1546 if self.hunk[x + 1].startswith(b' '):
1548 1547 top += 1
1549 1548 else:
1550 1549 break
1551 1550 if not toponly:
1552 1551 for x in range(hlen - 1):
1553 1552 if self.hunk[hlen - bot - 1].startswith(b' '):
1554 1553 bot += 1
1555 1554 else:
1556 1555 break
1557 1556
1558 1557 bot = min(fuzz, bot)
1559 1558 top = min(fuzz, top)
1560 1559 return old[top : len(old) - bot], new[top : len(new) - bot], top
1561 1560 return old, new, 0
1562 1561
1563 1562 def fuzzit(self, fuzz, toponly):
1564 1563 old, new, top = self._fuzzit(self.a, self.b, fuzz, toponly)
1565 1564 oldstart = self.starta + top
1566 1565 newstart = self.startb + top
1567 1566 # zero length hunk ranges already have their start decremented
1568 1567 if self.lena and oldstart > 0:
1569 1568 oldstart -= 1
1570 1569 if self.lenb and newstart > 0:
1571 1570 newstart -= 1
1572 1571 return old, oldstart, new, newstart
1573 1572
1574 1573
1575 1574 class binhunk:
1576 1575 """A binary patch file."""
1577 1576
1578 1577 def __init__(self, lr, fname):
1579 1578 self.text = None
1580 1579 self.delta = False
1581 1580 self.hunk = [b'GIT binary patch\n']
1582 1581 self._fname = fname
1583 1582 self._read(lr)
1584 1583
1585 1584 def complete(self):
1586 1585 return self.text is not None
1587 1586
1588 1587 def new(self, lines):
1589 1588 if self.delta:
1590 1589 return [applybindelta(self.text, b''.join(lines))]
1591 1590 return [self.text]
1592 1591
1593 1592 def _read(self, lr):
1594 1593 def getline(lr, hunk):
1595 1594 l = lr.readline()
1596 1595 hunk.append(l)
1597 1596 return l.rstrip(b'\r\n')
1598 1597
1599 1598 while True:
1600 1599 line = getline(lr, self.hunk)
1601 1600 if not line:
1602 1601 raise PatchParseError(
1603 1602 _(b'could not extract "%s" binary data') % self._fname
1604 1603 )
1605 1604 if line.startswith(b'literal '):
1606 1605 size = int(line[8:].rstrip())
1607 1606 break
1608 1607 if line.startswith(b'delta '):
1609 1608 size = int(line[6:].rstrip())
1610 1609 self.delta = True
1611 1610 break
1612 1611 dec = []
1613 1612 line = getline(lr, self.hunk)
1614 1613 while len(line) > 1:
1615 1614 l = line[0:1]
1616 1615 if l <= b'Z' and l >= b'A':
1617 1616 l = ord(l) - ord(b'A') + 1
1618 1617 else:
1619 1618 l = ord(l) - ord(b'a') + 27
1620 1619 try:
1621 1620 dec.append(util.b85decode(line[1:])[:l])
1622 1621 except ValueError as e:
1623 1622 raise PatchParseError(
1624 1623 _(b'could not decode "%s" binary patch: %s')
1625 1624 % (self._fname, stringutil.forcebytestr(e))
1626 1625 )
1627 1626 line = getline(lr, self.hunk)
1628 1627 text = zlib.decompress(b''.join(dec))
1629 1628 if len(text) != size:
1630 1629 raise PatchParseError(
1631 1630 _(b'"%s" length is %d bytes, should be %d')
1632 1631 % (self._fname, len(text), size)
1633 1632 )
1634 1633 self.text = text
1635 1634
1636 1635
1637 1636 def parsefilename(str):
1638 1637 # --- filename \t|space stuff
1639 1638 s = str[4:].rstrip(b'\r\n')
1640 1639 i = s.find(b'\t')
1641 1640 if i < 0:
1642 1641 i = s.find(b' ')
1643 1642 if i < 0:
1644 1643 return s
1645 1644 return s[:i]
1646 1645
1647 1646
1648 1647 def reversehunks(hunks):
1649 1648 '''reverse the signs in the hunks given as argument
1650 1649
1651 1650 This function operates on hunks coming out of patch.filterpatch, that is
1652 1651 a list of the form: [header1, hunk1, hunk2, header2...]. Example usage:
1653 1652
1654 1653 >>> rawpatch = b"""diff --git a/folder1/g b/folder1/g
1655 1654 ... --- a/folder1/g
1656 1655 ... +++ b/folder1/g
1657 1656 ... @@ -1,7 +1,7 @@
1658 1657 ... +firstline
1659 1658 ... c
1660 1659 ... 1
1661 1660 ... 2
1662 1661 ... + 3
1663 1662 ... -4
1664 1663 ... 5
1665 1664 ... d
1666 1665 ... +lastline"""
1667 1666 >>> hunks = parsepatch([rawpatch])
1668 1667 >>> hunkscomingfromfilterpatch = []
1669 1668 >>> for h in hunks:
1670 1669 ... hunkscomingfromfilterpatch.append(h)
1671 1670 ... hunkscomingfromfilterpatch.extend(h.hunks)
1672 1671
1673 1672 >>> reversedhunks = reversehunks(hunkscomingfromfilterpatch)
1674 1673 >>> from . import util
1675 1674 >>> fp = util.stringio()
1676 1675 >>> for c in reversedhunks:
1677 1676 ... c.write(fp)
1678 1677 >>> fp.seek(0) or None
1679 1678 >>> reversedpatch = fp.read()
1680 1679 >>> print(pycompat.sysstr(reversedpatch))
1681 1680 diff --git a/folder1/g b/folder1/g
1682 1681 --- a/folder1/g
1683 1682 +++ b/folder1/g
1684 1683 @@ -1,4 +1,3 @@
1685 1684 -firstline
1686 1685 c
1687 1686 1
1688 1687 2
1689 1688 @@ -2,6 +1,6 @@
1690 1689 c
1691 1690 1
1692 1691 2
1693 1692 - 3
1694 1693 +4
1695 1694 5
1696 1695 d
1697 1696 @@ -6,3 +5,2 @@
1698 1697 5
1699 1698 d
1700 1699 -lastline
1701 1700
1702 1701 '''
1703 1702
1704 1703 newhunks = []
1705 1704 for c in hunks:
1706 1705 if hasattr(c, 'reversehunk'):
1707 1706 c = c.reversehunk()
1708 1707 newhunks.append(c)
1709 1708 return newhunks
1710 1709
1711 1710
1712 1711 def parsepatch(originalchunks, maxcontext=None):
1713 1712 """patch -> [] of headers -> [] of hunks
1714 1713
1715 1714 If maxcontext is not None, trim context lines if necessary.
1716 1715
1717 1716 >>> rawpatch = b'''diff --git a/folder1/g b/folder1/g
1718 1717 ... --- a/folder1/g
1719 1718 ... +++ b/folder1/g
1720 1719 ... @@ -1,8 +1,10 @@
1721 1720 ... 1
1722 1721 ... 2
1723 1722 ... -3
1724 1723 ... 4
1725 1724 ... 5
1726 1725 ... 6
1727 1726 ... +6.1
1728 1727 ... +6.2
1729 1728 ... 7
1730 1729 ... 8
1731 1730 ... +9'''
1732 1731 >>> out = util.stringio()
1733 1732 >>> headers = parsepatch([rawpatch], maxcontext=1)
1734 1733 >>> for header in headers:
1735 1734 ... header.write(out)
1736 1735 ... for hunk in header.hunks:
1737 1736 ... hunk.write(out)
1738 1737 >>> print(pycompat.sysstr(out.getvalue()))
1739 1738 diff --git a/folder1/g b/folder1/g
1740 1739 --- a/folder1/g
1741 1740 +++ b/folder1/g
1742 1741 @@ -2,3 +2,2 @@
1743 1742 2
1744 1743 -3
1745 1744 4
1746 1745 @@ -6,2 +5,4 @@
1747 1746 6
1748 1747 +6.1
1749 1748 +6.2
1750 1749 7
1751 1750 @@ -8,1 +9,2 @@
1752 1751 8
1753 1752 +9
1754 1753 """
1755 1754
1756 1755 class parser:
1757 1756 """patch parsing state machine"""
1758 1757
1759 1758 def __init__(self):
1760 1759 self.fromline = 0
1761 1760 self.toline = 0
1762 1761 self.proc = b''
1763 1762 self.header = None
1764 1763 self.context = []
1765 1764 self.before = []
1766 1765 self.hunk = []
1767 1766 self.headers = []
1768 1767
1769 1768 def addrange(self, limits):
1770 1769 self.addcontext([])
1771 1770 fromstart, fromend, tostart, toend, proc = limits
1772 1771 self.fromline = int(fromstart)
1773 1772 self.toline = int(tostart)
1774 1773 self.proc = proc
1775 1774
1776 1775 def addcontext(self, context):
1777 1776 if self.hunk:
1778 1777 h = recordhunk(
1779 1778 self.header,
1780 1779 self.fromline,
1781 1780 self.toline,
1782 1781 self.proc,
1783 1782 self.before,
1784 1783 self.hunk,
1785 1784 context,
1786 1785 maxcontext,
1787 1786 )
1788 1787 self.header.hunks.append(h)
1789 1788 self.fromline += len(self.before) + h.removed
1790 1789 self.toline += len(self.before) + h.added
1791 1790 self.before = []
1792 1791 self.hunk = []
1793 1792 self.context = context
1794 1793
1795 1794 def addhunk(self, hunk):
1796 1795 if self.context:
1797 1796 self.before = self.context
1798 1797 self.context = []
1799 1798 if self.hunk:
1800 1799 self.addcontext([])
1801 1800 self.hunk = hunk
1802 1801
1803 1802 def newfile(self, hdr):
1804 1803 self.addcontext([])
1805 1804 h = header(hdr)
1806 1805 self.headers.append(h)
1807 1806 self.header = h
1808 1807
1809 1808 def addother(self, line):
1810 1809 pass # 'other' lines are ignored
1811 1810
1812 1811 def finished(self):
1813 1812 self.addcontext([])
1814 1813 return self.headers
1815 1814
1816 1815 transitions = {
1817 1816 b'file': {
1818 1817 b'context': addcontext,
1819 1818 b'file': newfile,
1820 1819 b'hunk': addhunk,
1821 1820 b'range': addrange,
1822 1821 },
1823 1822 b'context': {
1824 1823 b'file': newfile,
1825 1824 b'hunk': addhunk,
1826 1825 b'range': addrange,
1827 1826 b'other': addother,
1828 1827 },
1829 1828 b'hunk': {
1830 1829 b'context': addcontext,
1831 1830 b'file': newfile,
1832 1831 b'range': addrange,
1833 1832 },
1834 1833 b'range': {b'context': addcontext, b'hunk': addhunk},
1835 1834 b'other': {b'other': addother},
1836 1835 }
1837 1836
1838 1837 p = parser()
1839 1838 fp = stringio()
1840 1839 fp.write(b''.join(originalchunks))
1841 1840 fp.seek(0)
1842 1841
1843 1842 state = b'context'
1844 1843 for newstate, data in scanpatch(fp):
1845 1844 try:
1846 1845 p.transitions[state][newstate](p, data)
1847 1846 except KeyError:
1848 1847 raise PatchParseError(
1849 1848 b'unhandled transition: %s -> %s' % (state, newstate)
1850 1849 )
1851 1850 state = newstate
1852 1851 del fp
1853 1852 return p.finished()
1854 1853
1855 1854
1856 1855 def pathtransform(path, strip, prefix):
1857 1856 """turn a path from a patch into a path suitable for the repository
1858 1857
1859 1858 prefix, if not empty, is expected to be normalized with a / at the end.
1860 1859
1861 1860 Returns (stripped components, path in repository).
1862 1861
1863 1862 >>> pathtransform(b'a/b/c', 0, b'')
1864 1863 ('', 'a/b/c')
1865 1864 >>> pathtransform(b' a/b/c ', 0, b'')
1866 1865 ('', ' a/b/c')
1867 1866 >>> pathtransform(b' a/b/c ', 2, b'')
1868 1867 ('a/b/', 'c')
1869 1868 >>> pathtransform(b'a/b/c', 0, b'd/e/')
1870 1869 ('', 'd/e/a/b/c')
1871 1870 >>> pathtransform(b' a//b/c ', 2, b'd/e/')
1872 1871 ('a//b/', 'd/e/c')
1873 1872 >>> pathtransform(b'a/b/c', 3, b'')
1874 1873 Traceback (most recent call last):
1875 1874 PatchApplicationError: unable to strip away 1 of 3 dirs from a/b/c
1876 1875 """
1877 1876 pathlen = len(path)
1878 1877 i = 0
1879 1878 if strip == 0:
1880 1879 return b'', prefix + path.rstrip()
1881 1880 count = strip
1882 1881 while count > 0:
1883 1882 i = path.find(b'/', i)
1884 1883 if i == -1:
1885 1884 raise PatchApplicationError(
1886 1885 _(b"unable to strip away %d of %d dirs from %s")
1887 1886 % (count, strip, path)
1888 1887 )
1889 1888 i += 1
1890 1889 # consume '//' in the path
1891 1890 while i < pathlen - 1 and path[i : i + 1] == b'/':
1892 1891 i += 1
1893 1892 count -= 1
1894 1893 return path[:i].lstrip(), prefix + path[i:].rstrip()
1895 1894
1896 1895
1897 1896 def makepatchmeta(backend, afile_orig, bfile_orig, hunk, strip, prefix):
1898 1897 nulla = afile_orig == b"/dev/null"
1899 1898 nullb = bfile_orig == b"/dev/null"
1900 1899 create = nulla and hunk.starta == 0 and hunk.lena == 0
1901 1900 remove = nullb and hunk.startb == 0 and hunk.lenb == 0
1902 1901 abase, afile = pathtransform(afile_orig, strip, prefix)
1903 1902 gooda = not nulla and backend.exists(afile)
1904 1903 bbase, bfile = pathtransform(bfile_orig, strip, prefix)
1905 1904 if afile == bfile:
1906 1905 goodb = gooda
1907 1906 else:
1908 1907 goodb = not nullb and backend.exists(bfile)
1909 1908 missing = not goodb and not gooda and not create
1910 1909
1911 1910 # some diff programs apparently produce patches where the afile is
1912 1911 # not /dev/null, but afile starts with bfile
1913 1912 abasedir = afile[: afile.rfind(b'/') + 1]
1914 1913 bbasedir = bfile[: bfile.rfind(b'/') + 1]
1915 1914 if (
1916 1915 missing
1917 1916 and abasedir == bbasedir
1918 1917 and afile.startswith(bfile)
1919 1918 and hunk.starta == 0
1920 1919 and hunk.lena == 0
1921 1920 ):
1922 1921 create = True
1923 1922 missing = False
1924 1923
1925 1924 # If afile is "a/b/foo" and bfile is "a/b/foo.orig" we assume the
1926 1925 # diff is between a file and its backup. In this case, the original
1927 1926 # file should be patched (see original mpatch code).
1928 1927 isbackup = abase == bbase and bfile.startswith(afile)
1929 1928 fname = None
1930 1929 if not missing:
1931 1930 if gooda and goodb:
1932 1931 if isbackup:
1933 1932 fname = afile
1934 1933 else:
1935 1934 fname = bfile
1936 1935 elif gooda:
1937 1936 fname = afile
1938 1937
1939 1938 if not fname:
1940 1939 if not nullb:
1941 1940 if isbackup:
1942 1941 fname = afile
1943 1942 else:
1944 1943 fname = bfile
1945 1944 elif not nulla:
1946 1945 fname = afile
1947 1946 else:
1948 1947 raise PatchParseError(_(b"undefined source and destination files"))
1949 1948
1950 1949 gp = patchmeta(fname)
1951 1950 if create:
1952 1951 gp.op = b'ADD'
1953 1952 elif remove:
1954 1953 gp.op = b'DELETE'
1955 1954 return gp
1956 1955
1957 1956
1958 1957 def scanpatch(fp):
1959 1958 """like patch.iterhunks, but yield different events
1960 1959
1961 1960 - ('file', [header_lines + fromfile + tofile])
1962 1961 - ('context', [context_lines])
1963 1962 - ('hunk', [hunk_lines])
1964 1963 - ('range', (-start,len, +start,len, proc))
1965 1964 """
1966 1965 lines_re = re.compile(br'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
1967 1966 lr = linereader(fp)
1968 1967
1969 1968 def scanwhile(first, p):
1970 1969 """scan lr while predicate holds"""
1971 1970 lines = [first]
1972 1971 for line in iter(lr.readline, b''):
1973 1972 if p(line):
1974 1973 lines.append(line)
1975 1974 else:
1976 1975 lr.push(line)
1977 1976 break
1978 1977 return lines
1979 1978
1980 1979 for line in iter(lr.readline, b''):
1981 1980 if line.startswith(b'diff --git a/') or line.startswith(b'diff -r '):
1982 1981
1983 1982 def notheader(line):
1984 1983 s = line.split(None, 1)
1985 1984 return not s or s[0] not in (b'---', b'diff')
1986 1985
1987 1986 header = scanwhile(line, notheader)
1988 1987 fromfile = lr.readline()
1989 1988 if fromfile.startswith(b'---'):
1990 1989 tofile = lr.readline()
1991 1990 header += [fromfile, tofile]
1992 1991 else:
1993 1992 lr.push(fromfile)
1994 1993 yield b'file', header
1995 1994 elif line.startswith(b' '):
1996 1995 cs = (b' ', b'\\')
1997 1996 yield b'context', scanwhile(line, lambda l: l.startswith(cs))
1998 1997 elif line.startswith((b'-', b'+')):
1999 1998 cs = (b'-', b'+', b'\\')
2000 1999 yield b'hunk', scanwhile(line, lambda l: l.startswith(cs))
2001 2000 else:
2002 2001 m = lines_re.match(line)
2003 2002 if m:
2004 2003 yield b'range', m.groups()
2005 2004 else:
2006 2005 yield b'other', line
2007 2006
2008 2007
2009 2008 def scangitpatch(lr, firstline):
2010 2009 """
2011 2010 Git patches can emit:
2012 2011 - rename a to b
2013 2012 - change b
2014 2013 - copy a to c
2015 2014 - change c
2016 2015
2017 2016 We cannot apply this sequence as-is, the renamed 'a' could not be
2018 2017 found for it would have been renamed already. And we cannot copy
2019 2018 from 'b' instead because 'b' would have been changed already. So
2020 2019 we scan the git patch for copy and rename commands so we can
2021 2020 perform the copies ahead of time.
2022 2021 """
2023 2022 pos = 0
2024 2023 try:
2025 2024 pos = lr.fp.tell()
2026 2025 fp = lr.fp
2027 2026 except IOError:
2028 2027 fp = stringio(lr.fp.read())
2029 2028 gitlr = linereader(fp)
2030 2029 gitlr.push(firstline)
2031 2030 gitpatches = readgitpatch(gitlr)
2032 2031 fp.seek(pos)
2033 2032 return gitpatches
2034 2033
2035 2034
2036 2035 def iterhunks(fp):
2037 2036 """Read a patch and yield the following events:
2038 2037 - ("file", afile, bfile, firsthunk): select a new target file.
2039 2038 - ("hunk", hunk): a new hunk is ready to be applied, follows a
2040 2039 "file" event.
2041 2040 - ("git", gitchanges): current diff is in git format, gitchanges
2042 2041 maps filenames to gitpatch records. Unique event.
2043 2042 """
2044 2043 afile = b""
2045 2044 bfile = b""
2046 2045 state = None
2047 2046 hunknum = 0
2048 2047 emitfile = newfile = False
2049 2048 gitpatches = None
2050 2049
2051 2050 # our states
2052 2051 BFILE = 1
2053 2052 context = None
2054 2053 lr = linereader(fp)
2055 2054
2056 2055 for x in iter(lr.readline, b''):
2057 2056 if state == BFILE and (
2058 2057 (not context and x.startswith(b'@'))
2059 2058 or (context is not False and x.startswith(b'***************'))
2060 2059 or x.startswith(b'GIT binary patch')
2061 2060 ):
2062 2061 gp = None
2063 2062 if gitpatches and gitpatches[-1].ispatching(afile, bfile):
2064 2063 gp = gitpatches.pop()
2065 2064 if x.startswith(b'GIT binary patch'):
2066 2065 h = binhunk(lr, gp.path)
2067 2066 else:
2068 2067 if context is None and x.startswith(b'***************'):
2069 2068 context = True
2070 2069 h = hunk(x, hunknum + 1, lr, context)
2071 2070 hunknum += 1
2072 2071 if emitfile:
2073 2072 emitfile = False
2074 2073 yield b'file', (afile, bfile, h, gp and gp.copy() or None)
2075 2074 yield b'hunk', h
2076 2075 elif x.startswith(b'diff --git a/'):
2077 2076 m = gitre.match(x.rstrip(b'\r\n'))
2078 2077 if not m:
2079 2078 continue
2080 2079 if gitpatches is None:
2081 2080 # scan whole input for git metadata
2082 2081 gitpatches = scangitpatch(lr, x)
2083 2082 yield b'git', [
2084 2083 g.copy() for g in gitpatches if g.op in (b'COPY', b'RENAME')
2085 2084 ]
2086 2085 gitpatches.reverse()
2087 2086 afile = b'a/' + m.group(1)
2088 2087 bfile = b'b/' + m.group(2)
2089 2088 while gitpatches and not gitpatches[-1].ispatching(afile, bfile):
2090 2089 gp = gitpatches.pop()
2091 2090 yield b'file', (
2092 2091 b'a/' + gp.path,
2093 2092 b'b/' + gp.path,
2094 2093 None,
2095 2094 gp.copy(),
2096 2095 )
2097 2096 if not gitpatches:
2098 2097 raise PatchParseError(
2099 2098 _(b'failed to synchronize metadata for "%s"') % afile[2:]
2100 2099 )
2101 2100 newfile = True
2102 2101 elif x.startswith(b'---'):
2103 2102 # check for a unified diff
2104 2103 l2 = lr.readline()
2105 2104 if not l2.startswith(b'+++'):
2106 2105 lr.push(l2)
2107 2106 continue
2108 2107 newfile = True
2109 2108 context = False
2110 2109 afile = parsefilename(x)
2111 2110 bfile = parsefilename(l2)
2112 2111 elif x.startswith(b'***'):
2113 2112 # check for a context diff
2114 2113 l2 = lr.readline()
2115 2114 if not l2.startswith(b'---'):
2116 2115 lr.push(l2)
2117 2116 continue
2118 2117 l3 = lr.readline()
2119 2118 lr.push(l3)
2120 2119 if not l3.startswith(b"***************"):
2121 2120 lr.push(l2)
2122 2121 continue
2123 2122 newfile = True
2124 2123 context = True
2125 2124 afile = parsefilename(x)
2126 2125 bfile = parsefilename(l2)
2127 2126
2128 2127 if newfile:
2129 2128 newfile = False
2130 2129 emitfile = True
2131 2130 state = BFILE
2132 2131 hunknum = 0
2133 2132
2134 2133 while gitpatches:
2135 2134 gp = gitpatches.pop()
2136 2135 yield b'file', (b'a/' + gp.path, b'b/' + gp.path, None, gp.copy())
2137 2136
2138 2137
2139 2138 def applybindelta(binchunk, data):
2140 2139 """Apply a binary delta hunk
2141 2140 The algorithm used is the algorithm from git's patch-delta.c
2142 2141 """
2143 2142
2144 2143 def deltahead(binchunk):
2145 2144 i = 0
2146 2145 for c in pycompat.bytestr(binchunk):
2147 2146 i += 1
2148 2147 if not (ord(c) & 0x80):
2149 2148 return i
2150 2149 return i
2151 2150
2152 2151 out = b""
2153 2152 s = deltahead(binchunk)
2154 2153 binchunk = binchunk[s:]
2155 2154 s = deltahead(binchunk)
2156 2155 binchunk = binchunk[s:]
2157 2156 i = 0
2158 2157 while i < len(binchunk):
2159 2158 cmd = ord(binchunk[i : i + 1])
2160 2159 i += 1
2161 2160 if cmd & 0x80:
2162 2161 offset = 0
2163 2162 size = 0
2164 2163 if cmd & 0x01:
2165 2164 offset = ord(binchunk[i : i + 1])
2166 2165 i += 1
2167 2166 if cmd & 0x02:
2168 2167 offset |= ord(binchunk[i : i + 1]) << 8
2169 2168 i += 1
2170 2169 if cmd & 0x04:
2171 2170 offset |= ord(binchunk[i : i + 1]) << 16
2172 2171 i += 1
2173 2172 if cmd & 0x08:
2174 2173 offset |= ord(binchunk[i : i + 1]) << 24
2175 2174 i += 1
2176 2175 if cmd & 0x10:
2177 2176 size = ord(binchunk[i : i + 1])
2178 2177 i += 1
2179 2178 if cmd & 0x20:
2180 2179 size |= ord(binchunk[i : i + 1]) << 8
2181 2180 i += 1
2182 2181 if cmd & 0x40:
2183 2182 size |= ord(binchunk[i : i + 1]) << 16
2184 2183 i += 1
2185 2184 if size == 0:
2186 2185 size = 0x10000
2187 2186 offset_end = offset + size
2188 2187 out += data[offset:offset_end]
2189 2188 elif cmd != 0:
2190 2189 offset_end = i + cmd
2191 2190 out += binchunk[i:offset_end]
2192 2191 i += cmd
2193 2192 else:
2194 2193 raise PatchApplicationError(_(b'unexpected delta opcode 0'))
2195 2194 return out
2196 2195
2197 2196
2198 2197 def applydiff(ui, fp, backend, store, strip=1, prefix=b'', eolmode=b'strict'):
2199 2198 """Reads a patch from fp and tries to apply it.
2200 2199
2201 2200 Returns 0 for a clean patch, -1 if any rejects were found and 1 if
2202 2201 there was any fuzz.
2203 2202
2204 2203 If 'eolmode' is 'strict', the patch content and patched file are
2205 2204 read in binary mode. Otherwise, line endings are ignored when
2206 2205 patching then normalized according to 'eolmode'.
2207 2206 """
2208 2207 return _applydiff(
2209 2208 ui,
2210 2209 fp,
2211 2210 patchfile,
2212 2211 backend,
2213 2212 store,
2214 2213 strip=strip,
2215 2214 prefix=prefix,
2216 2215 eolmode=eolmode,
2217 2216 )
2218 2217
2219 2218
2220 2219 def _canonprefix(repo, prefix):
2221 2220 if prefix:
2222 2221 prefix = pathutil.canonpath(repo.root, repo.getcwd(), prefix)
2223 2222 if prefix != b'':
2224 2223 prefix += b'/'
2225 2224 return prefix
2226 2225
2227 2226
2228 2227 def _applydiff(
2229 2228 ui, fp, patcher, backend, store, strip=1, prefix=b'', eolmode=b'strict'
2230 2229 ):
2231 2230 prefix = _canonprefix(backend.repo, prefix)
2232 2231
2233 2232 def pstrip(p):
2234 2233 return pathtransform(p, strip - 1, prefix)[1]
2235 2234
2236 2235 rejects = 0
2237 2236 err = 0
2238 2237 current_file = None
2239 2238
2240 2239 for state, values in iterhunks(fp):
2241 2240 if state == b'hunk':
2242 2241 if not current_file:
2243 2242 continue
2244 2243 ret = current_file.apply(values)
2245 2244 if ret > 0:
2246 2245 err = 1
2247 2246 elif state == b'file':
2248 2247 if current_file:
2249 2248 rejects += current_file.close()
2250 2249 current_file = None
2251 2250 afile, bfile, first_hunk, gp = values
2252 2251 if gp:
2253 2252 gp.path = pstrip(gp.path)
2254 2253 if gp.oldpath:
2255 2254 gp.oldpath = pstrip(gp.oldpath)
2256 2255 else:
2257 2256 gp = makepatchmeta(
2258 2257 backend, afile, bfile, first_hunk, strip, prefix
2259 2258 )
2260 2259 if gp.op == b'RENAME':
2261 2260 backend.unlink(gp.oldpath)
2262 2261 if not first_hunk:
2263 2262 if gp.op == b'DELETE':
2264 2263 backend.unlink(gp.path)
2265 2264 continue
2266 2265 data, mode = None, None
2267 2266 if gp.op in (b'RENAME', b'COPY'):
2268 2267 data, mode = store.getfile(gp.oldpath)[:2]
2269 2268 if data is None:
2270 2269 # This means that the old path does not exist
2271 2270 raise PatchApplicationError(
2272 2271 _(b"source file '%s' does not exist") % gp.oldpath
2273 2272 )
2274 2273 if gp.mode:
2275 2274 mode = gp.mode
2276 2275 if gp.op == b'ADD':
2277 2276 # Added files without content have no hunk and
2278 2277 # must be created
2279 2278 data = b''
2280 2279 if data or mode:
2281 2280 if gp.op in (b'ADD', b'RENAME', b'COPY') and backend.exists(
2282 2281 gp.path
2283 2282 ):
2284 2283 raise PatchApplicationError(
2285 2284 _(
2286 2285 b"cannot create %s: destination "
2287 2286 b"already exists"
2288 2287 )
2289 2288 % gp.path
2290 2289 )
2291 2290 backend.setfile(gp.path, data, mode, gp.oldpath)
2292 2291 continue
2293 2292 try:
2294 2293 current_file = patcher(ui, gp, backend, store, eolmode=eolmode)
2295 2294 except PatchError as inst:
2296 2295 ui.warn(stringutil.forcebytestr(inst) + b'\n')
2297 2296 current_file = None
2298 2297 rejects += 1
2299 2298 continue
2300 2299 elif state == b'git':
2301 2300 for gp in values:
2302 2301 path = pstrip(gp.oldpath)
2303 2302 data, mode = backend.getfile(path)
2304 2303 if data is None:
2305 2304 # The error ignored here will trigger a getfile()
2306 2305 # error in a place more appropriate for error
2307 2306 # handling, and will not interrupt the patching
2308 2307 # process.
2309 2308 pass
2310 2309 else:
2311 2310 store.setfile(path, data, mode)
2312 2311 else:
2313 2312 raise error.Abort(_(b'unsupported parser state: %s') % state)
2314 2313
2315 2314 if current_file:
2316 2315 rejects += current_file.close()
2317 2316
2318 2317 if rejects:
2319 2318 return -1
2320 2319 return err
2321 2320
2322 2321
2323 2322 def _externalpatch(ui, repo, patcher, patchname, strip, files, similarity):
2324 2323 """use <patcher> to apply <patchname> to the working directory.
2325 2324 returns whether patch was applied with fuzz factor."""
2326 2325
2327 2326 fuzz = False
2328 2327 args = []
2329 2328 cwd = repo.root
2330 2329 if cwd:
2331 2330 args.append(b'-d %s' % procutil.shellquote(cwd))
2332 2331 cmd = b'%s %s -p%d < %s' % (
2333 2332 patcher,
2334 2333 b' '.join(args),
2335 2334 strip,
2336 2335 procutil.shellquote(patchname),
2337 2336 )
2338 2337 ui.debug(b'Using external patch tool: %s\n' % cmd)
2339 2338 fp = procutil.popen(cmd, b'rb')
2340 2339 try:
2341 2340 for line in fp:
2342 2341 line = line.rstrip()
2343 2342 ui.note(line + b'\n')
2344 2343 if line.startswith(b'patching file '):
2345 2344 pf = util.parsepatchoutput(line)
2346 2345 printed_file = False
2347 2346 files.add(pf)
2348 2347 elif line.find(b'with fuzz') >= 0:
2349 2348 fuzz = True
2350 2349 if not printed_file:
2351 2350 ui.warn(pf + b'\n')
2352 2351 printed_file = True
2353 2352 ui.warn(line + b'\n')
2354 2353 elif line.find(b'saving rejects to file') >= 0:
2355 2354 ui.warn(line + b'\n')
2356 2355 elif line.find(b'FAILED') >= 0:
2357 2356 if not printed_file:
2358 2357 ui.warn(pf + b'\n')
2359 2358 printed_file = True
2360 2359 ui.warn(line + b'\n')
2361 2360 finally:
2362 2361 if files:
2363 2362 scmutil.marktouched(repo, files, similarity)
2364 2363 code = fp.close()
2365 2364 if code:
2366 2365 raise PatchApplicationError(
2367 2366 _(b"patch command failed: %s") % procutil.explainexit(code)
2368 2367 )
2369 2368 return fuzz
2370 2369
2371 2370
2372 2371 def patchbackend(
2373 2372 ui, backend, patchobj, strip, prefix, files=None, eolmode=b'strict'
2374 2373 ):
2375 2374 if files is None:
2376 2375 files = set()
2377 2376 if eolmode is None:
2378 2377 eolmode = ui.config(b'patch', b'eol')
2379 2378 if eolmode.lower() not in eolmodes:
2380 2379 raise error.Abort(_(b'unsupported line endings type: %s') % eolmode)
2381 2380 eolmode = eolmode.lower()
2382 2381
2383 2382 store = filestore()
2384 2383 try:
2385 fp = open(patchobj, b'rb')
2384 fp = open(patchobj, 'rb')
2386 2385 except TypeError:
2387 2386 fp = patchobj
2388 2387 try:
2389 2388 ret = applydiff(
2390 2389 ui, fp, backend, store, strip=strip, prefix=prefix, eolmode=eolmode
2391 2390 )
2392 2391 finally:
2393 2392 if fp != patchobj:
2394 2393 fp.close()
2395 2394 files.update(backend.close())
2396 2395 store.close()
2397 2396 if ret < 0:
2398 2397 raise PatchApplicationError(_(b'patch failed to apply'))
2399 2398 return ret > 0
2400 2399
2401 2400
2402 2401 def internalpatch(
2403 2402 ui,
2404 2403 repo,
2405 2404 patchobj,
2406 2405 strip,
2407 2406 prefix=b'',
2408 2407 files=None,
2409 2408 eolmode=b'strict',
2410 2409 similarity=0,
2411 2410 ):
2412 2411 """use builtin patch to apply <patchobj> to the working directory.
2413 2412 returns whether patch was applied with fuzz factor."""
2414 2413 backend = workingbackend(ui, repo, similarity)
2415 2414 return patchbackend(ui, backend, patchobj, strip, prefix, files, eolmode)
2416 2415
2417 2416
2418 2417 def patchrepo(
2419 2418 ui, repo, ctx, store, patchobj, strip, prefix, files=None, eolmode=b'strict'
2420 2419 ):
2421 2420 backend = repobackend(ui, repo, ctx, store)
2422 2421 return patchbackend(ui, backend, patchobj, strip, prefix, files, eolmode)
2423 2422
2424 2423
2425 2424 def patch(
2426 2425 ui,
2427 2426 repo,
2428 2427 patchname,
2429 2428 strip=1,
2430 2429 prefix=b'',
2431 2430 files=None,
2432 2431 eolmode=b'strict',
2433 2432 similarity=0,
2434 2433 ):
2435 2434 """Apply <patchname> to the working directory.
2436 2435
2437 2436 'eolmode' specifies how end of lines should be handled. It can be:
2438 2437 - 'strict': inputs are read in binary mode, EOLs are preserved
2439 2438 - 'crlf': EOLs are ignored when patching and reset to CRLF
2440 2439 - 'lf': EOLs are ignored when patching and reset to LF
2441 2440 - None: get it from user settings, default to 'strict'
2442 2441 'eolmode' is ignored when using an external patcher program.
2443 2442
2444 2443 Returns whether patch was applied with fuzz factor.
2445 2444 """
2446 2445 patcher = ui.config(b'ui', b'patch')
2447 2446 if files is None:
2448 2447 files = set()
2449 2448 if patcher:
2450 2449 return _externalpatch(
2451 2450 ui, repo, patcher, patchname, strip, files, similarity
2452 2451 )
2453 2452 return internalpatch(
2454 2453 ui, repo, patchname, strip, prefix, files, eolmode, similarity
2455 2454 )
2456 2455
2457 2456
2458 2457 def changedfiles(ui, repo, patchpath, strip=1, prefix=b''):
2459 2458 backend = fsbackend(ui, repo.root)
2460 2459 prefix = _canonprefix(repo, prefix)
2461 with open(patchpath, b'rb') as fp:
2460 with open(patchpath, 'rb') as fp:
2462 2461 changed = set()
2463 2462 for state, values in iterhunks(fp):
2464 2463 if state == b'file':
2465 2464 afile, bfile, first_hunk, gp = values
2466 2465 if gp:
2467 2466 gp.path = pathtransform(gp.path, strip - 1, prefix)[1]
2468 2467 if gp.oldpath:
2469 2468 gp.oldpath = pathtransform(
2470 2469 gp.oldpath, strip - 1, prefix
2471 2470 )[1]
2472 2471 else:
2473 2472 gp = makepatchmeta(
2474 2473 backend, afile, bfile, first_hunk, strip, prefix
2475 2474 )
2476 2475 changed.add(gp.path)
2477 2476 if gp.op == b'RENAME':
2478 2477 changed.add(gp.oldpath)
2479 2478 elif state not in (b'hunk', b'git'):
2480 2479 raise error.Abort(_(b'unsupported parser state: %s') % state)
2481 2480 return changed
2482 2481
2483 2482
2484 2483 class GitDiffRequired(Exception):
2485 2484 pass
2486 2485
2487 2486
2488 2487 diffopts = diffutil.diffallopts
2489 2488 diffallopts = diffutil.diffallopts
2490 2489 difffeatureopts = diffutil.difffeatureopts
2491 2490
2492 2491
2493 2492 def diff(
2494 2493 repo,
2495 2494 node1=None,
2496 2495 node2=None,
2497 2496 match=None,
2498 2497 changes=None,
2499 2498 opts=None,
2500 2499 losedatafn=None,
2501 2500 pathfn=None,
2502 2501 copy=None,
2503 2502 copysourcematch=None,
2504 2503 hunksfilterfn=None,
2505 2504 ):
2506 2505 """yields diff of changes to files between two nodes, or node and
2507 2506 working directory.
2508 2507
2509 2508 if node1 is None, use first dirstate parent instead.
2510 2509 if node2 is None, compare node1 with working directory.
2511 2510
2512 2511 losedatafn(**kwarg) is a callable run when opts.upgrade=True and
2513 2512 every time some change cannot be represented with the current
2514 2513 patch format. Return False to upgrade to git patch format, True to
2515 2514 accept the loss or raise an exception to abort the diff. It is
2516 2515 called with the name of current file being diffed as 'fn'. If set
2517 2516 to None, patches will always be upgraded to git format when
2518 2517 necessary.
2519 2518
2520 2519 prefix is a filename prefix that is prepended to all filenames on
2521 2520 display (used for subrepos).
2522 2521
2523 2522 relroot, if not empty, must be normalized with a trailing /. Any match
2524 2523 patterns that fall outside it will be ignored.
2525 2524
2526 2525 copy, if not empty, should contain mappings {dst@y: src@x} of copy
2527 2526 information.
2528 2527
2529 2528 if copysourcematch is not None, then copy sources will be filtered by this
2530 2529 matcher
2531 2530
2532 2531 hunksfilterfn, if not None, should be a function taking a filectx and
2533 2532 hunks generator that may yield filtered hunks.
2534 2533 """
2535 2534 if not node1 and not node2:
2536 2535 node1 = repo.dirstate.p1()
2537 2536
2538 2537 ctx1 = repo[node1]
2539 2538 ctx2 = repo[node2]
2540 2539
2541 2540 for fctx1, fctx2, hdr, hunks in diffhunks(
2542 2541 repo,
2543 2542 ctx1=ctx1,
2544 2543 ctx2=ctx2,
2545 2544 match=match,
2546 2545 changes=changes,
2547 2546 opts=opts,
2548 2547 losedatafn=losedatafn,
2549 2548 pathfn=pathfn,
2550 2549 copy=copy,
2551 2550 copysourcematch=copysourcematch,
2552 2551 ):
2553 2552 if hunksfilterfn is not None:
2554 2553 # If the file has been removed, fctx2 is None; but this should
2555 2554 # not occur here since we catch removed files early in
2556 2555 # logcmdutil.getlinerangerevs() for 'hg log -L'.
2557 2556 assert (
2558 2557 fctx2 is not None
2559 2558 ), b'fctx2 unexpectly None in diff hunks filtering'
2560 2559 hunks = hunksfilterfn(fctx2, hunks)
2561 2560 text = b''.join(b''.join(hlines) for hrange, hlines in hunks)
2562 2561 if hdr and (text or len(hdr) > 1):
2563 2562 yield b'\n'.join(hdr) + b'\n'
2564 2563 if text:
2565 2564 yield text
2566 2565
2567 2566
2568 2567 def diffhunks(
2569 2568 repo,
2570 2569 ctx1,
2571 2570 ctx2,
2572 2571 match=None,
2573 2572 changes=None,
2574 2573 opts=None,
2575 2574 losedatafn=None,
2576 2575 pathfn=None,
2577 2576 copy=None,
2578 2577 copysourcematch=None,
2579 2578 ):
2580 2579 """Yield diff of changes to files in the form of (`header`, `hunks`) tuples
2581 2580 where `header` is a list of diff headers and `hunks` is an iterable of
2582 2581 (`hunkrange`, `hunklines`) tuples.
2583 2582
2584 2583 See diff() for the meaning of parameters.
2585 2584 """
2586 2585
2587 2586 if opts is None:
2588 2587 opts = mdiff.defaultopts
2589 2588
2590 2589 def lrugetfilectx():
2591 2590 cache = {}
2592 2591 order = collections.deque()
2593 2592
2594 2593 def getfilectx(f, ctx):
2595 2594 fctx = ctx.filectx(f, filelog=cache.get(f))
2596 2595 if f not in cache:
2597 2596 if len(cache) > 20:
2598 2597 del cache[order.popleft()]
2599 2598 cache[f] = fctx.filelog()
2600 2599 else:
2601 2600 order.remove(f)
2602 2601 order.append(f)
2603 2602 return fctx
2604 2603
2605 2604 return getfilectx
2606 2605
2607 2606 getfilectx = lrugetfilectx()
2608 2607
2609 2608 if not changes:
2610 2609 changes = ctx1.status(ctx2, match=match)
2611 2610 if isinstance(changes, list):
2612 2611 modified, added, removed = changes[:3]
2613 2612 else:
2614 2613 modified, added, removed = (
2615 2614 changes.modified,
2616 2615 changes.added,
2617 2616 changes.removed,
2618 2617 )
2619 2618
2620 2619 if not modified and not added and not removed:
2621 2620 return []
2622 2621
2623 2622 if repo.ui.debugflag:
2624 2623 hexfunc = hex
2625 2624 else:
2626 2625 hexfunc = short
2627 2626 revs = [hexfunc(node) for node in [ctx1.node(), ctx2.node()] if node]
2628 2627
2629 2628 if copy is None:
2630 2629 copy = {}
2631 2630 if opts.git or opts.upgrade:
2632 2631 copy = copies.pathcopies(ctx1, ctx2, match=match)
2633 2632
2634 2633 if copysourcematch:
2635 2634 # filter out copies where source side isn't inside the matcher
2636 2635 # (copies.pathcopies() already filtered out the destination)
2637 2636 copy = {dst: src for dst, src in copy.items() if copysourcematch(src)}
2638 2637
2639 2638 modifiedset = set(modified)
2640 2639 addedset = set(added)
2641 2640 removedset = set(removed)
2642 2641 for f in modified:
2643 2642 if f not in ctx1:
2644 2643 # Fix up added, since merged-in additions appear as
2645 2644 # modifications during merges
2646 2645 modifiedset.remove(f)
2647 2646 addedset.add(f)
2648 2647 for f in removed:
2649 2648 if f not in ctx1:
2650 2649 # Merged-in additions that are then removed are reported as removed.
2651 2650 # They are not in ctx1, so We don't want to show them in the diff.
2652 2651 removedset.remove(f)
2653 2652 modified = sorted(modifiedset)
2654 2653 added = sorted(addedset)
2655 2654 removed = sorted(removedset)
2656 2655 for dst, src in list(copy.items()):
2657 2656 if src not in ctx1:
2658 2657 # Files merged in during a merge and then copied/renamed are
2659 2658 # reported as copies. We want to show them in the diff as additions.
2660 2659 del copy[dst]
2661 2660
2662 2661 prefetchmatch = scmutil.matchfiles(
2663 2662 repo, list(modifiedset | addedset | removedset)
2664 2663 )
2665 2664 revmatches = [
2666 2665 (ctx1.rev(), prefetchmatch),
2667 2666 (ctx2.rev(), prefetchmatch),
2668 2667 ]
2669 2668 scmutil.prefetchfiles(repo, revmatches)
2670 2669
2671 2670 def difffn(opts, losedata):
2672 2671 return trydiff(
2673 2672 repo,
2674 2673 revs,
2675 2674 ctx1,
2676 2675 ctx2,
2677 2676 modified,
2678 2677 added,
2679 2678 removed,
2680 2679 copy,
2681 2680 getfilectx,
2682 2681 opts,
2683 2682 losedata,
2684 2683 pathfn,
2685 2684 )
2686 2685
2687 2686 if opts.upgrade and not opts.git:
2688 2687 try:
2689 2688
2690 2689 def losedata(fn):
2691 2690 if not losedatafn or not losedatafn(fn=fn):
2692 2691 raise GitDiffRequired
2693 2692
2694 2693 # Buffer the whole output until we are sure it can be generated
2695 2694 return list(difffn(opts.copy(git=False), losedata))
2696 2695 except GitDiffRequired:
2697 2696 return difffn(opts.copy(git=True), None)
2698 2697 else:
2699 2698 return difffn(opts, None)
2700 2699
2701 2700
2702 2701 def diffsinglehunk(hunklines):
2703 2702 """yield tokens for a list of lines in a single hunk"""
2704 2703 for line in hunklines:
2705 2704 # chomp
2706 2705 chompline = line.rstrip(b'\r\n')
2707 2706 # highlight tabs and trailing whitespace
2708 2707 stripline = chompline.rstrip()
2709 2708 if line.startswith(b'-'):
2710 2709 label = b'diff.deleted'
2711 2710 elif line.startswith(b'+'):
2712 2711 label = b'diff.inserted'
2713 2712 else:
2714 2713 raise error.ProgrammingError(b'unexpected hunk line: %s' % line)
2715 2714 for token in tabsplitter.findall(stripline):
2716 2715 if token.startswith(b'\t'):
2717 2716 yield (token, b'diff.tab')
2718 2717 else:
2719 2718 yield (token, label)
2720 2719
2721 2720 if chompline != stripline:
2722 2721 yield (chompline[len(stripline) :], b'diff.trailingwhitespace')
2723 2722 if chompline != line:
2724 2723 yield (line[len(chompline) :], b'')
2725 2724
2726 2725
2727 2726 def diffsinglehunkinline(hunklines):
2728 2727 """yield tokens for a list of lines in a single hunk, with inline colors"""
2729 2728 # prepare deleted, and inserted content
2730 2729 a = bytearray()
2731 2730 b = bytearray()
2732 2731 for line in hunklines:
2733 2732 if line[0:1] == b'-':
2734 2733 a += line[1:]
2735 2734 elif line[0:1] == b'+':
2736 2735 b += line[1:]
2737 2736 else:
2738 2737 raise error.ProgrammingError(b'unexpected hunk line: %s' % line)
2739 2738 # fast path: if either side is empty, use diffsinglehunk
2740 2739 if not a or not b:
2741 2740 for t in diffsinglehunk(hunklines):
2742 2741 yield t
2743 2742 return
2744 2743 # re-split the content into words
2745 2744 al = wordsplitter.findall(bytes(a))
2746 2745 bl = wordsplitter.findall(bytes(b))
2747 2746 # re-arrange the words to lines since the diff algorithm is line-based
2748 2747 aln = [s if s == b'\n' else s + b'\n' for s in al]
2749 2748 bln = [s if s == b'\n' else s + b'\n' for s in bl]
2750 2749 an = b''.join(aln)
2751 2750 bn = b''.join(bln)
2752 2751 # run the diff algorithm, prepare atokens and btokens
2753 2752 atokens = []
2754 2753 btokens = []
2755 2754 blocks = mdiff.allblocks(an, bn, lines1=aln, lines2=bln)
2756 2755 for (a1, a2, b1, b2), btype in blocks:
2757 2756 changed = btype == b'!'
2758 2757 for token in mdiff.splitnewlines(b''.join(al[a1:a2])):
2759 2758 atokens.append((changed, token))
2760 2759 for token in mdiff.splitnewlines(b''.join(bl[b1:b2])):
2761 2760 btokens.append((changed, token))
2762 2761
2763 2762 # yield deleted tokens, then inserted ones
2764 2763 for prefix, label, tokens in [
2765 2764 (b'-', b'diff.deleted', atokens),
2766 2765 (b'+', b'diff.inserted', btokens),
2767 2766 ]:
2768 2767 nextisnewline = True
2769 2768 for changed, token in tokens:
2770 2769 if nextisnewline:
2771 2770 yield (prefix, label)
2772 2771 nextisnewline = False
2773 2772 # special handling line end
2774 2773 isendofline = token.endswith(b'\n')
2775 2774 if isendofline:
2776 2775 chomp = token[:-1] # chomp
2777 2776 if chomp.endswith(b'\r'):
2778 2777 chomp = chomp[:-1]
2779 2778 endofline = token[len(chomp) :]
2780 2779 token = chomp.rstrip() # detect spaces at the end
2781 2780 endspaces = chomp[len(token) :]
2782 2781 # scan tabs
2783 2782 for maybetab in tabsplitter.findall(token):
2784 2783 if b'\t' == maybetab[0:1]:
2785 2784 currentlabel = b'diff.tab'
2786 2785 else:
2787 2786 if changed:
2788 2787 currentlabel = label + b'.changed'
2789 2788 else:
2790 2789 currentlabel = label + b'.unchanged'
2791 2790 yield (maybetab, currentlabel)
2792 2791 if isendofline:
2793 2792 if endspaces:
2794 2793 yield (endspaces, b'diff.trailingwhitespace')
2795 2794 yield (endofline, b'')
2796 2795 nextisnewline = True
2797 2796
2798 2797
2799 2798 def difflabel(func, *args, **kw):
2800 2799 '''yields 2-tuples of (output, label) based on the output of func()'''
2801 2800 if kw.get('opts') and kw['opts'].worddiff:
2802 2801 dodiffhunk = diffsinglehunkinline
2803 2802 else:
2804 2803 dodiffhunk = diffsinglehunk
2805 2804 headprefixes = [
2806 2805 (b'diff', b'diff.diffline'),
2807 2806 (b'copy', b'diff.extended'),
2808 2807 (b'rename', b'diff.extended'),
2809 2808 (b'old', b'diff.extended'),
2810 2809 (b'new', b'diff.extended'),
2811 2810 (b'deleted', b'diff.extended'),
2812 2811 (b'index', b'diff.extended'),
2813 2812 (b'similarity', b'diff.extended'),
2814 2813 (b'---', b'diff.file_a'),
2815 2814 (b'+++', b'diff.file_b'),
2816 2815 ]
2817 2816 textprefixes = [
2818 2817 (b'@', b'diff.hunk'),
2819 2818 # - and + are handled by diffsinglehunk
2820 2819 ]
2821 2820 head = False
2822 2821
2823 2822 # buffers a hunk, i.e. adjacent "-", "+" lines without other changes.
2824 2823 hunkbuffer = []
2825 2824
2826 2825 def consumehunkbuffer():
2827 2826 if hunkbuffer:
2828 2827 for token in dodiffhunk(hunkbuffer):
2829 2828 yield token
2830 2829 hunkbuffer[:] = []
2831 2830
2832 2831 for chunk in func(*args, **kw):
2833 2832 lines = chunk.split(b'\n')
2834 2833 linecount = len(lines)
2835 2834 for i, line in enumerate(lines):
2836 2835 if head:
2837 2836 if line.startswith(b'@'):
2838 2837 head = False
2839 2838 else:
2840 2839 if line and not line.startswith(
2841 2840 (b' ', b'+', b'-', b'@', b'\\')
2842 2841 ):
2843 2842 head = True
2844 2843 diffline = False
2845 2844 if not head and line and line.startswith((b'+', b'-')):
2846 2845 diffline = True
2847 2846
2848 2847 prefixes = textprefixes
2849 2848 if head:
2850 2849 prefixes = headprefixes
2851 2850 if diffline:
2852 2851 # buffered
2853 2852 bufferedline = line
2854 2853 if i + 1 < linecount:
2855 2854 bufferedline += b"\n"
2856 2855 hunkbuffer.append(bufferedline)
2857 2856 else:
2858 2857 # unbuffered
2859 2858 for token in consumehunkbuffer():
2860 2859 yield token
2861 2860 stripline = line.rstrip()
2862 2861 for prefix, label in prefixes:
2863 2862 if stripline.startswith(prefix):
2864 2863 yield (stripline, label)
2865 2864 if line != stripline:
2866 2865 yield (
2867 2866 line[len(stripline) :],
2868 2867 b'diff.trailingwhitespace',
2869 2868 )
2870 2869 break
2871 2870 else:
2872 2871 yield (line, b'')
2873 2872 if i + 1 < linecount:
2874 2873 yield (b'\n', b'')
2875 2874 for token in consumehunkbuffer():
2876 2875 yield token
2877 2876
2878 2877
2879 2878 def diffui(*args, **kw):
2880 2879 '''like diff(), but yields 2-tuples of (output, label) for ui.write()'''
2881 2880 return difflabel(diff, *args, **kw)
2882 2881
2883 2882
2884 2883 def _filepairs(modified, added, removed, copy, opts):
2885 2884 """generates tuples (f1, f2, copyop), where f1 is the name of the file
2886 2885 before and f2 is the the name after. For added files, f1 will be None,
2887 2886 and for removed files, f2 will be None. copyop may be set to None, 'copy'
2888 2887 or 'rename' (the latter two only if opts.git is set)."""
2889 2888 gone = set()
2890 2889
2891 2890 copyto = {v: k for k, v in copy.items()}
2892 2891
2893 2892 addedset, removedset = set(added), set(removed)
2894 2893
2895 2894 for f in sorted(modified + added + removed):
2896 2895 copyop = None
2897 2896 f1, f2 = f, f
2898 2897 if f in addedset:
2899 2898 f1 = None
2900 2899 if f in copy:
2901 2900 if opts.git:
2902 2901 f1 = copy[f]
2903 2902 if f1 in removedset and f1 not in gone:
2904 2903 copyop = b'rename'
2905 2904 gone.add(f1)
2906 2905 else:
2907 2906 copyop = b'copy'
2908 2907 elif f in removedset:
2909 2908 f2 = None
2910 2909 if opts.git:
2911 2910 # have we already reported a copy above?
2912 2911 if (
2913 2912 f in copyto
2914 2913 and copyto[f] in addedset
2915 2914 and copy[copyto[f]] == f
2916 2915 ):
2917 2916 continue
2918 2917 yield f1, f2, copyop
2919 2918
2920 2919
2921 2920 def _gitindex(text):
2922 2921 if not text:
2923 2922 text = b""
2924 2923 l = len(text)
2925 2924 s = hashutil.sha1(b'blob %d\0' % l)
2926 2925 s.update(text)
2927 2926 return hex(s.digest())
2928 2927
2929 2928
2930 2929 _gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'}
2931 2930
2932 2931
2933 2932 def trydiff(
2934 2933 repo,
2935 2934 revs,
2936 2935 ctx1,
2937 2936 ctx2,
2938 2937 modified,
2939 2938 added,
2940 2939 removed,
2941 2940 copy,
2942 2941 getfilectx,
2943 2942 opts,
2944 2943 losedatafn,
2945 2944 pathfn,
2946 2945 ):
2947 2946 """given input data, generate a diff and yield it in blocks
2948 2947
2949 2948 If generating a diff would lose data like flags or binary data and
2950 2949 losedatafn is not None, it will be called.
2951 2950
2952 2951 pathfn is applied to every path in the diff output.
2953 2952 """
2954 2953
2955 2954 if opts.noprefix:
2956 2955 aprefix = bprefix = b''
2957 2956 else:
2958 2957 aprefix = b'a/'
2959 2958 bprefix = b'b/'
2960 2959
2961 2960 def diffline(f, revs):
2962 2961 revinfo = b' '.join([b"-r %s" % rev for rev in revs])
2963 2962 return b'diff %s %s' % (revinfo, f)
2964 2963
2965 2964 def isempty(fctx):
2966 2965 return fctx is None or fctx.size() == 0
2967 2966
2968 2967 date1 = dateutil.datestr(ctx1.date())
2969 2968 date2 = dateutil.datestr(ctx2.date())
2970 2969
2971 2970 if not pathfn:
2972 2971 pathfn = lambda f: f
2973 2972
2974 2973 for f1, f2, copyop in _filepairs(modified, added, removed, copy, opts):
2975 2974 content1 = None
2976 2975 content2 = None
2977 2976 fctx1 = None
2978 2977 fctx2 = None
2979 2978 flag1 = None
2980 2979 flag2 = None
2981 2980 if f1:
2982 2981 fctx1 = getfilectx(f1, ctx1)
2983 2982 if opts.git or losedatafn:
2984 2983 flag1 = ctx1.flags(f1)
2985 2984 if f2:
2986 2985 fctx2 = getfilectx(f2, ctx2)
2987 2986 if opts.git or losedatafn:
2988 2987 flag2 = ctx2.flags(f2)
2989 2988 # if binary is True, output "summary" or "base85", but not "text diff"
2990 2989 if opts.text:
2991 2990 binary = False
2992 2991 else:
2993 2992 binary = any(f.isbinary() for f in [fctx1, fctx2] if f is not None)
2994 2993
2995 2994 if losedatafn and not opts.git:
2996 2995 if (
2997 2996 binary
2998 2997 or
2999 2998 # copy/rename
3000 2999 f2 in copy
3001 3000 or
3002 3001 # empty file creation
3003 3002 (not f1 and isempty(fctx2))
3004 3003 or
3005 3004 # empty file deletion
3006 3005 (isempty(fctx1) and not f2)
3007 3006 or
3008 3007 # create with flags
3009 3008 (not f1 and flag2)
3010 3009 or
3011 3010 # change flags
3012 3011 (f1 and f2 and flag1 != flag2)
3013 3012 ):
3014 3013 losedatafn(f2 or f1)
3015 3014
3016 3015 path1 = pathfn(f1 or f2)
3017 3016 path2 = pathfn(f2 or f1)
3018 3017 header = []
3019 3018 if opts.git:
3020 3019 header.append(
3021 3020 b'diff --git %s%s %s%s' % (aprefix, path1, bprefix, path2)
3022 3021 )
3023 3022 if not f1: # added
3024 3023 header.append(b'new file mode %s' % _gitmode[flag2])
3025 3024 elif not f2: # removed
3026 3025 header.append(b'deleted file mode %s' % _gitmode[flag1])
3027 3026 else: # modified/copied/renamed
3028 3027 mode1, mode2 = _gitmode[flag1], _gitmode[flag2]
3029 3028 if mode1 != mode2:
3030 3029 header.append(b'old mode %s' % mode1)
3031 3030 header.append(b'new mode %s' % mode2)
3032 3031 if copyop is not None:
3033 3032 if opts.showsimilarity:
3034 3033 sim = similar.score(ctx1[path1], ctx2[path2]) * 100
3035 3034 header.append(b'similarity index %d%%' % sim)
3036 3035 header.append(b'%s from %s' % (copyop, path1))
3037 3036 header.append(b'%s to %s' % (copyop, path2))
3038 3037 elif revs:
3039 3038 header.append(diffline(path1, revs))
3040 3039
3041 3040 # fctx.is | diffopts | what to | is fctx.data()
3042 3041 # binary() | text nobinary git index | output? | outputted?
3043 3042 # ------------------------------------|----------------------------
3044 3043 # yes | no no no * | summary | no
3045 3044 # yes | no no yes * | base85 | yes
3046 3045 # yes | no yes no * | summary | no
3047 3046 # yes | no yes yes 0 | summary | no
3048 3047 # yes | no yes yes >0 | summary | semi [1]
3049 3048 # yes | yes * * * | text diff | yes
3050 3049 # no | * * * * | text diff | yes
3051 3050 # [1]: hash(fctx.data()) is outputted. so fctx.data() cannot be faked
3052 3051 if binary and (
3053 3052 not opts.git or (opts.git and opts.nobinary and not opts.index)
3054 3053 ):
3055 3054 # fast path: no binary content will be displayed, content1 and
3056 3055 # content2 are only used for equivalent test. cmp() could have a
3057 3056 # fast path.
3058 3057 if fctx1 is not None:
3059 3058 content1 = b'\0'
3060 3059 if fctx2 is not None:
3061 3060 if fctx1 is not None and not fctx1.cmp(fctx2):
3062 3061 content2 = b'\0' # not different
3063 3062 else:
3064 3063 content2 = b'\0\0'
3065 3064 else:
3066 3065 # normal path: load contents
3067 3066 if fctx1 is not None:
3068 3067 content1 = fctx1.data()
3069 3068 if fctx2 is not None:
3070 3069 content2 = fctx2.data()
3071 3070
3072 3071 data1 = (ctx1, fctx1, path1, flag1, content1, date1)
3073 3072 data2 = (ctx2, fctx2, path2, flag2, content2, date2)
3074 3073 yield diffcontent(data1, data2, header, binary, opts)
3075 3074
3076 3075
3077 3076 def diffcontent(data1, data2, header, binary, opts):
3078 3077 """diffs two versions of a file.
3079 3078
3080 3079 data1 and data2 are tuples containg:
3081 3080
3082 3081 * ctx: changeset for the file
3083 3082 * fctx: file context for that file
3084 3083 * path1: name of the file
3085 3084 * flag: flags of the file
3086 3085 * content: full content of the file (can be null in case of binary)
3087 3086 * date: date of the changeset
3088 3087
3089 3088 header: the patch header
3090 3089 binary: whether the any of the version of file is binary or not
3091 3090 opts: user passed options
3092 3091
3093 3092 It exists as a separate function so that extensions like extdiff can wrap
3094 3093 it and use the file content directly.
3095 3094 """
3096 3095
3097 3096 ctx1, fctx1, path1, flag1, content1, date1 = data1
3098 3097 ctx2, fctx2, path2, flag2, content2, date2 = data2
3099 3098 index1 = _gitindex(content1) if path1 in ctx1 else sha1nodeconstants.nullhex
3100 3099 index2 = _gitindex(content2) if path2 in ctx2 else sha1nodeconstants.nullhex
3101 3100 if binary and opts.git and not opts.nobinary:
3102 3101 text = mdiff.b85diff(content1, content2)
3103 3102 if text:
3104 3103 header.append(b'index %s..%s' % (index1, index2))
3105 3104 hunks = ((None, [text]),)
3106 3105 else:
3107 3106 if opts.git and opts.index > 0:
3108 3107 flag = flag1
3109 3108 if flag is None:
3110 3109 flag = flag2
3111 3110 header.append(
3112 3111 b'index %s..%s %s'
3113 3112 % (
3114 3113 index1[0 : opts.index],
3115 3114 index2[0 : opts.index],
3116 3115 _gitmode[flag],
3117 3116 )
3118 3117 )
3119 3118
3120 3119 uheaders, hunks = mdiff.unidiff(
3121 3120 content1,
3122 3121 date1,
3123 3122 content2,
3124 3123 date2,
3125 3124 path1,
3126 3125 path2,
3127 3126 binary=binary,
3128 3127 opts=opts,
3129 3128 )
3130 3129 header.extend(uheaders)
3131 3130 return fctx1, fctx2, header, hunks
3132 3131
3133 3132
3134 3133 def diffstatsum(stats):
3135 3134 maxfile, maxtotal, addtotal, removetotal, binary = 0, 0, 0, 0, False
3136 3135 for f, a, r, b in stats:
3137 3136 maxfile = max(maxfile, encoding.colwidth(f))
3138 3137 maxtotal = max(maxtotal, a + r)
3139 3138 addtotal += a
3140 3139 removetotal += r
3141 3140 binary = binary or b
3142 3141
3143 3142 return maxfile, maxtotal, addtotal, removetotal, binary
3144 3143
3145 3144
3146 3145 def diffstatdata(lines):
3147 3146 diffre = re.compile(br'^diff .*-r [a-z0-9]+\s(.*)$')
3148 3147
3149 3148 results = []
3150 3149 filename, adds, removes, isbinary = None, 0, 0, False
3151 3150
3152 3151 def addresult():
3153 3152 if filename:
3154 3153 results.append((filename, adds, removes, isbinary))
3155 3154
3156 3155 # inheader is used to track if a line is in the
3157 3156 # header portion of the diff. This helps properly account
3158 3157 # for lines that start with '--' or '++'
3159 3158 inheader = False
3160 3159
3161 3160 for line in lines:
3162 3161 if line.startswith(b'diff'):
3163 3162 addresult()
3164 3163 # starting a new file diff
3165 3164 # set numbers to 0 and reset inheader
3166 3165 inheader = True
3167 3166 adds, removes, isbinary = 0, 0, False
3168 3167 if line.startswith(b'diff --git a/'):
3169 3168 filename = gitre.search(line).group(2)
3170 3169 elif line.startswith(b'diff -r'):
3171 3170 # format: "diff -r ... -r ... filename"
3172 3171 filename = diffre.search(line).group(1)
3173 3172 elif line.startswith(b'@@'):
3174 3173 inheader = False
3175 3174 elif line.startswith(b'+') and not inheader:
3176 3175 adds += 1
3177 3176 elif line.startswith(b'-') and not inheader:
3178 3177 removes += 1
3179 3178 elif line.startswith(b'GIT binary patch') or line.startswith(
3180 3179 b'Binary file'
3181 3180 ):
3182 3181 isbinary = True
3183 3182 elif line.startswith(b'rename from'):
3184 3183 filename = line[12:]
3185 3184 elif line.startswith(b'rename to'):
3186 3185 filename += b' => %s' % line[10:]
3187 3186 addresult()
3188 3187 return results
3189 3188
3190 3189
3191 3190 def diffstat(lines, width=80):
3192 3191 output = []
3193 3192 stats = diffstatdata(lines)
3194 3193 maxname, maxtotal, totaladds, totalremoves, hasbinary = diffstatsum(stats)
3195 3194
3196 3195 countwidth = len(str(maxtotal))
3197 3196 if hasbinary and countwidth < 3:
3198 3197 countwidth = 3
3199 3198 graphwidth = width - countwidth - maxname - 6
3200 3199 if graphwidth < 10:
3201 3200 graphwidth = 10
3202 3201
3203 3202 def scale(i):
3204 3203 if maxtotal <= graphwidth:
3205 3204 return i
3206 3205 # If diffstat runs out of room it doesn't print anything,
3207 3206 # which isn't very useful, so always print at least one + or -
3208 3207 # if there were at least some changes.
3209 3208 return max(i * graphwidth // maxtotal, int(bool(i)))
3210 3209
3211 3210 for filename, adds, removes, isbinary in stats:
3212 3211 if isbinary:
3213 3212 count = b'Bin'
3214 3213 else:
3215 3214 count = b'%d' % (adds + removes)
3216 3215 pluses = b'+' * scale(adds)
3217 3216 minuses = b'-' * scale(removes)
3218 3217 output.append(
3219 3218 b' %s%s | %*s %s%s\n'
3220 3219 % (
3221 3220 filename,
3222 3221 b' ' * (maxname - encoding.colwidth(filename)),
3223 3222 countwidth,
3224 3223 count,
3225 3224 pluses,
3226 3225 minuses,
3227 3226 )
3228 3227 )
3229 3228
3230 3229 if stats:
3231 3230 output.append(
3232 3231 _(b' %d files changed, %d insertions(+), %d deletions(-)\n')
3233 3232 % (len(stats), totaladds, totalremoves)
3234 3233 )
3235 3234
3236 3235 return b''.join(output)
3237 3236
3238 3237
3239 3238 def diffstatui(*args, **kw):
3240 3239 """like diffstat(), but yields 2-tuples of (output, label) for
3241 3240 ui.write()
3242 3241 """
3243 3242
3244 3243 for line in diffstat(*args, **kw).splitlines():
3245 3244 if line and line[-1] in b'+-':
3246 3245 name, graph = line.rsplit(b' ', 1)
3247 3246 yield (name + b' ', b'')
3248 3247 m = re.search(br'\++', graph)
3249 3248 if m:
3250 3249 yield (m.group(0), b'diffstat.inserted')
3251 3250 m = re.search(br'-+', graph)
3252 3251 if m:
3253 3252 yield (m.group(0), b'diffstat.deleted')
3254 3253 else:
3255 3254 yield (line, b'')
3256 3255 yield (b'\n', b'')
General Comments 0
You need to be logged in to leave comments. Login now