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, glob, popen2
23 from stgit.utils import *
26 class GitException(Exception):
30 # Different start-up variables read from the environment
31 if 'GIT_DIR' in os.environ:
32 base_dir = os.environ['GIT_DIR']
36 head_link = os.path.join(base_dir, 'HEAD')
42 """Handle the commit objects
44 def __init__(self, id_hash):
45 self.__id_hash = id_hash
47 lines = _output_lines('git-cat-file commit %s' % id_hash)
49 for i in range(len(lines)):
53 field = line.strip().split(' ', 1)
54 if field[0] == 'tree':
55 self.__tree = field[1]
56 elif field[0] == 'parent':
57 self.__parents.append(field[1])
58 if field[0] == 'author':
59 self.__author = field[1]
60 if field[0] == 'committer':
61 self.__committer = field[1]
62 self.__log = ''.join(lines[i+1:])
64 def get_id_hash(self):
71 return self.__parents[0]
73 def get_parents(self):
79 def get_committer(self):
80 return self.__committer
85 # dictionary of Commit objects, used to avoid multiple calls to git
91 def get_commit(id_hash):
92 """Commit objects factory. Save/look-up them in the __commits
95 if id_hash in __commits:
96 return __commits[id_hash]
98 commit = Commit(id_hash)
99 __commits[id_hash] = commit
103 """Return the list of file conflicts
105 conflicts_file = os.path.join(base_dir, 'conflicts')
106 if os.path.isfile(conflicts_file):
107 f = file(conflicts_file)
108 names = [line.strip() for line in f.readlines()]
114 def _input(cmd, file_desc):
115 p = popen2.Popen3(cmd)
117 line = file_desc.readline()
120 p.tochild.write(line)
123 raise GitException, '%s failed' % str(cmd)
127 string = p.fromchild.read()
129 raise GitException, '%s failed' % str(cmd)
132 def _output_one_line(cmd, file_desc = None):
134 if file_desc != None:
135 for line in file_desc:
136 p.tochild.write(line)
138 string = p.fromchild.readline().strip()
140 raise GitException, '%s failed' % str(cmd)
143 def _output_lines(cmd):
145 lines = p.fromchild.readlines()
147 raise GitException, '%s failed' % str(cmd)
150 def __run(cmd, args=None):
151 """__run: runs cmd using spawnvp.
153 Runs cmd using spawnvp. The shell is avoided so it won't mess up
154 our arguments. If args is very large, the command is run multiple
155 times; args is split xargs style: cmd is passed on each
156 invocation. Unlike xargs, returns immediately if any non-zero
157 return code is received.
163 for i in range(0, len(args)+1, 100):
164 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
169 def __check_base_dir():
170 return os.path.isdir(base_dir)
172 def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
174 """Returns a list of pairs - [status, filename]
176 os.system('git-update-index --refresh > /dev/null')
182 exclude_file = os.path.join(base_dir, 'info', 'exclude')
183 base_exclude = ['--exclude=%s' % s for s in
184 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
185 base_exclude.append('--exclude-per-directory=.gitignore')
187 if os.path.exists(exclude_file):
188 extra_exclude = ['--exclude-from=%s' % exclude_file]
192 extra_exclude = base_exclude = []
194 lines = _output_lines(['git-ls-files', '--others'] + base_exclude
196 cache_files += [('?', line.strip()) for line in lines]
199 conflicts = get_conflicts()
202 cache_files += [('C', filename) for filename in conflicts]
205 for line in _output_lines(['git-diff-index', '-r', tree_id] + files):
206 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
207 if fs[1] not in conflicts:
208 cache_files.append(fs)
213 """Return true if there are local changes in the tree
215 return len(__tree_status()) != 0
221 """Verifies the HEAD and returns the SHA1 id that represents it
226 __head = rev_parse('HEAD')
230 """Returns the name of the file pointed to by the HEAD link
232 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
235 """Sets the HEAD value
240 if __run('git-update-ref HEAD', [val]) != 0:
241 raise GitException, 'Could not update HEAD to "%s".' % val
243 def rev_parse(git_id):
244 """Parse the string and return a verified SHA1 id
246 return _output_one_line(['git-rev-parse', '--verify', git_id])
249 """Add the files or recursively add the directory contents
251 # generate the file list
254 if not os.path.exists(i):
255 raise GitException, 'Unknown file or directory: %s' % i
258 # recursive search. We only add files
259 for root, dirs, local_files in os.walk(i):
260 for name in [os.path.join(root, f) for f in local_files]:
261 if os.path.isfile(name):
262 files.append(os.path.normpath(name))
263 elif os.path.isfile(i):
264 files.append(os.path.normpath(i))
266 raise GitException, '%s is not a file or directory' % i
269 if __run('git-update-index --add --', files):
270 raise GitException, 'Unable to add file'
272 def rm(files, force = False):
273 """Remove a file from the repository
276 git_opt = '--force-remove'
282 if os.path.exists(f):
283 raise GitException, '%s exists. Remove it first' %f
285 __run('git-update-index --remove --', files)
288 __run('git-update-index --force-remove --', files)
290 def update_cache(files = [], force = False):
291 """Update the cache information for the given files
293 cache_files = __tree_status(files)
295 # everything is up-to-date
296 if len(cache_files) == 0:
299 # check for unresolved conflicts
300 if not force and [x for x in cache_files
301 if x[0] not in ['M', 'N', 'A', 'D']]:
302 raise GitException, 'Updating cache failed: unresolved conflicts'
305 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
306 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
307 m_files = [x[1] for x in cache_files if x[0] in ['M']]
309 if add_files and __run('git-update-index --add --', add_files) != 0:
310 raise GitException, 'Failed git-update-index --add'
311 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
312 raise GitException, 'Failed git-update-index --rm'
313 if m_files and __run('git-update-index --', m_files) != 0:
314 raise GitException, 'Failed git-update-index'
318 def commit(message, files = [], parents = [], allowempty = False,
319 cache_update = True, tree_id = None,
320 author_name = None, author_email = None, author_date = None,
321 committer_name = None, committer_email = None):
322 """Commit the current tree to repository
324 # Get the tree status
325 if cache_update and parents != []:
326 changes = update_cache(files)
327 if not changes and not allowempty:
328 raise GitException, 'No changes to commit'
330 # get the commit message
331 if message[-1:] != '\n':
335 # write the index to repository
337 tree_id = _output_one_line('git-write-tree')
344 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
346 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
348 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
350 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
352 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
353 cmd += 'git-commit-tree %s' % tree_id
359 commit_id = _output_one_line(cmd, message)
361 __set_head(commit_id)
365 def apply_diff(rev1, rev2):
366 """Apply the diff between rev1 and rev2 onto the current
367 index. This function doesn't need to raise an exception since it
368 is only used for fast-pushing a patch. If this operation fails,
369 the pushing would fall back to the three-way merge.
371 return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
374 def merge(base, head1, head2):
375 """Perform a 3-way merge between base, head1 and head2 into the
378 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
379 raise GitException, 'git-read-tree failed (local changes maybe?)'
381 # this can fail if there are conflicts
382 if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
383 raise GitException, 'git-merge-cache failed (possible conflicts)'
385 def status(files = [], modified = False, new = False, deleted = False,
386 conflict = False, unknown = False, noexclude = False):
387 """Show the tree status
389 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
390 all = not (modified or new or deleted or conflict or unknown)
405 cache_files = [x for x in cache_files if x[0] in filestat]
407 for fs in cache_files:
409 print '%s %s' % (fs[0], fs[1])
413 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
414 """Show the diff between rev1 and rev2
418 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
420 os.system('git-update-index --refresh > /dev/null')
421 diff_str = _output(['git-diff-index', '-p', rev1] + files)
424 out_fd.write(diff_str)
428 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
429 """Return the diffstat between rev1 and rev2
432 p=popen2.Popen3('git-apply --stat')
433 diff(files, rev1, rev2, p.tochild)
435 str = p.fromchild.read().rstrip()
437 raise GitException, 'git.diffstat failed'
440 def files(rev1, rev2):
441 """Return the files modified between rev1 and rev2
445 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
446 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
450 def barefiles(rev1, rev2):
451 """Return the files modified between rev1 and rev2, without status info
455 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
456 str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
460 def checkout(files = [], tree_id = None, force = False):
461 """Check out the given or all files
463 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
464 raise GitException, 'Failed git-read-tree -m %s' % tree_id
466 checkout_cmd = 'git-checkout-index -q -u'
468 checkout_cmd += ' -f'
470 checkout_cmd += ' -a'
472 checkout_cmd += ' --'
474 if __run(checkout_cmd, files) != 0:
475 raise GitException, 'Failed git-checkout-index'
478 """Switch the tree to the given id
480 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
481 raise GitException, 'git-read-tree failed (local changes maybe?)'
485 def reset(tree_id = None):
486 """Revert the tree changes relative to the given tree_id. It removes
492 cache_files = __tree_status(tree_id = tree_id)
493 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
495 checkout(tree_id = tree_id, force = True)
498 # checkout doesn't remove files
499 map(os.remove, rm_files)
501 def pull(repository = 'origin', refspec = None):
502 """Pull changes from the remote repository. At the moment, just
503 use the 'git pull' command
509 if __run('git pull', args) != 0:
510 raise GitException, 'Failed "git pull %s"' % repository
512 def apply_patch(filename = None):
513 """Apply a patch onto the current index. There must not be any
514 local changes in the tree, otherwise the command fails
516 os.system('git-update-index --refresh > /dev/null')
519 if __run('git-apply --index', [filename]) != 0:
520 raise GitException, 'Patch does not apply cleanly'
522 _input('git-apply --index', sys.stdin)
524 def clone(repository, local_dir):
525 """Clone a remote repository. At the moment, just use the
528 if __run('git clone', [repository, local_dir]) != 0:
529 raise GitException, 'Failed "git clone %s %s"' \
530 % (repository, local_dir)