chiark / gitweb /
Allow git.checkout() to work on unmerged indexes
[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 os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
229
230 def set_head_file(ref):
231     """Resets HEAD to point to a new ref
232     """
233     # head cache flushing is needed since we might have a different value
234     # in the new head
235     __clear_head_cache()
236     if __run('git-symbolic-ref HEAD', [ref]) != 0:
237         raise GitException, 'Could not set head to "%s"' % ref
238
239 def __set_head(val):
240     """Sets the HEAD value
241     """
242     global __head
243
244     if not __head or __head != val:
245         if __run('git-update-ref HEAD', [val]) != 0:
246             raise GitException, 'Could not update HEAD to "%s".' % val
247         __head = val
248
249     # only allow SHA1 hashes
250     assert(len(__head) == 40)
251
252 def __clear_head_cache():
253     """Sets the __head to None so that a re-read is forced
254     """
255     global __head
256
257     __head = None
258
259 def refresh_index():
260     """Refresh index with stat() information from the working directory.
261     """
262     __run('git-update-index -q --unmerged --refresh')
263
264 def rev_parse(git_id):
265     """Parse the string and return a verified SHA1 id
266     """
267     try:
268         return _output_one_line(['git-rev-parse', '--verify', git_id])
269     except GitException:
270         raise GitException, 'Unknown revision: %s' % git_id
271
272 def branch_exists(branch):
273     """Existence check for the named branch
274     """
275     for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
276         if line.strip() == branch:
277             return True
278         if re.compile('[ |/]'+branch+' ').search(line):
279             raise GitException, 'Bogus branch: %s' % line
280     return False
281
282 def create_branch(new_branch, tree_id = None):
283     """Create a new branch in the git repository
284     """
285     new_head = os.path.join('refs', 'heads', new_branch)
286     if branch_exists(new_head):
287         raise GitException, 'Branch "%s" already exists' % new_branch
288
289     current_head = get_head()
290     set_head_file(new_head)
291     __set_head(current_head)
292
293     # a checkout isn't needed if new branch points to the current head
294     if tree_id:
295         switch(tree_id)
296
297     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
298         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
299
300 def switch_branch(name):
301     """Switch to a git branch
302     """
303     global __head
304
305     new_head = os.path.join('refs', 'heads', name)
306     if not branch_exists(new_head):
307         raise GitException, 'Branch "%s" does not exist' % name
308
309     tree_id = rev_parse(new_head + '^{commit}')
310     if tree_id != get_head():
311         refresh_index()
312         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
313             raise GitException, 'git-read-tree failed (local changes maybe?)'
314         __head = tree_id
315     set_head_file(new_head)
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 delete_branch(name):
321     """Delete a git branch
322     """
323     branch_head = os.path.join('refs', 'heads', name)
324     if not branch_exists(branch_head):
325         raise GitException, 'Branch "%s" does not exist' % name
326     os.remove(os.path.join(basedir.get(), branch_head))
327
328 def rename_branch(from_name, to_name):
329     """Rename a git branch
330     """
331     from_head = os.path.join('refs', 'heads', from_name)
332     if not branch_exists(from_head):
333         raise GitException, 'Branch "%s" does not exist' % from_name
334     to_head = os.path.join('refs', 'heads', to_name)
335     if branch_exists(to_head):
336         raise GitException, 'Branch "%s" already exists' % to_name
337
338     if get_head_file() == from_name:
339         set_head_file(to_head)
340     os.rename(os.path.join(basedir.get(), from_head), \
341               os.path.join(basedir.get(), to_head))
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', [tree_id]) != 0:
616         raise GitException, 'Failed git-read-tree -m %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         checkout(files, tree_id, True)
647
648     # if the reset refers to the whole tree, switch the HEAD as well
649     if not files:
650         __set_head(tree_id)
651
652 def pull(repository = 'origin', refspec = None):
653     """Pull changes from the remote repository. At the moment, just
654     use the 'git-pull' command
655     """
656     # 'git-pull' updates the HEAD
657     __clear_head_cache()
658
659     args = [repository]
660     if refspec:
661         args.append(refspec)
662
663     if __run('git-pull', args) != 0:
664         raise GitException, 'Failed "git-pull %s"' % repository
665
666 def apply_patch(filename = None, base = None):
667     """Apply a patch onto the current or given index. There must not
668     be any local changes in the tree, otherwise the command fails
669     """
670     def __apply_patch():
671         if filename:
672             return __run('git-apply --index', [filename]) == 0
673         else:
674             try:
675                 _input('git-apply --index', sys.stdin)
676             except GitException:
677                 return False
678             return True
679
680     if base:
681         orig_head = get_head()
682         switch(base)
683     else:
684         refresh_index()         # needed since __apply_patch() doesn't do it
685
686     if not __apply_patch():
687         if base:
688             switch(orig_head)
689         raise GitException, 'Patch does not apply cleanly'
690     elif base:
691         top = commit(message = 'temporary commit used for applying a patch',
692                      parents = [base])
693         switch(orig_head)
694         merge(base, orig_head, top)
695
696 def clone(repository, local_dir):
697     """Clone a remote repository. At the moment, just use the
698     'git-clone' script
699     """
700     if __run('git-clone', [repository, local_dir]) != 0:
701         raise GitException, 'Failed "git-clone %s %s"' \
702               % (repository, local_dir)
703
704 def modifying_revs(files, base_rev):
705     """Return the revisions from the list modifying the given files
706     """
707     cmd = ['git-rev-list', '%s..' % base_rev, '--']
708     revs = [line.strip() for line in _output_lines(cmd + files)]
709
710     return revs