Add mergetool support to the classic StGit infrastructure
[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, re
22 from shutil import copyfile
23
24 from stgit.exception import *
25 from stgit import basedir
26 from stgit.utils import *
27 from stgit.out import *
28 from stgit.run import *
29 from stgit.config import config
30
31 # git exception class
32 class GitException(StgException):
33 pass
34
35 # When a subprocess has a problem, we want the exception to be a
36 # subclass of GitException.
37 class GitRunException(GitException):
38 pass
39 class GRun(Run):
40 exc = GitRunException
41 def __init__(self, *cmd):
42 """Initialise the Run object and insert the 'git' command name.
43 """
44 Run.__init__(self, 'git', *cmd)
45
46 #
47 # Classes
48 #
49
50 class Person:
51 """An author, committer, etc."""
52 def __init__(self, name = None, email = None, date = '',
53 desc = None):
54 self.name = self.email = self.date = None
55 if name or email or date:
56 assert not desc
57 self.name = name
58 self.email = email
59 self.date = date
60 elif desc:
61 assert not (name or email or date)
62 def parse_desc(s):
63 m = re.match(r'^(.+)<(.+)>(.*)$', s)
64 assert m
65 return [x.strip() or None for x in m.groups()]
66 self.name, self.email, self.date = parse_desc(desc)
67 def set_name(self, val):
68 if val:
69 self.name = val
70 def set_email(self, val):
71 if val:
72 self.email = val
73 def set_date(self, val):
74 if val:
75 self.date = val
76 def __str__(self):
77 if self.name and self.email:
78 return '%s <%s>' % (self.name, self.email)
79 else:
80 raise GitException, 'not enough identity data'
81
82 class Commit:
83 """Handle the commit objects
84 """
85 def __init__(self, id_hash):
86 self.__id_hash = id_hash
87
88 lines = GRun('cat-file', 'commit', id_hash).output_lines()
89 for i in range(len(lines)):
90 line = lines[i]
91 if not line:
92 break # we've seen all the header fields
93 key, val = line.split(' ', 1)
94 if key == 'tree':
95 self.__tree = val
96 elif key == 'author':
97 self.__author = val
98 elif key == 'committer':
99 self.__committer = val
100 else:
101 pass # ignore other headers
102 self.__log = '\n'.join(lines[i+1:])
103
104 def get_id_hash(self):
105 return self.__id_hash
106
107 def get_tree(self):
108 return self.__tree
109
110 def get_parent(self):
111 parents = self.get_parents()
112 if parents:
113 return parents[0]
114 else:
115 return None
116
117 def get_parents(self):
118 return GRun('rev-list', '--parents', '--max-count=1', self.__id_hash
119 ).output_one_line().split()[1:]
120
121 def get_author(self):
122 return self.__author
123
124 def get_committer(self):
125 return self.__committer
126
127 def get_log(self):
128 return self.__log
129
130 def __str__(self):
131 return self.get_id_hash()
132
133 # dictionary of Commit objects, used to avoid multiple calls to git
134 __commits = dict()
135
136 #
137 # Functions
138 #
139
140 def get_commit(id_hash):
141 """Commit objects factory. Save/look-up them in the __commits
142 dictionary
143 """
144 global __commits
145
146 if id_hash in __commits:
147 return __commits[id_hash]
148 else:
149 commit = Commit(id_hash)
150 __commits[id_hash] = commit
151 return commit
152
153 def get_conflicts():
154 """Return the list of file conflicts
155 """
156 names = set()
157 for line in GRun('ls-files', '-z', '--unmerged'
158 ).raw_output().split('\0')[:-1]:
159 stat, path = line.split('\t', 1)
160 names.add(path)
161 return list(names)
162
163 def exclude_files():
164 files = [os.path.join(basedir.get(), 'info', 'exclude')]
165 user_exclude = config.get('core.excludesfile')
166 if user_exclude:
167 files.append(user_exclude)
168 return files
169
170 def ls_files(files, tree = 'HEAD', full_name = True):
171 """Return the files known to GIT or raise an error otherwise. It also
172 converts the file to the full path relative the the .git directory.
173 """
174 if not files:
175 return []
176
177 args = []
178 if tree:
179 args.append('--with-tree=%s' % tree)
180 if full_name:
181 args.append('--full-name')
182 args.append('--')
183 args.extend(files)
184 try:
185 # use a set to avoid file names duplication due to different stages
186 fileset = set(GRun('ls-files', '--error-unmatch', *args).output_lines())
187 except GitRunException:
188 # just hide the details of the 'git ls-files' command we use
189 raise GitException, \
190 'Some of the given paths are either missing or not known to GIT'
191 return list(fileset)
192
193 def parse_git_ls(output):
194 """Parse the output of git diff-index, diff-files, etc. Doesn't handle
195 rename/copy output, so don't feed it output generated with the -M
196 or -C flags."""
197 t = None
198 for line in output.split('\0'):
199 if not line:
200 # There's a zero byte at the end of the output, which
201 # gives us an empty string as the last "line".
202 continue
203 if t == None:
204 mode_a, mode_b, sha1_a, sha1_b, t = line.split(' ')
205 else:
206 yield (t, line)
207 t = None
208
209 def tree_status(files = None, tree_id = 'HEAD', unknown = False,
210 noexclude = True, verbose = False):
211 """Get the status of all changed files, or of a selected set of
212 files. Returns a list of pairs - (status, filename).
213
214 If 'not files', it will check all files, and optionally all
215 unknown files. If 'files' is a list, it will only check the files
216 in the list.
217 """
218 assert not files or not unknown
219
220 if verbose:
221 out.start('Checking for changes in the working directory')
222
223 refresh_index()
224
225 if files is None:
226 files = []
227 cache_files = []
228
229 # unknown files
230 if unknown:
231 cmd = ['ls-files', '-z', '--others', '--directory',
232 '--no-empty-directory']
233 if not noexclude:
234 cmd += ['--exclude=%s' % s for s in
235 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
236 cmd += ['--exclude-per-directory=.gitignore']
237 cmd += ['--exclude-from=%s' % fn
238 for fn in exclude_files()
239 if os.path.exists(fn)]
240
241 lines = GRun(*cmd).raw_output().split('\0')
242 cache_files += [('?', line) for line in lines if line]
243
244 # conflicted files
245 conflicts = get_conflicts()
246 cache_files += [('C', filename) for filename in conflicts
247 if not files or filename in files]
248 reported_files = set(conflicts)
249 files_left = [f for f in files if f not in reported_files]
250
251 # files in the index. Only execute this code if no files were
252 # specified when calling the function (i.e. report all files) or
253 # files were specified but already found in the previous step
254 if not files or files_left:
255 args = [tree_id]
256 if files_left:
257 args += ['--'] + files_left
258 for t, fn in parse_git_ls(GRun('diff-index', '-z', *args).raw_output()):
259 # the condition is needed in case files is emtpy and
260 # diff-index lists those already reported
261 if not fn in reported_files:
262 cache_files.append((t, fn))
263 reported_files.add(fn)
264 files_left = [f for f in files if f not in reported_files]
265
266 # files in the index but changed on (or removed from) disk. Only
267 # execute this code if no files were specified when calling the
268 # function (i.e. report all files) or files were specified but
269 # already found in the previous step
270 if not files or files_left:
271 args = []
272 if files_left:
273 args += ['--'] + files_left
274 for t, fn in parse_git_ls(GRun('diff-files', '-z', *args).raw_output()):
275 # the condition is needed in case files is empty and
276 # diff-files lists those already reported
277 if not fn in reported_files:
278 cache_files.append((t, fn))
279 reported_files.add(fn)
280
281 if verbose:
282 out.done()
283
284 return cache_files
285
286 def local_changes(verbose = True):
287 """Return true if there are local changes in the tree
288 """
289 return len(tree_status(verbose = verbose)) != 0
290
291 def get_heads():
292 heads = []
293 hr = re.compile(r'^[0-9a-f]{40} refs/heads/(.+)$')
294 for line in GRun('show-ref', '--heads').output_lines():
295 m = hr.match(line)
296 heads.append(m.group(1))
297 return heads
298
299 # HEAD value cached
300 __head = None
301
302 def get_head():
303 """Verifies the HEAD and returns the SHA1 id that represents it
304 """
305 global __head
306
307 if not __head:
308 __head = rev_parse('HEAD')
309 return __head
310
311 class DetachedHeadException(GitException):
312 def __init__(self):
313 GitException.__init__(self, 'Not on any branch')
314
315 def get_head_file():
316 """Return the name of the file pointed to by the HEAD symref.
317 Throw an exception if HEAD is detached."""
318 try:
319 return strip_prefix(
320 'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD'
321 ).output_one_line())
322 except GitRunException:
323 raise DetachedHeadException()
324
325 def set_head_file(ref):
326 """Resets HEAD to point to a new ref
327 """
328 # head cache flushing is needed since we might have a different value
329 # in the new head
330 __clear_head_cache()
331 try:
332 GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref).run()
333 except GitRunException:
334 raise GitException, 'Could not set head to "%s"' % ref
335
336 def set_ref(ref, val):
337 """Point ref at a new commit object."""
338 try:
339 GRun('update-ref', ref, val).run()
340 except GitRunException:
341 raise GitException, 'Could not update %s to "%s".' % (ref, val)
342
343 def set_branch(branch, val):
344 set_ref('refs/heads/%s' % branch, val)
345
346 def __set_head(val):
347 """Sets the HEAD value
348 """
349 global __head
350
351 if not __head or __head != val:
352 set_ref('HEAD', val)
353 __head = val
354
355 # only allow SHA1 hashes
356 assert(len(__head) == 40)
357
358 def __clear_head_cache():
359 """Sets the __head to None so that a re-read is forced
360 """
361 global __head
362
363 __head = None
364
365 def refresh_index():
366 """Refresh index with stat() information from the working directory.
367 """
368 GRun('update-index', '-q', '--unmerged', '--refresh').run()
369
370 def rev_parse(git_id):
371 """Parse the string and return a verified SHA1 id
372 """
373 try:
374 return GRun('rev-parse', '--verify', git_id
375 ).discard_stderr().output_one_line()
376 except GitRunException:
377 raise GitException, 'Unknown revision: %s' % git_id
378
379 def ref_exists(ref):
380 try:
381 rev_parse(ref)
382 return True
383 except GitException:
384 return False
385
386 def branch_exists(branch):
387 return ref_exists('refs/heads/%s' % branch)
388
389 def create_branch(new_branch, tree_id = None):
390 """Create a new branch in the git repository
391 """
392 if branch_exists(new_branch):
393 raise GitException, 'Branch "%s" already exists' % new_branch
394
395 current_head_file = get_head_file()
396 current_head = get_head()
397 set_head_file(new_branch)
398 __set_head(current_head)
399
400 # a checkout isn't needed if new branch points to the current head
401 if tree_id:
402 try:
403 switch(tree_id)
404 except GitException:
405 # Tree switching failed. Revert the head file
406 set_head_file(current_head_file)
407 delete_branch(new_branch)
408 raise
409
410 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
411 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
412
413 def switch_branch(new_branch):
414 """Switch to a git branch
415 """
416 global __head
417
418 if not branch_exists(new_branch):
419 raise GitException, 'Branch "%s" does not exist' % new_branch
420
421 tree_id = rev_parse('refs/heads/%s^{commit}' % new_branch)
422 if tree_id != get_head():
423 refresh_index()
424 try:
425 GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
426 except GitRunException:
427 raise GitException, 'read-tree failed (local changes maybe?)'
428 __head = tree_id
429 set_head_file(new_branch)
430
431 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
432 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
433
434 def delete_ref(ref):
435 if not ref_exists(ref):
436 raise GitException, '%s does not exist' % ref
437 sha1 = GRun('show-ref', '-s', ref).output_one_line()
438 try:
439 GRun('update-ref', '-d', ref, sha1).run()
440 except GitRunException:
441 raise GitException, 'Failed to delete ref %s' % ref
442
443 def delete_branch(name):
444 delete_ref('refs/heads/%s' % name)
445
446 def rename_ref(from_ref, to_ref):
447 if not ref_exists(from_ref):
448 raise GitException, '"%s" does not exist' % from_ref
449 if ref_exists(to_ref):
450 raise GitException, '"%s" already exists' % to_ref
451
452 sha1 = GRun('show-ref', '-s', from_ref).output_one_line()
453 try:
454 GRun('update-ref', to_ref, sha1, '0'*40).run()
455 except GitRunException:
456 raise GitException, 'Failed to create new ref %s' % to_ref
457 try:
458 GRun('update-ref', '-d', from_ref, sha1).run()
459 except GitRunException:
460 raise GitException, 'Failed to delete ref %s' % from_ref
461
462 def rename_branch(from_name, to_name):
463 """Rename a git branch."""
464 rename_ref('refs/heads/%s' % from_name, 'refs/heads/%s' % to_name)
465 try:
466 if get_head_file() == from_name:
467 set_head_file(to_name)
468 except DetachedHeadException:
469 pass # detached HEAD, so the renamee can't be the current branch
470 reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
471 if os.path.exists(reflog_dir) \
472 and os.path.exists(os.path.join(reflog_dir, from_name)):
473 rename(reflog_dir, from_name, to_name)
474
475 # Persons caching
476 __user = None
477 __author = None
478 __committer = None
479
480 def user():
481 """Return the user information.
482 """
483 global __user
484 if not __user:
485 name=config.get('user.name')
486 email=config.get('user.email')
487 __user = Person(name, email)
488 return __user;
489
490 def author():
491 """Return the author information.
492 """
493 global __author
494 if not __author:
495 try:
496 # the environment variables take priority over config
497 try:
498 date = os.environ['GIT_AUTHOR_DATE']
499 except KeyError:
500 date = ''
501 __author = Person(os.environ['GIT_AUTHOR_NAME'],
502 os.environ['GIT_AUTHOR_EMAIL'],
503 date)
504 except KeyError:
505 __author = user()
506 return __author
507
508 def committer():
509 """Return the author information.
510 """
511 global __committer
512 if not __committer:
513 try:
514 # the environment variables take priority over config
515 try:
516 date = os.environ['GIT_COMMITTER_DATE']
517 except KeyError:
518 date = ''
519 __committer = Person(os.environ['GIT_COMMITTER_NAME'],
520 os.environ['GIT_COMMITTER_EMAIL'],
521 date)
522 except KeyError:
523 __committer = user()
524 return __committer
525
526 def update_cache(files = None, force = False):
527 """Update the cache information for the given files
528 """
529 cache_files = tree_status(files, verbose = False)
530
531 # everything is up-to-date
532 if len(cache_files) == 0:
533 return False
534
535 # check for unresolved conflicts
536 if not force and [x for x in cache_files
537 if x[0] not in ['M', 'N', 'A', 'D']]:
538 raise GitException, 'Updating cache failed: unresolved conflicts'
539
540 # update the cache
541 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
542 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
543 m_files = [x[1] for x in cache_files if x[0] in ['M']]
544
545 GRun('update-index', '--add', '--').xargs(add_files)
546 GRun('update-index', '--force-remove', '--').xargs(rm_files)
547 GRun('update-index', '--').xargs(m_files)
548
549 return True
550
551 def commit(message, files = None, parents = None, allowempty = False,
552 cache_update = True, tree_id = None, set_head = False,
553 author_name = None, author_email = None, author_date = None,
554 committer_name = None, committer_email = None):
555 """Commit the current tree to repository
556 """
557 if not parents:
558 parents = []
559
560 # Get the tree status
561 if cache_update and parents != []:
562 changes = update_cache(files)
563 if not changes and not allowempty:
564 raise GitException, 'No changes to commit'
565
566 # get the commit message
567 if not message:
568 message = '\n'
569 elif message[-1:] != '\n':
570 message += '\n'
571
572 # write the index to repository
573 if tree_id == None:
574 tree_id = GRun('write-tree').output_one_line()
575 set_head = True
576
577 # the commit
578 env = {}
579 if author_name:
580 env['GIT_AUTHOR_NAME'] = author_name
581 if author_email:
582 env['GIT_AUTHOR_EMAIL'] = author_email
583 if author_date:
584 env['GIT_AUTHOR_DATE'] = author_date
585 if committer_name:
586 env['GIT_COMMITTER_NAME'] = committer_name
587 if committer_email:
588 env['GIT_COMMITTER_EMAIL'] = committer_email
589 commit_id = GRun('commit-tree', tree_id,
590 *sum([['-p', p] for p in parents], [])
591 ).env(env).raw_input(message).output_one_line()
592 if set_head:
593 __set_head(commit_id)
594
595 return commit_id
596
597 def apply_diff(rev1, rev2, check_index = True, files = None):
598 """Apply the diff between rev1 and rev2 onto the current
599 index. This function doesn't need to raise an exception since it
600 is only used for fast-pushing a patch. If this operation fails,
601 the pushing would fall back to the three-way merge.
602 """
603 if check_index:
604 index_opt = ['--index']
605 else:
606 index_opt = []
607
608 if not files:
609 files = []
610
611 diff_str = diff(files, rev1, rev2)
612 if diff_str:
613 try:
614 GRun('apply', *index_opt).raw_input(
615 diff_str).discard_stderr().no_output()
616 except GitRunException:
617 return False
618
619 return True
620
621 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
622
623 def merge_recursive(base, head1, head2):
624 """Perform a 3-way merge between base, head1 and head2 into the
625 local tree
626 """
627 refresh_index()
628 p = GRun('merge-recursive', base, '--', head1, head2).env(
629 { 'GITHEAD_%s' % base: 'ancestor',
630 'GITHEAD_%s' % head1: 'current',
631 'GITHEAD_%s' % head2: 'patched'}).returns([0, 1])
632 output = p.output_lines()
633 if p.exitcode:
634 # There were conflicts
635 if config.get('stgit.autoimerge') == 'yes':
636 mergetool()
637 else:
638 conflicts = [l for l in output if l.startswith('CONFLICT')]
639 out.info(*conflicts)
640 raise GitException, "%d conflict(s)" % len(conflicts)
641
642 def mergetool(files = ()):
643 """Invoke 'git mergetool' to resolve any outstanding conflicts. If 'not
644 files', all the files in an unmerged state will be processed."""
645 GRun('mergetool', *list(files)).returns([0, 1]).run()
646 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
647 # merge_recursive())
648 conflicts = ['CONFLICT ' + f for f in get_conflicts()]
649 if conflicts:
650 out.info(*conflicts)
651 raise GitException, "%d conflict(s)" % len(conflicts)
652
653 def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = [],
654 binary = True):
655 """Show the diff between rev1 and rev2
656 """
657 if not files:
658 files = []
659 if binary and '--binary' not in diff_flags:
660 diff_flags = diff_flags + ['--binary']
661
662 if rev1 and rev2:
663 return GRun('diff-tree', '-p',
664 *(diff_flags + [rev1, rev2, '--'] + files)).raw_output()
665 elif rev1 or rev2:
666 refresh_index()
667 if rev2:
668 return GRun('diff-index', '-p', '-R',
669 *(diff_flags + [rev2, '--'] + files)).raw_output()
670 else:
671 return GRun('diff-index', '-p',
672 *(diff_flags + [rev1, '--'] + files)).raw_output()
673 else:
674 return ''
675
676 def files(rev1, rev2, diff_flags = []):
677 """Return the files modified between rev1 and rev2
678 """
679
680 result = []
681 for line in GRun('diff-tree', *(diff_flags + ['-r', rev1, rev2])
682 ).output_lines():
683 result.append('%s %s' % tuple(line.split(' ', 4)[-1].split('\t', 1)))
684
685 return '\n'.join(result)
686
687 def barefiles(rev1, rev2):
688 """Return the files modified between rev1 and rev2, without status info
689 """
690
691 result = []
692 for line in GRun('diff-tree', '-r', rev1, rev2).output_lines():
693 result.append(line.split(' ', 4)[-1].split('\t', 1)[-1])
694
695 return '\n'.join(result)
696
697 def pretty_commit(commit_id = 'HEAD', flags = []):
698 """Return a given commit (log + diff)
699 """
700 return GRun('show', *(flags + [commit_id])).raw_output()
701
702 def checkout(files = None, tree_id = None, force = False):
703 """Check out the given or all files
704 """
705 if tree_id:
706 try:
707 GRun('read-tree', '--reset', tree_id).run()
708 except GitRunException:
709 raise GitException, 'Failed "git read-tree" --reset %s' % tree_id
710
711 cmd = ['checkout-index', '-q', '-u']
712 if force:
713 cmd.append('-f')
714 if files:
715 GRun(*(cmd + ['--'])).xargs(files)
716 else:
717 GRun(*(cmd + ['-a'])).run()
718
719 def switch(tree_id, keep = False):
720 """Switch the tree to the given id
721 """
722 if keep:
723 # only update the index while keeping the local changes
724 GRun('read-tree', tree_id).run()
725 else:
726 refresh_index()
727 try:
728 GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
729 except GitRunException:
730 raise GitException, 'read-tree failed (local changes maybe?)'
731
732 __set_head(tree_id)
733
734 def reset(files = None, tree_id = None, check_out = True):
735 """Revert the tree changes relative to the given tree_id. It removes
736 any local changes
737 """
738 if not tree_id:
739 tree_id = get_head()
740
741 if check_out:
742 cache_files = tree_status(files, tree_id)
743 # files which were added but need to be removed
744 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
745
746 checkout(files, tree_id, True)
747 # checkout doesn't remove files
748 map(os.remove, rm_files)
749
750 # if the reset refers to the whole tree, switch the HEAD as well
751 if not files:
752 __set_head(tree_id)
753
754 def resolved(filenames, reset = None):
755 if reset:
756 stage = {'ancestor': 1, 'current': 2, 'patched': 3}[reset]
757 GRun('checkout-index', '--no-create', '--stage=%d' % stage,
758 '--stdin', '-z').input_nulterm(filenames).no_output()
759 GRun('update-index', '--add', '--').xargs(filenames)
760 for filename in filenames:
761 # update the access and modificatied times
762 os.utime(filename, None)
763
764 def fetch(repository = 'origin', refspec = None):
765 """Fetches changes from the remote repository, using 'git fetch'
766 by default.
767 """
768 # we update the HEAD
769 __clear_head_cache()
770
771 args = [repository]
772 if refspec:
773 args.append(refspec)
774
775 command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
776 config.get('stgit.fetchcmd')
777 Run(*(command.split() + args)).run()
778
779 def pull(repository = 'origin', refspec = None):
780 """Fetches changes from the remote repository, using 'git pull'
781 by default.
782 """
783 # we update the HEAD
784 __clear_head_cache()
785
786 args = [repository]
787 if refspec:
788 args.append(refspec)
789
790 command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
791 config.get('stgit.pullcmd')
792 Run(*(command.split() + args)).run()
793
794 def rebase(tree_id = None):
795 """Rebase the current tree to the give tree_id. The tree_id
796 argument may be something other than a GIT id if an external
797 command is invoked.
798 """
799 command = config.get('branch.%s.stgit.rebasecmd' % get_head_file()) \
800 or config.get('stgit.rebasecmd')
801 if tree_id:
802 args = [tree_id]
803 elif command:
804 args = []
805 else:
806 raise GitException, 'Default rebasing requires a commit id'
807 if command:
808 # clear the HEAD cache as the custom rebase command will update it
809 __clear_head_cache()
810 Run(*(command.split() + args)).run()
811 else:
812 # default rebasing
813 reset(tree_id = tree_id)
814
815 def repack():
816 """Repack all objects into a single pack
817 """
818 GRun('repack', '-a', '-d', '-f').run()
819
820 def apply_patch(filename = None, diff = None, base = None,
821 fail_dump = True):
822 """Apply a patch onto the current or given index. There must not
823 be any local changes in the tree, otherwise the command fails
824 """
825 if diff is None:
826 if filename:
827 f = file(filename)
828 else:
829 f = sys.stdin
830 diff = f.read()
831 if filename:
832 f.close()
833
834 if base:
835 orig_head = get_head()
836 switch(base)
837 else:
838 refresh_index()
839
840 try:
841 GRun('apply', '--index').raw_input(diff).no_output()
842 except GitRunException:
843 if base:
844 switch(orig_head)
845 if fail_dump:
846 # write the failed diff to a file
847 f = file('.stgit-failed.patch', 'w+')
848 f.write(diff)
849 f.close()
850 out.warn('Diff written to the .stgit-failed.patch file')
851
852 raise
853
854 if base:
855 top = commit(message = 'temporary commit used for applying a patch',
856 parents = [base])
857 switch(orig_head)
858 merge_recursive(base, orig_head, top)
859
860 def clone(repository, local_dir):
861 """Clone a remote repository. At the moment, just use the
862 'git clone' script
863 """
864 GRun('clone', repository, local_dir).run()
865
866 def modifying_revs(files, base_rev, head_rev):
867 """Return the revisions from the list modifying the given files."""
868 return GRun('rev-list', '%s..%s' % (base_rev, head_rev), '--', *files
869 ).output_lines()
870
871 def refspec_localpart(refspec):
872 m = re.match('^[^:]*:([^:]*)$', refspec)
873 if m:
874 return m.group(1)
875 else:
876 raise GitException, 'Cannot parse refspec "%s"' % line
877
878 def refspec_remotepart(refspec):
879 m = re.match('^([^:]*):[^:]*$', refspec)
880 if m:
881 return m.group(1)
882 else:
883 raise GitException, 'Cannot parse refspec "%s"' % line
884
885 def __remotes_from_config():
886 return config.sections_matching(r'remote\.(.*)\.url')
887
888 def __remotes_from_dir(dir):
889 d = os.path.join(basedir.get(), dir)
890 if os.path.exists(d):
891 return os.listdir(d)
892 else:
893 return []
894
895 def remotes_list():
896 """Return the list of remotes in the repository
897 """
898 return (set(__remotes_from_config())
899 | set(__remotes_from_dir('remotes'))
900 | set(__remotes_from_dir('branches')))
901
902 def remotes_local_branches(remote):
903 """Returns the list of local branches fetched from given remote
904 """
905
906 branches = []
907 if remote in __remotes_from_config():
908 for line in config.getall('remote.%s.fetch' % remote):
909 branches.append(refspec_localpart(line))
910 elif remote in __remotes_from_dir('remotes'):
911 stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
912 for line in stream:
913 # Only consider Pull lines
914 m = re.match('^Pull: (.*)\n$', line)
915 if m:
916 branches.append(refspec_localpart(m.group(1)))
917 stream.close()
918 elif remote in __remotes_from_dir('branches'):
919 # old-style branches only declare one branch
920 branches.append('refs/heads/'+remote);
921 else:
922 raise GitException, 'Unknown remote "%s"' % remote
923
924 return branches
925
926 def identify_remote(branchname):
927 """Return the name for the remote to pull the given branchname
928 from, or None if we believe it is a local branch.
929 """
930
931 for remote in remotes_list():
932 if branchname in remotes_local_branches(remote):
933 return remote
934
935 # if we get here we've found nothing, the branch is a local one
936 return None
937
938 def fetch_head():
939 """Return the git id for the tip of the parent branch as left by
940 'git fetch'.
941 """
942
943 fetch_head=None
944 stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
945 for line in stream:
946 # Only consider lines not tagged not-for-merge
947 m = re.match('^([^\t]*)\t\t', line)
948 if m:
949 if fetch_head:
950 raise GitException, 'StGit does not support multiple FETCH_HEAD'
951 else:
952 fetch_head=m.group(1)
953 stream.close()
954
955 if not fetch_head:
956 out.warn('No for-merge remote head found in FETCH_HEAD')
957
958 # here we are sure to have a single fetch_head
959 return fetch_head
960
961 def all_refs():
962 """Return a list of all refs in the current repository.
963 """
964
965 return [line.split()[1] for line in GRun('show-ref').output_lines()]