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