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