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):
37 """Handle the commit objects
39 def __init__(self, id_hash):
40 self.__id_hash = id_hash
42 lines = _output_lines('git-cat-file commit %s' % id_hash)
43 for i in range(len(lines)):
47 field = line.strip().split(' ', 1)
48 if field[0] == 'tree':
49 self.__tree = field[1]
50 if field[0] == 'author':
51 self.__author = field[1]
52 if field[0] == 'committer':
53 self.__committer = field[1]
54 self.__log = ''.join(lines[i+1:])
56 def get_id_hash(self):
63 parents = self.get_parents()
69 def get_parents(self):
70 return _output_lines('git-rev-list --parents --max-count=1 %s'
71 % self.__id_hash)[0].split()[1:]
76 def get_committer(self):
77 return self.__committer
83 return self.get_id_hash()
85 # dictionary of Commit objects, used to avoid multiple calls to git
92 def get_commit(id_hash):
93 """Commit objects factory. Save/look-up them in the __commits
98 if id_hash in __commits:
99 return __commits[id_hash]
101 commit = Commit(id_hash)
102 __commits[id_hash] = commit
106 """Return the list of file conflicts
108 conflicts_file = os.path.join(basedir.get(), 'conflicts')
109 if os.path.isfile(conflicts_file):
110 f = file(conflicts_file)
111 names = [line.strip() for line in f.readlines()]
117 def _input(cmd, file_desc):
118 p = popen2.Popen3(cmd, True)
120 line = file_desc.readline()
123 p.tochild.write(line)
126 raise GitException, '%s failed (%s)' % (str(cmd),
127 p.childerr.read().strip())
129 def _input_str(cmd, string):
130 p = popen2.Popen3(cmd, True)
131 p.tochild.write(string)
134 raise GitException, '%s failed (%s)' % (str(cmd),
135 p.childerr.read().strip())
138 p=popen2.Popen3(cmd, True)
139 output = p.fromchild.read()
141 raise GitException, '%s failed (%s)' % (str(cmd),
142 p.childerr.read().strip())
145 def _output_one_line(cmd, file_desc = None):
146 p=popen2.Popen3(cmd, True)
147 if file_desc != None:
148 for line in file_desc:
149 p.tochild.write(line)
151 output = p.fromchild.readline().strip()
153 raise GitException, '%s failed (%s)' % (str(cmd),
154 p.childerr.read().strip())
157 def _output_lines(cmd):
158 p=popen2.Popen3(cmd, True)
159 lines = p.fromchild.readlines()
161 raise GitException, '%s failed (%s)' % (str(cmd),
162 p.childerr.read().strip())
165 def __run(cmd, args=None):
166 """__run: runs cmd using spawnvp.
168 Runs cmd using spawnvp. The shell is avoided so it won't mess up
169 our arguments. If args is very large, the command is run multiple
170 times; args is split xargs style: cmd is passed on each
171 invocation. Unlike xargs, returns immediately if any non-zero
172 return code is received.
178 for i in range(0, len(args)+1, 100):
179 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
184 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
185 noexclude = True, verbose = False):
186 """Returns a list of pairs - [status, filename]
189 print 'Checking for changes in the working directory...',
200 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
201 base_exclude = ['--exclude=%s' % s for s in
202 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
203 base_exclude.append('--exclude-per-directory=.gitignore')
205 if os.path.exists(exclude_file):
206 extra_exclude = ['--exclude-from=%s' % exclude_file]
210 extra_exclude = base_exclude = []
212 lines = _output_lines(['git-ls-files', '--others', '--directory']
213 + base_exclude + extra_exclude)
214 cache_files += [('?', line.strip()) for line in lines]
217 conflicts = get_conflicts()
220 cache_files += [('C', filename) for filename in conflicts]
223 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
224 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
225 if fs[1] not in conflicts:
226 cache_files.append(fs)
234 """Return true if there are local changes in the tree
236 return len(__tree_status(verbose = True)) != 0
242 """Verifies the HEAD and returns the SHA1 id that represents it
247 __head = rev_parse('HEAD')
251 """Returns the name of the file pointed to by the HEAD link
253 return strip_prefix('refs/heads/',
254 _output_one_line('git-symbolic-ref HEAD'))
256 def set_head_file(ref):
257 """Resets HEAD to point to a new ref
259 # head cache flushing is needed since we might have a different value
262 if __run('git-symbolic-ref HEAD',
263 [os.path.join('refs', 'heads', ref)]) != 0:
264 raise GitException, 'Could not set head to "%s"' % ref
267 """Sets the HEAD value
271 if not __head or __head != val:
272 if __run('git-update-ref HEAD', [val]) != 0:
273 raise GitException, 'Could not update HEAD to "%s".' % val
276 # only allow SHA1 hashes
277 assert(len(__head) == 40)
279 def __clear_head_cache():
280 """Sets the __head to None so that a re-read is forced
287 """Refresh index with stat() information from the working directory.
289 __run('git-update-index -q --unmerged --refresh')
291 def rev_parse(git_id):
292 """Parse the string and return a verified SHA1 id
295 return _output_one_line(['git-rev-parse', '--verify', git_id])
297 raise GitException, 'Unknown revision: %s' % git_id
299 def branch_exists(branch):
300 """Existence check for the named branch
302 branch = os.path.join('refs', 'heads', branch)
303 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
304 if line.strip() == branch:
306 if re.compile('[ |/]'+branch+' ').search(line):
307 raise GitException, 'Bogus branch: %s' % line
310 def create_branch(new_branch, tree_id = None):
311 """Create a new branch in the git repository
313 if branch_exists(new_branch):
314 raise GitException, 'Branch "%s" already exists' % new_branch
316 current_head = get_head()
317 set_head_file(new_branch)
318 __set_head(current_head)
320 # a checkout isn't needed if new branch points to the current head
324 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
325 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
327 def switch_branch(new_branch):
328 """Switch to a git branch
332 if not branch_exists(new_branch):
333 raise GitException, 'Branch "%s" does not exist' % new_branch
335 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
337 if tree_id != get_head():
339 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
340 raise GitException, 'git-read-tree failed (local changes maybe?)'
342 set_head_file(new_branch)
344 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
345 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
347 def delete_branch(name):
348 """Delete a git branch
350 if not branch_exists(name):
351 raise GitException, 'Branch "%s" does not exist' % name
352 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
355 def rename_branch(from_name, to_name):
356 """Rename a git branch
358 if not branch_exists(from_name):
359 raise GitException, 'Branch "%s" does not exist' % from_name
360 if branch_exists(to_name):
361 raise GitException, 'Branch "%s" already exists' % to_name
363 if get_head_file() == from_name:
364 set_head_file(to_name)
365 rename(os.path.join(basedir.get(), 'refs', 'heads'),
369 """Add the files or recursively add the directory contents
371 # generate the file list
374 if not os.path.exists(i):
375 raise GitException, 'Unknown file or directory: %s' % i
378 # recursive search. We only add files
379 for root, dirs, local_files in os.walk(i):
380 for name in [os.path.join(root, f) for f in local_files]:
381 if os.path.isfile(name):
382 files.append(os.path.normpath(name))
383 elif os.path.isfile(i):
384 files.append(os.path.normpath(i))
386 raise GitException, '%s is not a file or directory' % i
389 if __run('git-update-index --add --', files):
390 raise GitException, 'Unable to add file'
392 def rm(files, force = False):
393 """Remove a file from the repository
397 if os.path.exists(f):
398 raise GitException, '%s exists. Remove it first' %f
400 __run('git-update-index --remove --', files)
403 __run('git-update-index --force-remove --', files)
405 def update_cache(files = None, force = False):
406 """Update the cache information for the given files
411 cache_files = __tree_status(files, verbose = False)
413 # everything is up-to-date
414 if len(cache_files) == 0:
417 # check for unresolved conflicts
418 if not force and [x for x in cache_files
419 if x[0] not in ['M', 'N', 'A', 'D']]:
420 raise GitException, 'Updating cache failed: unresolved conflicts'
423 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
424 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
425 m_files = [x[1] for x in cache_files if x[0] in ['M']]
427 if add_files and __run('git-update-index --add --', add_files) != 0:
428 raise GitException, 'Failed git-update-index --add'
429 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
430 raise GitException, 'Failed git-update-index --rm'
431 if m_files and __run('git-update-index --', m_files) != 0:
432 raise GitException, 'Failed git-update-index'
436 def commit(message, files = None, parents = None, allowempty = False,
437 cache_update = True, tree_id = None,
438 author_name = None, author_email = None, author_date = None,
439 committer_name = None, committer_email = None):
440 """Commit the current tree to repository
447 # Get the tree status
448 if cache_update and parents != []:
449 changes = update_cache(files)
450 if not changes and not allowempty:
451 raise GitException, 'No changes to commit'
453 # get the commit message
456 elif message[-1:] != '\n':
460 # write the index to repository
462 tree_id = _output_one_line('git-write-tree')
469 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
471 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
473 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
475 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
477 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
478 cmd += 'git-commit-tree %s' % tree_id
484 commit_id = _output_one_line(cmd, message)
486 __set_head(commit_id)
490 def apply_diff(rev1, rev2, check_index = True, files = None):
491 """Apply the diff between rev1 and rev2 onto the current
492 index. This function doesn't need to raise an exception since it
493 is only used for fast-pushing a patch. If this operation fails,
494 the pushing would fall back to the three-way merge.
497 index_opt = '--index'
504 diff_str = diff(files, rev1, rev2)
507 _input_str('git-apply %s' % index_opt, diff_str)
513 def merge(base, head1, head2):
514 """Perform a 3-way merge between base, head1 and head2 into the
518 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
519 raise GitException, 'git-read-tree failed (local changes maybe?)'
521 # check the index for unmerged entries
523 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
525 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
529 mode, hash, stage, path = stages_re.findall(line)[0]
531 if not path in files:
533 files[path]['1'] = ('', '')
534 files[path]['2'] = ('', '')
535 files[path]['3'] = ('', '')
537 files[path][stage] = (mode, hash)
539 # merge the unmerged files
543 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
544 stages['3'][1], path, stages['1'][0],
545 stages['2'][0], stages['3'][0]) != 0:
549 raise GitException, 'GIT index merging failed (possible conflicts)'
551 def status(files = None, modified = False, new = False, deleted = False,
552 conflict = False, unknown = False, noexclude = False):
553 """Show the tree status
558 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
559 all = not (modified or new or deleted or conflict or unknown)
574 cache_files = [x for x in cache_files if x[0] in filestat]
576 for fs in cache_files:
578 print '%s %s' % (fs[0], fs[1])
582 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
583 """Show the diff between rev1 and rev2
589 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
593 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
595 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
600 out_fd.write(diff_str)
604 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
605 """Return the diffstat between rev1 and rev2
610 p=popen2.Popen3('git-apply --stat')
611 diff(files, rev1, rev2, p.tochild)
613 diff_str = p.fromchild.read().rstrip()
615 raise GitException, 'git.diffstat failed'
618 def files(rev1, rev2):
619 """Return the files modified between rev1 and rev2
623 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
624 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
626 return result.rstrip()
628 def barefiles(rev1, rev2):
629 """Return the files modified between rev1 and rev2, without status info
633 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
634 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
636 return result.rstrip()
638 def pretty_commit(commit_id = 'HEAD'):
639 """Return a given commit (log + diff)
641 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
644 def checkout(files = None, tree_id = None, force = False):
645 """Check out the given or all files
650 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
651 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
653 checkout_cmd = 'git-checkout-index -q -u'
655 checkout_cmd += ' -f'
657 checkout_cmd += ' -a'
659 checkout_cmd += ' --'
661 if __run(checkout_cmd, files) != 0:
662 raise GitException, 'Failed git-checkout-index'
664 def switch(tree_id, keep = False):
665 """Switch the tree to the given id
669 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
670 raise GitException, 'git-read-tree failed (local changes maybe?)'
674 def reset(files = None, tree_id = None, check_out = True):
675 """Revert the tree changes relative to the given tree_id. It removes
682 cache_files = __tree_status(files, tree_id)
683 # files which were added but need to be removed
684 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
686 checkout(files, tree_id, True)
687 # checkout doesn't remove files
688 map(os.remove, rm_files)
690 # if the reset refers to the whole tree, switch the HEAD as well
694 def pull(repository = 'origin', refspec = None):
695 """Pull changes from the remote repository. At the moment, just
696 use the 'git-pull' command
698 # 'git-pull' updates the HEAD
705 if __run(config.get('stgit', 'pullcmd'), args) != 0:
706 raise GitException, 'Failed "git-pull %s"' % repository
708 def apply_patch(filename = None, diff = None, base = None,
710 """Apply a patch onto the current or given index. There must not
711 be any local changes in the tree, otherwise the command fails
714 orig_head = get_head()
729 _input_str('git-apply --index', diff)
734 # write the failed diff to a file
735 f = file('.stgit-failed.patch', 'w+')
742 top = commit(message = 'temporary commit used for applying a patch',
745 merge(base, orig_head, top)
747 def clone(repository, local_dir):
748 """Clone a remote repository. At the moment, just use the
751 if __run('git-clone', [repository, local_dir]) != 0:
752 raise GitException, 'Failed "git-clone %s %s"' \
753 % (repository, local_dir)
755 def modifying_revs(files, base_rev):
756 """Return the revisions from the list modifying the given files
758 cmd = ['git-rev-list', '%s..' % base_rev, '--']
759 revs = [line.strip() for line in _output_lines(cmd + files)]