chiark / gitweb /
Use 'git-*' instead of 'git *'
[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']):
276         if line.strip() == branch:
277             return True
278     return False
279
280 def create_branch(new_branch, tree_id = None):
281     """Create a new branch in the git repository
282     """
283     new_head = os.path.join('refs', 'heads', new_branch)
284     if branch_exists(new_head):
285         raise GitException, 'Branch "%s" already exists' % new_branch
286
287     current_head = get_head()
288     set_head_file(new_head)
289     __set_head(current_head)
290
291     # a checkout isn't needed if new branch points to the current head
292     if tree_id:
293         switch(tree_id)
294
295     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
296         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
297
298 def switch_branch(name):
299     """Switch to a git branch
300     """
301     global __head
302
303     new_head = os.path.join('refs', 'heads', name)
304     if not branch_exists(new_head):
305         raise GitException, 'Branch "%s" does not exist' % name
306
307     tree_id = rev_parse(new_head + '^{commit}')
308     if tree_id != get_head():
309         refresh_index()
310         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
311             raise GitException, 'git-read-tree failed (local changes maybe?)'
312         __head = tree_id
313     set_head_file(new_head)
314
315     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
316         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
317
318 def delete_branch(name):
319     """Delete a git branch
320     """
321     branch_head = os.path.join('refs', 'heads', name)
322     if not branch_exists(branch_head):
323         raise GitException, 'Branch "%s" does not exist' % name
324     os.remove(os.path.join(basedir.get(), branch_head))
325
326 def rename_branch(from_name, to_name):
327     """Rename a git branch
328     """
329     from_head = os.path.join('refs', 'heads', from_name)
330     if not branch_exists(from_head):
331         raise GitException, 'Branch "%s" does not exist' % from_name
332     to_head = os.path.join('refs', 'heads', to_name)
333     if branch_exists(to_head):
334         raise GitException, 'Branch "%s" already exists' % to_name
335
336     if get_head_file() == from_name:
337         set_head_file(to_head)
338     os.rename(os.path.join(basedir.get(), from_head), \
339               os.path.join(basedir.get(), to_head))
340
341 def add(names):
342     """Add the files or recursively add the directory contents
343     """
344     # generate the file list
345     files = []
346     for i in names:
347         if not os.path.exists(i):
348             raise GitException, 'Unknown file or directory: %s' % i
349
350         if os.path.isdir(i):
351             # recursive search. We only add files
352             for root, dirs, local_files in os.walk(i):
353                 for name in [os.path.join(root, f) for f in local_files]:
354                     if os.path.isfile(name):
355                         files.append(os.path.normpath(name))
356         elif os.path.isfile(i):
357             files.append(os.path.normpath(i))
358         else:
359             raise GitException, '%s is not a file or directory' % i
360
361     if files:
362         if __run('git-update-index --add --', files):
363             raise GitException, 'Unable to add file'
364
365 def rm(files, force = False):
366     """Remove a file from the repository
367     """
368     if not force:
369         for f in files:
370             if os.path.exists(f):
371                 raise GitException, '%s exists. Remove it first' %f
372         if files:
373             __run('git-update-index --remove --', files)
374     else:
375         if files:
376             __run('git-update-index --force-remove --', files)
377
378 def update_cache(files = None, force = False):
379     """Update the cache information for the given files
380     """
381     if not files:
382         files = []
383
384     cache_files = __tree_status(files)
385
386     # everything is up-to-date
387     if len(cache_files) == 0:
388         return False
389
390     # check for unresolved conflicts
391     if not force and [x for x in cache_files
392                       if x[0] not in ['M', 'N', 'A', 'D']]:
393         raise GitException, 'Updating cache failed: unresolved conflicts'
394
395     # update the cache
396     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
397     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
398     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
399
400     if add_files and __run('git-update-index --add --', add_files) != 0:
401         raise GitException, 'Failed git-update-index --add'
402     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
403         raise GitException, 'Failed git-update-index --rm'
404     if m_files and __run('git-update-index --', m_files) != 0:
405         raise GitException, 'Failed git-update-index'
406
407     return True
408
409 def commit(message, files = None, parents = None, allowempty = False,
410            cache_update = True, tree_id = None,
411            author_name = None, author_email = None, author_date = None,
412            committer_name = None, committer_email = None):
413     """Commit the current tree to repository
414     """
415     if not files:
416         files = []
417     if not parents:
418         parents = []
419
420     # Get the tree status
421     if cache_update and parents != []:
422         changes = update_cache(files)
423         if not changes and not allowempty:
424             raise GitException, 'No changes to commit'
425
426     # get the commit message
427     if message[-1:] != '\n':
428         message += '\n'
429
430     must_switch = True
431     # write the index to repository
432     if tree_id == None:
433         tree_id = _output_one_line('git-write-tree')
434     else:
435         must_switch = False
436
437     # the commit
438     cmd = ''
439     if author_name:
440         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
441     if author_email:
442         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
443     if author_date:
444         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
445     if committer_name:
446         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
447     if committer_email:
448         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
449     cmd += 'git-commit-tree %s' % tree_id
450
451     # get the parents
452     for p in parents:
453         cmd += ' -p %s' % p
454
455     commit_id = _output_one_line(cmd, message)
456     if must_switch:
457         __set_head(commit_id)
458
459     return commit_id
460
461 def apply_diff(rev1, rev2, check_index = True):
462     """Apply the diff between rev1 and rev2 onto the current
463     index. This function doesn't need to raise an exception since it
464     is only used for fast-pushing a patch. If this operation fails,
465     the pushing would fall back to the three-way merge.
466     """
467     if check_index:
468         index_opt = '--index'
469     else:
470         index_opt = ''
471     cmd = 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
472           % (rev1, rev2, index_opt)
473
474     return os.system(cmd) == 0
475
476 def merge(base, head1, head2):
477     """Perform a 3-way merge between base, head1 and head2 into the
478     local tree
479     """
480     refresh_index()
481     if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
482         raise GitException, 'git-read-tree failed (local changes maybe?)'
483
484     # check the index for unmerged entries
485     files = {}
486     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
487
488     for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
489         if not line:
490             continue
491
492         mode, hash, stage, path = stages_re.findall(line)[0]
493
494         if not path in files:
495             files[path] = {}
496             files[path]['1'] = ('', '')
497             files[path]['2'] = ('', '')
498             files[path]['3'] = ('', '')
499
500         files[path][stage] = (mode, hash)
501
502     # merge the unmerged files
503     errors = False
504     for path in files:
505         stages = files[path]
506         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
507                                  stages['3'][1], path, stages['1'][0],
508                                  stages['2'][0], stages['3'][0]) != 0:
509             errors = True
510
511     if errors:
512         raise GitException, 'GIT index merging failed (possible conflicts)'
513
514 def status(files = None, modified = False, new = False, deleted = False,
515            conflict = False, unknown = False, noexclude = False):
516     """Show the tree status
517     """
518     if not files:
519         files = []
520
521     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
522     all = not (modified or new or deleted or conflict or unknown)
523
524     if not all:
525         filestat = []
526         if modified:
527             filestat.append('M')
528         if new:
529             filestat.append('A')
530             filestat.append('N')
531         if deleted:
532             filestat.append('D')
533         if conflict:
534             filestat.append('C')
535         if unknown:
536             filestat.append('?')
537         cache_files = [x for x in cache_files if x[0] in filestat]
538
539     for fs in cache_files:
540         if all:
541             print '%s %s' % (fs[0], fs[1])
542         else:
543             print '%s' % fs[1]
544
545 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
546     """Show the diff between rev1 and rev2
547     """
548     if not files:
549         files = []
550
551     if rev1 and rev2:
552         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
553     elif rev1 or rev2:
554         refresh_index()
555         if rev2:
556             diff_str = _output(['git-diff-index', '-p', '-R', rev2] + files)
557         else:
558             diff_str = _output(['git-diff-index', '-p', rev1] + files)
559     else:
560         diff_str = ''
561
562     if out_fd:
563         out_fd.write(diff_str)
564     else:
565         return diff_str
566
567 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
568     """Return the diffstat between rev1 and rev2
569     """
570     if not files:
571         files = []
572
573     p=popen2.Popen3('git-apply --stat')
574     diff(files, rev1, rev2, p.tochild)
575     p.tochild.close()
576     diff_str = p.fromchild.read().rstrip()
577     if p.wait():
578         raise GitException, 'git.diffstat failed'
579     return diff_str
580
581 def files(rev1, rev2):
582     """Return the files modified between rev1 and rev2
583     """
584
585     result = ''
586     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
587         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
588
589     return result.rstrip()
590
591 def barefiles(rev1, rev2):
592     """Return the files modified between rev1 and rev2, without status info
593     """
594
595     result = ''
596     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
597         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
598
599     return result.rstrip()
600
601 def pretty_commit(commit_id = 'HEAD'):
602     """Return a given commit (log + diff)
603     """
604     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
605                     commit_id])
606
607 def checkout(files = None, tree_id = None, force = False):
608     """Check out the given or all files
609     """
610     if not files:
611         files = []
612
613     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
614         raise GitException, 'Failed git-read-tree -m %s' % tree_id
615
616     checkout_cmd = 'git-checkout-index -q -u'
617     if force:
618         checkout_cmd += ' -f'
619     if len(files) == 0:
620         checkout_cmd += ' -a'
621     else:
622         checkout_cmd += ' --'
623
624     if __run(checkout_cmd, files) != 0:
625         raise GitException, 'Failed git-checkout-index'
626
627 def switch(tree_id):
628     """Switch the tree to the given id
629     """
630     refresh_index()
631     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
632         raise GitException, 'git-read-tree failed (local changes maybe?)'
633
634     __set_head(tree_id)
635
636 def reset(files = None, tree_id = None, check_out = True):
637     """Revert the tree changes relative to the given tree_id. It removes
638     any local changes
639     """
640     if not tree_id:
641         tree_id = get_head()
642
643     if check_out:
644         checkout(files, tree_id, True)
645
646     # if the reset refers to the whole tree, switch the HEAD as well
647     if not files:
648         __set_head(tree_id)
649
650 def pull(repository = 'origin', refspec = None):
651     """Pull changes from the remote repository. At the moment, just
652     use the 'git-pull' command
653     """
654     # 'git-pull' updates the HEAD
655     __clear_head_cache()
656
657     args = [repository]
658     if refspec:
659         args.append(refspec)
660
661     if __run('git-pull', args) != 0:
662         raise GitException, 'Failed "git-pull %s"' % repository
663
664 def apply_patch(filename = None, base = None):
665     """Apply a patch onto the current or given index. There must not
666     be any local changes in the tree, otherwise the command fails
667     """
668     def __apply_patch():
669         if filename:
670             return __run('git-apply --index', [filename]) == 0
671         else:
672             try:
673                 _input('git-apply --index', sys.stdin)
674             except GitException:
675                 return False
676             return True
677
678     if base:
679         orig_head = get_head()
680         switch(base)
681     else:
682         refresh_index()         # needed since __apply_patch() doesn't do it
683
684     if not __apply_patch():
685         if base:
686             switch(orig_head)
687         raise GitException, 'Patch does not apply cleanly'
688     elif base:
689         top = commit(message = 'temporary commit used for applying a patch',
690                      parents = [base])
691         switch(orig_head)
692         merge(base, orig_head, top)
693
694 def clone(repository, local_dir):
695     """Clone a remote repository. At the moment, just use the
696     'git-clone' script
697     """
698     if __run('git-clone', [repository, local_dir]) != 0:
699         raise GitException, 'Failed "git-clone %s %s"' \
700               % (repository, local_dir)
701
702 def modifying_revs(files, base_rev):
703     """Return the revisions from the list modifying the given files
704     """
705     cmd = ['git-rev-list', '%s..' % base_rev, '--']
706     revs = [line.strip() for line in _output_lines(cmd + files)]
707
708     return revs