cdiff.py 28KB

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