Allow empty commit messages
[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 not message:
440 message = '\n'
441 elif message[-1:] != '\n':
442 message += '\n'
443
444 must_switch = True
445 # write the index to repository
446 if tree_id == None:
447 tree_id = _output_one_line('git-write-tree')
448 else:
449 must_switch = False
450
451 # the commit
452 cmd = ''
453 if author_name:
454 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
455 if author_email:
456 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
457 if author_date:
458 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
459 if committer_name:
460 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
461 if committer_email:
462 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
463 cmd += 'git-commit-tree %s' % tree_id
464
465 # get the parents
466 for p in parents:
467 cmd += ' -p %s' % p
468
469 commit_id = _output_one_line(cmd, message)
470 if must_switch:
471 __set_head(commit_id)
472
473 return commit_id
474
475 def apply_diff(rev1, rev2, check_index = True, files = None):
476 """Apply the diff between rev1 and rev2 onto the current
477 index. This function doesn't need to raise an exception since it
478 is only used for fast-pushing a patch. If this operation fails,
479 the pushing would fall back to the three-way merge.
480 """
481 if check_index:
482 index_opt = '--index'
483 else:
484 index_opt = ''
485
486 if not files:
487 files = []
488
489 diff_str = diff(files, rev1, rev2)
490 if diff_str:
491 try:
492 _input_str('git-apply %s' % index_opt, diff_str)
493 except GitException:
494 return False
495
496 return True
497
498 def merge(base, head1, head2):
499 """Perform a 3-way merge between base, head1 and head2 into the
500 local tree
501 """
502 refresh_index()
503 if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
504 raise GitException, 'git-read-tree failed (local changes maybe?)'
505
506 # check the index for unmerged entries
507 files = {}
508 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
509
510 for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
511 if not line:
512 continue
513
514 mode, hash, stage, path = stages_re.findall(line)[0]
515
516 if not path in files:
517 files[path] = {}
518 files[path]['1'] = ('', '')
519 files[path]['2'] = ('', '')
520 files[path]['3'] = ('', '')
521
522 files[path][stage] = (mode, hash)
523
524 # merge the unmerged files
525 errors = False
526 for path in files:
527 stages = files[path]
528 if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
529 stages['3'][1], path, stages['1'][0],
530 stages['2'][0], stages['3'][0]) != 0:
531 errors = True
532
533 if errors:
534 raise GitException, 'GIT index merging failed (possible conflicts)'
535
536 def status(files = None, modified = False, new = False, deleted = False,
537 conflict = False, unknown = False, noexclude = False):
538 """Show the tree status
539 """
540 if not files:
541 files = []
542
543 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
544 all = not (modified or new or deleted or conflict or unknown)
545
546 if not all:
547 filestat = []
548 if modified:
549 filestat.append('M')
550 if new:
551 filestat.append('A')
552 filestat.append('N')
553 if deleted:
554 filestat.append('D')
555 if conflict:
556 filestat.append('C')
557 if unknown:
558 filestat.append('?')
559 cache_files = [x for x in cache_files if x[0] in filestat]
560
561 for fs in cache_files:
562 if all:
563 print '%s %s' % (fs[0], fs[1])
564 else:
565 print '%s' % fs[1]
566
567 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
568 """Show the diff between rev1 and rev2
569 """
570 if not files:
571 files = []
572
573 if rev1 and rev2:
574 diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
575 elif rev1 or rev2:
576 refresh_index()
577 if rev2:
578 diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
579 else:
580 diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
581 else:
582 diff_str = ''
583
584 if out_fd:
585 out_fd.write(diff_str)
586 else:
587 return diff_str
588
589 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
590 """Return the diffstat between rev1 and rev2
591 """
592 if not files:
593 files = []
594
595 p=popen2.Popen3('git-apply --stat')
596 diff(files, rev1, rev2, p.tochild)
597 p.tochild.close()
598 diff_str = p.fromchild.read().rstrip()
599 if p.wait():
600 raise GitException, 'git.diffstat failed'
601 return diff_str
602
603 def files(rev1, rev2):
604 """Return the files modified between rev1 and rev2
605 """
606
607 result = ''
608 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
609 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
610
611 return result.rstrip()
612
613 def barefiles(rev1, rev2):
614 """Return the files modified between rev1 and rev2, without status info
615 """
616
617 result = ''
618 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
619 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
620
621 return result.rstrip()
622
623 def pretty_commit(commit_id = 'HEAD'):
624 """Return a given commit (log + diff)
625 """
626 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
627 commit_id])
628
629 def checkout(files = None, tree_id = None, force = False):
630 """Check out the given or all files
631 """
632 if not files:
633 files = []
634
635 if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
636 raise GitException, 'Failed git-read-tree --reset %s' % tree_id
637
638 checkout_cmd = 'git-checkout-index -q -u'
639 if force:
640 checkout_cmd += ' -f'
641 if len(files) == 0:
642 checkout_cmd += ' -a'
643 else:
644 checkout_cmd += ' --'
645
646 if __run(checkout_cmd, files) != 0:
647 raise GitException, 'Failed git-checkout-index'
648
649 def switch(tree_id, keep = False):
650 """Switch the tree to the given id
651 """
652 if not keep:
653 refresh_index()
654 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
655 raise GitException, 'git-read-tree failed (local changes maybe?)'
656
657 __set_head(tree_id)
658
659 def reset(files = None, tree_id = None, check_out = True):
660 """Revert the tree changes relative to the given tree_id. It removes
661 any local changes
662 """
663 if not tree_id:
664 tree_id = get_head()
665
666 if check_out:
667 cache_files = __tree_status(files, tree_id)
668 # files which were added but need to be removed
669 rm_files = [x[1] for x in cache_files if x[0] in ['A']]
670
671 checkout(files, tree_id, True)
672 # checkout doesn't remove files
673 map(os.remove, rm_files)
674
675 # if the reset refers to the whole tree, switch the HEAD as well
676 if not files:
677 __set_head(tree_id)
678
679 def pull(repository = 'origin', refspec = None):
680 """Pull changes from the remote repository. At the moment, just
681 use the 'git-pull' command
682 """
683 # 'git-pull' updates the HEAD
684 __clear_head_cache()
685
686 args = [repository]
687 if refspec:
688 args.append(refspec)
689
690 if __run('git-pull', args) != 0:
691 raise GitException, 'Failed "git-pull %s"' % repository
692
693 def apply_patch(filename = None, base = None):
694 """Apply a patch onto the current or given index. There must not
695 be any local changes in the tree, otherwise the command fails
696 """
697 def __apply_patch():
698 if filename:
699 return __run('git-apply --index', [filename]) == 0
700 else:
701 try:
702 _input('git-apply --index', sys.stdin)
703 except GitException:
704 return False
705 return True
706
707 if base:
708 orig_head = get_head()
709 switch(base)
710 else:
711 refresh_index() # needed since __apply_patch() doesn't do it
712
713 if not __apply_patch():
714 if base:
715 switch(orig_head)
716 raise GitException, 'Patch does not apply cleanly'
717 elif base:
718 top = commit(message = 'temporary commit used for applying a patch',
719 parents = [base])
720 switch(orig_head)
721 merge(base, orig_head, top)
722
723 def clone(repository, local_dir):
724 """Clone a remote repository. At the moment, just use the
725 'git-clone' script
726 """
727 if __run('git-clone', [repository, local_dir]) != 0:
728 raise GitException, 'Failed "git-clone %s %s"' \
729 % (repository, local_dir)
730
731 def modifying_revs(files, base_rev):
732 """Return the revisions from the list modifying the given files
733 """
734 cmd = ['git-rev-list', '%s..' % base_rev, '--']
735 revs = [line.strip() for line in _output_lines(cmd + files)]
736
737 return revs