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
28 class GitException(Exception):
38 """An author, committer, etc."""
39 def __init__(self, name = None, email = None, date = '',
41 if name or email or date:
47 assert not (name or email or date)
49 m = re.match(r'^(.+)<(.+)>(.*)$', s)
51 return [x.strip() or None for x in m.groups()]
52 self.name, self.email, self.date = parse_desc(desc)
53 def set_name(self, val):
56 def set_email(self, val):
59 def set_date(self, val):
63 if self.name and self.email:
64 return '%s <%s>' % (self.name, self.email)
66 raise GitException, 'not enough identity data'
69 """Handle the commit objects
71 def __init__(self, id_hash):
72 self.__id_hash = id_hash
74 lines = _output_lines('git-cat-file commit %s' % id_hash)
75 for i in range(len(lines)):
79 field = line.strip().split(' ', 1)
80 if field[0] == 'tree':
81 self.__tree = field[1]
82 if field[0] == 'author':
83 self.__author = field[1]
84 if field[0] == 'committer':
85 self.__committer = field[1]
86 self.__log = ''.join(lines[i+1:])
88 def get_id_hash(self):
95 parents = self.get_parents()
101 def get_parents(self):
102 return _output_lines('git-rev-list --parents --max-count=1 %s'
103 % self.__id_hash)[0].split()[1:]
105 def get_author(self):
108 def get_committer(self):
109 return self.__committer
115 return self.get_id_hash()
117 # dictionary of Commit objects, used to avoid multiple calls to git
124 def get_commit(id_hash):
125 """Commit objects factory. Save/look-up them in the __commits
130 if id_hash in __commits:
131 return __commits[id_hash]
133 commit = Commit(id_hash)
134 __commits[id_hash] = commit
138 """Return the list of file conflicts
140 conflicts_file = os.path.join(basedir.get(), 'conflicts')
141 if os.path.isfile(conflicts_file):
142 f = file(conflicts_file)
143 names = [line.strip() for line in f.readlines()]
149 def _input(cmd, file_desc):
150 p = popen2.Popen3(cmd, True)
152 line = file_desc.readline()
155 p.tochild.write(line)
158 raise GitException, '%s failed (%s)' % (str(cmd),
159 p.childerr.read().strip())
161 def _input_str(cmd, string):
162 p = popen2.Popen3(cmd, True)
163 p.tochild.write(string)
166 raise GitException, '%s failed (%s)' % (str(cmd),
167 p.childerr.read().strip())
170 p=popen2.Popen3(cmd, True)
171 output = p.fromchild.read()
173 raise GitException, '%s failed (%s)' % (str(cmd),
174 p.childerr.read().strip())
177 def _output_one_line(cmd, file_desc = None):
178 p=popen2.Popen3(cmd, True)
179 if file_desc != None:
180 for line in file_desc:
181 p.tochild.write(line)
183 output = p.fromchild.readline().strip()
185 raise GitException, '%s failed (%s)' % (str(cmd),
186 p.childerr.read().strip())
189 def _output_lines(cmd):
190 p=popen2.Popen3(cmd, True)
191 lines = p.fromchild.readlines()
193 raise GitException, '%s failed (%s)' % (str(cmd),
194 p.childerr.read().strip())
197 def __run(cmd, args=None):
198 """__run: runs cmd using spawnvp.
200 Runs cmd using spawnvp. The shell is avoided so it won't mess up
201 our arguments. If args is very large, the command is run multiple
202 times; args is split xargs style: cmd is passed on each
203 invocation. Unlike xargs, returns immediately if any non-zero
204 return code is received.
210 for i in range(0, len(args)+1, 100):
211 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
216 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
217 noexclude = True, verbose = False):
218 """Returns a list of pairs - [status, filename]
220 if verbose and sys.stdout.isatty():
221 print 'Checking for changes in the working directory...',
232 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
233 base_exclude = ['--exclude=%s' % s for s in
234 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
235 base_exclude.append('--exclude-per-directory=.gitignore')
237 if os.path.exists(exclude_file):
238 extra_exclude = ['--exclude-from=%s' % exclude_file]
242 extra_exclude = base_exclude = []
244 lines = _output_lines(['git-ls-files', '--others', '--directory']
245 + base_exclude + extra_exclude)
246 cache_files += [('?', line.strip()) for line in lines]
249 conflicts = get_conflicts()
252 cache_files += [('C', filename) for filename in conflicts]
255 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
256 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
257 if fs[1] not in conflicts:
258 cache_files.append(fs)
260 if verbose and sys.stdout.isatty():
266 """Return true if there are local changes in the tree
268 return len(__tree_status(verbose = True)) != 0
274 """Verifies the HEAD and returns the SHA1 id that represents it
279 __head = rev_parse('HEAD')
283 """Returns the name of the file pointed to by the HEAD link
285 return strip_prefix('refs/heads/',
286 _output_one_line('git-symbolic-ref HEAD'))
288 def set_head_file(ref):
289 """Resets HEAD to point to a new ref
291 # head cache flushing is needed since we might have a different value
294 if __run('git-symbolic-ref HEAD',
295 [os.path.join('refs', 'heads', ref)]) != 0:
296 raise GitException, 'Could not set head to "%s"' % ref
299 """Sets the HEAD value
303 if not __head or __head != val:
304 if __run('git-update-ref HEAD', [val]) != 0:
305 raise GitException, 'Could not update HEAD to "%s".' % val
308 # only allow SHA1 hashes
309 assert(len(__head) == 40)
311 def __clear_head_cache():
312 """Sets the __head to None so that a re-read is forced
319 """Refresh index with stat() information from the working directory.
321 __run('git-update-index -q --unmerged --refresh')
323 def rev_parse(git_id):
324 """Parse the string and return a verified SHA1 id
327 return _output_one_line(['git-rev-parse', '--verify', git_id])
329 raise GitException, 'Unknown revision: %s' % git_id
331 def branch_exists(branch):
332 """Existence check for the named branch
334 branch = os.path.join('refs', 'heads', branch)
335 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
336 if line.strip() == branch:
338 if re.compile('[ |/]'+branch+' ').search(line):
339 raise GitException, 'Bogus branch: %s' % line
342 def create_branch(new_branch, tree_id = None):
343 """Create a new branch in the git repository
345 if branch_exists(new_branch):
346 raise GitException, 'Branch "%s" already exists' % new_branch
348 current_head = get_head()
349 set_head_file(new_branch)
350 __set_head(current_head)
352 # a checkout isn't needed if new branch points to the current head
356 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
357 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
359 def switch_branch(new_branch):
360 """Switch to a git branch
364 if not branch_exists(new_branch):
365 raise GitException, 'Branch "%s" does not exist' % new_branch
367 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
369 if tree_id != get_head():
371 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
372 raise GitException, 'git-read-tree failed (local changes maybe?)'
374 set_head_file(new_branch)
376 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
377 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
379 def delete_branch(name):
380 """Delete a git branch
382 if not branch_exists(name):
383 raise GitException, 'Branch "%s" does not exist' % name
384 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
387 def rename_branch(from_name, to_name):
388 """Rename a git branch
390 if not branch_exists(from_name):
391 raise GitException, 'Branch "%s" does not exist' % from_name
392 if branch_exists(to_name):
393 raise GitException, 'Branch "%s" already exists' % to_name
395 if get_head_file() == from_name:
396 set_head_file(to_name)
397 rename(os.path.join(basedir.get(), 'refs', 'heads'),
401 """Add the files or recursively add the directory contents
403 # generate the file list
406 if not os.path.exists(i):
407 raise GitException, 'Unknown file or directory: %s' % i
410 # recursive search. We only add files
411 for root, dirs, local_files in os.walk(i):
412 for name in [os.path.join(root, f) for f in local_files]:
413 if os.path.isfile(name):
414 files.append(os.path.normpath(name))
415 elif os.path.isfile(i):
416 files.append(os.path.normpath(i))
418 raise GitException, '%s is not a file or directory' % i
421 if __run('git-update-index --add --', files):
422 raise GitException, 'Unable to add file'
424 def rm(files, force = False):
425 """Remove a file from the repository
429 if os.path.exists(f):
430 raise GitException, '%s exists. Remove it first' %f
432 __run('git-update-index --remove --', files)
435 __run('git-update-index --force-remove --', files)
443 """Return the user information.
447 if config.has_option('user', 'name') \
448 and config.has_option('user', 'email'):
449 __user = Person(config.get('user', 'name'),
450 config.get('user', 'email'))
452 raise GitException, 'unknown user details'
456 """Return the author information.
461 # the environment variables take priority over config
463 date = os.environ['GIT_AUTHOR_DATE']
466 __author = Person(os.environ['GIT_AUTHOR_NAME'],
467 os.environ['GIT_AUTHOR_EMAIL'],
474 """Return the author information.
479 # the environment variables take priority over config
481 date = os.environ['GIT_COMMITTER_DATE']
484 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
485 os.environ['GIT_COMMITTER_EMAIL'],
491 def update_cache(files = None, force = False):
492 """Update the cache information for the given files
497 cache_files = __tree_status(files, verbose = False)
499 # everything is up-to-date
500 if len(cache_files) == 0:
503 # check for unresolved conflicts
504 if not force and [x for x in cache_files
505 if x[0] not in ['M', 'N', 'A', 'D']]:
506 raise GitException, 'Updating cache failed: unresolved conflicts'
509 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
510 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
511 m_files = [x[1] for x in cache_files if x[0] in ['M']]
513 if add_files and __run('git-update-index --add --', add_files) != 0:
514 raise GitException, 'Failed git-update-index --add'
515 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
516 raise GitException, 'Failed git-update-index --rm'
517 if m_files and __run('git-update-index --', m_files) != 0:
518 raise GitException, 'Failed git-update-index'
522 def commit(message, files = None, parents = None, allowempty = False,
523 cache_update = True, tree_id = None,
524 author_name = None, author_email = None, author_date = None,
525 committer_name = None, committer_email = None):
526 """Commit the current tree to repository
533 # Get the tree status
534 if cache_update and parents != []:
535 changes = update_cache(files)
536 if not changes and not allowempty:
537 raise GitException, 'No changes to commit'
539 # get the commit message
542 elif message[-1:] != '\n':
546 # write the index to repository
548 tree_id = _output_one_line('git-write-tree')
555 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
557 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
559 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
561 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
563 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
564 cmd += 'git-commit-tree %s' % tree_id
570 commit_id = _output_one_line(cmd, message)
572 __set_head(commit_id)
576 def apply_diff(rev1, rev2, check_index = True, files = None):
577 """Apply the diff between rev1 and rev2 onto the current
578 index. This function doesn't need to raise an exception since it
579 is only used for fast-pushing a patch. If this operation fails,
580 the pushing would fall back to the three-way merge.
583 index_opt = '--index'
590 diff_str = diff(files, rev1, rev2)
593 _input_str('git-apply %s' % index_opt, diff_str)
599 def merge(base, head1, head2):
600 """Perform a 3-way merge between base, head1 and head2 into the
606 # use _output() to mask the verbose prints of the tool
607 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
611 # check the index for unmerged entries
613 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
615 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
619 mode, hash, stage, path = stages_re.findall(line)[0]
621 if not path in files:
623 files[path]['1'] = ('', '')
624 files[path]['2'] = ('', '')
625 files[path]['3'] = ('', '')
627 files[path][stage] = (mode, hash)
629 # merge the unmerged files
632 # remove additional files that might be generated for some
633 # newer versions of GIT
634 for suffix in [base, head1, head2]:
637 fname = path + '~' + suffix
638 if os.path.exists(fname):
642 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
643 stages['3'][1], path, stages['1'][0],
644 stages['2'][0], stages['3'][0]) != 0:
648 raise GitException, 'GIT index merging failed (possible conflicts)'
650 def status(files = None, modified = False, new = False, deleted = False,
651 conflict = False, unknown = False, noexclude = False):
652 """Show the tree status
657 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
658 all = not (modified or new or deleted or conflict or unknown)
673 cache_files = [x for x in cache_files if x[0] in filestat]
675 for fs in cache_files:
677 print '%s %s' % (fs[0], fs[1])
681 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
682 """Show the diff between rev1 and rev2
688 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
692 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
694 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
699 out_fd.write(diff_str)
703 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
704 """Return the diffstat between rev1 and rev2
709 p=popen2.Popen3('git-apply --stat')
710 diff(files, rev1, rev2, p.tochild)
712 diff_str = p.fromchild.read().rstrip()
714 raise GitException, 'git.diffstat failed'
717 def files(rev1, rev2):
718 """Return the files modified between rev1 and rev2
722 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
723 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
725 return result.rstrip()
727 def barefiles(rev1, rev2):
728 """Return the files modified between rev1 and rev2, without status info
732 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
733 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
735 return result.rstrip()
737 def pretty_commit(commit_id = 'HEAD'):
738 """Return a given commit (log + diff)
740 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
743 def checkout(files = None, tree_id = None, force = False):
744 """Check out the given or all files
749 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
750 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
752 checkout_cmd = 'git-checkout-index -q -u'
754 checkout_cmd += ' -f'
756 checkout_cmd += ' -a'
758 checkout_cmd += ' --'
760 if __run(checkout_cmd, files) != 0:
761 raise GitException, 'Failed git-checkout-index'
763 def switch(tree_id, keep = False):
764 """Switch the tree to the given id
768 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
769 raise GitException, 'git-read-tree failed (local changes maybe?)'
773 def reset(files = None, tree_id = None, check_out = True):
774 """Revert the tree changes relative to the given tree_id. It removes
781 cache_files = __tree_status(files, tree_id)
782 # files which were added but need to be removed
783 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
785 checkout(files, tree_id, True)
786 # checkout doesn't remove files
787 map(os.remove, rm_files)
789 # if the reset refers to the whole tree, switch the HEAD as well
793 def pull(repository = 'origin', refspec = None):
794 """Pull changes from the remote repository. At the moment, just
795 use the 'git-pull' command
797 # 'git-pull' updates the HEAD
804 if __run(config.get('stgit', 'pullcmd'), args) != 0:
805 raise GitException, 'Failed "git-pull %s"' % repository
808 """Repack all objects into a single pack
810 __run('git-repack -a -d -f')
812 def apply_patch(filename = None, diff = None, base = None,
814 """Apply a patch onto the current or given index. There must not
815 be any local changes in the tree, otherwise the command fails
818 orig_head = get_head()
833 _input_str('git-apply --index', diff)
838 # write the failed diff to a file
839 f = file('.stgit-failed.patch', 'w+')
846 top = commit(message = 'temporary commit used for applying a patch',
849 merge(base, orig_head, top)
851 def clone(repository, local_dir):
852 """Clone a remote repository. At the moment, just use the
855 if __run('git-clone', [repository, local_dir]) != 0:
856 raise GitException, 'Failed "git-clone %s %s"' \
857 % (repository, local_dir)
859 def modifying_revs(files, base_rev):
860 """Return the revisions from the list modifying the given files
862 cmd = ['git-rev-list', '%s..' % base_rev, '--']
863 revs = [line.strip() for line in _output_lines(cmd + files)]