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