chiark / gitweb /
Allow empty commit messages
[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 not message:
440         message = '\n'
441     elif message[-1:] != '\n':
442         message += '\n'
443
444     must_switch = True
445     # write the index to repository
446     if tree_id == None:
447         tree_id = _output_one_line('git-write-tree')
448     else:
449         must_switch = False
450
451     # the commit
452     cmd = ''
453     if author_name:
454         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
455     if author_email:
456         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
457     if author_date:
458         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
459     if committer_name:
460         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
461     if committer_email:
462         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
463     cmd += 'git-commit-tree %s' % tree_id
464
465     # get the parents
466     for p in parents:
467         cmd += ' -p %s' % p
468
469     commit_id = _output_one_line(cmd, message)
470     if must_switch:
471         __set_head(commit_id)
472
473     return commit_id
474
475 def apply_diff(rev1, rev2, check_index = True, files = None):
476     """Apply the diff between rev1 and rev2 onto the current
477     index. This function doesn't need to raise an exception since it
478     is only used for fast-pushing a patch. If this operation fails,
479     the pushing would fall back to the three-way merge.
480     """
481     if check_index:
482         index_opt = '--index'
483     else:
484         index_opt = ''
485
486     if not files:
487         files = []
488
489     diff_str = diff(files, rev1, rev2)
490     if diff_str:
491         try:
492             _input_str('git-apply %s' % index_opt, diff_str)
493         except GitException:
494             return False
495
496     return True
497
498 def merge(base, head1, head2):
499     """Perform a 3-way merge between base, head1 and head2 into the
500     local tree
501     """
502     refresh_index()
503     if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
504         raise GitException, 'git-read-tree failed (local changes maybe?)'
505
506     # check the index for unmerged entries
507     files = {}
508     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
509
510     for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
511         if not line:
512             continue
513
514         mode, hash, stage, path = stages_re.findall(line)[0]
515
516         if not path in files:
517             files[path] = {}
518             files[path]['1'] = ('', '')
519             files[path]['2'] = ('', '')
520             files[path]['3'] = ('', '')
521
522         files[path][stage] = (mode, hash)
523
524     # merge the unmerged files
525     errors = False
526     for path in files:
527         stages = files[path]
528         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
529                                  stages['3'][1], path, stages['1'][0],
530                                  stages['2'][0], stages['3'][0]) != 0:
531             errors = True
532
533     if errors:
534         raise GitException, 'GIT index merging failed (possible conflicts)'
535
536 def status(files = None, modified = False, new = False, deleted = False,
537            conflict = False, unknown = False, noexclude = False):
538     """Show the tree status
539     """
540     if not files:
541         files = []
542
543     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
544     all = not (modified or new or deleted or conflict or unknown)
545
546     if not all:
547         filestat = []
548         if modified:
549             filestat.append('M')
550         if new:
551             filestat.append('A')
552             filestat.append('N')
553         if deleted:
554             filestat.append('D')
555         if conflict:
556             filestat.append('C')
557         if unknown:
558             filestat.append('?')
559         cache_files = [x for x in cache_files if x[0] in filestat]
560
561     for fs in cache_files:
562         if all:
563             print '%s %s' % (fs[0], fs[1])
564         else:
565             print '%s' % fs[1]
566
567 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
568     """Show the diff between rev1 and rev2
569     """
570     if not files:
571         files = []
572
573     if rev1 and rev2:
574         diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
575     elif rev1 or rev2:
576         refresh_index()
577         if rev2:
578             diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
579         else:
580             diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
581     else:
582         diff_str = ''
583
584     if out_fd:
585         out_fd.write(diff_str)
586     else:
587         return diff_str
588
589 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
590     """Return the diffstat between rev1 and rev2
591     """
592     if not files:
593         files = []
594
595     p=popen2.Popen3('git-apply --stat')
596     diff(files, rev1, rev2, p.tochild)
597     p.tochild.close()
598     diff_str = p.fromchild.read().rstrip()
599     if p.wait():
600         raise GitException, 'git.diffstat failed'
601     return diff_str
602
603 def files(rev1, rev2):
604     """Return the files modified between rev1 and rev2
605     """
606
607     result = ''
608     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
609         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
610
611     return result.rstrip()
612
613 def barefiles(rev1, rev2):
614     """Return the files modified between rev1 and rev2, without status info
615     """
616
617     result = ''
618     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
619         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
620
621     return result.rstrip()
622
623 def pretty_commit(commit_id = 'HEAD'):
624     """Return a given commit (log + diff)
625     """
626     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
627                     commit_id])
628
629 def checkout(files = None, tree_id = None, force = False):
630     """Check out the given or all files
631     """
632     if not files:
633         files = []
634
635     if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
636         raise GitException, 'Failed git-read-tree --reset %s' % tree_id
637
638     checkout_cmd = 'git-checkout-index -q -u'
639     if force:
640         checkout_cmd += ' -f'
641     if len(files) == 0:
642         checkout_cmd += ' -a'
643     else:
644         checkout_cmd += ' --'
645
646     if __run(checkout_cmd, files) != 0:
647         raise GitException, 'Failed git-checkout-index'
648
649 def switch(tree_id, keep = False):
650     """Switch the tree to the given id
651     """
652     if not keep:
653         refresh_index()
654         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
655             raise GitException, 'git-read-tree failed (local changes maybe?)'
656
657     __set_head(tree_id)
658
659 def reset(files = None, tree_id = None, check_out = True):
660     """Revert the tree changes relative to the given tree_id. It removes
661     any local changes
662     """
663     if not tree_id:
664         tree_id = get_head()
665
666     if check_out:
667         cache_files = __tree_status(files, tree_id)
668         # files which were added but need to be removed
669         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
670
671         checkout(files, tree_id, True)
672         # checkout doesn't remove files
673         map(os.remove, rm_files)
674
675     # if the reset refers to the whole tree, switch the HEAD as well
676     if not files:
677         __set_head(tree_id)
678
679 def pull(repository = 'origin', refspec = None):
680     """Pull changes from the remote repository. At the moment, just
681     use the 'git-pull' command
682     """
683     # 'git-pull' updates the HEAD
684     __clear_head_cache()
685
686     args = [repository]
687     if refspec:
688         args.append(refspec)
689
690     if __run('git-pull', args) != 0:
691         raise GitException, 'Failed "git-pull %s"' % repository
692
693 def apply_patch(filename = None, base = None):
694     """Apply a patch onto the current or given index. There must not
695     be any local changes in the tree, otherwise the command fails
696     """
697     def __apply_patch():
698         if filename:
699             return __run('git-apply --index', [filename]) == 0
700         else:
701             try:
702                 _input('git-apply --index', sys.stdin)
703             except GitException:
704                 return False
705             return True
706
707     if base:
708         orig_head = get_head()
709         switch(base)
710     else:
711         refresh_index()         # needed since __apply_patch() doesn't do it
712
713     if not __apply_patch():
714         if base:
715             switch(orig_head)
716         raise GitException, 'Patch does not apply cleanly'
717     elif base:
718         top = commit(message = 'temporary commit used for applying a patch',
719                      parents = [base])
720         switch(orig_head)
721         merge(base, orig_head, top)
722
723 def clone(repository, local_dir):
724     """Clone a remote repository. At the moment, just use the
725     'git-clone' script
726     """
727     if __run('git-clone', [repository, local_dir]) != 0:
728         raise GitException, 'Failed "git-clone %s %s"' \
729               % (repository, local_dir)
730
731 def modifying_revs(files, base_rev):
732     """Return the revisions from the list modifying the given files
733     """
734     cmd = ['git-rev-list', '%s..' % base_rev, '--']
735     revs = [line.strip() for line in _output_lines(cmd + files)]
736
737     return revs