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