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 self.name = self.email = self.date = None
43 if name or email or date:
49 assert not (name or email or date)
51 m = re.match(r'^(.+)<(.+)>(.*)$', s)
53 return [x.strip() or None for x in m.groups()]
54 self.name, self.email, self.date = parse_desc(desc)
55 def set_name(self, val):
58 def set_email(self, val):
61 def set_date(self, val):
65 if self.name and self.email:
66 return '%s <%s>' % (self.name, self.email)
68 raise GitException, 'not enough identity data'
71 """Handle the commit objects
73 def __init__(self, id_hash):
74 self.__id_hash = id_hash
76 lines = _output_lines('git-cat-file commit %s' % id_hash)
77 for i in range(len(lines)):
81 field = line.strip().split(' ', 1)
82 if field[0] == 'tree':
83 self.__tree = field[1]
84 if field[0] == 'author':
85 self.__author = field[1]
86 if field[0] == 'committer':
87 self.__committer = field[1]
88 self.__log = ''.join(lines[i+1:])
90 def get_id_hash(self):
97 parents = self.get_parents()
103 def get_parents(self):
104 return _output_lines('git-rev-list --parents --max-count=1 %s'
105 % self.__id_hash)[0].split()[1:]
107 def get_author(self):
110 def get_committer(self):
111 return self.__committer
117 return self.get_id_hash()
119 # dictionary of Commit objects, used to avoid multiple calls to git
126 def get_commit(id_hash):
127 """Commit objects factory. Save/look-up them in the __commits
132 if id_hash in __commits:
133 return __commits[id_hash]
135 commit = Commit(id_hash)
136 __commits[id_hash] = commit
140 """Return the list of file conflicts
142 conflicts_file = os.path.join(basedir.get(), 'conflicts')
143 if os.path.isfile(conflicts_file):
144 f = file(conflicts_file)
145 names = [line.strip() for line in f.readlines()]
151 def _input(cmd, file_desc):
152 p = popen2.Popen3(cmd, True)
154 line = file_desc.readline()
157 p.tochild.write(line)
160 raise GitException, '%s failed (%s)' % (str(cmd),
161 p.childerr.read().strip())
163 def _input_str(cmd, string):
164 p = popen2.Popen3(cmd, True)
165 p.tochild.write(string)
168 raise GitException, '%s failed (%s)' % (str(cmd),
169 p.childerr.read().strip())
172 p=popen2.Popen3(cmd, True)
173 output = p.fromchild.read()
175 raise GitException, '%s failed (%s)' % (str(cmd),
176 p.childerr.read().strip())
179 def _output_one_line(cmd, file_desc = None):
180 p=popen2.Popen3(cmd, True)
181 if file_desc != None:
182 for line in file_desc:
183 p.tochild.write(line)
185 output = p.fromchild.readline().strip()
187 raise GitException, '%s failed (%s)' % (str(cmd),
188 p.childerr.read().strip())
191 def _output_lines(cmd):
192 p=popen2.Popen3(cmd, True)
193 lines = p.fromchild.readlines()
195 raise GitException, '%s failed (%s)' % (str(cmd),
196 p.childerr.read().strip())
199 def __run(cmd, args=None):
200 """__run: runs cmd using spawnvp.
202 Runs cmd using spawnvp. The shell is avoided so it won't mess up
203 our arguments. If args is very large, the command is run multiple
204 times; args is split xargs style: cmd is passed on each
205 invocation. Unlike xargs, returns immediately if any non-zero
206 return code is received.
212 for i in range(0, len(args)+1, 100):
213 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
218 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
219 noexclude = True, verbose = False):
220 """Returns a list of pairs - [status, filename]
222 if verbose and sys.stdout.isatty():
223 print '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)
262 if verbose and sys.stdout.isatty():
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
301 """Sets the HEAD value
305 if not __head or __head != val:
306 if __run('git-update-ref HEAD', [val]) != 0:
307 raise GitException, 'Could not update HEAD to "%s".' % val
310 # only allow SHA1 hashes
311 assert(len(__head) == 40)
313 def __clear_head_cache():
314 """Sets the __head to None so that a re-read is forced
321 """Refresh index with stat() information from the working directory.
323 __run('git-update-index -q --unmerged --refresh')
325 def rev_parse(git_id):
326 """Parse the string and return a verified SHA1 id
329 return _output_one_line(['git-rev-parse', '--verify', git_id])
331 raise GitException, 'Unknown revision: %s' % git_id
333 def branch_exists(branch):
334 """Existence check for the named branch
336 branch = os.path.join('refs', 'heads', branch)
337 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
338 if line.strip() == branch:
340 if re.compile('[ |/]'+branch+' ').search(line):
341 raise GitException, 'Bogus branch: %s' % line
344 def create_branch(new_branch, tree_id = None):
345 """Create a new branch in the git repository
347 if branch_exists(new_branch):
348 raise GitException, 'Branch "%s" already exists' % new_branch
350 current_head = get_head()
351 set_head_file(new_branch)
352 __set_head(current_head)
354 # a checkout isn't needed if new branch points to the current head
358 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
359 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
361 def switch_branch(new_branch):
362 """Switch to a git branch
366 if not branch_exists(new_branch):
367 raise GitException, 'Branch "%s" does not exist' % new_branch
369 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
371 if tree_id != get_head():
373 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
374 raise GitException, 'git-read-tree failed (local changes maybe?)'
376 set_head_file(new_branch)
378 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
379 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
381 def delete_branch(name):
382 """Delete a git branch
384 if not branch_exists(name):
385 raise GitException, 'Branch "%s" does not exist' % name
386 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
389 def rename_branch(from_name, to_name):
390 """Rename a git branch
392 if not branch_exists(from_name):
393 raise GitException, 'Branch "%s" does not exist' % from_name
394 if branch_exists(to_name):
395 raise GitException, 'Branch "%s" already exists' % to_name
397 if get_head_file() == from_name:
398 set_head_file(to_name)
399 rename(os.path.join(basedir.get(), 'refs', 'heads'),
402 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
403 if os.path.exists(reflog_dir) \
404 and os.path.exists(os.path.join(reflog_dir, from_name)):
405 rename(reflog_dir, from_name, to_name)
408 """Add the files or recursively add the directory contents
410 # generate the file list
413 if not os.path.exists(i):
414 raise GitException, 'Unknown file or directory: %s' % i
417 # recursive search. We only add files
418 for root, dirs, local_files in os.walk(i):
419 for name in [os.path.join(root, f) for f in local_files]:
420 if os.path.isfile(name):
421 files.append(os.path.normpath(name))
422 elif os.path.isfile(i):
423 files.append(os.path.normpath(i))
425 raise GitException, '%s is not a file or directory' % i
428 if __run('git-update-index --add --', files):
429 raise GitException, 'Unable to add file'
431 def rm(files, force = False):
432 """Remove a file from the repository
436 if os.path.exists(f):
437 raise GitException, '%s exists. Remove it first' %f
439 __run('git-update-index --remove --', files)
442 __run('git-update-index --force-remove --', files)
450 """Return the user information.
454 name=config.get('user.name')
455 email=config.get('user.email')
456 __user = Person(name, email)
460 """Return the author information.
465 # the environment variables take priority over config
467 date = os.environ['GIT_AUTHOR_DATE']
470 __author = Person(os.environ['GIT_AUTHOR_NAME'],
471 os.environ['GIT_AUTHOR_EMAIL'],
478 """Return the author information.
483 # the environment variables take priority over config
485 date = os.environ['GIT_COMMITTER_DATE']
488 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
489 os.environ['GIT_COMMITTER_EMAIL'],
495 def update_cache(files = None, force = False):
496 """Update the cache information for the given files
501 cache_files = __tree_status(files, verbose = False)
503 # everything is up-to-date
504 if len(cache_files) == 0:
507 # check for unresolved conflicts
508 if not force and [x for x in cache_files
509 if x[0] not in ['M', 'N', 'A', 'D']]:
510 raise GitException, 'Updating cache failed: unresolved conflicts'
513 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
514 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
515 m_files = [x[1] for x in cache_files if x[0] in ['M']]
517 if add_files and __run('git-update-index --add --', add_files) != 0:
518 raise GitException, 'Failed git-update-index --add'
519 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
520 raise GitException, 'Failed git-update-index --rm'
521 if m_files and __run('git-update-index --', m_files) != 0:
522 raise GitException, 'Failed git-update-index'
526 def commit(message, files = None, parents = None, allowempty = False,
527 cache_update = True, tree_id = None,
528 author_name = None, author_email = None, author_date = None,
529 committer_name = None, committer_email = None):
530 """Commit the current tree to repository
537 # Get the tree status
538 if cache_update and parents != []:
539 changes = update_cache(files)
540 if not changes and not allowempty:
541 raise GitException, 'No changes to commit'
543 # get the commit message
546 elif message[-1:] != '\n':
550 # write the index to repository
552 tree_id = _output_one_line('git-write-tree')
559 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
561 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
563 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
565 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
567 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
568 cmd += 'git-commit-tree %s' % tree_id
574 commit_id = _output_one_line(cmd, message)
576 __set_head(commit_id)
580 def apply_diff(rev1, rev2, check_index = True, files = None):
581 """Apply the diff between rev1 and rev2 onto the current
582 index. This function doesn't need to raise an exception since it
583 is only used for fast-pushing a patch. If this operation fails,
584 the pushing would fall back to the three-way merge.
587 index_opt = '--index'
594 diff_str = diff(files, rev1, rev2)
597 _input_str('git-apply %s' % index_opt, diff_str)
603 def merge(base, head1, head2, recursive = False):
604 """Perform a 3-way merge between base, head1 and head2 into the
610 # this operation tracks renames but it is slower (used in
611 # general when pushing or picking patches)
613 # use _output() to mask the verbose prints of the tool
614 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
618 # the fast case where we don't track renames (used when the
619 # distance between base and heads is small, i.e. folding or
620 # synchronising patches)
621 if __run('git-read-tree -u -m --aggressive',
622 [base, head1, head2]) != 0:
623 raise GitException, 'git-read-tree failed (local changes maybe?)'
625 # check the index for unmerged entries
627 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
629 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
633 mode, hash, stage, path = stages_re.findall(line)[0]
635 if not path in files:
637 files[path]['1'] = ('', '')
638 files[path]['2'] = ('', '')
639 files[path]['3'] = ('', '')
641 files[path][stage] = (mode, hash)
643 # merge the unmerged files
646 # remove additional files that might be generated for some
647 # newer versions of GIT
648 for suffix in [base, head1, head2]:
651 fname = path + '~' + suffix
652 if os.path.exists(fname):
656 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
657 stages['3'][1], path, stages['1'][0],
658 stages['2'][0], stages['3'][0]) != 0:
662 raise GitException, 'GIT index merging failed (possible conflicts)'
664 def status(files = None, modified = False, new = False, deleted = False,
665 conflict = False, unknown = False, noexclude = False):
666 """Show the tree status
671 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
672 all = not (modified or new or deleted or conflict or unknown)
687 cache_files = [x for x in cache_files if x[0] in filestat]
689 for fs in cache_files:
690 if files and not fs[1] in files:
693 print '%s %s' % (fs[0], fs[1])
697 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
698 """Show the diff between rev1 and rev2
704 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
708 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
710 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
715 out_fd.write(diff_str)
719 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
720 """Return the diffstat between rev1 and rev2
725 p=popen2.Popen3('git-apply --stat')
726 diff(files, rev1, rev2, p.tochild)
728 diff_str = p.fromchild.read().rstrip()
730 raise GitException, 'git.diffstat failed'
733 def files(rev1, rev2):
734 """Return the files modified between rev1 and rev2
738 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
739 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
741 return result.rstrip()
743 def barefiles(rev1, rev2):
744 """Return the files modified between rev1 and rev2, without status info
748 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
749 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
751 return result.rstrip()
753 def pretty_commit(commit_id = 'HEAD'):
754 """Return a given commit (log + diff)
756 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
759 def checkout(files = None, tree_id = None, force = False):
760 """Check out the given or all files
765 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
766 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
768 checkout_cmd = 'git-checkout-index -q -u'
770 checkout_cmd += ' -f'
772 checkout_cmd += ' -a'
774 checkout_cmd += ' --'
776 if __run(checkout_cmd, files) != 0:
777 raise GitException, 'Failed git-checkout-index'
779 def switch(tree_id, keep = False):
780 """Switch the tree to the given id
784 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
785 raise GitException, 'git-read-tree failed (local changes maybe?)'
789 def reset(files = None, tree_id = None, check_out = True):
790 """Revert the tree changes relative to the given tree_id. It removes
797 cache_files = __tree_status(files, tree_id)
798 # files which were added but need to be removed
799 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
801 checkout(files, tree_id, True)
802 # checkout doesn't remove files
803 map(os.remove, rm_files)
805 # if the reset refers to the whole tree, switch the HEAD as well
809 def fetch(repository = 'origin', refspec = None):
810 """Fetches changes from the remote repository, using 'git-fetch'
820 command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
821 config.get('stgit.fetchcmd')
822 if __run(command, args) != 0:
823 raise GitException, 'Failed "%s %s"' % (command, repository)
825 def pull(repository = 'origin', refspec = None):
826 """Fetches changes from the remote repository, using 'git-pull'
836 command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
837 config.get('stgit.pullcmd')
838 if __run(command, args) != 0:
839 raise GitException, 'Failed "%s %s"' % (command, repository)
842 """Repack all objects into a single pack
844 __run('git-repack -a -d -f')
846 def apply_patch(filename = None, diff = None, base = None,
848 """Apply a patch onto the current or given index. There must not
849 be any local changes in the tree, otherwise the command fails
861 orig_head = get_head()
867 _input_str('git-apply --index', diff)
872 # write the failed diff to a file
873 f = file('.stgit-failed.patch', 'w+')
876 print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
881 top = commit(message = 'temporary commit used for applying a patch',
884 merge(base, orig_head, top)
886 def clone(repository, local_dir):
887 """Clone a remote repository. At the moment, just use the
890 if __run('git-clone', [repository, local_dir]) != 0:
891 raise GitException, 'Failed "git-clone %s %s"' \
892 % (repository, local_dir)
894 def modifying_revs(files, base_rev):
895 """Return the revisions from the list modifying the given files
897 cmd = ['git-rev-list', '%s..' % base_rev, '--']
898 revs = [line.strip() for line in _output_lines(cmd + files)]
903 def refspec_localpart(refspec):
904 m = re.match('^[^:]*:([^:]*)$', refspec)
908 raise GitException, 'Cannot parse refspec "%s"' % line
910 def refspec_remotepart(refspec):
911 m = re.match('^([^:]*):[^:]*$', refspec)
915 raise GitException, 'Cannot parse refspec "%s"' % line
918 def __remotes_from_config():
919 return config.sections_matching(r'remote\.(.*)\.url')
921 def __remotes_from_dir(dir):
922 d = os.path.join(basedir.get(), dir)
923 if os.path.exists(d):
929 """Return the list of remotes in the repository
932 return Set(__remotes_from_config()) | \
933 Set(__remotes_from_dir('remotes')) | \
934 Set(__remotes_from_dir('branches'))
936 def remotes_local_branches(remote):
937 """Returns the list of local branches fetched from given remote
941 if remote in __remotes_from_config():
942 for line in config.getall('remote.%s.fetch' % remote):
943 branches.append(refspec_localpart(line))
944 elif remote in __remotes_from_dir('remotes'):
945 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
947 # Only consider Pull lines
948 m = re.match('^Pull: (.*)\n$', line)
950 branches.append(refspec_localpart(m.group(1)))
952 elif remote in __remotes_from_dir('branches'):
953 # old-style branches only declare one branch
954 branches.append('refs/heads/'+remote);
956 raise GitException, 'Unknown remote "%s"' % remote
960 def identify_remote(branchname):
961 """Return the name for the remote to pull the given branchname
962 from, or None if we believe it is a local branch.
965 for remote in remotes_list():
966 if branchname in remotes_local_branches(remote):
969 # if we get here we've found nothing, the branch is a local one
973 """Return the git id for the tip of the parent branch as left by
978 stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
980 # Only consider lines not tagged not-for-merge
981 m = re.match('^([^\t]*)\t\t', line)
984 raise GitException, "StGit does not support multiple FETCH_HEAD"
986 fetch_head=m.group(1)
989 # here we are sure to have a single fetch_head