1 """Python GIT interface
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
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.
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.
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
21 import sys, os, popen2, re, gitmergeonefile
23 from stgit import basedir
24 from stgit.utils import *
25 from stgit.config import config
29 class GitException(Exception):
39 """An author, committer, etc."""
40 def __init__(self, name = None, email = None, date = '',
42 if name or email or date:
48 assert not (name or email or date)
50 m = re.match(r'^(.+)<(.+)>(.*)$', s)
52 return [x.strip() or None for x in m.groups()]
53 self.name, self.email, self.date = parse_desc(desc)
54 def set_name(self, val):
57 def set_email(self, val):
60 def set_date(self, val):
64 if self.name and self.email:
65 return '%s <%s>' % (self.name, self.email)
67 raise GitException, 'not enough identity data'
70 """Handle the commit objects
72 def __init__(self, id_hash):
73 self.__id_hash = id_hash
75 lines = _output_lines('git-cat-file commit %s' % id_hash)
76 for i in range(len(lines)):
80 field = line.strip().split(' ', 1)
81 if field[0] == 'tree':
82 self.__tree = field[1]
83 if field[0] == 'author':
84 self.__author = field[1]
85 if field[0] == 'committer':
86 self.__committer = field[1]
87 self.__log = ''.join(lines[i+1:])
89 def get_id_hash(self):
96 parents = self.get_parents()
102 def get_parents(self):
103 return _output_lines('git-rev-list --parents --max-count=1 %s'
104 % self.__id_hash)[0].split()[1:]
106 def get_author(self):
109 def get_committer(self):
110 return self.__committer
116 return self.get_id_hash()
118 # dictionary of Commit objects, used to avoid multiple calls to git
125 def get_commit(id_hash):
126 """Commit objects factory. Save/look-up them in the __commits
131 if id_hash in __commits:
132 return __commits[id_hash]
134 commit = Commit(id_hash)
135 __commits[id_hash] = commit
139 """Return the list of file conflicts
141 conflicts_file = os.path.join(basedir.get(), 'conflicts')
142 if os.path.isfile(conflicts_file):
143 f = file(conflicts_file)
144 names = [line.strip() for line in f.readlines()]
150 def _input(cmd, file_desc):
151 p = popen2.Popen3(cmd, True)
153 line = file_desc.readline()
156 p.tochild.write(line)
159 raise GitException, '%s failed (%s)' % (str(cmd),
160 p.childerr.read().strip())
162 def _input_str(cmd, string):
163 p = popen2.Popen3(cmd, True)
164 p.tochild.write(string)
167 raise GitException, '%s failed (%s)' % (str(cmd),
168 p.childerr.read().strip())
171 p=popen2.Popen3(cmd, True)
172 output = p.fromchild.read()
174 raise GitException, '%s failed (%s)' % (str(cmd),
175 p.childerr.read().strip())
178 def _output_one_line(cmd, file_desc = None):
179 p=popen2.Popen3(cmd, True)
180 if file_desc != None:
181 for line in file_desc:
182 p.tochild.write(line)
184 output = p.fromchild.readline().strip()
186 raise GitException, '%s failed (%s)' % (str(cmd),
187 p.childerr.read().strip())
190 def _output_lines(cmd):
191 p=popen2.Popen3(cmd, True)
192 lines = p.fromchild.readlines()
194 raise GitException, '%s failed (%s)' % (str(cmd),
195 p.childerr.read().strip())
198 def __run(cmd, args=None):
199 """__run: runs cmd using spawnvp.
201 Runs cmd using spawnvp. The shell is avoided so it won't mess up
202 our arguments. If args is very large, the command is run multiple
203 times; args is split xargs style: cmd is passed on each
204 invocation. Unlike xargs, returns immediately if any non-zero
205 return code is received.
211 for i in range(0, len(args)+1, 100):
212 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
217 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
218 noexclude = True, verbose = False):
219 """Returns a list of pairs - [status, filename]
221 if verbose and sys.stdout.isatty():
222 print 'Checking for changes in the working directory...',
233 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
234 base_exclude = ['--exclude=%s' % s for s in
235 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
236 base_exclude.append('--exclude-per-directory=.gitignore')
238 if os.path.exists(exclude_file):
239 extra_exclude = ['--exclude-from=%s' % exclude_file]
243 extra_exclude = base_exclude = []
245 lines = _output_lines(['git-ls-files', '--others', '--directory']
246 + base_exclude + extra_exclude)
247 cache_files += [('?', line.strip()) for line in lines]
250 conflicts = get_conflicts()
253 cache_files += [('C', filename) for filename in conflicts]
256 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
257 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
258 if fs[1] not in conflicts:
259 cache_files.append(fs)
261 if verbose and sys.stdout.isatty():
266 def local_changes(verbose = True):
267 """Return true if there are local changes in the tree
269 return len(__tree_status(verbose = verbose)) != 0
275 """Verifies the HEAD and returns the SHA1 id that represents it
280 __head = rev_parse('HEAD')
284 """Returns the name of the file pointed to by the HEAD link
286 return strip_prefix('refs/heads/',
287 _output_one_line('git-symbolic-ref HEAD'))
289 def set_head_file(ref):
290 """Resets HEAD to point to a new ref
292 # head cache flushing is needed since we might have a different value
295 if __run('git-symbolic-ref HEAD',
296 [os.path.join('refs', 'heads', ref)]) != 0:
297 raise GitException, 'Could not set head to "%s"' % ref
300 """Sets the HEAD value
304 if not __head or __head != val:
305 if __run('git-update-ref HEAD', [val]) != 0:
306 raise GitException, 'Could not update HEAD to "%s".' % val
309 # only allow SHA1 hashes
310 assert(len(__head) == 40)
312 def __clear_head_cache():
313 """Sets the __head to None so that a re-read is forced
320 """Refresh index with stat() information from the working directory.
322 __run('git-update-index -q --unmerged --refresh')
324 def rev_parse(git_id):
325 """Parse the string and return a verified SHA1 id
328 return _output_one_line(['git-rev-parse', '--verify', git_id])
330 raise GitException, 'Unknown revision: %s' % git_id
332 def branch_exists(branch):
333 """Existence check for the named branch
335 branch = os.path.join('refs', 'heads', branch)
336 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
337 if line.strip() == branch:
339 if re.compile('[ |/]'+branch+' ').search(line):
340 raise GitException, 'Bogus branch: %s' % line
343 def create_branch(new_branch, tree_id = None):
344 """Create a new branch in the git repository
346 if branch_exists(new_branch):
347 raise GitException, 'Branch "%s" already exists' % new_branch
349 current_head = get_head()
350 set_head_file(new_branch)
351 __set_head(current_head)
353 # a checkout isn't needed if new branch points to the current head
357 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
358 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
360 def switch_branch(new_branch):
361 """Switch to a git branch
365 if not branch_exists(new_branch):
366 raise GitException, 'Branch "%s" does not exist' % new_branch
368 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
370 if tree_id != get_head():
372 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
373 raise GitException, 'git-read-tree failed (local changes maybe?)'
375 set_head_file(new_branch)
377 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
378 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
380 def delete_branch(name):
381 """Delete a git branch
383 if not branch_exists(name):
384 raise GitException, 'Branch "%s" does not exist' % name
385 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
388 def rename_branch(from_name, to_name):
389 """Rename a git branch
391 if not branch_exists(from_name):
392 raise GitException, 'Branch "%s" does not exist' % from_name
393 if branch_exists(to_name):
394 raise GitException, 'Branch "%s" already exists' % to_name
396 if get_head_file() == from_name:
397 set_head_file(to_name)
398 rename(os.path.join(basedir.get(), 'refs', 'heads'),
401 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
402 if os.path.exists(reflog_dir) \
403 and os.path.exists(os.path.join(reflog_dir, from_name)):
404 rename(reflog_dir, from_name, to_name)
407 """Add the files or recursively add the directory contents
409 # generate the file list
412 if not os.path.exists(i):
413 raise GitException, 'Unknown file or directory: %s' % i
416 # recursive search. We only add files
417 for root, dirs, local_files in os.walk(i):
418 for name in [os.path.join(root, f) for f in local_files]:
419 if os.path.isfile(name):
420 files.append(os.path.normpath(name))
421 elif os.path.isfile(i):
422 files.append(os.path.normpath(i))
424 raise GitException, '%s is not a file or directory' % i
427 if __run('git-update-index --add --', files):
428 raise GitException, 'Unable to add file'
430 def rm(files, force = False):
431 """Remove a file from the repository
435 if os.path.exists(f):
436 raise GitException, '%s exists. Remove it first' %f
438 __run('git-update-index --remove --', files)
441 __run('git-update-index --force-remove --', files)
449 """Return the user information.
453 name=config.get('user.name')
454 email=config.get('user.email')
456 __user = Person(name, email)
458 raise GitException, 'unknown user details'
462 """Return the author information.
467 # the environment variables take priority over config
469 date = os.environ['GIT_AUTHOR_DATE']
472 __author = Person(os.environ['GIT_AUTHOR_NAME'],
473 os.environ['GIT_AUTHOR_EMAIL'],
480 """Return the author information.
485 # the environment variables take priority over config
487 date = os.environ['GIT_COMMITTER_DATE']
490 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
491 os.environ['GIT_COMMITTER_EMAIL'],
497 def update_cache(files = None, force = False):
498 """Update the cache information for the given files
503 cache_files = __tree_status(files, verbose = False)
505 # everything is up-to-date
506 if len(cache_files) == 0:
509 # check for unresolved conflicts
510 if not force and [x for x in cache_files
511 if x[0] not in ['M', 'N', 'A', 'D']]:
512 raise GitException, 'Updating cache failed: unresolved conflicts'
515 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
516 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
517 m_files = [x[1] for x in cache_files if x[0] in ['M']]
519 if add_files and __run('git-update-index --add --', add_files) != 0:
520 raise GitException, 'Failed git-update-index --add'
521 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
522 raise GitException, 'Failed git-update-index --rm'
523 if m_files and __run('git-update-index --', m_files) != 0:
524 raise GitException, 'Failed git-update-index'
528 def commit(message, files = None, parents = None, allowempty = False,
529 cache_update = True, tree_id = None,
530 author_name = None, author_email = None, author_date = None,
531 committer_name = None, committer_email = None):
532 """Commit the current tree to repository
539 # Get the tree status
540 if cache_update and parents != []:
541 changes = update_cache(files)
542 if not changes and not allowempty:
543 raise GitException, 'No changes to commit'
545 # get the commit message
548 elif message[-1:] != '\n':
552 # write the index to repository
554 tree_id = _output_one_line('git-write-tree')
561 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
563 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
565 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
567 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
569 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
570 cmd += 'git-commit-tree %s' % tree_id
576 commit_id = _output_one_line(cmd, message)
578 __set_head(commit_id)
582 def apply_diff(rev1, rev2, check_index = True, files = None):
583 """Apply the diff between rev1 and rev2 onto the current
584 index. This function doesn't need to raise an exception since it
585 is only used for fast-pushing a patch. If this operation fails,
586 the pushing would fall back to the three-way merge.
589 index_opt = '--index'
596 diff_str = diff(files, rev1, rev2)
599 _input_str('git-apply %s' % index_opt, diff_str)
605 def merge(base, head1, head2, recursive = False):
606 """Perform a 3-way merge between base, head1 and head2 into the
612 # this operation tracks renames but it is slower (used in
613 # general when pushing or picking patches)
615 # use _output() to mask the verbose prints of the tool
616 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
620 # the fast case where we don't track renames (used when the
621 # distance between base and heads is small, i.e. folding or
622 # synchronising patches)
623 if __run('git-read-tree -u -m --aggressive',
624 [base, head1, head2]) != 0:
625 raise GitException, 'git-read-tree failed (local changes maybe?)'
627 # check the index for unmerged entries
629 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
631 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
635 mode, hash, stage, path = stages_re.findall(line)[0]
637 if not path in files:
639 files[path]['1'] = ('', '')
640 files[path]['2'] = ('', '')
641 files[path]['3'] = ('', '')
643 files[path][stage] = (mode, hash)
645 # merge the unmerged files
648 # remove additional files that might be generated for some
649 # newer versions of GIT
650 for suffix in [base, head1, head2]:
653 fname = path + '~' + suffix
654 if os.path.exists(fname):
658 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
659 stages['3'][1], path, stages['1'][0],
660 stages['2'][0], stages['3'][0]) != 0:
664 raise GitException, 'GIT index merging failed (possible conflicts)'
666 def status(files = None, modified = False, new = False, deleted = False,
667 conflict = False, unknown = False, noexclude = False):
668 """Show the tree status
673 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
674 all = not (modified or new or deleted or conflict or unknown)
689 cache_files = [x for x in cache_files if x[0] in filestat]
691 for fs in cache_files:
692 if files and not fs[1] in files:
695 print '%s %s' % (fs[0], fs[1])
699 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
700 """Show the diff between rev1 and rev2
706 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
710 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
712 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
717 out_fd.write(diff_str)
721 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
722 """Return the diffstat between rev1 and rev2
727 p=popen2.Popen3('git-apply --stat')
728 diff(files, rev1, rev2, p.tochild)
730 diff_str = p.fromchild.read().rstrip()
732 raise GitException, 'git.diffstat failed'
735 def files(rev1, rev2):
736 """Return the files modified between rev1 and rev2
740 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
741 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
743 return result.rstrip()
745 def barefiles(rev1, rev2):
746 """Return the files modified between rev1 and rev2, without status info
750 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
751 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
753 return result.rstrip()
755 def pretty_commit(commit_id = 'HEAD'):
756 """Return a given commit (log + diff)
758 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
761 def checkout(files = None, tree_id = None, force = False):
762 """Check out the given or all files
767 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
768 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
770 checkout_cmd = 'git-checkout-index -q -u'
772 checkout_cmd += ' -f'
774 checkout_cmd += ' -a'
776 checkout_cmd += ' --'
778 if __run(checkout_cmd, files) != 0:
779 raise GitException, 'Failed git-checkout-index'
781 def switch(tree_id, keep = False):
782 """Switch the tree to the given id
786 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
787 raise GitException, 'git-read-tree failed (local changes maybe?)'
791 def reset(files = None, tree_id = None, check_out = True):
792 """Revert the tree changes relative to the given tree_id. It removes
799 cache_files = __tree_status(files, tree_id)
800 # files which were added but need to be removed
801 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
803 checkout(files, tree_id, True)
804 # checkout doesn't remove files
805 map(os.remove, rm_files)
807 # if the reset refers to the whole tree, switch the HEAD as well
811 def pull(repository = 'origin', refspec = None):
812 """Pull changes from the remote repository. Uses 'git-fetch'
813 and moves the stack base.
822 command = config.get('stgit.pullcmd')
823 if __run(command, args) != 0:
824 raise GitException, 'Failed "%s %s"' % (command, repository)
826 if (config.get('stgit.pull-does-rebase')):
828 reset(tree_id = rev_parse(repository))
831 """Repack all objects into a single pack
833 __run('git-repack -a -d -f')
835 def apply_patch(filename = None, diff = None, base = None,
837 """Apply a patch onto the current or given index. There must not
838 be any local changes in the tree, otherwise the command fails
850 orig_head = get_head()
856 _input_str('git-apply --index', diff)
861 # write the failed diff to a file
862 f = file('.stgit-failed.patch', 'w+')
865 print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
870 top = commit(message = 'temporary commit used for applying a patch',
873 merge(base, orig_head, top)
875 def clone(repository, local_dir):
876 """Clone a remote repository. At the moment, just use the
879 if __run('git-clone', [repository, local_dir]) != 0:
880 raise GitException, 'Failed "git-clone %s %s"' \
881 % (repository, local_dir)
883 def modifying_revs(files, base_rev):
884 """Return the revisions from the list modifying the given files
886 cmd = ['git-rev-list', '%s..' % base_rev, '--']
887 revs = [line.strip() for line in _output_lines(cmd + files)]
892 def refspec_localpart(refspec):
893 m = re.match('^[^:]*:([^:]*)$', refspec)
897 raise GitException, 'Cannot parse refspec "%s"' % line
899 def refspec_remotepart(refspec):
900 m = re.match('^([^:]*):[^:]*$', refspec)
904 raise GitException, 'Cannot parse refspec "%s"' % line
907 def __remotes_from_config():
908 return config.sections_matching(r'remote\.(.*)\.url')
910 def __remotes_from_dir(dir):
911 return os.listdir(os.path.join(basedir.get(), dir))
914 """Return the list of remotes in the repository
917 return Set(__remotes_from_config()) | \
918 Set(__remotes_from_dir('remotes')) | \
919 Set(__remotes_from_dir('branches'))
921 def remotes_local_branches(remote):
922 """Returns the list of local branches fetched from given remote
926 if remote in __remotes_from_config():
927 for line in config.getall('remote.%s.fetch' % remote):
928 branches.append(refspec_localpart(line))
929 elif remote in __remotes_from_dir('remotes'):
930 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
932 # Only consider Pull lines
933 m = re.match('^Pull: (.*)\n$', line)
934 branches.append(refspec_localpart(m.group(1)))
936 elif remote in __remotes_from_dir('branches'):
937 # old-style branches only declare one branch
938 branches.append('refs/heads/'+remote);
940 raise GitException, 'Unknown remote "%s"' % remote
944 def identify_remote(branchname):
945 """Return the name for the remote to pull the given branchname
946 from, or None if we believe it is a local branch.
949 for remote in remotes_list():
950 if branchname in remotes_local_branches(remote):
953 # FIXME: in the case of local branch we should maybe set remote to
954 # "." but are we even sure it is the only case left ?
956 # if we get here we've found nothing