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