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