1#!/usr/bin/env python 2# -*- coding: utf-8 -*- 3 4""" 5Term based tool to view *colored*, *incremental* diff in a *Git/Mercurial/Svn* 6workspace or from stdin, with *side by side* and *auto pager* support. Requires 7python (>= 2.5.0) and ``less``. 8""" 9 10import sys 11import os 12import re 13import signal 14import subprocess 15import select 16import difflib 17 18META_INFO = { 19 'version' : '1.2', 20 'license' : 'BSD-3', 21 'author' : 'Matt Wang', 22 'email' : 'mattwyl(@)gmail(.)com', 23 'url' : 'https://github.com/ymattw/ydiff', 24 'keywords' : 'colored incremental side-by-side diff', 25 'description' : ('View colored, incremental diff in a workspace or from ' 26 'stdin, with side by side and auto pager support') 27} 28 29if sys.hexversion < 0x02050000: 30 raise SystemExit('*** Requires python >= 2.5.0') # pragma: no cover 31 32# Python < 2.6 does not have next() 33try: 34 next 35except NameError: 36 def next(obj): 37 return obj.next() 38 39try: 40 unicode 41except NameError: 42 unicode = str 43 44COLORS = { 45 'reset' : '\x1b[0m', 46 'underline' : '\x1b[4m', 47 'reverse' : '\x1b[7m', 48 'red' : '\x1b[31m', 49 'green' : '\x1b[32m', 50 'yellow' : '\x1b[33m', 51 'blue' : '\x1b[34m', 52 'magenta' : '\x1b[35m', 53 'cyan' : '\x1b[36m', 54 'lightred' : '\x1b[1;31m', 55 'lightgreen' : '\x1b[1;32m', 56 'lightyellow' : '\x1b[1;33m', 57 'lightblue' : '\x1b[1;34m', 58 'lightmagenta' : '\x1b[1;35m', 59 'lightcyan' : '\x1b[1;36m', 60} 61 62# Keys for revision control probe, diff and log (optional) with diff 63VCS_INFO = { 64 'Git': { 65 'probe': ['git', 'rev-parse'], 66 'diff': ['git', 'diff', '--no-ext-diff'], 67 'log': ['git', 'log', '--patch'], 68 }, 69 'Mercurial': { 70 'probe': ['hg', 'summary'], 71 'diff': ['hg', 'diff'], 72 'log': ['hg', 'log', '--patch'], 73 }, 74 'Perforce': { 75 'probe': ['p4', 'dirs', '.'], 76 'diff': ['p4', 'diff'], 77 'log': None, 78 }, 79 'Svn': { 80 'probe': ['svn', 'info'], 81 'diff': ['svn', 'diff'], 82 'log': ['svn', 'log', '--diff', '--use-merge-history'], 83 }, 84} 85 86 87def revision_control_probe(): 88 """Returns version control name (key in VCS_INFO) or None.""" 89 for vcs_name, ops in VCS_INFO.items(): 90 if check_command_status(ops.get('probe')): 91 return vcs_name 92 93 94def revision_control_diff(vcs_name, args): 95 """Return diff from revision control system.""" 96 cmd = VCS_INFO[vcs_name]['diff'] 97 return subprocess.Popen(cmd + args, stdout=subprocess.PIPE).stdout 98 99 100def revision_control_log(vcs_name, args): 101 """Return log from revision control system or None.""" 102 cmd = VCS_INFO[vcs_name].get('log') 103 if cmd is not None: 104 return subprocess.Popen(cmd + args, stdout=subprocess.PIPE).stdout 105 106 107def colorize(text, start_color, end_color='reset'): 108 return COLORS[start_color] + text + COLORS[end_color] 109 110 111def strsplit(text, width): 112 r"""strsplit() splits a given string into two substrings, respecting the 113 escape sequences (in a global var COLORS). 114 115 It returns 3-tuple: (first string, second string, number of visible chars 116 in the first string). 117 118 If some color was active at the splitting point, then the first string is 119 appended with the resetting sequence, and the second string is prefixed 120 with all active colors. 121 """ 122 first = "" 123 second = "" 124 found_colors = "" 125 chars_cnt = 0 126 bytes_cnt = 0 127 while text: 128 append_len = 0 129 if text[0] == "\x1b": 130 color_end = text.find("m") 131 if color_end != -1: 132 color = text[:color_end + 1] 133 if color == COLORS["reset"]: 134 found_colors = "" 135 else: 136 found_colors += color 137 append_len = len(color) 138 139 if append_len == 0: 140 # Current string does not start with any escape sequence, so, 141 # either add one more visible char to the "first" string, or 142 # break if that string is already large enough. 143 if chars_cnt >= width: 144 break 145 chars_cnt += 1 146 append_len = 1 147 148 first += text[:append_len] 149 text = text[append_len:] 150 bytes_cnt += append_len 151 152 second = text 153 154 # If the first string has some active colors at the splitting point, 155 # reset it and append the same colors to the second string 156 if found_colors: 157 return first + COLORS['reset'], found_colors + second, chars_cnt 158 159 return (first, second, chars_cnt) 160 161 162def strtrim(text, width, wrap_char, pad): 163 r"""strtrim() trims given string respecting the escape sequences (using 164 strsplit), so that if text is larger than width, it's trimmed to have 165 width-1 chars plus wrap_char. Additionally, if pad is True, short strings 166 are padded with space to have exactly needed width. 167 168 Returns resulting string. 169 """ 170 text, _, tlen = strsplit(text, width + 1) 171 if tlen > width: 172 text, _, _ = strsplit(text, width - 1) 173 text += wrap_char 174 elif pad: 175 # The string is short enough, but it might need to be padded. 176 text = '%s%*s' % (text, width - tlen, '') 177 return text 178 179 180class Hunk(object): 181 182 def __init__(self, hunk_headers, hunk_meta, old_addr, new_addr): 183 self._hunk_headers = hunk_headers 184 self._hunk_meta = hunk_meta 185 self._old_addr = old_addr # tuple (start, offset) 186 self._new_addr = new_addr # tuple (start, offset) 187 self._hunk_list = [] # list of tuple (attr, line) 188 189 def append(self, hunk_line): 190 """hunk_line is a 2-element tuple: (attr, text), where attr is: 191 '-': old, '+': new, ' ': common 192 """ 193 self._hunk_list.append(hunk_line) 194 195 def mdiff(self): 196 r"""The difflib._mdiff() function returns an interator which returns a 197 tuple: (from line tuple, to line tuple, boolean flag) 198 199 from/to line tuple -- (line num, line text) 200 line num -- integer or None (to indicate a context separation) 201 line text -- original line text with following markers inserted: 202 '\0+' -- marks start of added text 203 '\0-' -- marks start of deleted text 204 '\0^' -- marks start of changed text 205 '\1' -- marks end of added/deleted/changed text 206 207 boolean flag -- None indicates context separation, True indicates 208 either "from" or "to" line contains a change, otherwise False. 209 """ 210 return difflib._mdiff(self._get_old_text(), self._get_new_text()) 211 212 def _get_old_text(self): 213 return [line for (attr, line) in self._hunk_list if attr != '+'] 214 215 def _get_new_text(self): 216 return [line for (attr, line) in self._hunk_list if attr != '-'] 217 218 def is_completed(self): 219 old_completed = self._old_addr[1] == len(self._get_old_text()) 220 new_completed = self._new_addr[1] == len(self._get_new_text()) 221 return old_completed and new_completed 222 223 224class UnifiedDiff(object): 225 226 def __init__(self, headers, old_path, new_path, hunks): 227 self._headers = headers 228 self._old_path = old_path 229 self._new_path = new_path 230 self._hunks = hunks 231 232 def is_old_path(self, line): 233 return line.startswith('--- ') 234 235 def is_new_path(self, line): 236 return line.startswith('+++ ') 237 238 def is_hunk_meta(self, line): 239 """Minimal valid hunk meta is like '@@ -1 +1 @@', note extra chars 240 might occur after the ending @@, e.g. in git log. '## ' usually 241 indicates svn property changes in output from `svn log --diff` 242 """ 243 return (line.startswith('@@ -') and line.find(' @@') >= 8 or 244 line.startswith('## -') and line.find(' ##') >= 8) 245 246 def parse_hunk_meta(self, hunk_meta): 247 # @@ -3,7 +3,6 @@ 248 a = hunk_meta.split()[1].split(',') # -3 7 249 if len(a) > 1: 250 old_addr = (int(a[0][1:]), int(a[1])) 251 else: 252 # @@ -1 +1,2 @@ 253 old_addr = (int(a[0][1:]), 1) 254 255 b = hunk_meta.split()[2].split(',') # +3 6 256 if len(b) > 1: 257 new_addr = (int(b[0][1:]), int(b[1])) 258 else: 259 # @@ -0,0 +1 @@ 260 new_addr = (int(b[0][1:]), 1) 261 262 return (old_addr, new_addr) 263 264 def parse_hunk_line(self, line): 265 return (line[0], line[1:]) 266 267 def is_old(self, line): 268 """Exclude old path and header line from svn log --diff output, allow 269 '----' likely to see in diff from yaml file 270 """ 271 return (line.startswith('-') and not self.is_old_path(line) and 272 not re.match(r'^-{72}$', line.rstrip())) 273 274 def is_new(self, line): 275 return line.startswith('+') and not self.is_new_path(line) 276 277 def is_common(self, line): 278 return line.startswith(' ') 279 280 def is_eof(self, line): 281 # \ No newline at end of file 282 # \ No newline at end of property 283 return line.startswith(r'\ No newline at end of') 284 285 def is_only_in_dir(self, line): 286 return line.startswith('Only in ') 287 288 def is_binary_differ(self, line): 289 return re.match('^Binary files .* differ$', line.rstrip()) 290 291 292class PatchStream(object): 293 294 def __init__(self, diff_hdl): 295 self._diff_hdl = diff_hdl 296 self._stream_header_size = 0 297 self._stream_header = [] 298 299 # Test whether stream is empty by read 1 line 300 line = self._diff_hdl.readline() 301 if not line: 302 self._is_empty = True 303 else: 304 self._stream_header.append(line) 305 self._stream_header_size += 1 306 self._is_empty = False 307 308 def is_empty(self): 309 return self._is_empty 310 311 def read_stream_header(self, stream_header_size): 312 """Returns a small chunk for patch type detect, suppose to call once""" 313 for i in range(1, stream_header_size): 314 line = self._diff_hdl.readline() 315 if not line: 316 break 317 self._stream_header.append(line) 318 self._stream_header_size += 1 319 return self._stream_header 320 321 def __iter__(self): 322 for line in self._stream_header: 323 yield line 324 try: 325 for line in self._diff_hdl: 326 yield line 327 except RuntimeError: 328 return 329 330 331class PatchStreamForwarder(object): 332 """A blocking stream forwarder use `select` and line buffered mode. Feed 333 input stream to a diff format translator and read output stream from it. 334 Note input stream is non-seekable, and upstream has eaten some lines. 335 """ 336 def __init__(self, istream, translator): 337 assert isinstance(istream, PatchStream) 338 assert isinstance(translator, subprocess.Popen) 339 self._istream = iter(istream) 340 self._in = translator.stdin 341 self._out = translator.stdout 342 343 def _can_read(self, timeout=0): 344 return select.select([self._out.fileno()], [], [], timeout)[0] 345 346 def _forward_line(self): 347 try: 348 line = next(self._istream) 349 self._in.write(line) 350 except StopIteration: 351 self._in.close() 352 353 def __iter__(self): 354 while True: 355 if self._can_read(): 356 line = self._out.readline() 357 if line: 358 yield line 359 else: 360 return 361 elif not self._in.closed: 362 self._forward_line() 363 364 365class DiffParser(object): 366 367 def __init__(self, stream): 368 369 header = [decode(line) for line in stream.read_stream_header(100)] 370 size = len(header) 371 372 if size >= 4 and (header[0].startswith('*** ') and 373 header[1].startswith('--- ') and 374 header[2].rstrip() == '***************' and 375 header[3].startswith('*** ') and 376 header[3].rstrip().endswith(' ****')): 377 # For context diff, try use `filterdiff` to translate it to unified 378 # format and provide a new stream 379 # 380 self._type = 'context' 381 try: 382 # Use line buffered mode so that to readline() in block mode 383 self._translator = subprocess.Popen( 384 ['filterdiff', '--format=unified'], stdin=subprocess.PIPE, 385 stdout=subprocess.PIPE, bufsize=1) 386 except OSError: 387 raise SystemExit('*** Context diff support depends on ' 388 'filterdiff') 389 self._stream = PatchStreamForwarder(stream, self._translator) 390 return 391 392 for n in range(size): 393 if (header[n].startswith('--- ') and (n < size - 1) and 394 header[n + 1].startswith('+++ ')): 395 self._type = 'unified' 396 self._stream = stream 397 break 398 else: 399 # `filterdiff` translates unknown diff to nothing, fall through to 400 # unified diff give ydiff a chance to show everything as headers 401 # 402 sys.stderr.write("*** unknown format, fall through to 'unified'\n") 403 self._type = 'unified' 404 self._stream = stream 405 406 def get_diff_generator(self): 407 """parse all diff lines, construct a list of UnifiedDiff objects""" 408 diff = UnifiedDiff([], None, None, []) 409 headers = [] 410 411 for line in self._stream: 412 line = decode(line) 413 414 if diff.is_old_path(line): 415 # This is a new diff when current hunk is not yet genreated or 416 # is completed. We yield previous diff if exists and construct 417 # a new one for this case. Otherwise it's acutally an 'old' 418 # line starts with '--- '. 419 # 420 if (not diff._hunks or diff._hunks[-1].is_completed()): 421 if diff._old_path and diff._new_path and diff._hunks: 422 yield diff 423 diff = UnifiedDiff(headers, line, None, []) 424 headers = [] 425 else: 426 diff._hunks[-1].append(diff.parse_hunk_line(line)) 427 428 elif diff.is_new_path(line) and diff._old_path: 429 if not diff._new_path: 430 diff._new_path = line 431 else: 432 diff._hunks[-1].append(diff.parse_hunk_line(line)) 433 434 elif diff.is_hunk_meta(line): 435 hunk_meta = line 436 try: 437 old_addr, new_addr = diff.parse_hunk_meta(hunk_meta) 438 except (IndexError, ValueError): 439 raise RuntimeError('invalid hunk meta: %s' % hunk_meta) 440 hunk = Hunk(headers, hunk_meta, old_addr, new_addr) 441 headers = [] 442 diff._hunks.append(hunk) 443 444 elif diff._hunks and not headers and (diff.is_old(line) or 445 diff.is_new(line) or 446 diff.is_common(line)): 447 diff._hunks[-1].append(diff.parse_hunk_line(line)) 448 449 elif diff.is_eof(line): 450 # ignore 451 pass 452 453 elif diff.is_only_in_dir(line) or diff.is_binary_differ(line): 454 # 'Only in foo:' and 'Binary files ... differ' are considered 455 # as separate diffs, so yield current diff, then this line 456 # 457 if diff._old_path and diff._new_path and diff._hunks: 458 # Current diff is comppletely constructed 459 yield diff 460 headers.append(line) 461 yield UnifiedDiff(headers, '', '', []) 462 headers = [] 463 diff = UnifiedDiff([], None, None, []) 464 465 else: 466 # All other non-recognized lines are considered as headers or 467 # hunk headers respectively 468 # 469 headers.append(line) 470 471 # Validate and yield the last patch set if it is not yielded yet 472 if diff._old_path: 473 assert diff._new_path is not None 474 if diff._hunks: 475 assert len(diff._hunks[-1]._hunk_meta) > 0 476 assert len(diff._hunks[-1]._hunk_list) > 0 477 yield diff 478 479 if headers: 480 # Tolerate dangling headers, just yield a UnifiedDiff object with 481 # only header lines 482 # 483 yield UnifiedDiff(headers, '', '', []) 484 485 486class DiffMarker(object): 487 488 def __init__(self, side_by_side=False, width=0, tab_width=8, wrap=False): 489 self._side_by_side = side_by_side 490 self._width = width 491 self._tab_width = tab_width 492 self._wrap = wrap 493 494 def markup(self, diff): 495 """Returns a generator""" 496 if self._side_by_side: 497 for line in self._markup_side_by_side(diff): 498 yield line 499 else: 500 for line in self._markup_traditional(diff): 501 yield line 502 503 def _markup_traditional(self, diff): 504 """Returns a generator""" 505 for line in diff._headers: 506 yield self._markup_header(line) 507 508 yield self._markup_old_path(diff._old_path) 509 yield self._markup_new_path(diff._new_path) 510 511 for hunk in diff._hunks: 512 for hunk_header in hunk._hunk_headers: 513 yield self._markup_hunk_header(hunk_header) 514 yield self._markup_hunk_meta(hunk._hunk_meta) 515 for old, new, changed in hunk.mdiff(): 516 if changed: 517 if not old[0]: 518 # The '+' char after \x00 is kept 519 # DEBUG: yield 'NEW: %s %s\n' % (old, new) 520 line = new[1].strip('\x00\x01') 521 yield self._markup_new(line) 522 elif not new[0]: 523 # The '-' char after \x00 is kept 524 # DEBUG: yield 'OLD: %s %s\n' % (old, new) 525 line = old[1].strip('\x00\x01') 526 yield self._markup_old(line) 527 else: 528 # DEBUG: yield 'CHG: %s %s\n' % (old, new) 529 yield (self._markup_old('-') + 530 self._markup_mix(old[1], 'red')) 531 yield (self._markup_new('+') + 532 self._markup_mix(new[1], 'green')) 533 else: 534 yield self._markup_common(' ' + old[1]) 535 536 def _markup_side_by_side(self, diff): 537 """Returns a generator""" 538 539 def _normalize(line): 540 index = 0 541 while True: 542 index = line.find('\t', index) 543 if (index == -1): 544 break 545 # ignore special codes 546 offset = (line.count('\x00', 0, index) * 2 + 547 line.count('\x01', 0, index)) 548 # next stop modulo tab width 549 width = self._tab_width - (index - offset) % self._tab_width 550 line = line[:index] + ' ' * width + line[(index + 1):] 551 return (line 552 .replace('\n', '') 553 .replace('\r', '')) 554 555 def _fit_with_marker_mix(text, base_color): 556 """Wrap input text which contains mdiff tags, markup at the 557 meantime 558 """ 559 out = [COLORS[base_color]] 560 tag_re = re.compile(r'\x00[+^-]|\x01') 561 562 while text: 563 if text.startswith('\x00-'): # del 564 out.append(COLORS['reverse'] + COLORS[base_color]) 565 text = text[2:] 566 elif text.startswith('\x00+'): # add 567 out.append(COLORS['reverse'] + COLORS[base_color]) 568 text = text[2:] 569 elif text.startswith('\x00^'): # change 570 out.append(COLORS['underline'] + COLORS[base_color]) 571 text = text[2:] 572 elif text.startswith('\x01'): # reset 573 if len(text) > 1: 574 out.append(COLORS['reset'] + COLORS[base_color]) 575 text = text[1:] 576 else: 577 # FIXME: utf-8 wchar might break the rule here, e.g. 578 # u'\u554a' takes double width of a single letter, also 579 # this depends on your terminal font. I guess audience of 580 # this tool never put that kind of symbol in their code :-) 581 # 582 out.append(text[0]) 583 text = text[1:] 584 585 out.append(COLORS['reset']) 586 587 return ''.join(out) 588 589 # Set up number width, note last hunk might be empty 590 try: 591 (start, offset) = diff._hunks[-1]._old_addr 592 max1 = start + offset - 1 593 (start, offset) = diff._hunks[-1]._new_addr 594 max2 = start + offset - 1 595 except IndexError: 596 max1 = max2 = 0 597 num_width = max(len(str(max1)), len(str(max2))) 598 599 # Set up line width 600 width = self._width 601 if width <= 0: 602 # Autodetection of text width according to terminal size 603 try: 604 # Each line is like 'nnn TEXT nnn TEXT\n', so width is half of 605 # [terminal size minus the line number columns and 3 separating 606 # spaces 607 # 608 width = (terminal_size()[0] - num_width * 2 - 3) // 2 609 except Exception: 610 # If terminal detection failed, set back to default 611 width = 80 612 613 # Setup lineno and line format 614 left_num_fmt = colorize('%%(left_num)%ds' % num_width, 'yellow') 615 right_num_fmt = colorize('%%(right_num)%ds' % num_width, 'yellow') 616 line_fmt = (left_num_fmt + ' %(left)s ' + COLORS['reset'] + 617 right_num_fmt + ' %(right)s\n') 618 619 # yield header, old path and new path 620 for line in diff._headers: 621 yield self._markup_header(line) 622 yield self._markup_old_path(diff._old_path) 623 yield self._markup_new_path(diff._new_path) 624 625 # yield hunks 626 for hunk in diff._hunks: 627 for hunk_header in hunk._hunk_headers: 628 yield self._markup_hunk_header(hunk_header) 629 yield self._markup_hunk_meta(hunk._hunk_meta) 630 for old, new, changed in hunk.mdiff(): 631 if old[0]: 632 left_num = str(hunk._old_addr[0] + int(old[0]) - 1) 633 else: 634 left_num = ' ' 635 636 if new[0]: 637 right_num = str(hunk._new_addr[0] + int(new[0]) - 1) 638 else: 639 right_num = ' ' 640 641 left = _normalize(old[1]) 642 right = _normalize(new[1]) 643 644 if changed: 645 if not old[0]: 646 left = '' 647 right = right.rstrip('\x01') 648 if right.startswith('\x00+'): 649 right = right[2:] 650 right = self._markup_new(right) 651 elif not new[0]: 652 left = left.rstrip('\x01') 653 if left.startswith('\x00-'): 654 left = left[2:] 655 left = self._markup_old(left) 656 right = '' 657 else: 658 left = _fit_with_marker_mix(left, 'red') 659 right = _fit_with_marker_mix(right, 'green') 660 else: 661 left = self._markup_common(left) 662 right = self._markup_common(right) 663 664 if self._wrap: 665 # Need to wrap long lines, so here we'll iterate, 666 # shaving off `width` chars from both left and right 667 # strings, until both are empty. Also, line number needs to 668 # be printed only for the first part. 669 lncur = left_num 670 rncur = right_num 671 while left or right: 672 # Split both left and right lines, preserving escaping 673 # sequences correctly. 674 lcur, left, llen = strsplit(left, width) 675 rcur, right, rlen = strsplit(right, width) 676 677 # Pad left line with spaces if needed 678 if llen < width: 679 lcur = '%s%*s' % (lcur, width - llen, '') 680 681 yield line_fmt % { 682 'left_num': lncur, 683 'left': lcur, 684 'right_num': rncur, 685 'right': rcur 686 } 687 688 # Clean line numbers for further iterations 689 lncur = '' 690 rncur = '' 691 else: 692 # Don't need to wrap long lines; instead, a trailing '>' 693 # char needs to be appended. 694 wrap_char = colorize('>', 'lightmagenta') 695 left = strtrim(left, width, wrap_char, len(right) > 0) 696 right = strtrim(right, width, wrap_char, False) 697 698 yield line_fmt % { 699 'left_num': left_num, 700 'left': left, 701 'right_num': right_num, 702 'right': right 703 } 704 705 def _markup_header(self, line): 706 return colorize(line, 'cyan') 707 708 def _markup_old_path(self, line): 709 return colorize(line, 'yellow') 710 711 def _markup_new_path(self, line): 712 return colorize(line, 'yellow') 713 714 def _markup_hunk_header(self, line): 715 return colorize(line, 'lightcyan') 716 717 def _markup_hunk_meta(self, line): 718 return colorize(line, 'lightblue') 719 720 def _markup_common(self, line): 721 return colorize(line, 'reset') 722 723 def _markup_old(self, line): 724 return colorize(line, 'lightred') 725 726 def _markup_new(self, line): 727 return colorize(line, 'green') 728 729 def _markup_mix(self, line, base_color): 730 del_code = COLORS['reverse'] + COLORS[base_color] 731 add_code = COLORS['reverse'] + COLORS[base_color] 732 chg_code = COLORS['underline'] + COLORS[base_color] 733 rst_code = COLORS['reset'] + COLORS[base_color] 734 line = line.replace('\x00-', del_code) 735 line = line.replace('\x00+', add_code) 736 line = line.replace('\x00^', chg_code) 737 line = line.replace('\x01', rst_code) 738 return colorize(line, base_color) 739 740 741def markup_to_pager(stream, opts): 742 """Pipe unified diff stream to pager (less). 743 744 Note: have to create pager Popen object before the translator Popen object 745 in PatchStreamForwarder, otherwise the `stdin=subprocess.PIPE` would cause 746 trouble to the translator pipe (select() never see EOF after input stream 747 ended), most likely python bug 12607 (http://bugs.python.org/issue12607) 748 which was fixed in python 2.7.3. 749 750 See issue #30 (https://github.com/ymattw/ydiff/issues/30) for more 751 information. 752 """ 753 pager_cmd = [opts.pager] 754 pager_opts = (opts.pager_options.split(' ') 755 if opts.pager_options is not None 756 else None) 757 758 if opts.pager is None: 759 pager_cmd = ['less'] 760 if not os.getenv('LESS') and not opts.pager_options: 761 # Args stolen from git source: 762 # github.com/git/git/blob/master/pager.c 763 pager_opts = ['-FRSX', '--shift 1'] 764 765 pager_opts = pager_opts if pager_opts is not None else [] 766 pager_cmd.extend(pager_opts) 767 pager = subprocess.Popen( 768 pager_cmd, stdin=subprocess.PIPE, stdout=sys.stdout) 769 770 diffs = DiffParser(stream).get_diff_generator() 771 for diff in diffs: 772 marker = DiffMarker(side_by_side=opts.side_by_side, width=opts.width, 773 tab_width=opts.tab_width, wrap=opts.wrap) 774 color_diff = marker.markup(diff) 775 for line in color_diff: 776 pager.stdin.write(line.encode('utf-8')) 777 778 pager.stdin.close() 779 pager.wait() 780 781 782def check_command_status(arguments): 783 """Return True if command returns 0.""" 784 try: 785 return subprocess.call( 786 arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 787 except OSError: 788 return False 789 790 791def decode(line): 792 """Decode UTF-8 if necessary.""" 793 if isinstance(line, unicode): 794 return line 795 796 for encoding in ['utf-8', 'latin1']: 797 try: 798 return line.decode(encoding) 799 except UnicodeDecodeError: 800 pass 801 802 return '*** ydiff: undecodable bytes ***\n' 803 804 805def terminal_size(): 806 """Returns terminal size. Taken from https://gist.github.com/marsam/7268750 807 but removed win32 support which depends on 3rd party extension. 808 """ 809 width, height = None, None 810 try: 811 import struct 812 import fcntl 813 import termios 814 s = struct.pack('HHHH', 0, 0, 0, 0) 815 x = fcntl.ioctl(1, termios.TIOCGWINSZ, s) 816 height, width = struct.unpack('HHHH', x)[0:2] 817 except (IOError, AttributeError): 818 pass 819 return width, height 820 821 822def main(): 823 if sys.platform != 'win32': 824 signal.signal(signal.SIGPIPE, signal.SIG_DFL) 825 signal.signal(signal.SIGINT, signal.SIG_DFL) 826 827 from optparse import (OptionParser, BadOptionError, AmbiguousOptionError, 828 OptionGroup) 829 830 class PassThroughOptionParser(OptionParser): 831 """Stop parsing on first unknown option (e.g. --cached, -U10) and pass 832 them down. Note the `opt_str` in exception object does not give us 833 chance to take the full option back, e.g. for '-U10' it will only 834 contain '-U' and the '10' part will be lost. Ref: http://goo.gl/IqY4A 835 (on stackoverflow). My hack is to try parse and insert a '--' in place 836 and parse again. Let me know if someone has better solution. 837 """ 838 def _process_args(self, largs, rargs, values): 839 left = largs[:] 840 right = rargs[:] 841 try: 842 OptionParser._process_args(self, left, right, values) 843 except (BadOptionError, AmbiguousOptionError): 844 parsed_num = len(rargs) - len(right) - 1 845 rargs.insert(parsed_num, '--') 846 OptionParser._process_args(self, largs, rargs, values) 847 848 usage = """%prog [options] [file|dir ...]""" 849 parser = PassThroughOptionParser( 850 usage=usage, description=META_INFO['description'], 851 version='%%prog %s' % META_INFO['version']) 852 parser.add_option( 853 '-s', '--side-by-side', action='store_true', 854 help='enable side-by-side mode') 855 parser.add_option( 856 '-w', '--width', type='int', default=80, metavar='N', 857 help='set text width for side-by-side mode, 0 for auto detection, ' 858 'default is 80') 859 parser.add_option( 860 '-l', '--log', action='store_true', 861 help='show log with changes from revision control') 862 parser.add_option( 863 '-c', '--color', default='auto', metavar='M', 864 help="""colorize mode 'auto' (default), 'always', or 'never'""") 865 parser.add_option( 866 '-t', '--tab-width', type='int', default=8, metavar='N', 867 help="""convert tab characters to this many spaces (default: 8)""") 868 parser.add_option( 869 '', '--wrap', action='store_true', 870 help='wrap long lines in side-by-side view') 871 parser.add_option( 872 '-p', '--pager', metavar='M', 873 help="""pager application, suggested values are 'less' """ 874 """or 'cat'""") 875 parser.add_option( 876 '-o', '--pager-options', metavar='M', 877 help="""options to supply to pager application""") 878 879 # Hack: use OptionGroup text for extra help message after option list 880 option_group = OptionGroup( 881 parser, 'Note', ('Option parser will stop on first unknown option ' 882 'and pass them down to underneath revision control. ' 883 'Environment variable YDIFF_OPTIONS may be used to ' 884 'specify default options that will be placed at the ' 885 'beginning of the argument list.')) 886 parser.add_option_group(option_group) 887 888 # Place possible options defined in YDIFF_OPTIONS at the beginning of argv 889 ydiff_opts = [x for x in os.getenv('YDIFF_OPTIONS', '').split(' ') if x] 890 891 # TODO: Deprecate CDIFF_OPTIONS. Fall back to it and warn (for now). 892 if not ydiff_opts: 893 cdiff_opts = [x for x in os.getenv('CDIFF_OPTIONS', '').split(' ') 894 if x] 895 if cdiff_opts: 896 sys.stderr.write('*** CDIFF_OPTIONS will be depreated soon, ' 897 'please use YDIFF_OPTIONS instead\n') 898 ydiff_opts = cdiff_opts 899 900 opts, args = parser.parse_args(ydiff_opts + sys.argv[1:]) 901 902 if not sys.stdin.isatty(): 903 diff_hdl = (sys.stdin.buffer if hasattr(sys.stdin, 'buffer') 904 else sys.stdin) 905 else: 906 vcs_name = revision_control_probe() 907 if vcs_name is None: 908 supported_vcs = ', '.join(sorted(VCS_INFO.keys())) 909 sys.stderr.write('*** Not in a supported workspace, supported are:' 910 ' %s\n' % supported_vcs) 911 return 1 912 913 if opts.log: 914 diff_hdl = revision_control_log(vcs_name, args) 915 if diff_hdl is None: 916 sys.stderr.write('*** %s does not support log command.\n' % 917 vcs_name) 918 return 1 919 else: 920 # 'diff' is a must have feature. 921 diff_hdl = revision_control_diff(vcs_name, args) 922 923 stream = PatchStream(diff_hdl) 924 925 # Don't let empty diff pass thru 926 if stream.is_empty(): 927 return 0 928 929 if (opts.color == 'always' or 930 (opts.color == 'auto' and sys.stdout.isatty())): 931 markup_to_pager(stream, opts) 932 else: 933 # pipe out stream untouched to make sure it is still a patch 934 byte_output = (sys.stdout.buffer if hasattr(sys.stdout, 'buffer') 935 else sys.stdout) 936 for line in stream: 937 byte_output.write(line) 938 939 if diff_hdl is not sys.stdin: 940 diff_hdl.close() 941 942 return 0 943 944 945if __name__ == '__main__': 946 sys.exit(main()) 947 948# vim:set et sts=4 sw=4 tw=79: 949