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