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