chiark / gitweb /
Add file renaming support
[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
519     try:
520         # use _output() to mask the verbose prints of the tool
521         _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
522     except GitException:
523         pass
524
525     # check the index for unmerged entries
526     files = {}
527     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
528
529     for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
530         if not line:
531             continue
532
533         mode, hash, stage, path = stages_re.findall(line)[0]
534
535         if not path in files:
536             files[path] = {}
537             files[path]['1'] = ('', '')
538             files[path]['2'] = ('', '')
539             files[path]['3'] = ('', '')
540
541         files[path][stage] = (mode, hash)
542
543     # merge the unmerged files
544     errors = False
545     for path in files:
546         stages = files[path]
547         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
548                                  stages['3'][1], path, stages['1'][0],
549                                  stages['2'][0], stages['3'][0]) != 0:
550             errors = True
551
552     if errors:
553         raise GitException, 'GIT index merging failed (possible conflicts)'
554
555 def status(files = None, modified = False, new = False, deleted = False,
556            conflict = False, unknown = False, noexclude = False):
557     """Show the tree status
558     """
559     if not files:
560         files = []
561
562     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
563     all = not (modified or new or deleted or conflict or unknown)
564
565     if not all:
566         filestat = []
567         if modified:
568             filestat.append('M')
569         if new:
570             filestat.append('A')
571             filestat.append('N')
572         if deleted:
573             filestat.append('D')
574         if conflict:
575             filestat.append('C')
576         if unknown:
577             filestat.append('?')
578         cache_files = [x for x in cache_files if x[0] in filestat]
579
580     for fs in cache_files:
581         if all:
582             print '%s %s' % (fs[0], fs[1])
583         else:
584             print '%s' % fs[1]
585
586 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
587     """Show the diff between rev1 and rev2
588     """
589     if not files:
590         files = []
591
592     if rev1 and rev2:
593         diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
594     elif rev1 or rev2:
595         refresh_index()
596         if rev2:
597             diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
598         else:
599             diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
600     else:
601         diff_str = ''
602
603     if out_fd:
604         out_fd.write(diff_str)
605     else:
606         return diff_str
607
608 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
609     """Return the diffstat between rev1 and rev2
610     """
611     if not files:
612         files = []
613
614     p=popen2.Popen3('git-apply --stat')
615     diff(files, rev1, rev2, p.tochild)
616     p.tochild.close()
617     diff_str = p.fromchild.read().rstrip()
618     if p.wait():
619         raise GitException, 'git.diffstat failed'
620     return diff_str
621
622 def files(rev1, rev2):
623     """Return the files modified between rev1 and rev2
624     """
625
626     result = ''
627     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
628         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
629
630     return result.rstrip()
631
632 def barefiles(rev1, rev2):
633     """Return the files modified between rev1 and rev2, without status info
634     """
635
636     result = ''
637     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
638         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
639
640     return result.rstrip()
641
642 def pretty_commit(commit_id = 'HEAD'):
643     """Return a given commit (log + diff)
644     """
645     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
646                     commit_id])
647
648 def checkout(files = None, tree_id = None, force = False):
649     """Check out the given or all files
650     """
651     if not files:
652         files = []
653
654     if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
655         raise GitException, 'Failed git-read-tree --reset %s' % tree_id
656
657     checkout_cmd = 'git-checkout-index -q -u'
658     if force:
659         checkout_cmd += ' -f'
660     if len(files) == 0:
661         checkout_cmd += ' -a'
662     else:
663         checkout_cmd += ' --'
664
665     if __run(checkout_cmd, files) != 0:
666         raise GitException, 'Failed git-checkout-index'
667
668 def switch(tree_id, keep = False):
669     """Switch the tree to the given id
670     """
671     if not keep:
672         refresh_index()
673         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
674             raise GitException, 'git-read-tree failed (local changes maybe?)'
675
676     __set_head(tree_id)
677
678 def reset(files = None, tree_id = None, check_out = True):
679     """Revert the tree changes relative to the given tree_id. It removes
680     any local changes
681     """
682     if not tree_id:
683         tree_id = get_head()
684
685     if check_out:
686         cache_files = __tree_status(files, tree_id)
687         # files which were added but need to be removed
688         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
689
690         checkout(files, tree_id, True)
691         # checkout doesn't remove files
692         map(os.remove, rm_files)
693
694     # if the reset refers to the whole tree, switch the HEAD as well
695     if not files:
696         __set_head(tree_id)
697
698 def pull(repository = 'origin', refspec = None):
699     """Pull changes from the remote repository. At the moment, just
700     use the 'git-pull' command
701     """
702     # 'git-pull' updates the HEAD
703     __clear_head_cache()
704
705     args = [repository]
706     if refspec:
707         args.append(refspec)
708
709     if __run(config.get('stgit', 'pullcmd'), args) != 0:
710         raise GitException, 'Failed "git-pull %s"' % repository
711
712 def repack():
713     """Repack all objects into a single pack
714     """
715     __run('git-repack -a -d -f')
716
717 def apply_patch(filename = None, diff = None, base = None,
718                 fail_dump = True):
719     """Apply a patch onto the current or given index. There must not
720     be any local changes in the tree, otherwise the command fails
721     """
722     if base:
723         orig_head = get_head()
724         switch(base)
725     else:
726         refresh_index()
727
728     if diff is None:
729         if filename:
730             f = file(filename)
731         else:
732             f = sys.stdin
733         diff = f.read()
734         if filename:
735             f.close()
736
737     try:
738         _input_str('git-apply --index', diff)
739     except GitException:
740         if base:
741             switch(orig_head)
742         if fail_dump:
743             # write the failed diff to a file
744             f = file('.stgit-failed.patch', 'w+')
745             f.write(diff)
746             f.close()
747
748         raise
749
750     if base:
751         top = commit(message = 'temporary commit used for applying a patch',
752                      parents = [base])
753         switch(orig_head)
754         merge(base, orig_head, top)
755
756 def clone(repository, local_dir):
757     """Clone a remote repository. At the moment, just use the
758     'git-clone' script
759     """
760     if __run('git-clone', [repository, local_dir]) != 0:
761         raise GitException, 'Failed "git-clone %s %s"' \
762               % (repository, local_dir)
763
764 def modifying_revs(files, base_rev):
765     """Return the revisions from the list modifying the given files
766     """
767     cmd = ['git-rev-list', '%s..' % base_rev, '--']
768     revs = [line.strip() for line in _output_lines(cmd + files)]
769
770     return revs