New stg command: assimilate
[stgit/ydirson.git] / stgit / git.py
blob42b0d12b3fbae6800c93c5f8b82dc25abf6e6887
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 *
25 from stgit.config import config
27 # git exception class
28 class GitException(Exception):
29 pass
34 # Classes
36 class Commit:
37 """Handle the commit objects
38 """
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)):
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 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):
57 return self.__id_hash
59 def get_tree(self):
60 return self.__tree
62 def get_parent(self):
63 parents = self.get_parents()
64 if parents:
65 return parents[0]
66 else:
67 return None
69 def get_parents(self):
70 return _output_lines('git-rev-list --parents --max-count=1 %s'
71 % self.__id_hash)[0].split()[1:]
73 def get_author(self):
74 return self.__author
76 def get_committer(self):
77 return self.__committer
79 def get_log(self):
80 return self.__log
82 def __str__(self):
83 return self.get_id_hash()
85 # dictionary of Commit objects, used to avoid multiple calls to git
86 __commits = dict()
89 # Functions
92 def get_commit(id_hash):
93 """Commit objects factory. Save/look-up them in the __commits
94 dictionary
95 """
96 global __commits
98 if id_hash in __commits:
99 return __commits[id_hash]
100 else:
101 commit = Commit(id_hash)
102 __commits[id_hash] = commit
103 return commit
105 def get_conflicts():
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()]
112 f.close()
113 return names
114 else:
115 return None
117 def _input(cmd, file_desc):
118 p = popen2.Popen3(cmd, True)
119 while True:
120 line = file_desc.readline()
121 if not line:
122 break
123 p.tochild.write(line)
124 p.tochild.close()
125 if p.wait():
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)
132 p.tochild.close()
133 if p.wait():
134 raise GitException, '%s failed (%s)' % (str(cmd),
135 p.childerr.read().strip())
137 def _output(cmd):
138 p=popen2.Popen3(cmd, True)
139 output = p.fromchild.read()
140 if p.wait():
141 raise GitException, '%s failed (%s)' % (str(cmd),
142 p.childerr.read().strip())
143 return output
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)
150 p.tochild.close()
151 output = p.fromchild.readline().strip()
152 if p.wait():
153 raise GitException, '%s failed (%s)' % (str(cmd),
154 p.childerr.read().strip())
155 return output
157 def _output_lines(cmd):
158 p=popen2.Popen3(cmd, True)
159 lines = p.fromchild.readlines()
160 if p.wait():
161 raise GitException, '%s failed (%s)' % (str(cmd),
162 p.childerr.read().strip())
163 return lines
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.
175 args_l=cmd.split()
176 if args is None:
177 args = []
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))])
180 if r:
181 return r
182 return 0
184 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
185 noexclude = True):
186 """Returns a list of pairs - [status, filename]
188 refresh_index()
190 if not files:
191 files = []
192 cache_files = []
194 # unknown files
195 if unknown:
196 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
197 base_exclude = ['--exclude=%s' % s for s in
198 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
199 base_exclude.append('--exclude-per-directory=.gitignore')
201 if os.path.exists(exclude_file):
202 extra_exclude = ['--exclude-from=%s' % exclude_file]
203 else:
204 extra_exclude = []
205 if noexclude:
206 extra_exclude = base_exclude = []
208 lines = _output_lines(['git-ls-files', '--others', '--directory']
209 + base_exclude + extra_exclude)
210 cache_files += [('?', line.strip()) for line in lines]
212 # conflicted files
213 conflicts = get_conflicts()
214 if not conflicts:
215 conflicts = []
216 cache_files += [('C', filename) for filename in conflicts]
218 # the rest
219 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
220 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
221 if fs[1] not in conflicts:
222 cache_files.append(fs)
224 return cache_files
226 def local_changes():
227 """Return true if there are local changes in the tree
229 return len(__tree_status()) != 0
231 # HEAD value cached
232 __head = None
234 def get_head():
235 """Verifies the HEAD and returns the SHA1 id that represents it
237 global __head
239 if not __head:
240 __head = rev_parse('HEAD')
241 return __head
243 def get_head_file():
244 """Returns the name of the file pointed to by the HEAD link
246 return strip_prefix('refs/heads/',
247 _output_one_line('git-symbolic-ref HEAD'))
249 def set_head_file(ref):
250 """Resets HEAD to point to a new ref
252 # head cache flushing is needed since we might have a different value
253 # in the new head
254 __clear_head_cache()
255 if __run('git-symbolic-ref HEAD',
256 [os.path.join('refs', 'heads', ref)]) != 0:
257 raise GitException, 'Could not set head to "%s"' % ref
259 def __set_head(val):
260 """Sets the HEAD value
262 global __head
264 if not __head or __head != val:
265 if __run('git-update-ref HEAD', [val]) != 0:
266 raise GitException, 'Could not update HEAD to "%s".' % val
267 __head = val
269 # only allow SHA1 hashes
270 assert(len(__head) == 40)
272 def __clear_head_cache():
273 """Sets the __head to None so that a re-read is forced
275 global __head
277 __head = None
279 def refresh_index():
280 """Refresh index with stat() information from the working directory.
282 __run('git-update-index -q --unmerged --refresh')
284 def rev_parse(git_id):
285 """Parse the string and return a verified SHA1 id
287 try:
288 return _output_one_line(['git-rev-parse', '--verify', git_id])
289 except GitException:
290 raise GitException, 'Unknown revision: %s' % git_id
292 def branch_exists(branch):
293 """Existence check for the named branch
295 branch = os.path.join('refs', 'heads', branch)
296 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
297 if line.strip() == branch:
298 return True
299 if re.compile('[ |/]'+branch+' ').search(line):
300 raise GitException, 'Bogus branch: %s' % line
301 return False
303 def create_branch(new_branch, tree_id = None):
304 """Create a new branch in the git repository
306 if branch_exists(new_branch):
307 raise GitException, 'Branch "%s" already exists' % new_branch
309 current_head = get_head()
310 set_head_file(new_branch)
311 __set_head(current_head)
313 # a checkout isn't needed if new branch points to the current head
314 if tree_id:
315 switch(tree_id)
317 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
318 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
320 def switch_branch(new_branch):
321 """Switch to a git branch
323 global __head
325 if not branch_exists(new_branch):
326 raise GitException, 'Branch "%s" does not exist' % new_branch
328 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
329 + '^{commit}')
330 if tree_id != get_head():
331 refresh_index()
332 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
333 raise GitException, 'git-read-tree failed (local changes maybe?)'
334 __head = tree_id
335 set_head_file(new_branch)
337 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
338 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
340 def delete_branch(name):
341 """Delete a git branch
343 if not branch_exists(name):
344 raise GitException, 'Branch "%s" does not exist' % name
345 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
346 name)
348 def rename_branch(from_name, to_name):
349 """Rename a git branch
351 if not branch_exists(from_name):
352 raise GitException, 'Branch "%s" does not exist' % from_name
353 if branch_exists(to_name):
354 raise GitException, 'Branch "%s" already exists' % to_name
356 if get_head_file() == from_name:
357 set_head_file(to_name)
358 rename(os.path.join(basedir.get(), 'refs', 'heads'),
359 from_name, to_name)
361 def add(names):
362 """Add the files or recursively add the directory contents
364 # generate the file list
365 files = []
366 for i in names:
367 if not os.path.exists(i):
368 raise GitException, 'Unknown file or directory: %s' % i
370 if os.path.isdir(i):
371 # recursive search. We only add files
372 for root, dirs, local_files in os.walk(i):
373 for name in [os.path.join(root, f) for f in local_files]:
374 if os.path.isfile(name):
375 files.append(os.path.normpath(name))
376 elif os.path.isfile(i):
377 files.append(os.path.normpath(i))
378 else:
379 raise GitException, '%s is not a file or directory' % i
381 if files:
382 if __run('git-update-index --add --', files):
383 raise GitException, 'Unable to add file'
385 def rm(files, force = False):
386 """Remove a file from the repository
388 if not force:
389 for f in files:
390 if os.path.exists(f):
391 raise GitException, '%s exists. Remove it first' %f
392 if files:
393 __run('git-update-index --remove --', files)
394 else:
395 if files:
396 __run('git-update-index --force-remove --', files)
398 def update_cache(files = None, force = False):
399 """Update the cache information for the given files
401 if not files:
402 files = []
404 cache_files = __tree_status(files)
406 # everything is up-to-date
407 if len(cache_files) == 0:
408 return False
410 # check for unresolved conflicts
411 if not force and [x for x in cache_files
412 if x[0] not in ['M', 'N', 'A', 'D']]:
413 raise GitException, 'Updating cache failed: unresolved conflicts'
415 # update the cache
416 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
417 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
418 m_files = [x[1] for x in cache_files if x[0] in ['M']]
420 if add_files and __run('git-update-index --add --', add_files) != 0:
421 raise GitException, 'Failed git-update-index --add'
422 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
423 raise GitException, 'Failed git-update-index --rm'
424 if m_files and __run('git-update-index --', m_files) != 0:
425 raise GitException, 'Failed git-update-index'
427 return True
429 def commit(message, files = None, parents = None, allowempty = False,
430 cache_update = True, tree_id = None,
431 author_name = None, author_email = None, author_date = None,
432 committer_name = None, committer_email = None):
433 """Commit the current tree to repository
435 if not files:
436 files = []
437 if not parents:
438 parents = []
440 # Get the tree status
441 if cache_update and parents != []:
442 changes = update_cache(files)
443 if not changes and not allowempty:
444 raise GitException, 'No changes to commit'
446 # get the commit message
447 if not message:
448 message = '\n'
449 elif message[-1:] != '\n':
450 message += '\n'
452 must_switch = True
453 # write the index to repository
454 if tree_id == None:
455 tree_id = _output_one_line('git-write-tree')
456 else:
457 must_switch = False
459 # the commit
460 cmd = ''
461 if author_name:
462 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
463 if author_email:
464 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
465 if author_date:
466 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
467 if committer_name:
468 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
469 if committer_email:
470 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
471 cmd += 'git-commit-tree %s' % tree_id
473 # get the parents
474 for p in parents:
475 cmd += ' -p %s' % p
477 commit_id = _output_one_line(cmd, message)
478 if must_switch:
479 __set_head(commit_id)
481 return commit_id
483 def apply_diff(rev1, rev2, check_index = True, files = None):
484 """Apply the diff between rev1 and rev2 onto the current
485 index. This function doesn't need to raise an exception since it
486 is only used for fast-pushing a patch. If this operation fails,
487 the pushing would fall back to the three-way merge.
489 if check_index:
490 index_opt = '--index'
491 else:
492 index_opt = ''
494 if not files:
495 files = []
497 diff_str = diff(files, rev1, rev2)
498 if diff_str:
499 try:
500 _input_str('git-apply %s' % index_opt, diff_str)
501 except GitException:
502 return False
504 return True
506 def merge(base, head1, head2):
507 """Perform a 3-way merge between base, head1 and head2 into the
508 local tree
510 refresh_index()
511 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
512 raise GitException, 'git-read-tree failed (local changes maybe?)'
514 # check the index for unmerged entries
515 files = {}
516 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
518 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
519 if not line:
520 continue
522 mode, hash, stage, path = stages_re.findall(line)[0]
524 if not path in files:
525 files[path] = {}
526 files[path]['1'] = ('', '')
527 files[path]['2'] = ('', '')
528 files[path]['3'] = ('', '')
530 files[path][stage] = (mode, hash)
532 # merge the unmerged files
533 errors = False
534 for path in files:
535 stages = files[path]
536 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
537 stages['3'][1], path, stages['1'][0],
538 stages['2'][0], stages['3'][0]) != 0:
539 errors = True
541 if errors:
542 raise GitException, 'GIT index merging failed (possible conflicts)'
544 def status(files = None, modified = False, new = False, deleted = False,
545 conflict = False, unknown = False, noexclude = False):
546 """Show the tree status
548 if not files:
549 files = []
551 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
552 all = not (modified or new or deleted or conflict or unknown)
554 if not all:
555 filestat = []
556 if modified:
557 filestat.append('M')
558 if new:
559 filestat.append('A')
560 filestat.append('N')
561 if deleted:
562 filestat.append('D')
563 if conflict:
564 filestat.append('C')
565 if unknown:
566 filestat.append('?')
567 cache_files = [x for x in cache_files if x[0] in filestat]
569 for fs in cache_files:
570 if all:
571 print '%s %s' % (fs[0], fs[1])
572 else:
573 print '%s' % fs[1]
575 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
576 """Show the diff between rev1 and rev2
578 if not files:
579 files = []
581 if rev1 and rev2:
582 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
583 elif rev1 or rev2:
584 refresh_index()
585 if rev2:
586 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
587 else:
588 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
589 else:
590 diff_str = ''
592 if out_fd:
593 out_fd.write(diff_str)
594 else:
595 return diff_str
597 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
598 """Return the diffstat between rev1 and rev2
600 if not files:
601 files = []
603 p=popen2.Popen3('git-apply --stat')
604 diff(files, rev1, rev2, p.tochild)
605 p.tochild.close()
606 diff_str = p.fromchild.read().rstrip()
607 if p.wait():
608 raise GitException, 'git.diffstat failed'
609 return diff_str
611 def files(rev1, rev2):
612 """Return the files modified between rev1 and rev2
615 result = ''
616 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
617 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
619 return result.rstrip()
621 def barefiles(rev1, rev2):
622 """Return the files modified between rev1 and rev2, without status info
625 result = ''
626 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
627 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
629 return result.rstrip()
631 def pretty_commit(commit_id = 'HEAD'):
632 """Return a given commit (log + diff)
634 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
635 commit_id])
637 def checkout(files = None, tree_id = None, force = False):
638 """Check out the given or all files
640 if not files:
641 files = []
643 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
644 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
646 checkout_cmd = 'git-checkout-index -q -u'
647 if force:
648 checkout_cmd += ' -f'
649 if len(files) == 0:
650 checkout_cmd += ' -a'
651 else:
652 checkout_cmd += ' --'
654 if __run(checkout_cmd, files) != 0:
655 raise GitException, 'Failed git-checkout-index'
657 def switch(tree_id, keep = False):
658 """Switch the tree to the given id
660 if not keep:
661 refresh_index()
662 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
663 raise GitException, 'git-read-tree failed (local changes maybe?)'
665 __set_head(tree_id)
667 def reset(files = None, tree_id = None, check_out = True):
668 """Revert the tree changes relative to the given tree_id. It removes
669 any local changes
671 if not tree_id:
672 tree_id = get_head()
674 if check_out:
675 cache_files = __tree_status(files, tree_id)
676 # files which were added but need to be removed
677 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
679 checkout(files, tree_id, True)
680 # checkout doesn't remove files
681 map(os.remove, rm_files)
683 # if the reset refers to the whole tree, switch the HEAD as well
684 if not files:
685 __set_head(tree_id)
687 def pull(repository = 'origin', refspec = None):
688 """Pull changes from the remote repository. At the moment, just
689 use the 'git-pull' command
691 # 'git-pull' updates the HEAD
692 __clear_head_cache()
694 args = [repository]
695 if refspec:
696 args.append(refspec)
698 if __run(config.get('stgit', 'pullcmd'), args) != 0:
699 raise GitException, 'Failed "git-pull %s"' % repository
701 def apply_patch(filename = None, base = None):
702 """Apply a patch onto the current or given index. There must not
703 be any local changes in the tree, otherwise the command fails
705 def __apply_patch():
706 if filename:
707 return __run('git-apply --index', [filename]) == 0
708 else:
709 try:
710 _input('git-apply --index', sys.stdin)
711 except GitException:
712 return False
713 return True
715 if base:
716 orig_head = get_head()
717 switch(base)
718 else:
719 refresh_index() # needed since __apply_patch() doesn't do it
721 if not __apply_patch():
722 if base:
723 switch(orig_head)
724 raise GitException, 'Patch does not apply cleanly'
725 elif base:
726 top = commit(message = 'temporary commit used for applying a patch',
727 parents = [base])
728 switch(orig_head)
729 merge(base, orig_head, top)
731 def clone(repository, local_dir):
732 """Clone a remote repository. At the moment, just use the
733 'git-clone' script
735 if __run('git-clone', [repository, local_dir]) != 0:
736 raise GitException, 'Failed "git-clone %s %s"' \
737 % (repository, local_dir)
739 def modifying_revs(files, base_rev):
740 """Return the revisions from the list modifying the given files
742 cmd = ['git-rev-list', '%s..' % base_rev, '--']
743 revs = [line.strip() for line in _output_lines(cmd + files)]
745 return revs