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