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
22 from shutil import copyfile
24 from stgit import basedir
25 from stgit.utils import *
26 from stgit.config import config
30 class GitException(Exception):
40 """An author, committer, etc."""
41 def __init__(self, name = None, email = None, date = '',
43 self.name = self.email = self.date = None
44 if name or email or date:
50 assert not (name or email or date)
52 m = re.match(r'^(.+)<(.+)>(.*)$', s)
54 return [x.strip() or None for x in m.groups()]
55 self.name, self.email, self.date = parse_desc(desc)
56 def set_name(self, val):
59 def set_email(self, val):
62 def set_date(self, val):
66 if self.name and self.email:
67 return '%s <%s>' % (self.name, self.email)
69 raise GitException, 'not enough identity data'
72 """Handle the commit objects
74 def __init__(self, id_hash):
75 self.__id_hash = id_hash
77 lines = _output_lines(['git-cat-file', 'commit', id_hash])
78 for i in range(len(lines)):
82 field = line.strip().split(' ', 1)
83 if field[0] == 'tree':
84 self.__tree = field[1]
85 if field[0] == 'author':
86 self.__author = field[1]
87 if field[0] == 'committer':
88 self.__committer = field[1]
89 self.__log = ''.join(lines[i+1:])
91 def get_id_hash(self):
98 parents = self.get_parents()
104 def get_parents(self):
105 return _output_lines(['git-rev-list', '--parents', '--max-count=1',
106 self.__id_hash])[0].split()[1:]
108 def get_author(self):
111 def get_committer(self):
112 return self.__committer
118 return self.get_id_hash()
120 # dictionary of Commit objects, used to avoid multiple calls to git
127 def get_commit(id_hash):
128 """Commit objects factory. Save/look-up them in the __commits
133 if id_hash in __commits:
134 return __commits[id_hash]
136 commit = Commit(id_hash)
137 __commits[id_hash] = commit
141 """Return the list of file conflicts
143 conflicts_file = os.path.join(basedir.get(), 'conflicts')
144 if os.path.isfile(conflicts_file):
145 f = file(conflicts_file)
146 names = [line.strip() for line in f.readlines()]
152 def _input(cmd, file_desc):
153 p = popen2.Popen3(cmd, True)
155 line = file_desc.readline()
158 p.tochild.write(line)
161 raise GitException, '%s failed (%s)' % (str(cmd),
162 p.childerr.read().strip())
164 def _input_str(cmd, string):
165 p = popen2.Popen3(cmd, True)
166 p.tochild.write(string)
169 raise GitException, '%s failed (%s)' % (str(cmd),
170 p.childerr.read().strip())
173 p=popen2.Popen3(cmd, True)
174 output = p.fromchild.read()
176 raise GitException, '%s failed (%s)' % (str(cmd),
177 p.childerr.read().strip())
180 def _output_one_line(cmd, file_desc = None):
181 p=popen2.Popen3(cmd, True)
182 if file_desc != None:
183 for line in file_desc:
184 p.tochild.write(line)
186 output = p.fromchild.readline().strip()
188 raise GitException, '%s failed (%s)' % (str(cmd),
189 p.childerr.read().strip())
192 def _output_lines(cmd):
193 p=popen2.Popen3(cmd, True)
194 lines = p.fromchild.readlines()
196 raise GitException, '%s failed (%s)' % (str(cmd),
197 p.childerr.read().strip())
200 def __run(cmd, args=None):
201 """__run: runs cmd using spawnvp.
203 Runs cmd using spawnvp. The shell is avoided so it won't mess up
204 our arguments. If args is very large, the command is run multiple
205 times; args is split xargs style: cmd is passed on each
206 invocation. Unlike xargs, returns immediately if any non-zero
207 return code is received.
213 for i in range(0, len(args)+1, 100):
214 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
219 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
220 noexclude = True, verbose = False):
221 """Returns a list of pairs - [status, filename]
224 out.start('Checking for changes in the working directory')
234 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
235 base_exclude = ['--exclude=%s' % s for s in
236 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
237 base_exclude.append('--exclude-per-directory=.gitignore')
239 if os.path.exists(exclude_file):
240 extra_exclude = ['--exclude-from=%s' % exclude_file]
244 extra_exclude = base_exclude = []
246 lines = _output_lines(['git-ls-files', '--others', '--directory']
247 + base_exclude + extra_exclude)
248 cache_files += [('?', line.strip()) for line in lines]
251 conflicts = get_conflicts()
254 cache_files += [('C', filename) for filename in conflicts]
257 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
258 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
259 if fs[1] not in conflicts:
260 cache_files.append(fs)
267 def local_changes(verbose = True):
268 """Return true if there are local changes in the tree
270 return len(__tree_status(verbose = verbose)) != 0
276 """Verifies the HEAD and returns the SHA1 id that represents it
281 __head = rev_parse('HEAD')
285 """Returns the name of the file pointed to by the HEAD link
287 return strip_prefix('refs/heads/',
288 _output_one_line(['git-symbolic-ref', 'HEAD']))
290 def set_head_file(ref):
291 """Resets HEAD to point to a new ref
293 # head cache flushing is needed since we might have a different value
296 if __run('git-symbolic-ref HEAD',
297 [os.path.join('refs', 'heads', ref)]) != 0:
298 raise GitException, 'Could not set head to "%s"' % ref
300 def set_branch(branch, val):
301 """Point branch at a new commit object."""
302 if __run('git-update-ref', [branch, val]) != 0:
303 raise GitException, 'Could not update %s to "%s".' % (branch, val)
306 """Sets the HEAD value
310 if not __head or __head != val:
311 set_branch('HEAD', val)
314 # only allow SHA1 hashes
315 assert(len(__head) == 40)
317 def __clear_head_cache():
318 """Sets the __head to None so that a re-read is forced
325 """Refresh index with stat() information from the working directory.
327 __run('git-update-index -q --unmerged --refresh')
329 def rev_parse(git_id):
330 """Parse the string and return a verified SHA1 id
333 return _output_one_line(['git-rev-parse', '--verify', git_id])
335 raise GitException, 'Unknown revision: %s' % git_id
337 def branch_exists(branch):
338 """Existence check for the named branch
340 branch = os.path.join('refs', 'heads', branch)
341 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
342 if line.strip() == branch:
344 if re.compile('[ |/]'+branch+' ').search(line):
345 raise GitException, 'Bogus branch: %s' % line
348 def create_branch(new_branch, tree_id = None):
349 """Create a new branch in the git repository
351 if branch_exists(new_branch):
352 raise GitException, 'Branch "%s" already exists' % new_branch
354 current_head = get_head()
355 set_head_file(new_branch)
356 __set_head(current_head)
358 # a checkout isn't needed if new branch points to the current head
362 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
363 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
365 def switch_branch(new_branch):
366 """Switch to a git branch
370 if not branch_exists(new_branch):
371 raise GitException, 'Branch "%s" does not exist' % new_branch
373 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
375 if tree_id != get_head():
377 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
378 raise GitException, 'git-read-tree failed (local changes maybe?)'
380 set_head_file(new_branch)
382 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
383 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
385 def delete_branch(name):
386 """Delete a git branch
388 if not branch_exists(name):
389 raise GitException, 'Branch "%s" does not exist' % name
390 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
393 def rename_branch(from_name, to_name):
394 """Rename a git branch
396 if not branch_exists(from_name):
397 raise GitException, 'Branch "%s" does not exist' % from_name
398 if branch_exists(to_name):
399 raise GitException, 'Branch "%s" already exists' % to_name
401 if get_head_file() == from_name:
402 set_head_file(to_name)
403 rename(os.path.join(basedir.get(), 'refs', 'heads'),
406 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
407 if os.path.exists(reflog_dir) \
408 and os.path.exists(os.path.join(reflog_dir, from_name)):
409 rename(reflog_dir, from_name, to_name)
412 """Add the files or recursively add the directory contents
414 # generate the file list
417 if not os.path.exists(i):
418 raise GitException, 'Unknown file or directory: %s' % i
421 # recursive search. We only add files
422 for root, dirs, local_files in os.walk(i):
423 for name in [os.path.join(root, f) for f in local_files]:
424 if os.path.isfile(name):
425 files.append(os.path.normpath(name))
426 elif os.path.isfile(i):
427 files.append(os.path.normpath(i))
429 raise GitException, '%s is not a file or directory' % i
432 if __run('git-update-index --add --', files):
433 raise GitException, 'Unable to add file'
435 def __copy_single(source, target, target2=''):
436 """Copy file or dir named 'source' to name target+target2"""
438 # "source" (file or dir) must match one or more git-controlled file
439 realfiles = _output_lines(['git-ls-files', source])
440 if len(realfiles) == 0:
441 raise GitException, '"%s" matches no git-controled files' % source
443 if os.path.isdir(source):
444 # physically copy the files, and record them to add them in one run
446 re_string='^'+source+'/(.*)$'
447 prefix_regexp = re.compile(re_string)
448 for f in [f.strip() for f in realfiles]:
449 m = prefix_regexp.match(f)
451 raise Exception, '"%s" does not match "%s"' % (f, re_string)
452 newname = target+target2+'/'+m.group(1)
453 if not os.path.exists(os.path.dirname(newname)):
454 os.makedirs(os.path.dirname(newname))
456 newfiles.append(newname)
459 else: # files, symlinks, ...
460 newname = target+target2
461 copyfile(source, newname)
465 def copy(filespecs, target):
466 if os.path.isdir(target):
467 # target is a directory: copy each entry on the command line,
468 # with the same name, into the target
469 target = target.rstrip('/')
471 # first, check that none of the children of the target
472 # matching the command line aleady exist
473 for filespec in filespecs:
474 entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
475 if os.path.exists(entry):
476 raise GitException, 'Target "%s" already exists' % entry
478 for filespec in filespecs:
479 filespec = filespec.rstrip('/')
480 basename = '/' + os.path.basename(filespec)
481 __copy_single(filespec, target, basename)
483 elif os.path.exists(target):
484 raise GitException, 'Target "%s" exists but is not a directory' % target
485 elif len(filespecs) != 1:
486 raise GitException, 'Cannot copy more than one file to non-directory'
489 # at this point: len(filespecs)==1 and target does not exist
491 # check target directory
492 targetdir = os.path.dirname(target)
493 if targetdir != '' and not os.path.isdir(targetdir):
494 raise GitException, 'Target directory "%s" does not exist' % targetdir
496 __copy_single(filespecs[0].rstrip('/'), target)
499 def rm(files, force = False):
500 """Remove a file from the repository
504 if os.path.exists(f):
505 raise GitException, '%s exists. Remove it first' %f
507 __run('git-update-index --remove --', files)
510 __run('git-update-index --force-remove --', files)
518 """Return the user information.
522 name=config.get('user.name')
523 email=config.get('user.email')
524 __user = Person(name, email)
528 """Return the author information.
533 # the environment variables take priority over config
535 date = os.environ['GIT_AUTHOR_DATE']
538 __author = Person(os.environ['GIT_AUTHOR_NAME'],
539 os.environ['GIT_AUTHOR_EMAIL'],
546 """Return the author information.
551 # the environment variables take priority over config
553 date = os.environ['GIT_COMMITTER_DATE']
556 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
557 os.environ['GIT_COMMITTER_EMAIL'],
563 def update_cache(files = None, force = False):
564 """Update the cache information for the given files
569 cache_files = __tree_status(files, verbose = False)
571 # everything is up-to-date
572 if len(cache_files) == 0:
575 # check for unresolved conflicts
576 if not force and [x for x in cache_files
577 if x[0] not in ['M', 'N', 'A', 'D']]:
578 raise GitException, 'Updating cache failed: unresolved conflicts'
581 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
582 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
583 m_files = [x[1] for x in cache_files if x[0] in ['M']]
585 if add_files and __run('git-update-index --add --', add_files) != 0:
586 raise GitException, 'Failed git-update-index --add'
587 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
588 raise GitException, 'Failed git-update-index --rm'
589 if m_files and __run('git-update-index --', m_files) != 0:
590 raise GitException, 'Failed git-update-index'
594 def commit(message, files = None, parents = None, allowempty = False,
595 cache_update = True, tree_id = None,
596 author_name = None, author_email = None, author_date = None,
597 committer_name = None, committer_email = None):
598 """Commit the current tree to repository
605 # Get the tree status
606 if cache_update and parents != []:
607 changes = update_cache(files)
608 if not changes and not allowempty:
609 raise GitException, 'No changes to commit'
611 # get the commit message
614 elif message[-1:] != '\n':
618 # write the index to repository
620 tree_id = _output_one_line(['git-write-tree'])
627 cmd += ['GIT_AUTHOR_NAME=%s' % author_name]
629 cmd += ['GIT_AUTHOR_EMAIL=%s' % author_email]
631 cmd += ['GIT_AUTHOR_DATE=%s' % author_date]
633 cmd += ['GIT_COMMITTER_NAME=%s' % committer_name]
635 cmd += ['GIT_COMMITTER_EMAIL=%s' % committer_email]
636 cmd += ['git-commit-tree', tree_id]
642 commit_id = _output_one_line(cmd, message)
644 __set_head(commit_id)
648 def apply_diff(rev1, rev2, check_index = True, files = None):
649 """Apply the diff between rev1 and rev2 onto the current
650 index. This function doesn't need to raise an exception since it
651 is only used for fast-pushing a patch. If this operation fails,
652 the pushing would fall back to the three-way merge.
655 index_opt = ['--index']
662 diff_str = diff(files, rev1, rev2)
665 _input_str(['git-apply'] + index_opt, diff_str)
671 def merge(base, head1, head2, recursive = False):
672 """Perform a 3-way merge between base, head1 and head2 into the
679 # this operation tracks renames but it is slower (used in
680 # general when pushing or picking patches)
682 # use _output() to mask the verbose prints of the tool
683 _output(['git-merge-recursive', base, '--', head1, head2])
684 except GitException, ex:
688 # the fast case where we don't track renames (used when the
689 # distance between base and heads is small, i.e. folding or
690 # synchronising patches)
691 if __run('git-read-tree -u -m --aggressive',
692 [base, head1, head2]) != 0:
693 raise GitException, 'git-read-tree failed (local changes maybe?)'
695 # check the index for unmerged entries
697 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
699 for line in _output(['git-ls-files', '--unmerged', '--stage', '-z']).split('\0'):
703 mode, hash, stage, path = stages_re.findall(line)[0]
705 if not path in files:
707 files[path]['1'] = ('', '')
708 files[path]['2'] = ('', '')
709 files[path]['3'] = ('', '')
711 files[path][stage] = (mode, hash)
713 if err_output and not files:
714 # if no unmerged files, there was probably a different type of
715 # error and we have to abort the merge
716 raise GitException, err_output
718 # merge the unmerged files
721 # remove additional files that might be generated for some
722 # newer versions of GIT
723 for suffix in [base, head1, head2]:
726 fname = path + '~' + suffix
727 if os.path.exists(fname):
731 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
732 stages['3'][1], path, stages['1'][0],
733 stages['2'][0], stages['3'][0]) != 0:
737 raise GitException, 'GIT index merging failed (possible conflicts)'
739 def status(files = None, modified = False, new = False, deleted = False,
740 conflict = False, unknown = False, noexclude = False):
741 """Show the tree status
746 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
747 all = not (modified or new or deleted or conflict or unknown)
762 cache_files = [x for x in cache_files if x[0] in filestat]
764 for fs in cache_files:
765 if files and not fs[1] in files:
768 out.stdout('%s %s' % (fs[0], fs[1]))
770 out.stdout('%s' % fs[1])
772 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None,
774 """Show the diff between rev1 and rev2
780 diff_str = _output(['git-diff-tree', '-p'] + diff_flags
781 + [rev1, rev2, '--'] + files)
785 diff_str = _output(['git-diff-index', '-p', '-R']
786 + diff_flags + [rev2, '--'] + files)
788 diff_str = _output(['git-diff-index', '-p']
789 + diff_flags + [rev1, '--'] + files)
794 out_fd.write(diff_str)
798 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
799 """Return the diffstat between rev1 and rev2
804 p=popen2.Popen3('git-apply --stat')
805 diff(files, rev1, rev2, p.tochild)
807 diff_str = p.fromchild.read().rstrip()
809 raise GitException, 'git.diffstat failed'
812 def files(rev1, rev2):
813 """Return the files modified between rev1 and rev2
817 for line in _output_lines(['git-diff-tree', '-r', rev1, rev2]):
818 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
820 return result.rstrip()
822 def barefiles(rev1, rev2):
823 """Return the files modified between rev1 and rev2, without status info
827 for line in _output_lines(['git-diff-tree', '-r', rev1, rev2]):
828 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
830 return result.rstrip()
832 def pretty_commit(commit_id = 'HEAD'):
833 """Return a given commit (log + diff)
835 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
838 def checkout(files = None, tree_id = None, force = False):
839 """Check out the given or all files
844 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
845 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
847 checkout_cmd = 'git-checkout-index -q -u'
849 checkout_cmd += ' -f'
851 checkout_cmd += ' -a'
853 checkout_cmd += ' --'
855 if __run(checkout_cmd, files) != 0:
856 raise GitException, 'Failed git-checkout-index'
858 def switch(tree_id, keep = False):
859 """Switch the tree to the given id
863 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
864 raise GitException, 'git-read-tree failed (local changes maybe?)'
868 def reset(files = None, tree_id = None, check_out = True):
869 """Revert the tree changes relative to the given tree_id. It removes
876 cache_files = __tree_status(files, tree_id)
877 # files which were added but need to be removed
878 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
880 checkout(files, tree_id, True)
881 # checkout doesn't remove files
882 map(os.remove, rm_files)
884 # if the reset refers to the whole tree, switch the HEAD as well
888 def fetch(repository = 'origin', refspec = None):
889 """Fetches changes from the remote repository, using 'git-fetch'
899 command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
900 config.get('stgit.fetchcmd')
901 if __run(command, args) != 0:
902 raise GitException, 'Failed "%s %s"' % (command, repository)
904 def pull(repository = 'origin', refspec = None):
905 """Fetches changes from the remote repository, using 'git-pull'
915 command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
916 config.get('stgit.pullcmd')
917 if __run(command, args) != 0:
918 raise GitException, 'Failed "%s %s"' % (command, repository)
921 """Repack all objects into a single pack
923 __run('git-repack -a -d -f')
925 def apply_patch(filename = None, diff = None, base = None,
927 """Apply a patch onto the current or given index. There must not
928 be any local changes in the tree, otherwise the command fails
940 orig_head = get_head()
946 _input_str(['git-apply', '--index'], diff)
951 # write the failed diff to a file
952 f = file('.stgit-failed.patch', 'w+')
955 out.warn('Diff written to the .stgit-failed.patch file')
960 top = commit(message = 'temporary commit used for applying a patch',
963 merge(base, orig_head, top)
965 def clone(repository, local_dir):
966 """Clone a remote repository. At the moment, just use the
969 if __run('git-clone', [repository, local_dir]) != 0:
970 raise GitException, 'Failed "git-clone %s %s"' \
971 % (repository, local_dir)
973 def modifying_revs(files, base_rev, head_rev):
974 """Return the revisions from the list modifying the given files
976 cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
977 revs = [line.strip() for line in _output_lines(cmd + files)]
982 def refspec_localpart(refspec):
983 m = re.match('^[^:]*:([^:]*)$', refspec)
987 raise GitException, 'Cannot parse refspec "%s"' % line
989 def refspec_remotepart(refspec):
990 m = re.match('^([^:]*):[^:]*$', refspec)
994 raise GitException, 'Cannot parse refspec "%s"' % line
997 def __remotes_from_config():
998 return config.sections_matching(r'remote\.(.*)\.url')
1000 def __remotes_from_dir(dir):
1001 d = os.path.join(basedir.get(), dir)
1002 if os.path.exists(d):
1003 return os.listdir(d)
1008 """Return the list of remotes in the repository
1011 return Set(__remotes_from_config()) | \
1012 Set(__remotes_from_dir('remotes')) | \
1013 Set(__remotes_from_dir('branches'))
1015 def remotes_local_branches(remote):
1016 """Returns the list of local branches fetched from given remote
1020 if remote in __remotes_from_config():
1021 for line in config.getall('remote.%s.fetch' % remote):
1022 branches.append(refspec_localpart(line))
1023 elif remote in __remotes_from_dir('remotes'):
1024 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1026 # Only consider Pull lines
1027 m = re.match('^Pull: (.*)\n$', line)
1029 branches.append(refspec_localpart(m.group(1)))
1031 elif remote in __remotes_from_dir('branches'):
1032 # old-style branches only declare one branch
1033 branches.append('refs/heads/'+remote);
1035 raise GitException, 'Unknown remote "%s"' % remote
1039 def identify_remote(branchname):
1040 """Return the name for the remote to pull the given branchname
1041 from, or None if we believe it is a local branch.
1044 for remote in remotes_list():
1045 if branchname in remotes_local_branches(remote):
1048 # if we get here we've found nothing, the branch is a local one
1052 """Return the git id for the tip of the parent branch as left by
1057 stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1059 # Only consider lines not tagged not-for-merge
1060 m = re.match('^([^\t]*)\t\t', line)
1063 raise GitException, "StGit does not support multiple FETCH_HEAD"
1065 fetch_head=m.group(1)
1068 # here we are sure to have a single fetch_head