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