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 *
27 class GitException(Exception):
36 """Handle the commit objects
38 def __init__(self, id_hash):
39 self.__id_hash = id_hash
41 lines = _output_lines('git-cat-file commit %s' % id_hash)
42 for i in range(len(lines)):
46 field = line.strip().split(' ', 1)
47 if field[0] == 'tree':
48 self.__tree = field[1]
49 if field[0] == 'author':
50 self.__author = field[1]
51 if field[0] == 'committer':
52 self.__committer = field[1]
53 self.__log = ''.join(lines[i+1:])
55 def get_id_hash(self):
62 return self.get_parents()[0]
64 def get_parents(self):
65 return _output_lines('git-rev-list --parents --max-count=1 %s'
66 % self.__id_hash)[0].split()[1:]
71 def get_committer(self):
72 return self.__committer
77 # dictionary of Commit objects, used to avoid multiple calls to git
84 def get_commit(id_hash):
85 """Commit objects factory. Save/look-up them in the __commits
90 if id_hash in __commits:
91 return __commits[id_hash]
93 commit = Commit(id_hash)
94 __commits[id_hash] = commit
98 """Return the list of file conflicts
100 conflicts_file = os.path.join(basedir.get(), 'conflicts')
101 if os.path.isfile(conflicts_file):
102 f = file(conflicts_file)
103 names = [line.strip() for line in f.readlines()]
109 def _input(cmd, file_desc):
110 p = popen2.Popen3(cmd, True)
112 line = file_desc.readline()
115 p.tochild.write(line)
118 raise GitException, '%s failed (%s)' % (str(cmd),
119 p.childerr.read().strip())
121 def _input_str(cmd, string):
122 p = popen2.Popen3(cmd, True)
123 p.tochild.write(string)
126 raise GitException, '%s failed (%s)' % (str(cmd),
127 p.childerr.read().strip())
130 p=popen2.Popen3(cmd, True)
131 output = p.fromchild.read()
133 raise GitException, '%s failed (%s)' % (str(cmd),
134 p.childerr.read().strip())
137 def _output_one_line(cmd, file_desc = None):
138 p=popen2.Popen3(cmd, True)
139 if file_desc != None:
140 for line in file_desc:
141 p.tochild.write(line)
143 output = p.fromchild.readline().strip()
145 raise GitException, '%s failed (%s)' % (str(cmd),
146 p.childerr.read().strip())
149 def _output_lines(cmd):
150 p=popen2.Popen3(cmd, True)
151 lines = p.fromchild.readlines()
153 raise GitException, '%s failed (%s)' % (str(cmd),
154 p.childerr.read().strip())
157 def __run(cmd, args=None):
158 """__run: runs cmd using spawnvp.
160 Runs cmd using spawnvp. The shell is avoided so it won't mess up
161 our arguments. If args is very large, the command is run multiple
162 times; args is split xargs style: cmd is passed on each
163 invocation. Unlike xargs, returns immediately if any non-zero
164 return code is received.
170 for i in range(0, len(args)+1, 100):
171 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
176 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
178 """Returns a list of pairs - [status, filename]
188 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
189 base_exclude = ['--exclude=%s' % s for s in
190 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
191 base_exclude.append('--exclude-per-directory=.gitignore')
193 if os.path.exists(exclude_file):
194 extra_exclude = ['--exclude-from=%s' % exclude_file]
198 extra_exclude = base_exclude = []
200 lines = _output_lines(['git-ls-files', '--others', '--directory']
201 + base_exclude + extra_exclude)
202 cache_files += [('?', line.strip()) for line in lines]
205 conflicts = get_conflicts()
208 cache_files += [('C', filename) for filename in conflicts]
211 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
212 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
213 if fs[1] not in conflicts:
214 cache_files.append(fs)
219 """Return true if there are local changes in the tree
221 return len(__tree_status()) != 0
227 """Verifies the HEAD and returns the SHA1 id that represents it
232 __head = rev_parse('HEAD')
236 """Returns the name of the file pointed to by the HEAD link
238 return strip_prefix('refs/heads/',
239 _output_one_line('git-symbolic-ref HEAD'))
241 def set_head_file(ref):
242 """Resets HEAD to point to a new ref
244 # head cache flushing is needed since we might have a different value
247 if __run('git-symbolic-ref HEAD',
248 [os.path.join('refs', 'heads', ref)]) != 0:
249 raise GitException, 'Could not set head to "%s"' % ref
252 """Sets the HEAD value
256 if not __head or __head != val:
257 if __run('git-update-ref HEAD', [val]) != 0:
258 raise GitException, 'Could not update HEAD to "%s".' % val
261 # only allow SHA1 hashes
262 assert(len(__head) == 40)
264 def __clear_head_cache():
265 """Sets the __head to None so that a re-read is forced
272 """Refresh index with stat() information from the working directory.
274 __run('git-update-index -q --unmerged --refresh')
276 def rev_parse(git_id):
277 """Parse the string and return a verified SHA1 id
280 return _output_one_line(['git-rev-parse', '--verify', git_id])
282 raise GitException, 'Unknown revision: %s' % git_id
284 def branch_exists(branch):
285 """Existence check for the named branch
287 branch = os.path.join('refs', 'heads', branch)
288 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
289 if line.strip() == branch:
291 if re.compile('[ |/]'+branch+' ').search(line):
292 raise GitException, 'Bogus branch: %s' % line
295 def create_branch(new_branch, tree_id = None):
296 """Create a new branch in the git repository
298 if branch_exists(new_branch):
299 raise GitException, 'Branch "%s" already exists' % new_branch
301 current_head = get_head()
302 set_head_file(new_branch)
303 __set_head(current_head)
305 # a checkout isn't needed if new branch points to the current head
309 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
310 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
312 def switch_branch(new_branch):
313 """Switch to a git branch
317 if not branch_exists(new_branch):
318 raise GitException, 'Branch "%s" does not exist' % new_branch
320 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
322 if tree_id != get_head():
324 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
325 raise GitException, 'git-read-tree failed (local changes maybe?)'
327 set_head_file(new_branch)
329 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
330 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
332 def delete_branch(name):
333 """Delete a git branch
335 if not branch_exists(name):
336 raise GitException, 'Branch "%s" does not exist' % name
337 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
340 def rename_branch(from_name, to_name):
341 """Rename a git branch
343 if not branch_exists(from_name):
344 raise GitException, 'Branch "%s" does not exist' % from_name
345 if branch_exists(to_name):
346 raise GitException, 'Branch "%s" already exists' % to_name
348 if get_head_file() == from_name:
349 set_head_file(to_name)
350 rename(os.path.join(basedir.get(), 'refs', 'heads'),
354 """Add the files or recursively add the directory contents
356 # generate the file list
359 if not os.path.exists(i):
360 raise GitException, 'Unknown file or directory: %s' % i
363 # recursive search. We only add files
364 for root, dirs, local_files in os.walk(i):
365 for name in [os.path.join(root, f) for f in local_files]:
366 if os.path.isfile(name):
367 files.append(os.path.normpath(name))
368 elif os.path.isfile(i):
369 files.append(os.path.normpath(i))
371 raise GitException, '%s is not a file or directory' % i
374 if __run('git-update-index --add --', files):
375 raise GitException, 'Unable to add file'
377 def rm(files, force = False):
378 """Remove a file from the repository
382 if os.path.exists(f):
383 raise GitException, '%s exists. Remove it first' %f
385 __run('git-update-index --remove --', files)
388 __run('git-update-index --force-remove --', files)
390 def update_cache(files = None, force = False):
391 """Update the cache information for the given files
396 cache_files = __tree_status(files)
398 # everything is up-to-date
399 if len(cache_files) == 0:
402 # check for unresolved conflicts
403 if not force and [x for x in cache_files
404 if x[0] not in ['M', 'N', 'A', 'D']]:
405 raise GitException, 'Updating cache failed: unresolved conflicts'
408 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
409 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
410 m_files = [x[1] for x in cache_files if x[0] in ['M']]
412 if add_files and __run('git-update-index --add --', add_files) != 0:
413 raise GitException, 'Failed git-update-index --add'
414 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
415 raise GitException, 'Failed git-update-index --rm'
416 if m_files and __run('git-update-index --', m_files) != 0:
417 raise GitException, 'Failed git-update-index'
421 def commit(message, files = None, parents = None, allowempty = False,
422 cache_update = True, tree_id = None,
423 author_name = None, author_email = None, author_date = None,
424 committer_name = None, committer_email = None):
425 """Commit the current tree to repository
432 # Get the tree status
433 if cache_update and parents != []:
434 changes = update_cache(files)
435 if not changes and not allowempty:
436 raise GitException, 'No changes to commit'
438 # get the commit message
441 elif message[-1:] != '\n':
445 # write the index to repository
447 tree_id = _output_one_line('git-write-tree')
454 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
456 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
458 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
460 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
462 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
463 cmd += 'git-commit-tree %s' % tree_id
469 commit_id = _output_one_line(cmd, message)
471 __set_head(commit_id)
475 def apply_diff(rev1, rev2, check_index = True, files = None):
476 """Apply the diff between rev1 and rev2 onto the current
477 index. This function doesn't need to raise an exception since it
478 is only used for fast-pushing a patch. If this operation fails,
479 the pushing would fall back to the three-way merge.
482 index_opt = '--index'
489 diff_str = diff(files, rev1, rev2)
492 _input_str('git-apply %s' % index_opt, diff_str)
498 def merge(base, head1, head2):
499 """Perform a 3-way merge between base, head1 and head2 into the
503 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
504 raise GitException, 'git-read-tree failed (local changes maybe?)'
506 # check the index for unmerged entries
508 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
510 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
514 mode, hash, stage, path = stages_re.findall(line)[0]
516 if not path in files:
518 files[path]['1'] = ('', '')
519 files[path]['2'] = ('', '')
520 files[path]['3'] = ('', '')
522 files[path][stage] = (mode, hash)
524 # merge the unmerged files
528 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
529 stages['3'][1], path, stages['1'][0],
530 stages['2'][0], stages['3'][0]) != 0:
534 raise GitException, 'GIT index merging failed (possible conflicts)'
536 def status(files = None, modified = False, new = False, deleted = False,
537 conflict = False, unknown = False, noexclude = False):
538 """Show the tree status
543 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
544 all = not (modified or new or deleted or conflict or unknown)
559 cache_files = [x for x in cache_files if x[0] in filestat]
561 for fs in cache_files:
563 print '%s %s' % (fs[0], fs[1])
567 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
568 """Show the diff between rev1 and rev2
574 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
578 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
580 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
585 out_fd.write(diff_str)
589 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
590 """Return the diffstat between rev1 and rev2
595 p=popen2.Popen3('git-apply --stat')
596 diff(files, rev1, rev2, p.tochild)
598 diff_str = p.fromchild.read().rstrip()
600 raise GitException, 'git.diffstat failed'
603 def files(rev1, rev2):
604 """Return the files modified between rev1 and rev2
608 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
609 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
611 return result.rstrip()
613 def barefiles(rev1, rev2):
614 """Return the files modified between rev1 and rev2, without status info
618 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
619 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
621 return result.rstrip()
623 def pretty_commit(commit_id = 'HEAD'):
624 """Return a given commit (log + diff)
626 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
629 def checkout(files = None, tree_id = None, force = False):
630 """Check out the given or all files
635 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
636 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
638 checkout_cmd = 'git-checkout-index -q -u'
640 checkout_cmd += ' -f'
642 checkout_cmd += ' -a'
644 checkout_cmd += ' --'
646 if __run(checkout_cmd, files) != 0:
647 raise GitException, 'Failed git-checkout-index'
649 def switch(tree_id, keep = False):
650 """Switch the tree to the given id
654 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
655 raise GitException, 'git-read-tree failed (local changes maybe?)'
659 def reset(files = None, tree_id = None, check_out = True):
660 """Revert the tree changes relative to the given tree_id. It removes
667 cache_files = __tree_status(files, tree_id)
668 # files which were added but need to be removed
669 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
671 checkout(files, tree_id, True)
672 # checkout doesn't remove files
673 map(os.remove, rm_files)
675 # if the reset refers to the whole tree, switch the HEAD as well
679 def pull(repository = 'origin', refspec = None):
680 """Pull changes from the remote repository. At the moment, just
681 use the 'git-pull' command
683 # 'git-pull' updates the HEAD
690 if __run('git-pull', args) != 0:
691 raise GitException, 'Failed "git-pull %s"' % repository
693 def apply_patch(filename = None, base = None):
694 """Apply a patch onto the current or given index. There must not
695 be any local changes in the tree, otherwise the command fails
699 return __run('git-apply --index', [filename]) == 0
702 _input('git-apply --index', sys.stdin)
708 orig_head = get_head()
711 refresh_index() # needed since __apply_patch() doesn't do it
713 if not __apply_patch():
716 raise GitException, 'Patch does not apply cleanly'
718 top = commit(message = 'temporary commit used for applying a patch',
721 merge(base, orig_head, top)
723 def clone(repository, local_dir):
724 """Clone a remote repository. At the moment, just use the
727 if __run('git-clone', [repository, local_dir]) != 0:
728 raise GitException, 'Failed "git-clone %s %s"' \
729 % (repository, local_dir)
731 def modifying_revs(files, base_rev):
732 """Return the revisions from the list modifying the given files
734 cmd = ['git-rev-list', '%s..' % base_rev, '--']
735 revs = [line.strip() for line in _output_lines(cmd + files)]