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 %s' % 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 %s'
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]
223 if verbose and sys.stdout.isatty():
224 print 'Checking for changes in the working directory...',
235 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
236 base_exclude = ['--exclude=%s' % s for s in
237 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
238 base_exclude.append('--exclude-per-directory=.gitignore')
240 if os.path.exists(exclude_file):
241 extra_exclude = ['--exclude-from=%s' % exclude_file]
245 extra_exclude = base_exclude = []
247 lines = _output_lines(['git-ls-files', '--others', '--directory']
248 + base_exclude + extra_exclude)
249 cache_files += [('?', line.strip()) for line in lines]
252 conflicts = get_conflicts()
255 cache_files += [('C', filename) for filename in conflicts]
258 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
259 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
260 if fs[1] not in conflicts:
261 cache_files.append(fs)
263 if verbose and sys.stdout.isatty():
268 def local_changes(verbose = True):
269 """Return true if there are local changes in the tree
271 return len(__tree_status(verbose = verbose)) != 0
277 """Verifies the HEAD and returns the SHA1 id that represents it
282 __head = rev_parse('HEAD')
286 """Returns the name of the file pointed to by the HEAD link
288 return strip_prefix('refs/heads/',
289 _output_one_line('git-symbolic-ref HEAD'))
291 def set_head_file(ref):
292 """Resets HEAD to point to a new ref
294 # head cache flushing is needed since we might have a different value
297 if __run('git-symbolic-ref HEAD',
298 [os.path.join('refs', 'heads', ref)]) != 0:
299 raise GitException, 'Could not set head to "%s"' % ref
302 """Sets the HEAD value
306 if not __head or __head != val:
307 if __run('git-update-ref HEAD', [val]) != 0:
308 raise GitException, 'Could not update HEAD to "%s".' % val
311 # only allow SHA1 hashes
312 assert(len(__head) == 40)
314 def __clear_head_cache():
315 """Sets the __head to None so that a re-read is forced
322 """Refresh index with stat() information from the working directory.
324 __run('git-update-index -q --unmerged --refresh')
326 def rev_parse(git_id):
327 """Parse the string and return a verified SHA1 id
330 return _output_one_line(['git-rev-parse', '--verify', git_id])
332 raise GitException, 'Unknown revision: %s' % git_id
334 def branch_exists(branch):
335 """Existence check for the named branch
337 branch = os.path.join('refs', 'heads', branch)
338 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
339 if line.strip() == branch:
341 if re.compile('[ |/]'+branch+' ').search(line):
342 raise GitException, 'Bogus branch: %s' % line
345 def create_branch(new_branch, tree_id = None):
346 """Create a new branch in the git repository
348 if branch_exists(new_branch):
349 raise GitException, 'Branch "%s" already exists' % new_branch
351 current_head = get_head()
352 set_head_file(new_branch)
353 __set_head(current_head)
355 # a checkout isn't needed if new branch points to the current head
359 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
360 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
362 def switch_branch(new_branch):
363 """Switch to a git branch
367 if not branch_exists(new_branch):
368 raise GitException, 'Branch "%s" does not exist' % new_branch
370 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
372 if tree_id != get_head():
374 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
375 raise GitException, 'git-read-tree failed (local changes maybe?)'
377 set_head_file(new_branch)
379 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
380 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
382 def delete_branch(name):
383 """Delete a git branch
385 if not branch_exists(name):
386 raise GitException, 'Branch "%s" does not exist' % name
387 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
390 def rename_branch(from_name, to_name):
391 """Rename a git branch
393 if not branch_exists(from_name):
394 raise GitException, 'Branch "%s" does not exist' % from_name
395 if branch_exists(to_name):
396 raise GitException, 'Branch "%s" already exists' % to_name
398 if get_head_file() == from_name:
399 set_head_file(to_name)
400 rename(os.path.join(basedir.get(), 'refs', 'heads'),
403 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
404 if os.path.exists(reflog_dir) \
405 and os.path.exists(os.path.join(reflog_dir, from_name)):
406 rename(reflog_dir, from_name, to_name)
409 """Add the files or recursively add the directory contents
411 # generate the file list
414 if not os.path.exists(i):
415 raise GitException, 'Unknown file or directory: %s' % i
418 # recursive search. We only add files
419 for root, dirs, local_files in os.walk(i):
420 for name in [os.path.join(root, f) for f in local_files]:
421 if os.path.isfile(name):
422 files.append(os.path.normpath(name))
423 elif os.path.isfile(i):
424 files.append(os.path.normpath(i))
426 raise GitException, '%s is not a file or directory' % i
429 if __run('git-update-index --add --', files):
430 raise GitException, 'Unable to add file'
432 def __copy_single(source, target, target2=''):
433 """Copy file or dir named 'source' to name target+target2"""
435 # "source" (file or dir) must match one or more git-controlled file
436 realfiles = _output_lines(['git-ls-files', source])
437 if len(realfiles) == 0:
438 raise GitException, '"%s" matches no git-controled files' % source
440 if os.path.isdir(source):
441 # physically copy the files, and record them to add them in one run
443 re_string='^'+source+'/(.*)$'
444 prefix_regexp = re.compile(re_string)
445 for f in [f.strip() for f in realfiles]:
446 m = prefix_regexp.match(f)
448 print '"%s" does not match "%s"' % (f, re_string)
450 newname = target+target2+'/'+m.group(1)
451 if not os.path.exists(os.path.dirname(newname)):
452 os.makedirs(os.path.dirname(newname))
454 newfiles.append(newname)
457 else: # files, symlinks, ...
458 newname = target+target2
459 copyfile(source, newname)
463 def copy(filespecs, target):
464 if os.path.isdir(target):
465 # target is a directory: copy each entry on the command line,
466 # with the same name, into the target
467 target = target.rstrip('/')
469 # first, check that none of the children of the target
470 # matching the command line aleady exist
471 for filespec in filespecs:
472 entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
473 if os.path.exists(entry):
474 raise GitException, 'Target "%s" already exists' % entry
476 for filespec in filespecs:
477 filespec = filespec.rstrip('/')
478 basename = '/' + os.path.basename(filespec)
479 __copy_single(filespec, target, basename)
481 elif os.path.exists(target):
482 raise GitException, 'Target "%s" exists but is not a directory' % target
483 elif len(filespecs) != 1:
484 raise GitException, 'Cannot copy more than one file to non-directory'
487 # at this point: len(filespecs)==1 and target does not exist
489 # check target directory
490 targetdir = os.path.dirname(target)
491 if targetdir != '' and not os.path.isdir(targetdir):
492 raise GitException, 'Target directory "%s" does not exist' % targetdir
494 __copy_single(filespecs[0].rstrip('/'), target)
497 def rm(files, force = False):
498 """Remove a file from the repository
502 if os.path.exists(f):
503 raise GitException, '%s exists. Remove it first' %f
505 __run('git-update-index --remove --', files)
508 __run('git-update-index --force-remove --', files)
516 """Return the user information.
520 name=config.get('user.name')
521 email=config.get('user.email')
522 __user = Person(name, email)
526 """Return the author information.
531 # the environment variables take priority over config
533 date = os.environ['GIT_AUTHOR_DATE']
536 __author = Person(os.environ['GIT_AUTHOR_NAME'],
537 os.environ['GIT_AUTHOR_EMAIL'],
544 """Return the author information.
549 # the environment variables take priority over config
551 date = os.environ['GIT_COMMITTER_DATE']
554 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
555 os.environ['GIT_COMMITTER_EMAIL'],
561 def update_cache(files = None, force = False):
562 """Update the cache information for the given files
567 cache_files = __tree_status(files, verbose = False)
569 # everything is up-to-date
570 if len(cache_files) == 0:
573 # check for unresolved conflicts
574 if not force and [x for x in cache_files
575 if x[0] not in ['M', 'N', 'A', 'D']]:
576 raise GitException, 'Updating cache failed: unresolved conflicts'
579 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
580 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
581 m_files = [x[1] for x in cache_files if x[0] in ['M']]
583 if add_files and __run('git-update-index --add --', add_files) != 0:
584 raise GitException, 'Failed git-update-index --add'
585 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
586 raise GitException, 'Failed git-update-index --rm'
587 if m_files and __run('git-update-index --', m_files) != 0:
588 raise GitException, 'Failed git-update-index'
592 def commit(message, files = None, parents = None, allowempty = False,
593 cache_update = True, tree_id = None,
594 author_name = None, author_email = None, author_date = None,
595 committer_name = None, committer_email = None):
596 """Commit the current tree to repository
603 # Get the tree status
604 if cache_update and parents != []:
605 changes = update_cache(files)
606 if not changes and not allowempty:
607 raise GitException, 'No changes to commit'
609 # get the commit message
612 elif message[-1:] != '\n':
616 # write the index to repository
618 tree_id = _output_one_line('git-write-tree')
625 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
627 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
629 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
631 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
633 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
634 cmd += 'git-commit-tree %s' % tree_id
640 commit_id = _output_one_line(cmd, message)
642 __set_head(commit_id)
646 def apply_diff(rev1, rev2, check_index = True, files = None):
647 """Apply the diff between rev1 and rev2 onto the current
648 index. This function doesn't need to raise an exception since it
649 is only used for fast-pushing a patch. If this operation fails,
650 the pushing would fall back to the three-way merge.
653 index_opt = '--index'
660 diff_str = diff(files, rev1, rev2)
663 _input_str('git-apply %s' % index_opt, diff_str)
669 def merge(base, head1, head2, recursive = False):
670 """Perform a 3-way merge between base, head1 and head2 into the
677 # this operation tracks renames but it is slower (used in
678 # general when pushing or picking patches)
680 # use _output() to mask the verbose prints of the tool
681 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
682 except GitException, ex:
686 # the fast case where we don't track renames (used when the
687 # distance between base and heads is small, i.e. folding or
688 # synchronising patches)
689 if __run('git-read-tree -u -m --aggressive',
690 [base, head1, head2]) != 0:
691 raise GitException, 'git-read-tree failed (local changes maybe?)'
693 # check the index for unmerged entries
695 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
697 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
701 mode, hash, stage, path = stages_re.findall(line)[0]
703 if not path in files:
705 files[path]['1'] = ('', '')
706 files[path]['2'] = ('', '')
707 files[path]['3'] = ('', '')
709 files[path][stage] = (mode, hash)
711 if err_output and not files:
712 # if no unmerged files, there was probably a different type of
713 # error and we have to abort the merge
714 raise GitException, err_output
716 # merge the unmerged files
719 # remove additional files that might be generated for some
720 # newer versions of GIT
721 for suffix in [base, head1, head2]:
724 fname = path + '~' + suffix
725 if os.path.exists(fname):
729 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
730 stages['3'][1], path, stages['1'][0],
731 stages['2'][0], stages['3'][0]) != 0:
735 raise GitException, 'GIT index merging failed (possible conflicts)'
737 def status(files = None, modified = False, new = False, deleted = False,
738 conflict = False, unknown = False, noexclude = False):
739 """Show the tree status
744 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
745 all = not (modified or new or deleted or conflict or unknown)
760 cache_files = [x for x in cache_files if x[0] in filestat]
762 for fs in cache_files:
763 if files and not fs[1] in files:
766 print '%s %s' % (fs[0], fs[1])
770 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
771 """Show the diff between rev1 and rev2
777 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
781 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
783 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
788 out_fd.write(diff_str)
792 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
793 """Return the diffstat between rev1 and rev2
798 p=popen2.Popen3('git-apply --stat')
799 diff(files, rev1, rev2, p.tochild)
801 diff_str = p.fromchild.read().rstrip()
803 raise GitException, 'git.diffstat failed'
806 def files(rev1, rev2):
807 """Return the files modified between rev1 and rev2
811 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
812 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
814 return result.rstrip()
816 def barefiles(rev1, rev2):
817 """Return the files modified between rev1 and rev2, without status info
821 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
822 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
824 return result.rstrip()
826 def pretty_commit(commit_id = 'HEAD'):
827 """Return a given commit (log + diff)
829 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
832 def checkout(files = None, tree_id = None, force = False):
833 """Check out the given or all files
838 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
839 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
841 checkout_cmd = 'git-checkout-index -q -u'
843 checkout_cmd += ' -f'
845 checkout_cmd += ' -a'
847 checkout_cmd += ' --'
849 if __run(checkout_cmd, files) != 0:
850 raise GitException, 'Failed git-checkout-index'
852 def switch(tree_id, keep = False):
853 """Switch the tree to the given id
857 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
858 raise GitException, 'git-read-tree failed (local changes maybe?)'
862 def reset(files = None, tree_id = None, check_out = True):
863 """Revert the tree changes relative to the given tree_id. It removes
870 cache_files = __tree_status(files, tree_id)
871 # files which were added but need to be removed
872 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
874 checkout(files, tree_id, True)
875 # checkout doesn't remove files
876 map(os.remove, rm_files)
878 # if the reset refers to the whole tree, switch the HEAD as well
882 def fetch(repository = 'origin', refspec = None):
883 """Fetches changes from the remote repository, using 'git-fetch'
893 command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
894 config.get('stgit.fetchcmd')
895 if __run(command, args) != 0:
896 raise GitException, 'Failed "%s %s"' % (command, repository)
898 def pull(repository = 'origin', refspec = None):
899 """Fetches changes from the remote repository, using 'git-pull'
909 command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
910 config.get('stgit.pullcmd')
911 if __run(command, args) != 0:
912 raise GitException, 'Failed "%s %s"' % (command, repository)
915 """Repack all objects into a single pack
917 __run('git-repack -a -d -f')
919 def apply_patch(filename = None, diff = None, base = None,
921 """Apply a patch onto the current or given index. There must not
922 be any local changes in the tree, otherwise the command fails
934 orig_head = get_head()
940 _input_str('git-apply --index', diff)
945 # write the failed diff to a file
946 f = file('.stgit-failed.patch', 'w+')
949 print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
954 top = commit(message = 'temporary commit used for applying a patch',
957 merge(base, orig_head, top)
959 def clone(repository, local_dir):
960 """Clone a remote repository. At the moment, just use the
963 if __run('git-clone', [repository, local_dir]) != 0:
964 raise GitException, 'Failed "git-clone %s %s"' \
965 % (repository, local_dir)
967 def modifying_revs(files, base_rev, head_rev):
968 """Return the revisions from the list modifying the given files
970 cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
971 revs = [line.strip() for line in _output_lines(cmd + files)]
976 def refspec_localpart(refspec):
977 m = re.match('^[^:]*:([^:]*)$', refspec)
981 raise GitException, 'Cannot parse refspec "%s"' % line
983 def refspec_remotepart(refspec):
984 m = re.match('^([^:]*):[^:]*$', refspec)
988 raise GitException, 'Cannot parse refspec "%s"' % line
991 def __remotes_from_config():
992 return config.sections_matching(r'remote\.(.*)\.url')
994 def __remotes_from_dir(dir):
995 d = os.path.join(basedir.get(), dir)
996 if os.path.exists(d):
1002 """Return the list of remotes in the repository
1005 return Set(__remotes_from_config()) | \
1006 Set(__remotes_from_dir('remotes')) | \
1007 Set(__remotes_from_dir('branches'))
1009 def remotes_local_branches(remote):
1010 """Returns the list of local branches fetched from given remote
1014 if remote in __remotes_from_config():
1015 for line in config.getall('remote.%s.fetch' % remote):
1016 branches.append(refspec_localpart(line))
1017 elif remote in __remotes_from_dir('remotes'):
1018 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1020 # Only consider Pull lines
1021 m = re.match('^Pull: (.*)\n$', line)
1023 branches.append(refspec_localpart(m.group(1)))
1025 elif remote in __remotes_from_dir('branches'):
1026 # old-style branches only declare one branch
1027 branches.append('refs/heads/'+remote);
1029 raise GitException, 'Unknown remote "%s"' % remote
1033 def identify_remote(branchname):
1034 """Return the name for the remote to pull the given branchname
1035 from, or None if we believe it is a local branch.
1038 for remote in remotes_list():
1039 if branchname in remotes_local_branches(remote):
1042 # if we get here we've found nothing, the branch is a local one
1046 """Return the git id for the tip of the parent branch as left by
1051 stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1053 # Only consider lines not tagged not-for-merge
1054 m = re.match('^([^\t]*)\t\t', line)
1057 raise GitException, "StGit does not support multiple FETCH_HEAD"
1059 fetch_head=m.group(1)
1062 # here we are sure to have a single fetch_head