chiark / gitweb /
Deal with merge conflicts directly
[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.utils import *
24
25 # git exception class
26 class GitException(Exception):
27     pass
28
29
30
31 #
32 # Classes
33 #
34 class Commit:
35     """Handle the commit objects
36     """
37     def __init__(self, id_hash):
38         self.__id_hash = id_hash
39
40         lines = _output_lines('git-cat-file commit %s' % id_hash)
41         self.__parents = []
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             elif field[0] == 'parent':
50                 self.__parents.append(field[1])
51             if field[0] == 'author':
52                 self.__author = field[1]
53             if field[0] == 'committer':
54                 self.__committer = field[1]
55         self.__log = ''.join(lines[i+1:])
56
57     def get_id_hash(self):
58         return self.__id_hash
59
60     def get_tree(self):
61         return self.__tree
62
63     def get_parent(self):
64         return self.__parents[0]
65
66     def get_parents(self):
67         return self.__parents
68
69     def get_author(self):
70         return self.__author
71
72     def get_committer(self):
73         return self.__committer
74
75     def get_log(self):
76         return self.__log
77
78 # dictionary of Commit objects, used to avoid multiple calls to git
79 __commits = dict()
80
81 #
82 # Functions
83 #
84
85 # GIT_DIR value cached
86 __base_dir = None
87
88 def get_base_dir():
89     """Different start-up variables read from the environment
90     """
91     global __base_dir
92
93     if not __base_dir:
94         if 'GIT_DIR' in os.environ:
95             __base_dir = os.environ['GIT_DIR']
96         else:
97             __base_dir = _output_one_line('git-rev-parse --git-dir')
98
99     return __base_dir
100
101 def get_commit(id_hash):
102     """Commit objects factory. Save/look-up them in the __commits
103     dictionary
104     """
105     global __commits
106
107     if id_hash in __commits:
108         return __commits[id_hash]
109     else:
110         commit = Commit(id_hash)
111         __commits[id_hash] = commit
112         return commit
113
114 def get_conflicts():
115     """Return the list of file conflicts
116     """
117     conflicts_file = os.path.join(get_base_dir(), 'conflicts')
118     if os.path.isfile(conflicts_file):
119         f = file(conflicts_file)
120         names = [line.strip() for line in f.readlines()]
121         f.close()
122         return names
123     else:
124         return None
125
126 def _input(cmd, file_desc):
127     p = popen2.Popen3(cmd, True)
128     while True:
129         line = file_desc.readline()
130         if not line:
131             break
132         p.tochild.write(line)
133     p.tochild.close()
134     if p.wait():
135         raise GitException, '%s failed' % str(cmd)
136
137 def _output(cmd):
138     p=popen2.Popen3(cmd, True)
139     output = p.fromchild.read()
140     if p.wait():
141         raise GitException, '%s failed' % str(cmd)
142     return output
143
144 def _output_one_line(cmd, file_desc = None):
145     p=popen2.Popen3(cmd, True)
146     if file_desc != None:
147         for line in file_desc:
148             p.tochild.write(line)
149         p.tochild.close()
150     output = p.fromchild.readline().strip()
151     if p.wait():
152         raise GitException, '%s failed' % str(cmd)
153     return output
154
155 def _output_lines(cmd):
156     p=popen2.Popen3(cmd, True)
157     lines = p.fromchild.readlines()
158     if p.wait():
159         raise GitException, '%s failed' % str(cmd)
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(get_base_dir(), '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'] + base_exclude
206                         + 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 os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
244
245 def set_head_file(ref):
246     """Resets HEAD to point to a new ref
247     """
248     # head cache flushing is needed since we might have a different value
249     # in the new head
250     __clear_head_cache()
251     if __run('git-symbolic-ref HEAD', [ref]) != 0:
252         raise GitException, 'Could not set head to "%s"' % ref
253
254 def __set_head(val):
255     """Sets the HEAD value
256     """
257     global __head
258
259     if not __head or __head != val:
260         if __run('git-update-ref HEAD', [val]) != 0:
261             raise GitException, 'Could not update HEAD to "%s".' % val
262         __head = val
263
264     # only allow SHA1 hashes
265     assert(len(__head) == 40)
266
267 def __clear_head_cache():
268     """Sets the __head to None so that a re-read is forced
269     """
270     global __head
271
272     __head = None
273
274 def refresh_index():
275     """Refresh index with stat() information from the working directory.
276     """
277     __run('git-update-index -q --unmerged --refresh')
278
279 def rev_parse(git_id):
280     """Parse the string and return a verified SHA1 id
281     """
282     try:
283         return _output_one_line(['git-rev-parse', '--verify', git_id])
284     except GitException:
285         raise GitException, 'Unknown revision: %s' % git_id
286
287 def branch_exists(branch):
288     """Existence check for the named branch
289     """
290     for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
291         if line.strip() == branch:
292             return True
293     return False
294
295 def create_branch(new_branch, tree_id = None):
296     """Create a new branch in the git repository
297     """
298     new_head = os.path.join('refs', 'heads', new_branch)
299     if branch_exists(new_head):
300         raise GitException, 'Branch "%s" already exists' % new_branch
301
302     current_head = get_head()
303     set_head_file(new_head)
304     __set_head(current_head)
305
306     # a checkout isn't needed if new branch points to the current head
307     if tree_id:
308         switch(tree_id)
309
310     if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
311         os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
312
313 def switch_branch(name):
314     """Switch to a git branch
315     """
316     global __head
317
318     new_head = os.path.join('refs', 'heads', name)
319     if not branch_exists(new_head):
320         raise GitException, 'Branch "%s" does not exist' % name
321
322     tree_id = rev_parse(new_head + '^{commit}')
323     if tree_id != get_head():
324         refresh_index()
325         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
326             raise GitException, 'git-read-tree failed (local changes maybe?)'
327         __head = tree_id
328     set_head_file(new_head)
329
330     if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
331         os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
332
333 def delete_branch(name):
334     """Delete a git branch
335     """
336     branch_head = os.path.join('refs', 'heads', name)
337     if not branch_exists(branch_head):
338         raise GitException, 'Branch "%s" does not exist' % name
339     os.remove(os.path.join(get_base_dir(), branch_head))
340
341 def rename_branch(from_name, to_name):
342     """Rename a git branch
343     """
344     from_head = os.path.join('refs', 'heads', from_name)
345     if not branch_exists(from_head):
346         raise GitException, 'Branch "%s" does not exist' % from_name
347     to_head = os.path.join('refs', 'heads', to_name)
348     if branch_exists(to_head):
349         raise GitException, 'Branch "%s" already exists' % to_name
350
351     if get_head_file() == from_name:
352         set_head_file(to_head)
353     os.rename(os.path.join(get_base_dir(), from_head), \
354               os.path.join(get_base_dir(), to_head))
355
356 def add(names):
357     """Add the files or recursively add the directory contents
358     """
359     # generate the file list
360     files = []
361     for i in names:
362         if not os.path.exists(i):
363             raise GitException, 'Unknown file or directory: %s' % i
364
365         if os.path.isdir(i):
366             # recursive search. We only add files
367             for root, dirs, local_files in os.walk(i):
368                 for name in [os.path.join(root, f) for f in local_files]:
369                     if os.path.isfile(name):
370                         files.append(os.path.normpath(name))
371         elif os.path.isfile(i):
372             files.append(os.path.normpath(i))
373         else:
374             raise GitException, '%s is not a file or directory' % i
375
376     if files:
377         if __run('git-update-index --add --', files):
378             raise GitException, 'Unable to add file'
379
380 def rm(files, force = False):
381     """Remove a file from the repository
382     """
383     if not force:
384         for f in files:
385             if os.path.exists(f):
386                 raise GitException, '%s exists. Remove it first' %f
387         if files:
388             __run('git-update-index --remove --', files)
389     else:
390         if files:
391             __run('git-update-index --force-remove --', files)
392
393 def update_cache(files = None, force = False):
394     """Update the cache information for the given files
395     """
396     if not files:
397         files = []
398
399     cache_files = __tree_status(files)
400
401     # everything is up-to-date
402     if len(cache_files) == 0:
403         return False
404
405     # check for unresolved conflicts
406     if not force and [x for x in cache_files
407                       if x[0] not in ['M', 'N', 'A', 'D']]:
408         raise GitException, 'Updating cache failed: unresolved conflicts'
409
410     # update the cache
411     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
412     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
413     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
414
415     if add_files and __run('git-update-index --add --', add_files) != 0:
416         raise GitException, 'Failed git-update-index --add'
417     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
418         raise GitException, 'Failed git-update-index --rm'
419     if m_files and __run('git-update-index --', m_files) != 0:
420         raise GitException, 'Failed git-update-index'
421
422     return True
423
424 def commit(message, files = None, parents = None, allowempty = False,
425            cache_update = True, tree_id = None,
426            author_name = None, author_email = None, author_date = None,
427            committer_name = None, committer_email = None):
428     """Commit the current tree to repository
429     """
430     if not files:
431         files = []
432     if not parents:
433         parents = []
434
435     # Get the tree status
436     if cache_update and parents != []:
437         changes = update_cache(files)
438         if not changes and not allowempty:
439             raise GitException, 'No changes to commit'
440
441     # get the commit message
442     if message[-1:] != '\n':
443         message += '\n'
444
445     must_switch = True
446     # write the index to repository
447     if tree_id == None:
448         tree_id = _output_one_line('git-write-tree')
449     else:
450         must_switch = False
451
452     # the commit
453     cmd = ''
454     if author_name:
455         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
456     if author_email:
457         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
458     if author_date:
459         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
460     if committer_name:
461         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
462     if committer_email:
463         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
464     cmd += 'git-commit-tree %s' % tree_id
465
466     # get the parents
467     for p in parents:
468         cmd += ' -p %s' % p
469
470     commit_id = _output_one_line(cmd, message)
471     if must_switch:
472         __set_head(commit_id)
473
474     return commit_id
475
476 def apply_diff(rev1, rev2, check_index = True):
477     """Apply the diff between rev1 and rev2 onto the current
478     index. This function doesn't need to raise an exception since it
479     is only used for fast-pushing a patch. If this operation fails,
480     the pushing would fall back to the three-way merge.
481     """
482     if check_index:
483         index_opt = '--index'
484     else:
485         index_opt = ''
486     cmd = 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
487           % (rev1, rev2, index_opt)
488
489     return os.system(cmd) == 0
490
491 def merge(base, head1, head2):
492     """Perform a 3-way merge between base, head1 and head2 into the
493     local tree
494     """
495     refresh_index()
496     if __run('git-read-tree -u -m --aggressive', [base, head1, head2]) != 0:
497         raise GitException, 'git-read-tree failed (local changes maybe?)'
498
499     # check the index for unmerged entries
500     files = {}
501     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
502
503     for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
504         if not line:
505             continue
506
507         mode, hash, stage, path = stages_re.findall(line)[0]
508
509         if not path in files:
510             files[path] = {}
511             files[path]['1'] = ('', '')
512             files[path]['2'] = ('', '')
513             files[path]['3'] = ('', '')
514
515         files[path][stage] = (mode, hash)
516
517     # merge the unmerged files
518     errors = False
519     for path in files:
520         stages = files[path]
521         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
522                                  stages['3'][1], path, stages['1'][0],
523                                  stages['2'][0], stages['3'][0]) != 0:
524             errors = True
525
526     if errors:
527         raise GitException, 'GIT index merging failed (possible conflicts)'
528
529 def status(files = None, modified = False, new = False, deleted = False,
530            conflict = False, unknown = False, noexclude = False):
531     """Show the tree status
532     """
533     if not files:
534         files = []
535
536     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
537     all = not (modified or new or deleted or conflict or unknown)
538
539     if not all:
540         filestat = []
541         if modified:
542             filestat.append('M')
543         if new:
544             filestat.append('A')
545             filestat.append('N')
546         if deleted:
547             filestat.append('D')
548         if conflict:
549             filestat.append('C')
550         if unknown:
551             filestat.append('?')
552         cache_files = [x for x in cache_files if x[0] in filestat]
553
554     for fs in cache_files:
555         if all:
556             print '%s %s' % (fs[0], fs[1])
557         else:
558             print '%s' % fs[1]
559
560 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
561     """Show the diff between rev1 and rev2
562     """
563     if not files:
564         files = []
565
566     if rev1 and rev2:
567         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
568     elif rev1 or rev2:
569         refresh_index()
570         if rev2:
571             diff_str = _output(['git-diff-index', '-p', '-R', rev2] + files)
572         else:
573             diff_str = _output(['git-diff-index', '-p', rev1] + files)
574     else:
575         diff_str = ''
576
577     if out_fd:
578         out_fd.write(diff_str)
579     else:
580         return diff_str
581
582 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
583     """Return the diffstat between rev1 and rev2
584     """
585     if not files:
586         files = []
587
588     p=popen2.Popen3('git-apply --stat')
589     diff(files, rev1, rev2, p.tochild)
590     p.tochild.close()
591     diff_str = p.fromchild.read().rstrip()
592     if p.wait():
593         raise GitException, 'git.diffstat failed'
594     return diff_str
595
596 def files(rev1, rev2):
597     """Return the files modified between rev1 and rev2
598     """
599
600     result = ''
601     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
602         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
603
604     return result.rstrip()
605
606 def barefiles(rev1, rev2):
607     """Return the files modified between rev1 and rev2, without status info
608     """
609
610     result = ''
611     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
612         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
613
614     return result.rstrip()
615
616 def checkout(files = None, tree_id = None, force = False):
617     """Check out the given or all files
618     """
619     if not files:
620         files = []
621
622     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
623         raise GitException, 'Failed git-read-tree -m %s' % tree_id
624
625     checkout_cmd = 'git-checkout-index -q -u'
626     if force:
627         checkout_cmd += ' -f'
628     if len(files) == 0:
629         checkout_cmd += ' -a'
630     else:
631         checkout_cmd += ' --'
632
633     if __run(checkout_cmd, files) != 0:
634         raise GitException, 'Failed git-checkout-index'
635
636 def switch(tree_id):
637     """Switch the tree to the given id
638     """
639     refresh_index()
640     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
641         raise GitException, 'git-read-tree failed (local changes maybe?)'
642
643     __set_head(tree_id)
644
645 def reset(files = None, tree_id = None):
646     """Revert the tree changes relative to the given tree_id. It removes
647     any local changes
648     """
649     if not tree_id:
650         tree_id = get_head()
651
652     checkout(files, tree_id, True)
653
654     # if the reset refers to the whole tree, switch the HEAD as well
655     if not files:
656         __set_head(tree_id)
657
658 def pull(repository = 'origin', refspec = None):
659     """Pull changes from the remote repository. At the moment, just
660     use the 'git pull' command
661     """
662     # 'git pull' updates the HEAD
663     __clear_head_cache()
664
665     args = [repository]
666     if refspec:
667         args.append(refspec)
668
669     if __run('git pull', args) != 0:
670         raise GitException, 'Failed "git pull %s"' % repository
671
672 def apply_patch(filename = None, base = None):
673     """Apply a patch onto the current or given index. There must not
674     be any local changes in the tree, otherwise the command fails
675     """
676     def __apply_patch():
677         if filename:
678             return __run('git-apply --index', [filename]) == 0
679         else:
680             try:
681                 _input('git-apply --index', sys.stdin)
682             except GitException:
683                 return False
684             return True
685
686     if base:
687         orig_head = get_head()
688         switch(base)
689     else:
690         refresh_index()         # needed since __apply_patch() doesn't do it
691
692     if not __apply_patch():
693         if base:
694             switch(orig_head)
695         raise GitException, 'Patch does not apply cleanly'
696     elif base:
697         top = commit(message = 'temporary commit used for applying a patch',
698                      parents = [base])
699         switch(orig_head)
700         merge(base, orig_head, top)
701
702 def clone(repository, local_dir):
703     """Clone a remote repository. At the moment, just use the
704     'git clone' script
705     """
706     if __run('git clone', [repository, local_dir]) != 0:
707         raise GitException, 'Failed "git clone %s %s"' \
708               % (repository, local_dir)
709
710 def modifying_revs(files, base_rev):
711     """Return the revisions from the list modifying the given files
712     """
713     cmd = ['git-rev-list', '%s..' % base_rev, '--']
714     revs = [line.strip() for line in _output_lines(cmd + files)]
715
716     return revs