Add '--' to git-diff-index calls
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, popen2, re, gitmergeonefile
22
23 from stgit import basedir
24 from stgit.utils import *
25
26 # git exception class
27 class GitException(Exception):
28 pass
29
30
31
32 #
33 # Classes
34 #
35 class Commit:
36 """Handle the commit objects
37 """
38 def __init__(self, id_hash):
39 self.__id_hash = id_hash
40
41 lines = _output_lines('git-cat-file commit %s' % id_hash)
42 self.__parents = []
43 for i in range(len(lines)):
44 line = lines[i]
45 if line == '\n':
46 break
47 field = line.strip().split(' ', 1)
48 if field[0] == 'tree':
49 self.__tree = field[1]
50 elif field[0] == 'parent':
51 self.__parents.append(field[1])
52 if field[0] == 'author':
53 self.__author = field[1]
54 if field[0] == 'committer':
55 self.__committer = field[1]
56 self.__log = ''.join(lines[i+1:])
57
58 def get_id_hash(self):
59 return self.__id_hash
60
61 def get_tree(self):
62 return self.__tree
63
64 def get_parent(self):
65 return self.__parents[0]
66
67 def get_parents(self):
68 return self.__parents
69
70 def get_author(self):
71 return self.__author
72
73 def get_committer(self):
74 return self.__committer
75
76 def get_log(self):
77 return self.__log
78
79 # dictionary of Commit objects, used to avoid multiple calls to git
80 __commits = dict()
81
82 #
83 # Functions
84 #
85
86 def get_commit(id_hash):
87 """Commit objects factory. Save/look-up them in the __commits
88 dictionary
89 """
90 global __commits
91
92 if id_hash in __commits:
93 return __commits[id_hash]
94 else:
95 commit = Commit(id_hash)
96 __commits[id_hash] = commit
97 return commit
98
99 def get_conflicts():
100 """Return the list of file conflicts
101 """
102 conflicts_file = os.path.join(basedir.get(), 'conflicts')
103 if os.path.isfile(conflicts_file):
104 f = file(conflicts_file)
105 names = [line.strip() for line in f.readlines()]
106 f.close()
107 return names
108 else:
109 return None
110
111 def _input(cmd, file_desc):
112 p = popen2.Popen3(cmd, True)
113 while True:
114 line = file_desc.readline()
115 if not line:
116 break
117 p.tochild.write(line)
118 p.tochild.close()
119 if p.wait():
120 raise GitException, '%s failed (%s)' % (str(cmd),
121 p.childerr.read().strip())
122
123 def _input_str(cmd, string):
124 p = popen2.Popen3(cmd, True)
125 p.tochild.write(string)
126 p.tochild.close()
127 if p.wait():
128 raise GitException, '%s failed (%s)' % (str(cmd),
129 p.childerr.read().strip())
130
131 def _output(cmd):
132 p=popen2.Popen3(cmd, True)
133 output = p.fromchild.read()
134 if p.wait():
135 raise GitException, '%s failed (%s)' % (str(cmd),
136 p.childerr.read().strip())
137 return output
138
139 def _output_one_line(cmd, file_desc = None):
140 p=popen2.Popen3(cmd, True)
141 if file_desc != None:
142 for line in file_desc:
143 p.tochild.write(line)
144 p.tochild.close()
145 output = p.fromchild.readline().strip()
146 if p.wait():
147 raise GitException, '%s failed (%s)' % (str(cmd),
148 p.childerr.read().strip())
149 return output
150
151 def _output_lines(cmd):
152 p=popen2.Popen3(cmd, True)
153 lines = p.fromchild.readlines()
154 if p.wait():
155 raise GitException, '%s failed (%s)' % (str(cmd),
156 p.childerr.read().strip())
157 return lines
158
159 def __run(cmd, args=None):
160 """__run: runs cmd using spawnvp.
161
162 Runs cmd using spawnvp. The shell is avoided so it won't mess up
163 our arguments. If args is very large, the command is run multiple
164 times; args is split xargs style: cmd is passed on each
165 invocation. Unlike xargs, returns immediately if any non-zero
166 return code is received.
167 """
168
169 args_l=cmd.split()
170 if args is None:
171 args = []
172 for i in range(0, len(args)+1, 100):
173 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
174 if r:
175 return r
176 return 0
177
178 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
179 noexclude = True):
180 """Returns a list of pairs - [status, filename]
181 """
182 refresh_index()
183
184 if not files:
185 files = []
186 cache_files = []
187
188 # unknown files
189 if unknown:
190 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
191 base_exclude = ['--exclude=%s' % s for s in
192 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
193 base_exclude.append('--exclude-per-directory=.gitignore')
194
195 if os.path.exists(exclude_file):
196 extra_exclude = ['--exclude-from=%s' % exclude_file]
197 else:
198 extra_exclude = []
199 if noexclude:
200 extra_exclude = base_exclude = []
201
202 lines = _output_lines(['git-ls-files', '--others', '--directory']
203 + base_exclude + extra_exclude)
204 cache_files += [('?', line.strip()) for line in lines]
205
206 # conflicted files
207 conflicts = get_conflicts()
208 if not conflicts:
209 conflicts = []
210 cache_files += [('C', filename) for filename in conflicts]
211
212 # the rest
213 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
214 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
215 if fs[1] not in conflicts:
216 cache_files.append(fs)
217
218 return cache_files
219
220 def local_changes():
221 """Return true if there are local changes in the tree
222 """
223 return len(__tree_status()) != 0
224
225 # HEAD value cached
226 __head = None
227
228 def get_head():
229 """Verifies the HEAD and returns the SHA1 id that represents it
230 """
231 global __head
232
233 if not __head:
234 __head = rev_parse('HEAD')
235 return __head
236
237 def get_head_file():
238 """Returns the name of the file pointed to by the HEAD link
239 """
240 return strip_prefix('refs/heads/',
241 _output_one_line('git-symbolic-ref HEAD'))
242
243 def set_head_file(ref):
244 """Resets HEAD to point to a new ref
245 """
246 # head cache flushing is needed since we might have a different value
247 # in the new head
248 __clear_head_cache()
249 if __run('git-symbolic-ref HEAD',
250 [os.path.join('refs', 'heads', ref)]) != 0:
251 raise GitException, 'Could not set head to "%s"' % ref
252
253 def __set_head(val):
254 """Sets the HEAD value
255 """
256 global __head
257
258 if not __head or __head != val:
259 if __run('git-update-ref HEAD', [val]) != 0:
260 raise GitException, 'Could not update HEAD to "%s".' % val
261 __head = val
262
263 # only allow SHA1 hashes
264 assert(len(__head) == 40)
265
266 def __clear_head_cache():
267 """Sets the __head to None so that a re-read is forced
268 """
269 global __head
270
271 __head = None
272
273 def refresh_index():
274 """Refresh index with stat() information from the working directory.
275 """
276 __run('git-update-index -q --unmerged --refresh')
277
278 def rev_parse(git_id):
279 """Parse the string and return a verified SHA1 id
280 """
281 try:
282 return _output_one_line(['git-rev-parse', '--verify', git_id])
283 except GitException:
284 raise GitException, 'Unknown revision: %s' % git_id
285
286 def branch_exists(branch):
287 """Existence check for the named branch
288 """
289 branch = os.path.join('refs', 'heads', branch)
290 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
291 if line.strip() == branch:
292 return True
293 if re.compile('[ |/]'+branch+' ').search(line):
294 raise GitException, 'Bogus branch: %s' % line
295 return False
296
297 def create_branch(new_branch, tree_id = None):
298 """Create a new branch in the git repository
299 """
300 if branch_exists(new_branch):
301 raise GitException, 'Branch "%s" already exists' % new_branch
302
303 current_head = get_head()
304 set_head_file(new_branch)
305 __set_head(current_head)
306
307 # a checkout isn't needed if new branch points to the current head
308 if tree_id:
309 switch(tree_id)
310
311 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
312 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
313
314 def switch_branch(new_branch):
315 """Switch to a git branch
316 """
317 global __head
318
319 if not branch_exists(new_branch):
320 raise GitException, 'Branch "%s" does not exist' % new_branch
321
322 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
323 + '^{commit}')
324 if tree_id != get_head():
325 refresh_index()
326 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
327 raise GitException, 'git-read-tree failed (local changes maybe?)'
328 __head = tree_id
329 set_head_file(new_branch)
330
331 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
332 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
333
334 def delete_branch(name):
335 """Delete a git branch
336 """
337 if not branch_exists(name):
338 raise GitException, 'Branch "%s" does not exist' % name
339 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
340 name)
341
342 def rename_branch(from_name, to_name):
343 """Rename a git branch
344 """
345 if not branch_exists(from_name):
346 raise GitException, 'Branch "%s" does not exist' % from_name
347 if branch_exists(to_name):
348 raise GitException, 'Branch "%s" already exists' % to_name
349
350 if get_head_file() == from_name:
351 set_head_file(to_name)
352 rename(os.path.join(basedir.get(), 'refs', 'heads'),
353 from_name, to_name)
354
355 def add(names):
356 """Add the files or recursively add the directory contents
357 """
358 # generate the file list
359 files = []
360 for i in names:
361 if not os.path.exists(i):
362 raise GitException, 'Unknown file or directory: %s' % i
363
364 if os.path.isdir(i):
365 # recursive search. We only add files
366 for root, dirs, local_files in os.walk(i):
367 for name in [os.path.join(root, f) for f in local_files]:
368 if os.path.isfile(name):
369 files.append(os.path.normpath(name))
370 elif os.path.isfile(i):
371 files.append(os.path.normpath(i))
372 else:
373 raise GitException, '%s is not a file or directory' % i
374
375 if files:
376 if __run('git-update-index --add --', files):
377 raise GitException, 'Unable to add file'
378
379 def rm(files, force = False):
380 """Remove a file from the repository
381 """
382 if not force:
383 for f in files:
384 if os.path.exists(f):
385 raise GitException, '%s exists. Remove it first' %f
386 if files:
387 __run('git-update-index --remove --', files)
388 else:
389 if files:
390 __run('git-update-index --force-remove --', files)
391
392 def update_cache(files = None, force = False):
393 """Update the cache information for the given files
394 """
395 if not files:
396 files = []
397
398 cache_files = __tree_status(files)
399
400 # everything is up-to-date
401 if len(cache_files) == 0:
402 return False
403
404 # check for unresolved conflicts
405 if not force and [x for x in cache_files
406 if x[0] not in ['M', 'N', 'A', 'D']]:
407 raise GitException, 'Updating cache failed: unresolved conflicts'
408
409 # update the cache
410 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
411 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
412 m_files = [x[1] for x in cache_files if x[0] in ['M']]
413
414 if add_files and __run('git-update-index --add --', add_files) != 0:
415 raise GitException, 'Failed git-update-index --add'
416 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
417 raise GitException, 'Failed git-update-index --rm'
418 if m_files and __run('git-update-index --', m_files) != 0:
419 raise GitException, 'Failed git-update-index'
420
421 return True
422
423 def commit(message, files = None, parents = None, allowempty = False,
424 cache_update = True, tree_id = None,
425 author_name = None, author_email = None, author_date = None,
426 committer_name = None, committer_email = None):
427 """Commit the current tree to repository
428 """
429 if not files:
430 files = []
431 if not parents:
432 parents = []
433
434 # Get the tree status
435 if cache_update and parents != []:
436 changes = update_cache(files)
437 if not changes and not allowempty:
438 raise GitException, 'No changes to commit'
439
440 # get the commit message
441 if message[-1:] != '\n':
442 message += '\n'
443
444 must_switch = True
445 # write the index to repository
446 if tree_id == None:
447 tree_id = _output_one_line('git-write-tree')
448 else:
449 must_switch = False
450
451 # the commit
452 cmd = ''
453 if author_name:
454 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
455 if author_email:
456 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
457 if author_date:
458 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
459 if committer_name:
460 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
461 if committer_email:
462 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
463 cmd += 'git-commit-tree %s' % tree_id
464
465 # get the parents
466 for p in parents:
467 cmd += ' -p %s' % p
468
469 commit_id = _output_one_line(cmd, message)
470 if must_switch:
471 __set_head(commit_id)
472
473 return commit_id
474
475 def apply_diff(rev1, rev2, check_index = True, files = None):
476 """Apply the diff between rev1 and rev2 onto the current
477 index. This function doesn't need to raise an exception since it
478 is only used for fast-pushing a patch. If this operation fails,
479 the pushing would fall back to the three-way merge.
480 """
481 if check_index:
482 index_opt = '--index'
483 else:
484 index_opt = ''
485
486 if not files:
487 files = []
488
489 diff_str = diff(files, rev1, rev2)
490 if diff_str:
491 try:
492 _input_str('git-apply %s' % index_opt, diff_str)
493 except GitException:
494 return False
495
496 return True
497
498 def merge(base, head1, head2):
499 """Perform a 3-way merge between base, head1 and head2 into the
500 local tree
501 """
502 refresh_index()
503 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
504 raise GitException, 'git-read-tree failed (local changes maybe?)'
505
506 # check the index for unmerged entries
507 files = {}
508 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
509
510 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
511 if not line:
512 continue
513
514 mode, hash, stage, path = stages_re.findall(line)[0]
515
516 if not path in files:
517 files[path] = {}
518 files[path]['1'] = ('', '')
519 files[path]['2'] = ('', '')
520 files[path]['3'] = ('', '')
521
522 files[path][stage] = (mode, hash)
523
524 # merge the unmerged files
525 errors = False
526 for path in files:
527 stages = files[path]
528 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
529 stages['3'][1], path, stages['1'][0],
530 stages['2'][0], stages['3'][0]) != 0:
531 errors = True
532
533 if errors:
534 raise GitException, 'GIT index merging failed (possible conflicts)'
535
536 def status(files = None, modified = False, new = False, deleted = False,
537 conflict = False, unknown = False, noexclude = False):
538 """Show the tree status
539 """
540 if not files:
541 files = []
542
543 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
544 all = not (modified or new or deleted or conflict or unknown)
545
546 if not all:
547 filestat = []
548 if modified:
549 filestat.append('M')
550 if new:
551 filestat.append('A')
552 filestat.append('N')
553 if deleted:
554 filestat.append('D')
555 if conflict:
556 filestat.append('C')
557 if unknown:
558 filestat.append('?')
559 cache_files = [x for x in cache_files if x[0] in filestat]
560
561 for fs in cache_files:
562 if all:
563 print '%s %s' % (fs[0], fs[1])
564 else:
565 print '%s' % fs[1]
566
567 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
568 """Show the diff between rev1 and rev2
569 """
570 if not files:
571 files = []
572
573 if rev1 and rev2:
574 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
575 elif rev1 or rev2:
576 refresh_index()
577 if rev2:
578 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
579 else:
580 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
581 else:
582 diff_str = ''
583
584 if out_fd:
585 out_fd.write(diff_str)
586 else:
587 return diff_str
588
589 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
590 """Return the diffstat between rev1 and rev2
591 """
592 if not files:
593 files = []
594
595 p=popen2.Popen3('git-apply --stat')
596 diff(files, rev1, rev2, p.tochild)
597 p.tochild.close()
598 diff_str = p.fromchild.read().rstrip()
599 if p.wait():
600 raise GitException, 'git.diffstat failed'
601 return diff_str
602
603 def files(rev1, rev2):
604 """Return the files modified between rev1 and rev2
605 """
606
607 result = ''
608 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
609 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
610
611 return result.rstrip()
612
613 def barefiles(rev1, rev2):
614 """Return the files modified between rev1 and rev2, without status info
615 """
616
617 result = ''
618 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
619 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
620
621 return result.rstrip()
622
623 def pretty_commit(commit_id = 'HEAD'):
624 """Return a given commit (log + diff)
625 """
626 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
627 commit_id])
628
629 def checkout(files = None, tree_id = None, force = False):
630 """Check out the given or all files
631 """
632 if not files:
633 files = []
634
635 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
636 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
637
638 checkout_cmd = 'git-checkout-index -q -u'
639 if force:
640 checkout_cmd += ' -f'
641 if len(files) == 0:
642 checkout_cmd += ' -a'
643 else:
644 checkout_cmd += ' --'
645
646 if __run(checkout_cmd, files) != 0:
647 raise GitException, 'Failed git-checkout-index'
648
649 def switch(tree_id):
650 """Switch the tree to the given id
651 """
652 refresh_index()
653 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
654 raise GitException, 'git-read-tree failed (local changes maybe?)'
655
656 __set_head(tree_id)
657
658 def reset(files = None, tree_id = None, check_out = True):
659 """Revert the tree changes relative to the given tree_id. It removes
660 any local changes
661 """
662 if not tree_id:
663 tree_id = get_head()
664
665 if check_out:
666 cache_files = __tree_status(files, tree_id)
667 # files which were added but need to be removed
668 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
669
670 checkout(files, tree_id, True)
671 # checkout doesn't remove files
672 map(os.remove, rm_files)
673
674 # if the reset refers to the whole tree, switch the HEAD as well
675 if not files:
676 __set_head(tree_id)
677
678 def pull(repository = 'origin', refspec = None):
679 """Pull changes from the remote repository. At the moment, just
680 use the 'git-pull' command
681 """
682 # 'git-pull' updates the HEAD
683 __clear_head_cache()
684
685 args = [repository]
686 if refspec:
687 args.append(refspec)
688
689 if __run('git-pull', args) != 0:
690 raise GitException, 'Failed "git-pull %s"' % repository
691
692 def apply_patch(filename = None, base = None):
693 """Apply a patch onto the current or given index. There must not
694 be any local changes in the tree, otherwise the command fails
695 """
696 def __apply_patch():
697 if filename:
698 return __run('git-apply --index', [filename]) == 0
699 else:
700 try:
701 _input('git-apply --index', sys.stdin)
702 except GitException:
703 return False
704 return True
705
706 if base:
707 orig_head = get_head()
708 switch(base)
709 else:
710 refresh_index() # needed since __apply_patch() doesn't do it
711
712 if not __apply_patch():
713 if base:
714 switch(orig_head)
715 raise GitException, 'Patch does not apply cleanly'
716 elif base:
717 top = commit(message = 'temporary commit used for applying a patch',
718 parents = [base])
719 switch(orig_head)
720 merge(base, orig_head, top)
721
722 def clone(repository, local_dir):
723 """Clone a remote repository. At the moment, just use the
724 'git-clone' script
725 """
726 if __run('git-clone', [repository, local_dir]) != 0:
727 raise GitException, 'Failed "git-clone %s %s"' \
728 % (repository, local_dir)
729
730 def modifying_revs(files, base_rev):
731 """Return the revisions from the list modifying the given files
732 """
733 cmd = ['git-rev-list', '%s..' % base_rev, '--']
734 revs = [line.strip() for line in _output_lines(cmd + files)]
735
736 return revs