Add file renaming support
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, popen2, re, gitmergeonefile
22
23 from stgit import basedir
24 from stgit.utils import *
25 from stgit.config import config
26
27 # git exception class
28 class GitException(Exception):
29 pass
30
31
32
33 #
34 # Classes
35 #
36 class Commit:
37 """Handle the commit objects
38 """
39 def __init__(self, id_hash):
40 self.__id_hash = id_hash
41
42 lines = _output_lines('git-cat-file commit %s' % id_hash)
43 for i in range(len(lines)):
44 line = lines[i]
45 if line == '\n':
46 break
47 field = line.strip().split(' ', 1)
48 if field[0] == 'tree':
49 self.__tree = field[1]
50 if field[0] == 'author':
51 self.__author = field[1]
52 if field[0] == 'committer':
53 self.__committer = field[1]
54 self.__log = ''.join(lines[i+1:])
55
56 def get_id_hash(self):
57 return self.__id_hash
58
59 def get_tree(self):
60 return self.__tree
61
62 def get_parent(self):
63 parents = self.get_parents()
64 if parents:
65 return parents[0]
66 else:
67 return None
68
69 def get_parents(self):
70 return _output_lines('git-rev-list --parents --max-count=1 %s'
71 % self.__id_hash)[0].split()[1:]
72
73 def get_author(self):
74 return self.__author
75
76 def get_committer(self):
77 return self.__committer
78
79 def get_log(self):
80 return self.__log
81
82 def __str__(self):
83 return self.get_id_hash()
84
85 # dictionary of Commit objects, used to avoid multiple calls to git
86 __commits = dict()
87
88 #
89 # Functions
90 #
91
92 def get_commit(id_hash):
93 """Commit objects factory. Save/look-up them in the __commits
94 dictionary
95 """
96 global __commits
97
98 if id_hash in __commits:
99 return __commits[id_hash]
100 else:
101 commit = Commit(id_hash)
102 __commits[id_hash] = commit
103 return commit
104
105 def get_conflicts():
106 """Return the list of file conflicts
107 """
108 conflicts_file = os.path.join(basedir.get(), 'conflicts')
109 if os.path.isfile(conflicts_file):
110 f = file(conflicts_file)
111 names = [line.strip() for line in f.readlines()]
112 f.close()
113 return names
114 else:
115 return None
116
117 def _input(cmd, file_desc):
118 p = popen2.Popen3(cmd, True)
119 while True:
120 line = file_desc.readline()
121 if not line:
122 break
123 p.tochild.write(line)
124 p.tochild.close()
125 if p.wait():
126 raise GitException, '%s failed (%s)' % (str(cmd),
127 p.childerr.read().strip())
128
129 def _input_str(cmd, string):
130 p = popen2.Popen3(cmd, True)
131 p.tochild.write(string)
132 p.tochild.close()
133 if p.wait():
134 raise GitException, '%s failed (%s)' % (str(cmd),
135 p.childerr.read().strip())
136
137 def _output(cmd):
138 p=popen2.Popen3(cmd, True)
139 output = p.fromchild.read()
140 if p.wait():
141 raise GitException, '%s failed (%s)' % (str(cmd),
142 p.childerr.read().strip())
143 return output
144
145 def _output_one_line(cmd, file_desc = None):
146 p=popen2.Popen3(cmd, True)
147 if file_desc != None:
148 for line in file_desc:
149 p.tochild.write(line)
150 p.tochild.close()
151 output = p.fromchild.readline().strip()
152 if p.wait():
153 raise GitException, '%s failed (%s)' % (str(cmd),
154 p.childerr.read().strip())
155 return output
156
157 def _output_lines(cmd):
158 p=popen2.Popen3(cmd, True)
159 lines = p.fromchild.readlines()
160 if p.wait():
161 raise GitException, '%s failed (%s)' % (str(cmd),
162 p.childerr.read().strip())
163 return lines
164
165 def __run(cmd, args=None):
166 """__run: runs cmd using spawnvp.
167
168 Runs cmd using spawnvp. The shell is avoided so it won't mess up
169 our arguments. If args is very large, the command is run multiple
170 times; args is split xargs style: cmd is passed on each
171 invocation. Unlike xargs, returns immediately if any non-zero
172 return code is received.
173 """
174
175 args_l=cmd.split()
176 if args is None:
177 args = []
178 for i in range(0, len(args)+1, 100):
179 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
180 if r:
181 return r
182 return 0
183
184 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
185 noexclude = True, verbose = False):
186 """Returns a list of pairs - [status, filename]
187 """
188 if verbose:
189 print 'Checking for changes in the working directory...',
190 sys.stdout.flush()
191
192 refresh_index()
193
194 if not files:
195 files = []
196 cache_files = []
197
198 # unknown files
199 if unknown:
200 exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
201 base_exclude = ['--exclude=%s' % s for s in
202 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
203 base_exclude.append('--exclude-per-directory=.gitignore')
204
205 if os.path.exists(exclude_file):
206 extra_exclude = ['--exclude-from=%s' % exclude_file]
207 else:
208 extra_exclude = []
209 if noexclude:
210 extra_exclude = base_exclude = []
211
212 lines = _output_lines(['git-ls-files', '--others', '--directory']
213 + base_exclude + extra_exclude)
214 cache_files += [('?', line.strip()) for line in lines]
215
216 # conflicted files
217 conflicts = get_conflicts()
218 if not conflicts:
219 conflicts = []
220 cache_files += [('C', filename) for filename in conflicts]
221
222 # the rest
223 for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
224 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
225 if fs[1] not in conflicts:
226 cache_files.append(fs)
227
228 if verbose:
229 print 'done'
230
231 return cache_files
232
233 def local_changes():
234 """Return true if there are local changes in the tree
235 """
236 return len(__tree_status(verbose = True)) != 0
237
238 # HEAD value cached
239 __head = None
240
241 def get_head():
242 """Verifies the HEAD and returns the SHA1 id that represents it
243 """
244 global __head
245
246 if not __head:
247 __head = rev_parse('HEAD')
248 return __head
249
250 def get_head_file():
251 """Returns the name of the file pointed to by the HEAD link
252 """
253 return strip_prefix('refs/heads/',
254 _output_one_line('git-symbolic-ref HEAD'))
255
256 def set_head_file(ref):
257 """Resets HEAD to point to a new ref
258 """
259 # head cache flushing is needed since we might have a different value
260 # in the new head
261 __clear_head_cache()
262 if __run('git-symbolic-ref HEAD',
263 [os.path.join('refs', 'heads', ref)]) != 0:
264 raise GitException, 'Could not set head to "%s"' % ref
265
266 def __set_head(val):
267 """Sets the HEAD value
268 """
269 global __head
270
271 if not __head or __head != val:
272 if __run('git-update-ref HEAD', [val]) != 0:
273 raise GitException, 'Could not update HEAD to "%s".' % val
274 __head = val
275
276 # only allow SHA1 hashes
277 assert(len(__head) == 40)
278
279 def __clear_head_cache():
280 """Sets the __head to None so that a re-read is forced
281 """
282 global __head
283
284 __head = None
285
286 def refresh_index():
287 """Refresh index with stat() information from the working directory.
288 """
289 __run('git-update-index -q --unmerged --refresh')
290
291 def rev_parse(git_id):
292 """Parse the string and return a verified SHA1 id
293 """
294 try:
295 return _output_one_line(['git-rev-parse', '--verify', git_id])
296 except GitException:
297 raise GitException, 'Unknown revision: %s' % git_id
298
299 def branch_exists(branch):
300 """Existence check for the named branch
301 """
302 branch = os.path.join('refs', 'heads', branch)
303 for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
304 if line.strip() == branch:
305 return True
306 if re.compile('[ |/]'+branch+' ').search(line):
307 raise GitException, 'Bogus branch: %s' % line
308 return False
309
310 def create_branch(new_branch, tree_id = None):
311 """Create a new branch in the git repository
312 """
313 if branch_exists(new_branch):
314 raise GitException, 'Branch "%s" already exists' % new_branch
315
316 current_head = get_head()
317 set_head_file(new_branch)
318 __set_head(current_head)
319
320 # a checkout isn't needed if new branch points to the current head
321 if tree_id:
322 switch(tree_id)
323
324 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
325 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
326
327 def switch_branch(new_branch):
328 """Switch to a git branch
329 """
330 global __head
331
332 if not branch_exists(new_branch):
333 raise GitException, 'Branch "%s" does not exist' % new_branch
334
335 tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
336 + '^{commit}')
337 if tree_id != get_head():
338 refresh_index()
339 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
340 raise GitException, 'git-read-tree failed (local changes maybe?)'
341 __head = tree_id
342 set_head_file(new_branch)
343
344 if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
345 os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
346
347 def delete_branch(name):
348 """Delete a git branch
349 """
350 if not branch_exists(name):
351 raise GitException, 'Branch "%s" does not exist' % name
352 remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
353 name)
354
355 def rename_branch(from_name, to_name):
356 """Rename a git branch
357 """
358 if not branch_exists(from_name):
359 raise GitException, 'Branch "%s" does not exist' % from_name
360 if branch_exists(to_name):
361 raise GitException, 'Branch "%s" already exists' % to_name
362
363 if get_head_file() == from_name:
364 set_head_file(to_name)
365 rename(os.path.join(basedir.get(), 'refs', 'heads'),
366 from_name, to_name)
367
368 def add(names):
369 """Add the files or recursively add the directory contents
370 """
371 # generate the file list
372 files = []
373 for i in names:
374 if not os.path.exists(i):
375 raise GitException, 'Unknown file or directory: %s' % i
376
377 if os.path.isdir(i):
378 # recursive search. We only add files
379 for root, dirs, local_files in os.walk(i):
380 for name in [os.path.join(root, f) for f in local_files]:
381 if os.path.isfile(name):
382 files.append(os.path.normpath(name))
383 elif os.path.isfile(i):
384 files.append(os.path.normpath(i))
385 else:
386 raise GitException, '%s is not a file or directory' % i
387
388 if files:
389 if __run('git-update-index --add --', files):
390 raise GitException, 'Unable to add file'
391
392 def rm(files, force = False):
393 """Remove a file from the repository
394 """
395 if not force:
396 for f in files:
397 if os.path.exists(f):
398 raise GitException, '%s exists. Remove it first' %f
399 if files:
400 __run('git-update-index --remove --', files)
401 else:
402 if files:
403 __run('git-update-index --force-remove --', files)
404
405 def update_cache(files = None, force = False):
406 """Update the cache information for the given files
407 """
408 if not files:
409 files = []
410
411 cache_files = __tree_status(files, verbose = False)
412
413 # everything is up-to-date
414 if len(cache_files) == 0:
415 return False
416
417 # check for unresolved conflicts
418 if not force and [x for x in cache_files
419 if x[0] not in ['M', 'N', 'A', 'D']]:
420 raise GitException, 'Updating cache failed: unresolved conflicts'
421
422 # update the cache
423 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
424 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
425 m_files = [x[1] for x in cache_files if x[0] in ['M']]
426
427 if add_files and __run('git-update-index --add --', add_files) != 0:
428 raise GitException, 'Failed git-update-index --add'
429 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
430 raise GitException, 'Failed git-update-index --rm'
431 if m_files and __run('git-update-index --', m_files) != 0:
432 raise GitException, 'Failed git-update-index'
433
434 return True
435
436 def commit(message, files = None, parents = None, allowempty = False,
437 cache_update = True, tree_id = None,
438 author_name = None, author_email = None, author_date = None,
439 committer_name = None, committer_email = None):
440 """Commit the current tree to repository
441 """
442 if not files:
443 files = []
444 if not parents:
445 parents = []
446
447 # Get the tree status
448 if cache_update and parents != []:
449 changes = update_cache(files)
450 if not changes and not allowempty:
451 raise GitException, 'No changes to commit'
452
453 # get the commit message
454 if not message:
455 message = '\n'
456 elif message[-1:] != '\n':
457 message += '\n'
458
459 must_switch = True
460 # write the index to repository
461 if tree_id == None:
462 tree_id = _output_one_line('git-write-tree')
463 else:
464 must_switch = False
465
466 # the commit
467 cmd = ''
468 if author_name:
469 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
470 if author_email:
471 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
472 if author_date:
473 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
474 if committer_name:
475 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
476 if committer_email:
477 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
478 cmd += 'git-commit-tree %s' % tree_id
479
480 # get the parents
481 for p in parents:
482 cmd += ' -p %s' % p
483
484 commit_id = _output_one_line(cmd, message)
485 if must_switch:
486 __set_head(commit_id)
487
488 return commit_id
489
490 def apply_diff(rev1, rev2, check_index = True, files = None):
491 """Apply the diff between rev1 and rev2 onto the current
492 index. This function doesn't need to raise an exception since it
493 is only used for fast-pushing a patch. If this operation fails,
494 the pushing would fall back to the three-way merge.
495 """
496 if check_index:
497 index_opt = '--index'
498 else:
499 index_opt = ''
500
501 if not files:
502 files = []
503
504 diff_str = diff(files, rev1, rev2)
505 if diff_str:
506 try:
507 _input_str('git-apply %s' % index_opt, diff_str)
508 except GitException:
509 return False
510
511 return True
512
513 def merge(base, head1, head2):
514 """Perform a 3-way merge between base, head1 and head2 into the
515 local tree
516 """
517 refresh_index()
518
519 try:
520 # use _output() to mask the verbose prints of the tool
521 _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
522 except GitException:
523 pass
524
525 # check the index for unmerged entries
526 files = {}
527 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
528
529 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
530 if not line:
531 continue
532
533 mode, hash, stage, path = stages_re.findall(line)[0]
534
535 if not path in files:
536 files[path] = {}
537 files[path]['1'] = ('', '')
538 files[path]['2'] = ('', '')
539 files[path]['3'] = ('', '')
540
541 files[path][stage] = (mode, hash)
542
543 # merge the unmerged files
544 errors = False
545 for path in files:
546 stages = files[path]
547 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
548 stages['3'][1], path, stages['1'][0],
549 stages['2'][0], stages['3'][0]) != 0:
550 errors = True
551
552 if errors:
553 raise GitException, 'GIT index merging failed (possible conflicts)'
554
555 def status(files = None, modified = False, new = False, deleted = False,
556 conflict = False, unknown = False, noexclude = False):
557 """Show the tree status
558 """
559 if not files:
560 files = []
561
562 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
563 all = not (modified or new or deleted or conflict or unknown)
564
565 if not all:
566 filestat = []
567 if modified:
568 filestat.append('M')
569 if new:
570 filestat.append('A')
571 filestat.append('N')
572 if deleted:
573 filestat.append('D')
574 if conflict:
575 filestat.append('C')
576 if unknown:
577 filestat.append('?')
578 cache_files = [x for x in cache_files if x[0] in filestat]
579
580 for fs in cache_files:
581 if all:
582 print '%s %s' % (fs[0], fs[1])
583 else:
584 print '%s' % fs[1]
585
586 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
587 """Show the diff between rev1 and rev2
588 """
589 if not files:
590 files = []
591
592 if rev1 and rev2:
593 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
594 elif rev1 or rev2:
595 refresh_index()
596 if rev2:
597 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
598 else:
599 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
600 else:
601 diff_str = ''
602
603 if out_fd:
604 out_fd.write(diff_str)
605 else:
606 return diff_str
607
608 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
609 """Return the diffstat between rev1 and rev2
610 """
611 if not files:
612 files = []
613
614 p=popen2.Popen3('git-apply --stat')
615 diff(files, rev1, rev2, p.tochild)
616 p.tochild.close()
617 diff_str = p.fromchild.read().rstrip()
618 if p.wait():
619 raise GitException, 'git.diffstat failed'
620 return diff_str
621
622 def files(rev1, rev2):
623 """Return the files modified between rev1 and rev2
624 """
625
626 result = ''
627 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
628 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
629
630 return result.rstrip()
631
632 def barefiles(rev1, rev2):
633 """Return the files modified between rev1 and rev2, without status info
634 """
635
636 result = ''
637 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
638 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
639
640 return result.rstrip()
641
642 def pretty_commit(commit_id = 'HEAD'):
643 """Return a given commit (log + diff)
644 """
645 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
646 commit_id])
647
648 def checkout(files = None, tree_id = None, force = False):
649 """Check out the given or all files
650 """
651 if not files:
652 files = []
653
654 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
655 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
656
657 checkout_cmd = 'git-checkout-index -q -u'
658 if force:
659 checkout_cmd += ' -f'
660 if len(files) == 0:
661 checkout_cmd += ' -a'
662 else:
663 checkout_cmd += ' --'
664
665 if __run(checkout_cmd, files) != 0:
666 raise GitException, 'Failed git-checkout-index'
667
668 def switch(tree_id, keep = False):
669 """Switch the tree to the given id
670 """
671 if not keep:
672 refresh_index()
673 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
674 raise GitException, 'git-read-tree failed (local changes maybe?)'
675
676 __set_head(tree_id)
677
678 def reset(files = None, tree_id = None, check_out = True):
679 """Revert the tree changes relative to the given tree_id. It removes
680 any local changes
681 """
682 if not tree_id:
683 tree_id = get_head()
684
685 if check_out:
686 cache_files = __tree_status(files, tree_id)
687 # files which were added but need to be removed
688 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
689
690 checkout(files, tree_id, True)
691 # checkout doesn't remove files
692 map(os.remove, rm_files)
693
694 # if the reset refers to the whole tree, switch the HEAD as well
695 if not files:
696 __set_head(tree_id)
697
698 def pull(repository = 'origin', refspec = None):
699 """Pull changes from the remote repository. At the moment, just
700 use the 'git-pull' command
701 """
702 # 'git-pull' updates the HEAD
703 __clear_head_cache()
704
705 args = [repository]
706 if refspec:
707 args.append(refspec)
708
709 if __run(config.get('stgit', 'pullcmd'), args) != 0:
710 raise GitException, 'Failed "git-pull %s"' % repository
711
712 def repack():
713 """Repack all objects into a single pack
714 """
715 __run('git-repack -a -d -f')
716
717 def apply_patch(filename = None, diff = None, base = None,
718 fail_dump = True):
719 """Apply a patch onto the current or given index. There must not
720 be any local changes in the tree, otherwise the command fails
721 """
722 if base:
723 orig_head = get_head()
724 switch(base)
725 else:
726 refresh_index()
727
728 if diff is None:
729 if filename:
730 f = file(filename)
731 else:
732 f = sys.stdin
733 diff = f.read()
734 if filename:
735 f.close()
736
737 try:
738 _input_str('git-apply --index', diff)
739 except GitException:
740 if base:
741 switch(orig_head)
742 if fail_dump:
743 # write the failed diff to a file
744 f = file('.stgit-failed.patch', 'w+')
745 f.write(diff)
746 f.close()
747
748 raise
749
750 if base:
751 top = commit(message = 'temporary commit used for applying a patch',
752 parents = [base])
753 switch(orig_head)
754 merge(base, orig_head, top)
755
756 def clone(repository, local_dir):
757 """Clone a remote repository. At the moment, just use the
758 'git-clone' script
759 """
760 if __run('git-clone', [repository, local_dir]) != 0:
761 raise GitException, 'Failed "git-clone %s %s"' \
762 % (repository, local_dir)
763
764 def modifying_revs(files, base_rev):
765 """Return the revisions from the list modifying the given files
766 """
767 cmd = ['git-rev-list', '%s..' % base_rev, '--']
768 revs = [line.strip() for line in _output_lines(cmd + files)]
769
770 return revs