New stg command: assimilate
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
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.
10
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.
15
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 """
20
21 import sys, os, popen2, re, gitmergeonefile
22
23 from stgit import basedir
24 from stgit.utils import *
25 from stgit.config import config
26
27 # git exception class
28 class GitException(Exception):
29 pass
30
31
32
33 #
34 # Classes
35 #
36 class Commit:
37 """Handle the commit objects
38 """
39 def __init__(self, id_hash):
40 self.__id_hash = id_hash
41
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:])
55
56 def get_id_hash(self):
57 return self.__id_hash
58
59 def get_tree(self):
60 return self.__tree
61
62 def get_parent(self):
63 parents = self.get_parents()
64 if parents:
65 return parents[0]
66 else:
67 return None
68
69 def get_parents(self):
70 return _output_lines('git-rev-list --parents --max-count=1 %s'
71 % self.__id_hash)[0].split()[1:]
72
73 def get_author(self):
74 return self.__author
75
76 def get_committer(self):
77 return self.__committer
78
79 def get_log(self):
80 return self.__log
81
82 def __str__(self):
83 return self.get_id_hash()
84
85 # dictionary of Commit objects, used to avoid multiple calls to git
86 __commits = dict()
87
88 #
89 # Functions
90 #
91
92 def get_commit(id_hash):
93 """Commit objects factory. Save/look-up them in the __commits
94 dictionary
95 """
96 global __commits
97
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
104
105 def get_conflicts():
106 """Return the list of file conflicts
107 """
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
116
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())
128
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())
136
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
144
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
156
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
164
165 def __run(cmd, args=None):
166 """__run: runs cmd using spawnvp.
167
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.
173 """
174
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
183
184 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
185 noexclude = True):
186 """Returns a list of pairs - [status, filename]
187 """
188 refresh_index()
189
190 if not files:
191 files = []
192 cache_files = []
193
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')
200
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 = []
207
208 lines = _output_lines(['git-ls-files', '--others', '--directory']
209 + base_exclude + extra_exclude)
210 cache_files += [('?', line.strip()) for line in lines]
211
212 # conflicted files
213 conflicts = get_conflicts()
214 if not conflicts:
215 conflicts = []
216 cache_files += [('C', filename) for filename in conflicts]
217
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)
223
224 return cache_files
225
226 def local_changes():
227 """Return true if there are local changes in the tree
228 """
229 return len(__tree_status()) != 0
230
231 # HEAD value cached
232 __head = None
233
234 def get_head():
235 """Verifies the HEAD and returns the SHA1 id that represents it
236 """
237 global __head
238
239 if not __head:
240 __head = rev_parse('HEAD')
241 return __head
242
243 def get_head_file():
244 """Returns the name of the file pointed to by the HEAD link
245 """
246 return strip_prefix('refs/heads/',
247 _output_one_line('git-symbolic-ref HEAD'))
248
249 def set_head_file(ref):
250 """Resets HEAD to point to a new ref
251 """
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
258
259 def __set_head(val):
260 """Sets the HEAD value
261 """
262 global __head
263
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
268
269 # only allow SHA1 hashes
270 assert(len(__head) == 40)
271
272 def __clear_head_cache():
273 """Sets the __head to None so that a re-read is forced
274 """
275 global __head
276
277 __head = None
278
279 def refresh_index():
280 """Refresh index with stat() information from the working directory.
281 """
282 __run('git-update-index -q --unmerged --refresh')
283
284 def rev_parse(git_id):
285 """Parse the string and return a verified SHA1 id
286 """
287 try:
288 return _output_one_line(['git-rev-parse', '--verify', git_id])
289 except GitException:
290 raise GitException, 'Unknown revision: %s' % git_id
291
292 def branch_exists(branch):
293 """Existence check for the named branch
294 """
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
302
303 def create_branch(new_branch, tree_id = None):
304 """Create a new branch in the git repository
305 """
306 if branch_exists(new_branch):
307 raise GitException, 'Branch "%s" already exists' % new_branch
308
309 current_head = get_head()
310 set_head_file(new_branch)
311 __set_head(current_head)
312
313 # a checkout isn't needed if new branch points to the current head
314 if tree_id:
315 switch(tree_id)
316
317 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
318 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
319
320 def switch_branch(new_branch):
321 """Switch to a git branch
322 """
323 global __head
324
325 if not branch_exists(new_branch):
326 raise GitException, 'Branch "%s" does not exist' % new_branch
327
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)
336
337 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
338 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
339
340 def delete_branch(name):
341 """Delete a git branch
342 """
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)
347
348 def rename_branch(from_name, to_name):
349 """Rename a git branch
350 """
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
355
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)
360
361 def add(names):
362 """Add the files or recursively add the directory contents
363 """
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
369
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
380
381 if files:
382 if __run('git-update-index --add --', files):
383 raise GitException, 'Unable to add file'
384
385 def rm(files, force = False):
386 """Remove a file from the repository
387 """
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)
397
398 def update_cache(files = None, force = False):
399 """Update the cache information for the given files
400 """
401 if not files:
402 files = []
403
404 cache_files = __tree_status(files)
405
406 # everything is up-to-date
407 if len(cache_files) == 0:
408 return False
409
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'
414
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']]
419
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'
426
427 return True
428
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
434 """
435 if not files:
436 files = []
437 if not parents:
438 parents = []
439
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'
445
446 # get the commit message
447 if not message:
448 message = '\n'
449 elif message[-1:] != '\n':
450 message += '\n'
451
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
458
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
472
473 # get the parents
474 for p in parents:
475 cmd += ' -p %s' % p
476
477 commit_id = _output_one_line(cmd, message)
478 if must_switch:
479 __set_head(commit_id)
480
481 return commit_id
482
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.
488 """
489 if check_index:
490 index_opt = '--index'
491 else:
492 index_opt = ''
493
494 if not files:
495 files = []
496
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
503
504 return True
505
506 def merge(base, head1, head2):
507 """Perform a 3-way merge between base, head1 and head2 into the
508 local tree
509 """
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?)'
513
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)
517
518 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
519 if not line:
520 continue
521
522 mode, hash, stage, path = stages_re.findall(line)[0]
523
524 if not path in files:
525 files[path] = {}
526 files[path]['1'] = ('', '')
527 files[path]['2'] = ('', '')
528 files[path]['3'] = ('', '')
529
530 files[path][stage] = (mode, hash)
531
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
540
541 if errors:
542 raise GitException, 'GIT index merging failed (possible conflicts)'
543
544 def status(files = None, modified = False, new = False, deleted = False,
545 conflict = False, unknown = False, noexclude = False):
546 """Show the tree status
547 """
548 if not files:
549 files = []
550
551 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
552 all = not (modified or new or deleted or conflict or unknown)
553
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]
568
569 for fs in cache_files:
570 if all:
571 print '%s %s' % (fs[0], fs[1])
572 else:
573 print '%s' % fs[1]
574
575 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
576 """Show the diff between rev1 and rev2
577 """
578 if not files:
579 files = []
580
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 = ''
591
592 if out_fd:
593 out_fd.write(diff_str)
594 else:
595 return diff_str
596
597 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
598 """Return the diffstat between rev1 and rev2
599 """
600 if not files:
601 files = []
602
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
610
611 def files(rev1, rev2):
612 """Return the files modified between rev1 and rev2
613 """
614
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))
618
619 return result.rstrip()
620
621 def barefiles(rev1, rev2):
622 """Return the files modified between rev1 and rev2, without status info
623 """
624
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]
628
629 return result.rstrip()
630
631 def pretty_commit(commit_id = 'HEAD'):
632 """Return a given commit (log + diff)
633 """
634 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
635 commit_id])
636
637 def checkout(files = None, tree_id = None, force = False):
638 """Check out the given or all files
639 """
640 if not files:
641 files = []
642
643 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
644 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
645
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 += ' --'
653
654 if __run(checkout_cmd, files) != 0:
655 raise GitException, 'Failed git-checkout-index'
656
657 def switch(tree_id, keep = False):
658 """Switch the tree to the given id
659 """
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?)'
664
665 __set_head(tree_id)
666
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
670 """
671 if not tree_id:
672 tree_id = get_head()
673
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']]
678
679 checkout(files, tree_id, True)
680 # checkout doesn't remove files
681 map(os.remove, rm_files)
682
683 # if the reset refers to the whole tree, switch the HEAD as well
684 if not files:
685 __set_head(tree_id)
686
687 def pull(repository = 'origin', refspec = None):
688 """Pull changes from the remote repository. At the moment, just
689 use the 'git-pull' command
690 """
691 # 'git-pull' updates the HEAD
692 __clear_head_cache()
693
694 args = [repository]
695 if refspec:
696 args.append(refspec)
697
698 if __run(config.get('stgit', 'pullcmd'), args) != 0:
699 raise GitException, 'Failed "git-pull %s"' % repository
700
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
704 """
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
714
715 if base:
716 orig_head = get_head()
717 switch(base)
718 else:
719 refresh_index() # needed since __apply_patch() doesn't do it
720
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)
730
731 def clone(repository, local_dir):
732 """Clone a remote repository. At the moment, just use the
733 'git-clone' script
734 """
735 if __run('git-clone', [repository, local_dir]) != 0:
736 raise GitException, 'Failed "git-clone %s %s"' \
737 % (repository, local_dir)
738
739 def modifying_revs(files, base_rev):
740 """Return the revisions from the list modifying the given files
741 """
742 cmd = ['git-rev-list', '%s..' % base_rev, '--']
743 revs = [line.strip() for line in _output_lines(cmd + files)]
744
745 return revs