chiark / gitweb /
Show the stderr for failed GIT commands
[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         self.__parents = []
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             elif field[0] == 'parent':
51                 self.__parents.append(field[1])
52             if field[0] == 'author':
53                 self.__author = field[1]
54             if field[0] == 'committer':
55                 self.__committer = field[1]
56         self.__log = ''.join(lines[i+1:])
57
58     def get_id_hash(self):
59         return self.__id_hash
60
61     def get_tree(self):
62         return self.__tree
63
64     def get_parent(self):
65         return self.__parents[0]
66
67     def get_parents(self):
68         return self.__parents
69
70     def get_author(self):
71         return self.__author
72
73     def get_committer(self):
74         return self.__committer
75
76     def get_log(self):
77         return self.__log
78
79 # dictionary of Commit objects, used to avoid multiple calls to git
80 __commits = dict()
81
82 #
83 # Functions
84 #
85
86 def get_commit(id_hash):
87     """Commit objects factory. Save/look-up them in the __commits
88     dictionary
89     """
90     global __commits
91
92     if id_hash in __commits:
93         return __commits[id_hash]
94     else:
95         commit = Commit(id_hash)
96         __commits[id_hash] = commit
97         return commit
98
99 def get_conflicts():
100     """Return the list of file conflicts
101     """
102     conflicts_file = os.path.join(basedir.get(), 'conflicts')
103     if os.path.isfile(conflicts_file):
104         f = file(conflicts_file)
105         names = [line.strip() for line in f.readlines()]
106         f.close()
107         return names
108     else:
109         return None
110
111 def _input(cmd, file_desc):
112     p = popen2.Popen3(cmd, True)
113     while True:
114         line = file_desc.readline()
115         if not line:
116             break
117         p.tochild.write(line)
118     p.tochild.close()
119     if p.wait():
120         raise GitException, '%s failed (%s)' % (str(cmd),
121                                                 p.childerr.read().strip())
122
123 def _input_str(cmd, string):
124     p = popen2.Popen3(cmd, True)
125     p.tochild.write(string)
126     p.tochild.close()
127     if p.wait():
128         raise GitException, '%s failed (%s)' % (str(cmd),
129                                                 p.childerr.read().strip())
130
131 def _output(cmd):
132     p=popen2.Popen3(cmd, True)
133     output = p.fromchild.read()
134     if p.wait():
135         raise GitException, '%s failed (%s)' % (str(cmd),
136                                                 p.childerr.read().strip())
137     return output
138
139 def _output_one_line(cmd, file_desc = None):
140     p=popen2.Popen3(cmd, True)
141     if file_desc != None:
142         for line in file_desc:
143             p.tochild.write(line)
144         p.tochild.close()
145     output = p.fromchild.readline().strip()
146     if p.wait():
147         raise GitException, '%s failed (%s)' % (str(cmd),
148                                                 p.childerr.read().strip())
149     return output
150
151 def _output_lines(cmd):
152     p=popen2.Popen3(cmd, True)
153     lines = p.fromchild.readlines()
154     if p.wait():
155         raise GitException, '%s failed (%s)' % (str(cmd),
156                                                 p.childerr.read().strip())
157     return lines
158
159 def __run(cmd, args=None):
160     """__run: runs cmd using spawnvp.
161
162     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
163     our arguments.  If args is very large, the command is run multiple
164     times; args is split xargs style: cmd is passed on each
165     invocation.  Unlike xargs, returns immediately if any non-zero
166     return code is received.  
167     """
168     
169     args_l=cmd.split()
170     if args is None:
171         args = []
172     for i in range(0, len(args)+1, 100):
173         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
174     if r:
175         return r
176     return 0
177
178 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
179                   noexclude = True):
180     """Returns a list of pairs - [status, filename]
181     """
182     refresh_index()
183
184     if not files:
185         files = []
186     cache_files = []
187
188     # unknown files
189     if unknown:
190         exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
191         base_exclude = ['--exclude=%s' % s for s in
192                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
193         base_exclude.append('--exclude-per-directory=.gitignore')
194
195         if os.path.exists(exclude_file):
196             extra_exclude = ['--exclude-from=%s' % exclude_file]
197         else:
198             extra_exclude = []
199         if noexclude:
200             extra_exclude = base_exclude = []
201
202         lines = _output_lines(['git-ls-files', '--others', '--directory']
203                         + base_exclude + extra_exclude)
204         cache_files += [('?', line.strip()) for line in lines]
205
206     # conflicted files
207     conflicts = get_conflicts()
208     if not conflicts:
209         conflicts = []
210     cache_files += [('C', filename) for filename in conflicts]
211
212     # the rest
213     for line in _output_lines(['git-diff-index', tree_id] + files):
214         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
215         if fs[1] not in conflicts:
216             cache_files.append(fs)
217
218     return cache_files
219
220 def local_changes():
221     """Return true if there are local changes in the tree
222     """
223     return len(__tree_status()) != 0
224
225 # HEAD value cached
226 __head = None
227
228 def get_head():
229     """Verifies the HEAD and returns the SHA1 id that represents it
230     """
231     global __head
232
233     if not __head:
234         __head = rev_parse('HEAD')
235     return __head
236
237 def get_head_file():
238     """Returns the name of the file pointed to by the HEAD link
239     """
240     return strip_prefix('refs/heads/',
241                         _output_one_line('git-symbolic-ref HEAD'))
242
243 def set_head_file(ref):
244     """Resets HEAD to point to a new ref
245     """
246     # head cache flushing is needed since we might have a different value
247     # in the new head
248     __clear_head_cache()
249     if __run('git-symbolic-ref HEAD',
250              [os.path.join('refs', 'heads', ref)]) != 0:
251         raise GitException, 'Could not set head to "%s"' % ref
252
253 def __set_head(val):
254     """Sets the HEAD value
255     """
256     global __head
257
258     if not __head or __head != val:
259         if __run('git-update-ref HEAD', [val]) != 0:
260             raise GitException, 'Could not update HEAD to "%s".' % val
261         __head = val
262
263     # only allow SHA1 hashes
264     assert(len(__head) == 40)
265
266 def __clear_head_cache():
267     """Sets the __head to None so that a re-read is forced
268     """
269     global __head
270
271     __head = None
272
273 def refresh_index():
274     """Refresh index with stat() information from the working directory.
275     """
276     __run('git-update-index -q --unmerged --refresh')
277
278 def rev_parse(git_id):
279     """Parse the string and return a verified SHA1 id
280     """
281     try:
282         return _output_one_line(['git-rev-parse', '--verify', git_id])
283     except GitException:
284         raise GitException, 'Unknown revision: %s' % git_id
285
286 def branch_exists(branch):
287     """Existence check for the named branch
288     """
289     branch = os.path.join('refs', 'heads', branch)
290     for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
291         if line.strip() == branch:
292             return True
293         if re.compile('[ |/]'+branch+' ').search(line):
294             raise GitException, 'Bogus branch: %s' % line
295     return False
296
297 def create_branch(new_branch, tree_id = None):
298     """Create a new branch in the git repository
299     """
300     if branch_exists(new_branch):
301         raise GitException, 'Branch "%s" already exists' % new_branch
302
303     current_head = get_head()
304     set_head_file(new_branch)
305     __set_head(current_head)
306
307     # a checkout isn't needed if new branch points to the current head
308     if tree_id:
309         switch(tree_id)
310
311     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
312         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
313
314 def switch_branch(new_branch):
315     """Switch to a git branch
316     """
317     global __head
318
319     if not branch_exists(new_branch):
320         raise GitException, 'Branch "%s" does not exist' % new_branch
321
322     tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
323                         + '^{commit}')
324     if tree_id != get_head():
325         refresh_index()
326         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
327             raise GitException, 'git-read-tree failed (local changes maybe?)'
328         __head = tree_id
329     set_head_file(new_branch)
330
331     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
332         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
333
334 def delete_branch(name):
335     """Delete a git branch
336     """
337     if not branch_exists(name):
338         raise GitException, 'Branch "%s" does not exist' % name
339     remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
340                          name)
341
342 def rename_branch(from_name, to_name):
343     """Rename a git branch
344     """
345     if not branch_exists(from_name):
346         raise GitException, 'Branch "%s" does not exist' % from_name
347     if branch_exists(to_name):
348         raise GitException, 'Branch "%s" already exists' % to_name
349
350     if get_head_file() == from_name:
351         set_head_file(to_name)
352     rename(os.path.join(basedir.get(), 'refs', 'heads'),
353            from_name, to_name)
354
355 def add(names):
356     """Add the files or recursively add the directory contents
357     """
358     # generate the file list
359     files = []
360     for i in names:
361         if not os.path.exists(i):
362             raise GitException, 'Unknown file or directory: %s' % i
363
364         if os.path.isdir(i):
365             # recursive search. We only add files
366             for root, dirs, local_files in os.walk(i):
367                 for name in [os.path.join(root, f) for f in local_files]:
368                     if os.path.isfile(name):
369                         files.append(os.path.normpath(name))
370         elif os.path.isfile(i):
371             files.append(os.path.normpath(i))
372         else:
373             raise GitException, '%s is not a file or directory' % i
374
375     if files:
376         if __run('git-update-index --add --', files):
377             raise GitException, 'Unable to add file'
378
379 def rm(files, force = False):
380     """Remove a file from the repository
381     """
382     if not force:
383         for f in files:
384             if os.path.exists(f):
385                 raise GitException, '%s exists. Remove it first' %f
386         if files:
387             __run('git-update-index --remove --', files)
388     else:
389         if files:
390             __run('git-update-index --force-remove --', files)
391
392 def update_cache(files = None, force = False):
393     """Update the cache information for the given files
394     """
395     if not files:
396         files = []
397
398     cache_files = __tree_status(files)
399
400     # everything is up-to-date
401     if len(cache_files) == 0:
402         return False
403
404     # check for unresolved conflicts
405     if not force and [x for x in cache_files
406                       if x[0] not in ['M', 'N', 'A', 'D']]:
407         raise GitException, 'Updating cache failed: unresolved conflicts'
408
409     # update the cache
410     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
411     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
412     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
413
414     if add_files and __run('git-update-index --add --', add_files) != 0:
415         raise GitException, 'Failed git-update-index --add'
416     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
417         raise GitException, 'Failed git-update-index --rm'
418     if m_files and __run('git-update-index --', m_files) != 0:
419         raise GitException, 'Failed git-update-index'
420
421     return True
422
423 def commit(message, files = None, parents = None, allowempty = False,
424            cache_update = True, tree_id = None,
425            author_name = None, author_email = None, author_date = None,
426            committer_name = None, committer_email = None):
427     """Commit the current tree to repository
428     """
429     if not files:
430         files = []
431     if not parents:
432         parents = []
433
434     # Get the tree status
435     if cache_update and parents != []:
436         changes = update_cache(files)
437         if not changes and not allowempty:
438             raise GitException, 'No changes to commit'
439
440     # get the commit message
441     if 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):
650     """Switch the tree to the given id
651     """
652     refresh_index()
653     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
654         raise GitException, 'git-read-tree failed (local changes maybe?)'
655
656     __set_head(tree_id)
657
658 def reset(files = None, tree_id = None, check_out = True):
659     """Revert the tree changes relative to the given tree_id. It removes
660     any local changes
661     """
662     if not tree_id:
663         tree_id = get_head()
664
665     if check_out:
666         cache_files = __tree_status(files, tree_id)
667         # files which were added but need to be removed
668         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
669
670         checkout(files, tree_id, True)
671         # checkout doesn't remove files
672         map(os.remove, rm_files)
673
674     # if the reset refers to the whole tree, switch the HEAD as well
675     if not files:
676         __set_head(tree_id)
677
678 def pull(repository = 'origin', refspec = None):
679     """Pull changes from the remote repository. At the moment, just
680     use the 'git-pull' command
681     """
682     # 'git-pull' updates the HEAD
683     __clear_head_cache()
684
685     args = [repository]
686     if refspec:
687         args.append(refspec)
688
689     if __run('git-pull', args) != 0:
690         raise GitException, 'Failed "git-pull %s"' % repository
691
692 def apply_patch(filename = None, base = None):
693     """Apply a patch onto the current or given index. There must not
694     be any local changes in the tree, otherwise the command fails
695     """
696     def __apply_patch():
697         if filename:
698             return __run('git-apply --index', [filename]) == 0
699         else:
700             try:
701                 _input('git-apply --index', sys.stdin)
702             except GitException:
703                 return False
704             return True
705
706     if base:
707         orig_head = get_head()
708         switch(base)
709     else:
710         refresh_index()         # needed since __apply_patch() doesn't do it
711
712     if not __apply_patch():
713         if base:
714             switch(orig_head)
715         raise GitException, 'Patch does not apply cleanly'
716     elif base:
717         top = commit(message = 'temporary commit used for applying a patch',
718                      parents = [base])
719         switch(orig_head)
720         merge(base, orig_head, top)
721
722 def clone(repository, local_dir):
723     """Clone a remote repository. At the moment, just use the
724     'git-clone' script
725     """
726     if __run('git-clone', [repository, local_dir]) != 0:
727         raise GitException, 'Failed "git-clone %s %s"' \
728               % (repository, local_dir)
729
730 def modifying_revs(files, base_rev):
731     """Return the revisions from the list modifying the given files
732     """
733     cmd = ['git-rev-list', '%s..' % base_rev, '--']
734     revs = [line.strip() for line in _output_lines(cmd + files)]
735
736     return revs