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