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