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