Use 'git-*' instead of 'git *'
[stgit.git] / stgit / git.py
blobd75b54ee36b5c99bb75a5ece3a5e935631993d04
1 """Python GIT interface
2 """
4 __copyright__ = """
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
19 """
21 import sys, os, popen2, re, gitmergeonefile
23 from stgit import basedir
24 from stgit.utils import *
26 # git exception class
27 class GitException(Exception):
28 pass
33 # Classes
35 class Commit:
36 """Handle the commit objects
37 """
38 def __init__(self, id_hash):
39 self.__id_hash = id_hash
41 lines = _output_lines('git-cat-file commit %s' % id_hash)
42 self.__parents = []
43 for i in range(len(lines)):
44 line = lines[i]
45 if line == '\n':
46 break
47 field = line.strip().split(' ', 1)
48 if field[0] == 'tree':
49 self.__tree = field[1]
50 elif field[0] == 'parent':
51 self.__parents.append(field[1])
52 if field[0] == 'author':
53 self.__author = field[1]
54 if field[0] == 'committer':
55 self.__committer = field[1]
56 self.__log = ''.join(lines[i+1:])
58 def get_id_hash(self):
59 return self.__id_hash
61 def get_tree(self):
62 return self.__tree
64 def get_parent(self):
65 return self.__parents[0]
67 def get_parents(self):
68 return self.__parents
70 def get_author(self):
71 return self.__author
73 def get_committer(self):
74 return self.__committer
76 def get_log(self):
77 return self.__log
79 # dictionary of Commit objects, used to avoid multiple calls to git
80 __commits = dict()
83 # Functions
86 def get_commit(id_hash):
87 """Commit objects factory. Save/look-up them in the __commits
88 dictionary
89 """
90 global __commits
92 if id_hash in __commits:
93 return __commits[id_hash]
94 else:
95 commit = Commit(id_hash)
96 __commits[id_hash] = commit
97 return commit
99 def get_conflicts():
100 """Return the list of file conflicts
102 conflicts_file = os.path.join(basedir.get(), 'conflicts')
103 if os.path.isfile(conflicts_file):
104 f = file(conflicts_file)
105 names = [line.strip() for line in f.readlines()]
106 f.close()
107 return names
108 else:
109 return None
111 def _input(cmd, file_desc):
112 p = popen2.Popen3(cmd, True)
113 while True:
114 line = file_desc.readline()
115 if not line:
116 break
117 p.tochild.write(line)
118 p.tochild.close()
119 if p.wait():
120 raise GitException, '%s failed' % str(cmd)
122 def _output(cmd):
123 p=popen2.Popen3(cmd, True)
124 output = p.fromchild.read()
125 if p.wait():
126 raise GitException, '%s failed' % str(cmd)
127 return output
129 def _output_one_line(cmd, file_desc = None):
130 p=popen2.Popen3(cmd, True)
131 if file_desc != None:
132 for line in file_desc:
133 p.tochild.write(line)
134 p.tochild.close()
135 output = p.fromchild.readline().strip()
136 if p.wait():
137 raise GitException, '%s failed' % str(cmd)
138 return output
140 def _output_lines(cmd):
141 p=popen2.Popen3(cmd, True)
142 lines = p.fromchild.readlines()
143 if p.wait():
144 raise GitException, '%s failed' % str(cmd)
145 return lines
147 def __run(cmd, args=None):
148 """__run: runs cmd using spawnvp.
150 Runs cmd using spawnvp. The shell is avoided so it won't mess up
151 our arguments. If args is very large, the command is run multiple
152 times; args is split xargs style: cmd is passed on each
153 invocation. Unlike xargs, returns immediately if any non-zero
154 return code is received.
157 args_l=cmd.split()
158 if args is None:
159 args = []
160 for i in range(0, len(args)+1, 100):
161 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
162 if r:
163 return r
164 return 0
166 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
167 noexclude = True):
168 """Returns a list of pairs - [status, filename]
170 refresh_index()
172 if not files:
173 files = []
174 cache_files = []
176 # unknown files
177 if unknown:
178 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
179 base_exclude = ['--exclude=%s' % s for s in
180 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
181 base_exclude.append('--exclude-per-directory=.gitignore')
183 if os.path.exists(exclude_file):
184 extra_exclude = ['--exclude-from=%s' % exclude_file]
185 else:
186 extra_exclude = []
187 if noexclude:
188 extra_exclude = base_exclude = []
190 lines = _output_lines(['git-ls-files', '--others', '--directory']
191 + base_exclude + extra_exclude)
192 cache_files += [('?', line.strip()) for line in lines]
194 # conflicted files
195 conflicts = get_conflicts()
196 if not conflicts:
197 conflicts = []
198 cache_files += [('C', filename) for filename in conflicts]
200 # the rest
201 for line in _output_lines(['git-diff-index', tree_id] + files):
202 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
203 if fs[1] not in conflicts:
204 cache_files.append(fs)
206 return cache_files
208 def local_changes():
209 """Return true if there are local changes in the tree
211 return len(__tree_status()) != 0
213 # HEAD value cached
214 __head = None
216 def get_head():
217 """Verifies the HEAD and returns the SHA1 id that represents it
219 global __head
221 if not __head:
222 __head = rev_parse('HEAD')
223 return __head
225 def get_head_file():
226 """Returns the name of the file pointed to by the HEAD link
228 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
230 def set_head_file(ref):
231 """Resets HEAD to point to a new ref
233 # head cache flushing is needed since we might have a different value
234 # in the new head
235 __clear_head_cache()
236 if __run('git-symbolic-ref HEAD', [ref]) != 0:
237 raise GitException, 'Could not set head to "%s"' % ref
239 def __set_head(val):
240 """Sets the HEAD value
242 global __head
244 if not __head or __head != val:
245 if __run('git-update-ref HEAD', [val]) != 0:
246 raise GitException, 'Could not update HEAD to "%s".' % val
247 __head = val
249 # only allow SHA1 hashes
250 assert(len(__head) == 40)
252 def __clear_head_cache():
253 """Sets the __head to None so that a re-read is forced
255 global __head
257 __head = None
259 def refresh_index():
260 """Refresh index with stat() information from the working directory.
262 __run('git-update-index -q --unmerged --refresh')
264 def rev_parse(git_id):
265 """Parse the string and return a verified SHA1 id
267 try:
268 return _output_one_line(['git-rev-parse', '--verify', git_id])
269 except GitException:
270 raise GitException, 'Unknown revision: %s' % git_id
272 def branch_exists(branch):
273 """Existence check for the named branch
275 for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
276 if line.strip() == branch:
277 return True
278 return False
280 def create_branch(new_branch, tree_id = None):
281 """Create a new branch in the git repository
283 new_head = os.path.join('refs', 'heads', new_branch)
284 if branch_exists(new_head):
285 raise GitException, 'Branch "%s" already exists' % new_branch
287 current_head = get_head()
288 set_head_file(new_head)
289 __set_head(current_head)
291 # a checkout isn't needed if new branch points to the current head
292 if tree_id:
293 switch(tree_id)
295 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
296 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
298 def switch_branch(name):
299 """Switch to a git branch
301 global __head
303 new_head = os.path.join('refs', 'heads', name)
304 if not branch_exists(new_head):
305 raise GitException, 'Branch "%s" does not exist' % name
307 tree_id = rev_parse(new_head + '^{commit}')
308 if tree_id != get_head():
309 refresh_index()
310 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
311 raise GitException, 'git-read-tree failed (local changes maybe?)'
312 __head = tree_id
313 set_head_file(new_head)
315 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
316 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
318 def delete_branch(name):
319 """Delete a git branch
321 branch_head = os.path.join('refs', 'heads', name)
322 if not branch_exists(branch_head):
323 raise GitException, 'Branch "%s" does not exist' % name
324 os.remove(os.path.join(basedir.get(), branch_head))
326 def rename_branch(from_name, to_name):
327 """Rename a git branch
329 from_head = os.path.join('refs', 'heads', from_name)
330 if not branch_exists(from_head):
331 raise GitException, 'Branch "%s" does not exist' % from_name
332 to_head = os.path.join('refs', 'heads', to_name)
333 if branch_exists(to_head):
334 raise GitException, 'Branch "%s" already exists' % to_name
336 if get_head_file() == from_name:
337 set_head_file(to_head)
338 os.rename(os.path.join(basedir.get(), from_head), \
339 os.path.join(basedir.get(), to_head))
341 def add(names):
342 """Add the files or recursively add the directory contents
344 # generate the file list
345 files = []
346 for i in names:
347 if not os.path.exists(i):
348 raise GitException, 'Unknown file or directory: %s' % i
350 if os.path.isdir(i):
351 # recursive search. We only add files
352 for root, dirs, local_files in os.walk(i):
353 for name in [os.path.join(root, f) for f in local_files]:
354 if os.path.isfile(name):
355 files.append(os.path.normpath(name))
356 elif os.path.isfile(i):
357 files.append(os.path.normpath(i))
358 else:
359 raise GitException, '%s is not a file or directory' % i
361 if files:
362 if __run('git-update-index --add --', files):
363 raise GitException, 'Unable to add file'
365 def rm(files, force = False):
366 """Remove a file from the repository
368 if not force:
369 for f in files:
370 if os.path.exists(f):
371 raise GitException, '%s exists. Remove it first' %f
372 if files:
373 __run('git-update-index --remove --', files)
374 else:
375 if files:
376 __run('git-update-index --force-remove --', files)
378 def update_cache(files = None, force = False):
379 """Update the cache information for the given files
381 if not files:
382 files = []
384 cache_files = __tree_status(files)
386 # everything is up-to-date
387 if len(cache_files) == 0:
388 return False
390 # check for unresolved conflicts
391 if not force and [x for x in cache_files
392 if x[0] not in ['M', 'N', 'A', 'D']]:
393 raise GitException, 'Updating cache failed: unresolved conflicts'
395 # update the cache
396 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
397 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
398 m_files = [x[1] for x in cache_files if x[0] in ['M']]
400 if add_files and __run('git-update-index --add --', add_files) != 0:
401 raise GitException, 'Failed git-update-index --add'
402 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
403 raise GitException, 'Failed git-update-index --rm'
404 if m_files and __run('git-update-index --', m_files) != 0:
405 raise GitException, 'Failed git-update-index'
407 return True
409 def commit(message, files = None, parents = None, allowempty = False,
410 cache_update = True, tree_id = None,
411 author_name = None, author_email = None, author_date = None,
412 committer_name = None, committer_email = None):
413 """Commit the current tree to repository
415 if not files:
416 files = []
417 if not parents:
418 parents = []
420 # Get the tree status
421 if cache_update and parents != []:
422 changes = update_cache(files)
423 if not changes and not allowempty:
424 raise GitException, 'No changes to commit'
426 # get the commit message
427 if message[-1:] != '\n':
428 message += '\n'
430 must_switch = True
431 # write the index to repository
432 if tree_id == None:
433 tree_id = _output_one_line('git-write-tree')
434 else:
435 must_switch = False
437 # the commit
438 cmd = ''
439 if author_name:
440 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
441 if author_email:
442 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
443 if author_date:
444 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
445 if committer_name:
446 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
447 if committer_email:
448 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
449 cmd += 'git-commit-tree %s' % tree_id
451 # get the parents
452 for p in parents:
453 cmd += ' -p %s' % p
455 commit_id = _output_one_line(cmd, message)
456 if must_switch:
457 __set_head(commit_id)
459 return commit_id
461 def apply_diff(rev1, rev2, check_index = True):
462 """Apply the diff between rev1 and rev2 onto the current
463 index. This function doesn't need to raise an exception since it
464 is only used for fast-pushing a patch. If this operation fails,
465 the pushing would fall back to the three-way merge.
467 if check_index:
468 index_opt = '--index'
469 else:
470 index_opt = ''
471 cmd = 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
472 % (rev1, rev2, index_opt)
474 return os.system(cmd) == 0
476 def merge(base, head1, head2):
477 """Perform a 3-way merge between base, head1 and head2 into the
478 local tree
480 refresh_index()
481 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
482 raise GitException, 'git-read-tree failed (local changes maybe?)'
484 # check the index for unmerged entries
485 files = {}
486 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
488 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
489 if not line:
490 continue
492 mode, hash, stage, path = stages_re.findall(line)[0]
494 if not path in files:
495 files[path] = {}
496 files[path]['1'] = ('', '')
497 files[path]['2'] = ('', '')
498 files[path]['3'] = ('', '')
500 files[path][stage] = (mode, hash)
502 # merge the unmerged files
503 errors = False
504 for path in files:
505 stages = files[path]
506 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
507 stages['3'][1], path, stages['1'][0],
508 stages['2'][0], stages['3'][0]) != 0:
509 errors = True
511 if errors:
512 raise GitException, 'GIT index merging failed (possible conflicts)'
514 def status(files = None, modified = False, new = False, deleted = False,
515 conflict = False, unknown = False, noexclude = False):
516 """Show the tree status
518 if not files:
519 files = []
521 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
522 all = not (modified or new or deleted or conflict or unknown)
524 if not all:
525 filestat = []
526 if modified:
527 filestat.append('M')
528 if new:
529 filestat.append('A')
530 filestat.append('N')
531 if deleted:
532 filestat.append('D')
533 if conflict:
534 filestat.append('C')
535 if unknown:
536 filestat.append('?')
537 cache_files = [x for x in cache_files if x[0] in filestat]
539 for fs in cache_files:
540 if all:
541 print '%s %s' % (fs[0], fs[1])
542 else:
543 print '%s' % fs[1]
545 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
546 """Show the diff between rev1 and rev2
548 if not files:
549 files = []
551 if rev1 and rev2:
552 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
553 elif rev1 or rev2:
554 refresh_index()
555 if rev2:
556 diff_str = _output(['git-diff-index', '-p', '-R', rev2] + files)
557 else:
558 diff_str = _output(['git-diff-index', '-p', rev1] + files)
559 else:
560 diff_str = ''
562 if out_fd:
563 out_fd.write(diff_str)
564 else:
565 return diff_str
567 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
568 """Return the diffstat between rev1 and rev2
570 if not files:
571 files = []
573 p=popen2.Popen3('git-apply --stat')
574 diff(files, rev1, rev2, p.tochild)
575 p.tochild.close()
576 diff_str = p.fromchild.read().rstrip()
577 if p.wait():
578 raise GitException, 'git.diffstat failed'
579 return diff_str
581 def files(rev1, rev2):
582 """Return the files modified between rev1 and rev2
585 result = ''
586 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
587 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
589 return result.rstrip()
591 def barefiles(rev1, rev2):
592 """Return the files modified between rev1 and rev2, without status info
595 result = ''
596 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
597 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
599 return result.rstrip()
601 def pretty_commit(commit_id = 'HEAD'):
602 """Return a given commit (log + diff)
604 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
605 commit_id])
607 def checkout(files = None, tree_id = None, force = False):
608 """Check out the given or all files
610 if not files:
611 files = []
613 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
614 raise GitException, 'Failed git-read-tree -m %s' % tree_id
616 checkout_cmd = 'git-checkout-index -q -u'
617 if force:
618 checkout_cmd += ' -f'
619 if len(files) == 0:
620 checkout_cmd += ' -a'
621 else:
622 checkout_cmd += ' --'
624 if __run(checkout_cmd, files) != 0:
625 raise GitException, 'Failed git-checkout-index'
627 def switch(tree_id):
628 """Switch the tree to the given id
630 refresh_index()
631 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
632 raise GitException, 'git-read-tree failed (local changes maybe?)'
634 __set_head(tree_id)
636 def reset(files = None, tree_id = None, check_out = True):
637 """Revert the tree changes relative to the given tree_id. It removes
638 any local changes
640 if not tree_id:
641 tree_id = get_head()
643 if check_out:
644 checkout(files, tree_id, True)
646 # if the reset refers to the whole tree, switch the HEAD as well
647 if not files:
648 __set_head(tree_id)
650 def pull(repository = 'origin', refspec = None):
651 """Pull changes from the remote repository. At the moment, just
652 use the 'git-pull' command
654 # 'git-pull' updates the HEAD
655 __clear_head_cache()
657 args = [repository]
658 if refspec:
659 args.append(refspec)
661 if __run('git-pull', args) != 0:
662 raise GitException, 'Failed "git-pull %s"' % repository
664 def apply_patch(filename = None, base = None):
665 """Apply a patch onto the current or given index. There must not
666 be any local changes in the tree, otherwise the command fails
668 def __apply_patch():
669 if filename:
670 return __run('git-apply --index', [filename]) == 0
671 else:
672 try:
673 _input('git-apply --index', sys.stdin)
674 except GitException:
675 return False
676 return True
678 if base:
679 orig_head = get_head()
680 switch(base)
681 else:
682 refresh_index() # needed since __apply_patch() doesn't do it
684 if not __apply_patch():
685 if base:
686 switch(orig_head)
687 raise GitException, 'Patch does not apply cleanly'
688 elif base:
689 top = commit(message = 'temporary commit used for applying a patch',
690 parents = [base])
691 switch(orig_head)
692 merge(base, orig_head, top)
694 def clone(repository, local_dir):
695 """Clone a remote repository. At the moment, just use the
696 'git-clone' script
698 if __run('git-clone', [repository, local_dir]) != 0:
699 raise GitException, 'Failed "git-clone %s %s"' \
700 % (repository, local_dir)
702 def modifying_revs(files, base_rev):
703 """Return the revisions from the list modifying the given files
705 cmd = ['git-rev-list', '%s..' % base_rev, '--']
706 revs = [line.strip() for line in _output_lines(cmd + files)]
708 return revs