cdiff.py 33KB

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