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