chiark / gitweb /
Add the --noreply option to 'mail'
[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