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
301 def set_branch(branch, val):
302 """Point branch at a new commit object."""
303 if __run('git-update-ref', [branch, val]) != 0:
304 raise GitException, 'Could not update %s to "%s".' % (branch, val)
307 """Sets the HEAD value
311 if not __head or __head != val:
312 set_branch('HEAD', val)
315 # only allow SHA1 hashes
316 assert(len(__head) == 40)
318 def __clear_head_cache():
319 """Sets the __head to None so that a re-read is forced
326 """Refresh index with stat() information from the working directory.
328 __run('git-update-index -q --unmerged --refresh')
330 def rev_parse(git_id):
331 """Parse the string and return a verified SHA1 id
334 return _output_one_line(['git-rev-parse', '--verify', git_id])
336 raise GitException, 'Unknown revision: %s' % git_id
338 def branch_exists(branch):
339 """Existence check for the named branch
341 branch = os.path.join('refs', 'heads', branch)
342 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
343 if line.strip() == branch:
345 if re.compile('[ |/]'+branch+' ').search(line):
346 raise GitException, 'Bogus branch: %s' % line
349 def create_branch(new_branch, tree_id = None):
350 """Create a new branch in the git repository
352 if branch_exists(new_branch):
353 raise GitException, 'Branch "%s" already exists' % new_branch
355 current_head = get_head()
356 set_head_file(new_branch)
357 __set_head(current_head)
359 # a checkout isn't needed if new branch points to the current head
363 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
364 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
366 def switch_branch(new_branch):
367 """Switch to a git branch
371 if not branch_exists(new_branch):
372 raise GitException, 'Branch "%s" does not exist' % new_branch
374 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
376 if tree_id != get_head():
378 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
379 raise GitException, 'git-read-tree failed (local changes maybe?)'
381 set_head_file(new_branch)
383 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
384 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
386 def delete_branch(name):
387 """Delete a git branch
389 if not branch_exists(name):
390 raise GitException, 'Branch "%s" does not exist' % name
391 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
394 def rename_branch(from_name, to_name):
395 """Rename a git branch
397 if not branch_exists(from_name):
398 raise GitException, 'Branch "%s" does not exist' % from_name
399 if branch_exists(to_name):
400 raise GitException, 'Branch "%s" already exists' % to_name
402 if get_head_file() == from_name:
403 set_head_file(to_name)
404 rename(os.path.join(basedir.get(), 'refs', 'heads'),
407 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
408 if os.path.exists(reflog_dir) \
409 and os.path.exists(os.path.join(reflog_dir, from_name)):
410 rename(reflog_dir, from_name, to_name)
413 """Add the files or recursively add the directory contents
415 # generate the file list
418 if not os.path.exists(i):
419 raise GitException, 'Unknown file or directory: %s' % i
422 # recursive search. We only add files
423 for root, dirs, local_files in os.walk(i):
424 for name in [os.path.join(root, f) for f in local_files]:
425 if os.path.isfile(name):
426 files.append(os.path.normpath(name))
427 elif os.path.isfile(i):
428 files.append(os.path.normpath(i))
430 raise GitException, '%s is not a file or directory' % i
433 if __run('git-update-index --add --', files):
434 raise GitException, 'Unable to add file'
436 def __copy_single(source, target, target2=''):
437 """Copy file or dir named 'source' to name target+target2"""
439 # "source" (file or dir) must match one or more git-controlled file
440 realfiles = _output_lines(['git-ls-files', source])
441 if len(realfiles) == 0:
442 raise GitException, '"%s" matches no git-controled files' % source
444 if os.path.isdir(source):
445 # physically copy the files, and record them to add them in one run
447 re_string='^'+source+'/(.*)$'
448 prefix_regexp = re.compile(re_string)
449 for f in [f.strip() for f in realfiles]:
450 m = prefix_regexp.match(f)
452 print '"%s" does not match "%s"' % (f, re_string)
454 newname = target+target2+'/'+m.group(1)
455 if not os.path.exists(os.path.dirname(newname)):
456 os.makedirs(os.path.dirname(newname))
458 newfiles.append(newname)
461 else: # files, symlinks, ...
462 newname = target+target2
463 copyfile(source, newname)
467 def copy(filespecs, target):
468 if os.path.isdir(target):
469 # target is a directory: copy each entry on the command line,
470 # with the same name, into the target
471 target = target.rstrip('/')
473 # first, check that none of the children of the target
474 # matching the command line aleady exist
475 for filespec in filespecs:
476 entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
477 if os.path.exists(entry):
478 raise GitException, 'Target "%s" already exists' % entry
480 for filespec in filespecs:
481 filespec = filespec.rstrip('/')
482 basename = '/' + os.path.basename(filespec)
483 __copy_single(filespec, target, basename)
485 elif os.path.exists(target):
486 raise GitException, 'Target "%s" exists but is not a directory' % target
487 elif len(filespecs) != 1:
488 raise GitException, 'Cannot copy more than one file to non-directory'
491 # at this point: len(filespecs)==1 and target does not exist
493 # check target directory
494 targetdir = os.path.dirname(target)
495 if targetdir != '' and not os.path.isdir(targetdir):
496 raise GitException, 'Target directory "%s" does not exist' % targetdir
498 __copy_single(filespecs[0].rstrip('/'), target)
501 def rm(files, force = False):
502 """Remove a file from the repository
506 if os.path.exists(f):
507 raise GitException, '%s exists. Remove it first' %f
509 __run('git-update-index --remove --', files)
512 __run('git-update-index --force-remove --', files)
520 """Return the user information.
524 name=config.get('user.name')
525 email=config.get('user.email')
526 __user = Person(name, email)
530 """Return the author information.
535 # the environment variables take priority over config
537 date = os.environ['GIT_AUTHOR_DATE']
540 __author = Person(os.environ['GIT_AUTHOR_NAME'],
541 os.environ['GIT_AUTHOR_EMAIL'],
548 """Return the author information.
553 # the environment variables take priority over config
555 date = os.environ['GIT_COMMITTER_DATE']
558 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
559 os.environ['GIT_COMMITTER_EMAIL'],
565 def update_cache(files = None, force = False):
566 """Update the cache information for the given files
571 cache_files = __tree_status(files, verbose = False)
573 # everything is up-to-date
574 if len(cache_files) == 0:
577 # check for unresolved conflicts
578 if not force and [x for x in cache_files
579 if x[0] not in ['M', 'N', 'A', 'D']]:
580 raise GitException, 'Updating cache failed: unresolved conflicts'
583 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
584 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
585 m_files = [x[1] for x in cache_files if x[0] in ['M']]
587 if add_files and __run('git-update-index --add --', add_files) != 0:
588 raise GitException, 'Failed git-update-index --add'
589 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
590 raise GitException, 'Failed git-update-index --rm'
591 if m_files and __run('git-update-index --', m_files) != 0:
592 raise GitException, 'Failed git-update-index'
596 def commit(message, files = None, parents = None, allowempty = False,
597 cache_update = True, tree_id = None,
598 author_name = None, author_email = None, author_date = None,
599 committer_name = None, committer_email = None):
600 """Commit the current tree to repository
607 # Get the tree status
608 if cache_update and parents != []:
609 changes = update_cache(files)
610 if not changes and not allowempty:
611 raise GitException, 'No changes to commit'
613 # get the commit message
616 elif message[-1:] != '\n':
620 # write the index to repository
622 tree_id = _output_one_line('git-write-tree')
629 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
631 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
633 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
635 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
637 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
638 cmd += 'git-commit-tree %s' % tree_id
644 commit_id = _output_one_line(cmd, message)
646 __set_head(commit_id)
650 def apply_diff(rev1, rev2, check_index = True, files = None):
651 """Apply the diff between rev1 and rev2 onto the current
652 index. This function doesn't need to raise an exception since it
653 is only used for fast-pushing a patch. If this operation fails,
654 the pushing would fall back to the three-way merge.
657 index_opt = '--index'
664 diff_str = diff(files, rev1, rev2)
667 _input_str('git-apply %s' % index_opt, diff_str)
673 def merge(base, head1, head2, recursive = False):
674 """Perform a 3-way merge between base, head1 and head2 into the
681 # this operation tracks renames but it is slower (used in
682 # general when pushing or picking patches)
684 # use _output() to mask the verbose prints of the tool
685 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
686 except GitException, ex:
690 # the fast case where we don't track renames (used when the
691 # distance between base and heads is small, i.e. folding or
692 # synchronising patches)
693 if __run('git-read-tree -u -m --aggressive',
694 [base, head1, head2]) != 0:
695 raise GitException, 'git-read-tree failed (local changes maybe?)'
697 # check the index for unmerged entries
699 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
701 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
705 mode, hash, stage, path = stages_re.findall(line)[0]
707 if not path in files:
709 files[path]['1'] = ('', '')
710 files[path]['2'] = ('', '')
711 files[path]['3'] = ('', '')
713 files[path][stage] = (mode, hash)
715 if err_output and not files:
716 # if no unmerged files, there was probably a different type of
717 # error and we have to abort the merge
718 raise GitException, err_output
720 # merge the unmerged files
723 # remove additional files that might be generated for some
724 # newer versions of GIT
725 for suffix in [base, head1, head2]:
728 fname = path + '~' + suffix
729 if os.path.exists(fname):
733 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
734 stages['3'][1], path, stages['1'][0],
735 stages['2'][0], stages['3'][0]) != 0:
739 raise GitException, 'GIT index merging failed (possible conflicts)'
741 def status(files = None, modified = False, new = False, deleted = False,
742 conflict = False, unknown = False, noexclude = False):
743 """Show the tree status
748 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
749 all = not (modified or new or deleted or conflict or unknown)
764 cache_files = [x for x in cache_files if x[0] in filestat]
766 for fs in cache_files:
767 if files and not fs[1] in files:
770 print '%s %s' % (fs[0], fs[1])
774 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
775 """Show the diff between rev1 and rev2
781 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
785 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
787 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
792 out_fd.write(diff_str)
796 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
797 """Return the diffstat between rev1 and rev2
802 p=popen2.Popen3('git-apply --stat')
803 diff(files, rev1, rev2, p.tochild)
805 diff_str = p.fromchild.read().rstrip()
807 raise GitException, 'git.diffstat failed'
810 def files(rev1, rev2):
811 """Return the files modified between rev1 and rev2
815 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
816 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
818 return result.rstrip()
820 def barefiles(rev1, rev2):
821 """Return the files modified between rev1 and rev2, without status info
825 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
826 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
828 return result.rstrip()
830 def pretty_commit(commit_id = 'HEAD'):
831 """Return a given commit (log + diff)
833 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
836 def checkout(files = None, tree_id = None, force = False):
837 """Check out the given or all files
842 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
843 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
845 checkout_cmd = 'git-checkout-index -q -u'
847 checkout_cmd += ' -f'
849 checkout_cmd += ' -a'
851 checkout_cmd += ' --'
853 if __run(checkout_cmd, files) != 0:
854 raise GitException, 'Failed git-checkout-index'
856 def switch(tree_id, keep = False):
857 """Switch the tree to the given id
861 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
862 raise GitException, 'git-read-tree failed (local changes maybe?)'
866 def reset(files = None, tree_id = None, check_out = True):
867 """Revert the tree changes relative to the given tree_id. It removes
874 cache_files = __tree_status(files, tree_id)
875 # files which were added but need to be removed
876 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
878 checkout(files, tree_id, True)
879 # checkout doesn't remove files
880 map(os.remove, rm_files)
882 # if the reset refers to the whole tree, switch the HEAD as well
886 def fetch(repository = 'origin', refspec = None):
887 """Fetches changes from the remote repository, using 'git-fetch'
897 command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
898 config.get('stgit.fetchcmd')
899 if __run(command, args) != 0:
900 raise GitException, 'Failed "%s %s"' % (command, repository)
902 def pull(repository = 'origin', refspec = None):
903 """Fetches changes from the remote repository, using 'git-pull'
913 command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
914 config.get('stgit.pullcmd')
915 if __run(command, args) != 0:
916 raise GitException, 'Failed "%s %s"' % (command, repository)
919 """Repack all objects into a single pack
921 __run('git-repack -a -d -f')
923 def apply_patch(filename = None, diff = None, base = None,
925 """Apply a patch onto the current or given index. There must not
926 be any local changes in the tree, otherwise the command fails
938 orig_head = get_head()
944 _input_str('git-apply --index', diff)
949 # write the failed diff to a file
950 f = file('.stgit-failed.patch', 'w+')
953 print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
958 top = commit(message = 'temporary commit used for applying a patch',
961 merge(base, orig_head, top)
963 def clone(repository, local_dir):
964 """Clone a remote repository. At the moment, just use the
967 if __run('git-clone', [repository, local_dir]) != 0:
968 raise GitException, 'Failed "git-clone %s %s"' \
969 % (repository, local_dir)
971 def modifying_revs(files, base_rev, head_rev):
972 """Return the revisions from the list modifying the given files
974 cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
975 revs = [line.strip() for line in _output_lines(cmd + files)]
980 def refspec_localpart(refspec):
981 m = re.match('^[^:]*:([^:]*)$', refspec)
985 raise GitException, 'Cannot parse refspec "%s"' % line
987 def refspec_remotepart(refspec):
988 m = re.match('^([^:]*):[^:]*$', refspec)
992 raise GitException, 'Cannot parse refspec "%s"' % line
995 def __remotes_from_config():
996 return config.sections_matching(r'remote\.(.*)\.url')
998 def __remotes_from_dir(dir):
999 d = os.path.join(basedir.get(), dir)
1000 if os.path.exists(d):
1001 return os.listdir(d)
1006 """Return the list of remotes in the repository
1009 return Set(__remotes_from_config()) | \
1010 Set(__remotes_from_dir('remotes')) | \
1011 Set(__remotes_from_dir('branches'))
1013 def remotes_local_branches(remote):
1014 """Returns the list of local branches fetched from given remote
1018 if remote in __remotes_from_config():
1019 for line in config.getall('remote.%s.fetch' % remote):
1020 branches.append(refspec_localpart(line))
1021 elif remote in __remotes_from_dir('remotes'):
1022 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1024 # Only consider Pull lines
1025 m = re.match('^Pull: (.*)\n$', line)
1027 branches.append(refspec_localpart(m.group(1)))
1029 elif remote in __remotes_from_dir('branches'):
1030 # old-style branches only declare one branch
1031 branches.append('refs/heads/'+remote);
1033 raise GitException, 'Unknown remote "%s"' % remote
1037 def identify_remote(branchname):
1038 """Return the name for the remote to pull the given branchname
1039 from, or None if we believe it is a local branch.
1042 for remote in remotes_list():
1043 if branchname in remotes_local_branches(remote):
1046 # if we get here we've found nothing, the branch is a local one
1050 """Return the git id for the tip of the parent branch as left by
1055 stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1057 # Only consider lines not tagged not-for-merge
1058 m = re.match('^([^\t]*)\t\t', line)
1061 raise GitException, "StGit does not support multiple FETCH_HEAD"
1063 fetch_head=m.group(1)
1066 # here we are sure to have a single fetch_head