difftool launchers: better behavior when amending commits
[git-cola.git] / cola / utils.py
blobfa3cbdde371d110e684190a1c6d89b06310227e1
1 #!/usr/bin/env python
2 # Copyright (c) 2008 David Aguilar
3 import os
4 import re
5 import sys
6 import platform
7 import subprocess
8 from glob import glob
9 from cStringIO import StringIO
11 from cola import defaults
12 from cola.exception import ColaException
14 PREFIX = os.path.realpath(os.path.dirname(os.path.dirname(sys.argv[0])))
15 QMDIR = os.path.join(PREFIX, 'share', 'cola', 'qm')
16 ICONSDIR = os.path.join(PREFIX, 'share', 'cola', 'icons')
17 STYLEDIR = os.path.join(PREFIX, 'share', 'cola', 'styles')
18 DOCDIR = os.path.join(PREFIX, 'share', 'doc', 'cola')
20 KNOWN_FILE_TYPES = {
21 'ascii c': 'c.png',
22 'python': 'script.png',
23 'ruby': 'script.png',
24 'shell': 'script.png',
25 'perl': 'script.png',
26 'java': 'script.png',
27 'assembler': 'binary.png',
28 'binary': 'binary.png',
29 'byte': 'binary.png',
30 'image': 'image.png',
33 class RunCommandException(ColaException):
34 """Thrown when something bad happened when we tried to run the
35 subprocess."""
36 pass
38 def run_cmd(*command):
39 """
40 Runs a *command argument list and returns the output.
41 e.g. run_cmd("echo", "hello", "world")
42 """
43 # Start the process
44 try:
45 proc = subprocess.Popen(command,
46 stdout=subprocess.PIPE,
47 stderr=subprocess.PIPE)
49 # Wait for the process to return
50 stdout_value = proc.stdout.read()
51 proc.stdout.close()
52 proc.wait()
53 status = proc.poll()
55 # Strip off trailing whitespace by default
56 return stdout_value.rstrip()
57 except:
58 raise RunCommandException('ERROR Running: [%s]' % ' '.join(command))
61 def get_qm_for_locale(locale):
62 regex = re.compile(r'([^\.])+\..*$')
63 match = regex.match(locale)
64 if match:
65 locale = match.group(1)
67 basename = locale.split('_')[0]
69 return os.path.join(QMDIR, basename +'.qm')
71 def get_resource_dirs(styledir):
72 return [ r for r in glob(styledir+ '/*') if os.path.isdir(r) ]
74 def get_stylesheet(name):
75 stylesheet = os.path.join(STYLEDIR, name+'.qss')
76 if os.path.exists(stylesheet):
77 return stylesheet
78 else:
79 return None
81 def get_htmldocs():
82 return os.path.join(DOCDIR, 'git-cola.html')
84 def ident_file_type(filename):
85 """Returns an icon based on the contents of filename."""
86 if os.path.exists(filename):
87 fileinfo = run_cmd('file','-b',filename)
88 for filetype, iconname in KNOWN_FILE_TYPES.iteritems():
89 if filetype in fileinfo.lower():
90 return iconname
91 else:
92 return 'removed.png'
93 # Fallback for modified files of an unknown type
94 return 'generic.png'
96 def get_file_icon(filename):
97 """
98 Returns the full path to an icon file corresponding to
99 filename"s contents.
101 icon_file = ident_file_type(filename)
102 return get_icon(icon_file)
104 def get_icon(icon_file):
105 return os.path.join(ICONSDIR, icon_file)
107 def fork(*args):
108 if os.name in ('nt', 'dos'):
109 for path in os.environ['PATH'].split(os.pathsep):
110 file = os.path.join(path, args[0]) + ".exe"
111 try:
112 return os.spawnv(os.P_NOWAIT, file, (file,) + args[1:])
113 except os.error:
114 pass
115 raise IOError('cannot find executable: %s' % program)
116 else:
117 argv = map(shell_quote, args)
118 return os.system(' '.join(argv) + '&')
120 # c = a - b
121 def sublist(a,b):
122 c = []
123 for item in a:
124 if item not in b:
125 c.append(item)
126 return c
128 __grep_cache = {}
129 def grep(pattern, items, squash=True):
130 isdict = type(items) is dict
131 if pattern in __grep_cache:
132 regex = __grep_cache[pattern]
133 else:
134 regex = __grep_cache[pattern] = re.compile(pattern)
135 matched = []
136 matchdict = {}
137 for item in items:
138 match = regex.match(item)
139 if not match: continue
140 groups = match.groups()
141 if not groups:
142 subitems = match.group(0)
143 else:
144 if len(groups) == 1:
145 subitems = groups[0]
146 else:
147 subitems = list(groups)
148 if isdict:
149 matchdict[item] = items[item]
150 else:
151 matched.append(subitems)
153 if isdict:
154 return matchdict
155 else:
156 if squash and len(matched) == 1:
157 return matched[0]
158 else:
159 return matched
161 def basename(path):
162 """Avoid os.path.basename because we are explicitly
163 parsing git"s output, which contains /"s regardless
164 of platform (a.t.m.)
166 base_regex = re.compile('(.*?/)?([^/]+)$')
167 match = base_regex.match(path)
168 if match:
169 return match.group(2)
170 else:
171 return pathstr
173 def shell_quote(*inputs):
175 Quote strings so that they can be suitably martialled
176 off to the shell. This method supports POSIX sh syntax.
177 This is crucial to properly handle command line arguments
178 with spaces, quotes, double-quotes, etc.
181 regex = re.compile('[^\w!%+,\-./:@^]')
182 quote_regex = re.compile("((?:'\\''){2,})")
184 ret = []
185 for input in inputs:
186 if not input:
187 continue
189 if '\x00' in input:
190 raise AssertionError,('No way to quote strings '
191 'containing null(\\000) bytes')
193 # = does need quoting else in command position it's a
194 # program-local environment setting
195 match = regex.search(input)
196 if match and '=' not in input:
197 # ' -> '\''
198 input = input.replace("'", "'\\''")
200 # make multiple ' in a row look simpler
201 # '\'''\'''\'' -> '"'''"'
202 quote_match = quote_regex.match(input)
203 if quote_match:
204 quotes = match.group(1)
205 input.replace(quotes, ("'" *(len(quotes)/4)) + "\"'")
207 input = "'%s'" % input
208 if input.startswith("''"):
209 input = input[2:]
211 if input.endswith("''"):
212 input = input[:-2]
213 ret.append(input)
214 return ' '.join(ret)
216 HEADER_LENGTH = 80
217 def header(msg):
218 pad = HEADER_LENGTH - len(msg) - 4 # len(':+') + len('+:')
219 extra = pad % 2
220 pad /= 2
221 return(':+'
222 +(' ' * pad)
223 + msg
224 +(' ' * (pad + extra))
225 + '+:'
226 + '\n')
228 def parse_geom(geomstr):
229 regex = re.compile('^(\d+)x(\d+)\+(\d+),(\d+).*?')
230 match = regex.match(geomstr)
231 if match:
232 defaults.WIDTH = int(match.group(1))
233 defaults.HEIGHT = int(match.group(2))
234 defaults.X = int(match.group(3))
235 defaults.Y = int(match.group(4))
236 return (defaults.WIDTH, defaults.HEIGHT, defaults.X, defaults.Y)
238 def get_geom():
239 return ('%dx%d+%d,%d'
240 % (defaults.WIDTH, defaults.HEIGHT, defaults.X, defaults.Y))
242 def project_name():
243 return os.path.basename(defaults.DIRECTORY)
245 def slurp(path):
246 file = open(path)
247 slushy = file.read()
248 file.close()
249 return slushy.decode('utf-8')
251 def write(path, contents):
252 file = open(path, 'w')
253 file.write(contents.encode('utf-8'))
254 file.close()
256 class DiffParser(object):
257 def __init__(self, model, filename='',
258 cached=True, branch=None, reverse=False):
260 self.__header_re = re.compile('^@@ -(\d+),(\d+) \+(\d+),(\d+) @@.*')
261 self.__headers = []
263 self.__idx = -1
264 self.__diffs = []
265 self.__diff_spans = []
266 self.__diff_offsets = []
268 self.start = None
269 self.end = None
270 self.offset = None
271 self.diffs = []
272 self.selected = []
274 (header, diff) = model.diff_helper(filename=filename,
275 branch=branch,
276 with_diff_header=True,
277 cached=cached and not bool(branch),
278 reverse=cached or bool(branch) or reverse)
279 self.model = model
280 self.diff = diff
281 self.header = header
282 self.parse_diff(diff)
284 # Always index into the non-reversed diff
285 self.fwd_header, self.fwd_diff = \
286 model.diff_helper(filename=filename,
287 branch=branch,
288 with_diff_header=True,
289 cached=cached and not bool(branch),
290 reverse=bool(branch))
292 def write_diff(self,filename,which,selected=False,noop=False):
293 if not noop and which < len(self.diffs):
294 diff = self.diffs[which]
295 write(filename, self.header + '\n' + diff + '\n')
296 return True
297 else:
298 return False
300 def get_diffs(self):
301 return self.__diffs
303 def get_diff_subset(self, diff, start, end):
304 adds = 0
305 deletes = 0
306 newdiff = []
307 local_offset = 0
308 offset = self.__diff_spans[diff][0]
309 diffguts = '\n'.join(self.__diffs[diff])
311 for line in self.__diffs[diff]:
312 line_start = offset + local_offset
313 local_offset += len(line) + 1 #\n
314 line_end = offset + local_offset
315 # |line1 |line2 |line3 |
316 # |--selection--|
317 # '-start '-end
318 # selection has head of diff (line3)
319 if start < line_start and end > line_start and end < line_end:
320 newdiff.append(line)
321 if line.startswith('+'):
322 adds += 1
323 if line.startswith('-'):
324 deletes += 1
325 # selection has all of diff (line2)
326 elif start <= line_start and end >= line_end:
327 newdiff.append(line)
328 if line.startswith('+'):
329 adds += 1
330 if line.startswith('-'):
331 deletes += 1
332 # selection has tail of diff (line1)
333 elif start >= line_start and start < line_end - 1:
334 newdiff.append(line)
335 if line.startswith('+'):
336 adds += 1
337 if line.startswith('-'):
338 deletes += 1
339 else:
340 # Don't add new lines unless selected
341 if line.startswith('+'):
342 continue
343 elif line.startswith('-'):
344 # Don't remove lines unless selected
345 newdiff.append(' ' + line[1:])
346 else:
347 newdiff.append(line)
349 new_count = self.__headers[diff][1] + adds - deletes
350 if new_count != self.__headers[diff][3]:
351 header = '@@ -%d,%d +%d,%d @@' % (
352 self.__headers[diff][0],
353 self.__headers[diff][1],
354 self.__headers[diff][2],
355 new_count)
356 newdiff[0] = header
358 return (self.header + '\n' + '\n'.join(newdiff) + '\n')
360 def get_spans(self):
361 return self.__diff_spans
363 def get_offsets(self):
364 return self.__diff_offsets
366 def set_diff_to_offset(self, offset):
367 self.offset = offset
368 self.diffs, self.selected = self.get_diff_for_offset(offset)
370 def set_diffs_to_range(self, start, end):
371 self.start = start
372 self.end = end
373 self.diffs, self.selected = self.get_diffs_for_range(start,end)
375 def get_diff_for_offset(self, offset):
376 for idx, diff_offset in enumerate(self.__diff_offsets):
377 if offset < diff_offset:
378 return (['\n'.join(self.__diffs[idx])], [idx])
379 return ([],[])
381 def get_diffs_for_range(self, start, end):
382 diffs = []
383 indices = []
384 for idx, span in enumerate(self.__diff_spans):
385 has_end_of_diff = start >= span[0] and start < span[1]
386 has_all_of_diff = start <= span[0] and end >= span[1]
387 has_head_of_diff = end >= span[0] and end <= span[1]
389 selected_diff =(has_end_of_diff
390 or has_all_of_diff
391 or has_head_of_diff)
392 if selected_diff:
393 diff = '\n'.join(self.__diffs[idx])
394 diffs.append(diff)
395 indices.append(idx)
396 return diffs, indices
398 def parse_diff(self, diff):
399 total_offset = 0
400 self.__idx = -1
401 self.__headers = []
403 for idx, line in enumerate(diff.splitlines()):
404 match = self.__header_re.match(line)
405 if match:
406 self.__headers.append([
407 int(match.group(1)),
408 int(match.group(2)),
409 int(match.group(3)),
410 int(match.group(4))
412 self.__diffs.append( [line] )
414 line_len = len(line) + 1 #\n
415 self.__diff_spans.append([total_offset,
416 total_offset + line_len])
417 total_offset += line_len
418 self.__diff_offsets.append(total_offset)
419 self.__idx += 1
420 else:
421 if self.__idx < 0:
422 errmsg = 'Malformed diff?\n\n%s' % diff
423 raise AssertionError, errmsg
424 line_len = len(line) + 1
425 total_offset += line_len
427 self.__diffs[self.__idx].append(line)
428 self.__diff_spans[-1][-1] += line_len
429 self.__diff_offsets[self.__idx] += line_len
431 def process_diff_selection(self, selected, offset, selection,
432 apply_to_worktree=False):
433 if selection:
434 start = self.fwd_diff.index(selection)
435 end = start + len(selection)
436 self.set_diffs_to_range(start, end)
437 else:
438 self.set_diff_to_offset(offset)
439 selected = False
440 # Process diff selection only
441 if selected:
442 for idx in self.selected:
443 contents = self.get_diff_subset(idx, start, end)
444 if contents:
445 tmpfile = self.model.get_tmp_filename()
446 write(tmpfile, contents)
447 if apply_to_worktree:
448 self.model.apply_diff_to_worktree(tmpfile)
449 else:
450 self.model.apply_diff(tmpfile)
451 os.unlink(tmpfile)
452 # Process a complete hunk
453 else:
454 for idx, diff in enumerate(self.diffs):
455 tmpfile = self.model.get_tmp_filename()
456 if self.write_diff(tmpfile,idx):
457 if apply_to_worktree:
458 self.model.apply_diff_to_worktree(tmpfile)
459 else:
460 self.model.apply_diff(tmpfile)
461 os.unlink(tmpfile)
463 def strip_prefix(prefix, string):
464 """Return string, without the prefix. Blow up if string doesn't
465 start with prefix."""
466 assert string.startswith(prefix)
467 return string[len(prefix):]
469 def sanitize_input(input):
470 for c in """ \t!@#$%^&*()\\;,<>"'[]{}~|""":
471 input = input.replace(c, '_')
472 return input
474 def is_linux():
475 return platform.system() == 'Linux'
477 def is_debian():
478 return os.path.exists('/usr/bin/apt-get')
480 def is_broken():
481 return (platform.system() == 'Windows'
482 or 'Macintosh' in platform.platform())