chiark / gitweb /
Use __run instead of os.system() in git.merge()
[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
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27     pass
28
29
30 # Different start-up variables read from the environment
31 if 'GIT_DIR' in os.environ:
32     base_dir = os.environ['GIT_DIR']
33 else:
34     base_dir = '.git'
35
36 head_link = os.path.join(base_dir, 'HEAD')
37
38 #
39 # Classes
40 #
41 class Commit:
42     """Handle the commit objects
43     """
44     def __init__(self, id_hash):
45         self.__id_hash = id_hash
46
47         lines = _output_lines('git-cat-file commit %s' % id_hash)
48         self.__parents = []
49         for i in range(len(lines)):
50             line = lines[i]
51             if line == '\n':
52                 break
53             field = line.strip().split(' ', 1)
54             if field[0] == 'tree':
55                 self.__tree = field[1]
56             elif field[0] == 'parent':
57                 self.__parents.append(field[1])
58             if field[0] == 'author':
59                 self.__author = field[1]
60             if field[0] == 'committer':
61                 self.__committer = field[1]
62         self.__log = ''.join(lines[i+1:])
63
64     def get_id_hash(self):
65         return self.__id_hash
66
67     def get_tree(self):
68         return self.__tree
69
70     def get_parent(self):
71         return self.__parents[0]
72
73     def get_parents(self):
74         return self.__parents
75
76     def get_author(self):
77         return self.__author
78
79     def get_committer(self):
80         return self.__committer
81
82     def get_log(self):
83         return self.__log
84
85 # dictionary of Commit objects, used to avoid multiple calls to git
86 __commits = dict()
87
88 #
89 # Functions
90 #
91 def get_commit(id_hash):
92     """Commit objects factory. Save/look-up them in the __commits
93     dictionary
94     """
95     global __commits
96
97     if id_hash in __commits:
98         return __commits[id_hash]
99     else:
100         commit = Commit(id_hash)
101         __commits[id_hash] = commit
102         return commit
103
104 def get_conflicts():
105     """Return the list of file conflicts
106     """
107     conflicts_file = os.path.join(base_dir, 'conflicts')
108     if os.path.isfile(conflicts_file):
109         f = file(conflicts_file)
110         names = [line.strip() for line in f.readlines()]
111         f.close()
112         return names
113     else:
114         return None
115
116 def _input(cmd, file_desc):
117     p = popen2.Popen3(cmd, True)
118     while True:
119         line = file_desc.readline()
120         if not line:
121             break
122         p.tochild.write(line)
123     p.tochild.close()
124     if p.wait():
125         raise GitException, '%s failed' % str(cmd)
126
127 def _output(cmd):
128     p=popen2.Popen3(cmd, True)
129     string = p.fromchild.read()
130     if p.wait():
131         raise GitException, '%s failed' % str(cmd)
132     return string
133
134 def _output_one_line(cmd, file_desc = None):
135     p=popen2.Popen3(cmd, True)
136     if file_desc != None:
137         for line in file_desc:
138             p.tochild.write(line)
139         p.tochild.close()
140     string = p.fromchild.readline().strip()
141     if p.wait():
142         raise GitException, '%s failed' % str(cmd)
143     return string
144
145 def _output_lines(cmd):
146     p=popen2.Popen3(cmd, True)
147     lines = p.fromchild.readlines()
148     if p.wait():
149         raise GitException, '%s failed' % str(cmd)
150     return lines
151
152 def __run(cmd, args=None):
153     """__run: runs cmd using spawnvp.
154
155     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
156     our arguments.  If args is very large, the command is run multiple
157     times; args is split xargs style: cmd is passed on each
158     invocation.  Unlike xargs, returns immediately if any non-zero
159     return code is received.  
160     """
161     
162     args_l=cmd.split()
163     if args is None:
164         args = []
165     for i in range(0, len(args)+1, 100):
166         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
167     if r:
168         return r
169     return 0
170
171 def __check_base_dir():
172     return os.path.isdir(base_dir)
173
174 def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
175                   noexclude = True):
176     """Returns a list of pairs - [status, filename]
177     """
178     refresh_index()
179
180     cache_files = []
181
182     # unknown files
183     if unknown:
184         exclude_file = os.path.join(base_dir, 'info', 'exclude')
185         base_exclude = ['--exclude=%s' % s for s in
186                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
187         base_exclude.append('--exclude-per-directory=.gitignore')
188
189         if os.path.exists(exclude_file):
190             extra_exclude = ['--exclude-from=%s' % exclude_file]
191         else:
192             extra_exclude = []
193         if noexclude:
194             extra_exclude = base_exclude = []
195
196         lines = _output_lines(['git-ls-files', '--others'] + base_exclude
197                         + extra_exclude)
198         cache_files += [('?', line.strip()) for line in lines]
199
200     # conflicted files
201     conflicts = get_conflicts()
202     if not conflicts:
203         conflicts = []
204     cache_files += [('C', filename) for filename in conflicts]
205
206     # the rest
207     for line in _output_lines(['git-diff-index', '-r', tree_id] + files):
208         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
209         if fs[1] not in conflicts:
210             cache_files.append(fs)
211
212     return cache_files
213
214 def local_changes():
215     """Return true if there are local changes in the tree
216     """
217     return len(__tree_status()) != 0
218
219 # HEAD value cached
220 __head = None
221
222 def get_head():
223     """Verifies the HEAD and returns the SHA1 id that represents it
224     """
225     global __head
226
227     if not __head:
228         __head = rev_parse('HEAD')
229     return __head
230
231 def get_head_file():
232     """Returns the name of the file pointed to by the HEAD link
233     """
234     return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
235
236 def set_head_file(ref):
237     """Resets HEAD to point to a new ref
238     """
239     # head cache flushing is needed since we might have a different value
240     # in the new head
241     __clear_head_cache()
242     if __run('git-symbolic-ref HEAD', [ref]) != 0:
243         raise GitException, 'Could not set head to "%s"' % ref
244
245 def __set_head(val):
246     """Sets the HEAD value
247     """
248     global __head
249
250     if not __head or __head != val:
251         if __run('git-update-ref HEAD', [val]) != 0:
252             raise GitException, 'Could not update HEAD to "%s".' % val
253         __head = val
254
255 def __clear_head_cache():
256     """Sets the __head to None so that a re-read is forced
257     """
258     global __head
259
260     __head = None
261
262 def refresh_index():
263     """Refresh index with stat() information from the working directory.
264     """
265     __run('git-update-index -q --unmerged --refresh')
266
267 def rev_parse(git_id):
268     """Parse the string and return a verified SHA1 id
269     """
270     try:
271         return _output_one_line(['git-rev-parse', '--verify', git_id])
272     except GitException:
273         raise GitException, 'Unknown revision: %s' % git_id
274
275 def branch_exists(branch):
276     """Existance check for the named branch
277     """
278     for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
279         if line.strip() == branch:
280             return True
281     return False
282
283 def create_branch(new_branch, tree_id = None):
284     """Create a new branch in the git repository
285     """
286     new_head = os.path.join('refs', 'heads', new_branch)
287     if branch_exists(new_head):
288         raise GitException, 'Branch "%s" already exists' % new_branch
289
290     current_head = get_head()
291     set_head_file(new_head)
292     __set_head(current_head)
293
294     # a checkout isn't needed if new branch points to the current head
295     if tree_id:
296         switch(tree_id)
297
298     if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
299         os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
300
301 def switch_branch(name):
302     """Switch to a git branch
303     """
304     global __head
305
306     new_head = os.path.join('refs', 'heads', name)
307     if not branch_exists(new_head):
308         raise GitException, 'Branch "%s" does not exist' % name
309
310     tree_id = rev_parse(new_head + '^0')
311     if tree_id != get_head():
312         refresh_index()
313         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
314             raise GitException, 'git-read-tree failed (local changes maybe?)'
315         __head = tree_id
316     set_head_file(new_head)
317
318     if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
319         os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
320
321 def delete_branch(name):
322     """Delete a git branch
323     """
324     branch_head = os.path.join('refs', 'heads', name)
325     if not branch_exists(branch_head):
326         raise GitException, 'Branch "%s" does not exist' % name
327     os.remove(os.path.join(base_dir, branch_head))
328
329 def rename_branch(from_name, to_name):
330     """Rename a git branch
331     """
332     from_head = os.path.join('refs', 'heads', from_name)
333     if not branch_exists(from_head):
334         raise GitException, 'Branch "%s" does not exist' % from_name
335     to_head = os.path.join('refs', 'heads', to_name)
336     if branch_exists(to_head):
337         raise GitException, 'Branch "%s" already exists' % to_name
338
339     if get_head_file() == from_name:
340         set_head_file(to_head)
341     os.rename(os.path.join(base_dir, from_head), os.path.join(base_dir, to_head))
342
343 def add(names):
344     """Add the files or recursively add the directory contents
345     """
346     # generate the file list
347     files = []
348     for i in names:
349         if not os.path.exists(i):
350             raise GitException, 'Unknown file or directory: %s' % i
351
352         if os.path.isdir(i):
353             # recursive search. We only add files
354             for root, dirs, local_files in os.walk(i):
355                 for name in [os.path.join(root, f) for f in local_files]:
356                     if os.path.isfile(name):
357                         files.append(os.path.normpath(name))
358         elif os.path.isfile(i):
359             files.append(os.path.normpath(i))
360         else:
361             raise GitException, '%s is not a file or directory' % i
362
363     if files:
364         if __run('git-update-index --add --', files):
365             raise GitException, 'Unable to add file'
366
367 def rm(files, force = False):
368     """Remove a file from the repository
369     """
370     if not force:
371         for f in files:
372             if os.path.exists(f):
373                 raise GitException, '%s exists. Remove it first' %f
374         if files:
375             __run('git-update-index --remove --', files)
376     else:
377         if files:
378             __run('git-update-index --force-remove --', files)
379
380 def update_cache(files = [], force = False):
381     """Update the cache information for the given files
382     """
383     cache_files = __tree_status(files)
384
385     # everything is up-to-date
386     if len(cache_files) == 0:
387         return False
388
389     # check for unresolved conflicts
390     if not force and [x for x in cache_files
391                       if x[0] not in ['M', 'N', 'A', 'D']]:
392         raise GitException, 'Updating cache failed: unresolved conflicts'
393
394     # update the cache
395     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
396     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
397     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
398
399     if add_files and __run('git-update-index --add --', add_files) != 0:
400         raise GitException, 'Failed git-update-index --add'
401     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
402         raise GitException, 'Failed git-update-index --rm'
403     if m_files and __run('git-update-index --', m_files) != 0:
404         raise GitException, 'Failed git-update-index'
405
406     return True
407
408 def commit(message, files = [], parents = [], allowempty = False,
409            cache_update = True, tree_id = None,
410            author_name = None, author_email = None, author_date = None,
411            committer_name = None, committer_email = None):
412     """Commit the current tree to repository
413     """
414     # Get the tree status
415     if cache_update and parents != []:
416         changes = update_cache(files)
417         if not changes and not allowempty:
418             raise GitException, 'No changes to commit'
419
420     # get the commit message
421     if message[-1:] != '\n':
422         message += '\n'
423
424     must_switch = True
425     # write the index to repository
426     if tree_id == None:
427         tree_id = _output_one_line('git-write-tree')
428     else:
429         must_switch = False
430
431     # the commit
432     cmd = ''
433     if author_name:
434         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
435     if author_email:
436         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
437     if author_date:
438         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
439     if committer_name:
440         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
441     if committer_email:
442         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
443     cmd += 'git-commit-tree %s' % tree_id
444
445     # get the parents
446     for p in parents:
447         cmd += ' -p %s' % p
448
449     commit_id = _output_one_line(cmd, message)
450     if must_switch:
451         __set_head(commit_id)
452
453     return commit_id
454
455 def apply_diff(rev1, rev2):
456     """Apply the diff between rev1 and rev2 onto the current
457     index. This function doesn't need to raise an exception since it
458     is only used for fast-pushing a patch. If this operation fails,
459     the pushing would fall back to the three-way merge.
460     """
461     return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
462                      % (rev1, rev2)) == 0
463
464 def merge(base, head1, head2):
465     """Perform a 3-way merge between base, head1 and head2 into the
466     local tree
467     """
468     refresh_index()
469     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
470         raise GitException, 'git-read-tree failed (local changes maybe?)'
471
472     # this can fail if there are conflicts
473     if __run('git-merge-index -o -q gitmergeonefile.py -a') != 0:
474         raise GitException, 'git-merge-index failed (possible conflicts)'
475
476 def status(files = [], modified = False, new = False, deleted = False,
477            conflict = False, unknown = False, noexclude = False):
478     """Show the tree status
479     """
480     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
481     all = not (modified or new or deleted or conflict or unknown)
482
483     if not all:
484         filestat = []
485         if modified:
486             filestat.append('M')
487         if new:
488             filestat.append('A')
489             filestat.append('N')
490         if deleted:
491             filestat.append('D')
492         if conflict:
493             filestat.append('C')
494         if unknown:
495             filestat.append('?')
496         cache_files = [x for x in cache_files if x[0] in filestat]
497
498     for fs in cache_files:
499         if all:
500             print '%s %s' % (fs[0], fs[1])
501         else:
502             print '%s' % fs[1]
503
504 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
505     """Show the diff between rev1 and rev2
506     """
507
508     if rev2:
509         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
510     else:
511         refresh_index()
512         diff_str = _output(['git-diff-index', '-p', rev1] + files)
513
514     if out_fd:
515         out_fd.write(diff_str)
516     else:
517         return diff_str
518
519 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
520     """Return the diffstat between rev1 and rev2
521     """
522
523     p=popen2.Popen3('git-apply --stat')
524     diff(files, rev1, rev2, p.tochild)
525     p.tochild.close()
526     str = p.fromchild.read().rstrip()
527     if p.wait():
528         raise GitException, 'git.diffstat failed'
529     return str
530
531 def files(rev1, rev2):
532     """Return the files modified between rev1 and rev2
533     """
534
535     str = ''
536     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
537         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
538
539     return str.rstrip()
540
541 def barefiles(rev1, rev2):
542     """Return the files modified between rev1 and rev2, without status info
543     """
544
545     str = ''
546     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
547         str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
548
549     return str.rstrip()
550
551 def checkout(files = [], tree_id = None, force = False):
552     """Check out the given or all files
553     """
554     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
555         raise GitException, 'Failed git-read-tree -m %s' % tree_id
556
557     checkout_cmd = 'git-checkout-index -q -u'
558     if force:
559         checkout_cmd += ' -f'
560     if len(files) == 0:
561         checkout_cmd += ' -a'
562     else:
563         checkout_cmd += ' --'
564
565     if __run(checkout_cmd, files) != 0:
566         raise GitException, 'Failed git-checkout-index'
567
568 def switch(tree_id):
569     """Switch the tree to the given id
570     """
571     refresh_index()
572     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
573         raise GitException, 'git-read-tree failed (local changes maybe?)'
574
575     __set_head(tree_id)
576
577 def reset(tree_id = None):
578     """Revert the tree changes relative to the given tree_id. It removes
579     any local changes
580     """
581     if not tree_id:
582         tree_id = get_head()
583
584     cache_files = __tree_status(tree_id = tree_id)
585     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
586
587     checkout(tree_id = tree_id, force = True)
588     __set_head(tree_id)
589
590     # checkout doesn't remove files
591     map(os.remove, rm_files)
592
593 def pull(repository = 'origin', refspec = None):
594     """Pull changes from the remote repository. At the moment, just
595     use the 'git pull' command
596     """
597     # 'git pull' updates the HEAD
598     __clear_head_cache()
599
600     args = [repository]
601     if refspec:
602         args.append(refspec)
603
604     if __run('git pull', args) != 0:
605         raise GitException, 'Failed "git pull %s"' % repository
606
607 def apply_patch(filename = None, base = None):
608     """Apply a patch onto the current or given index. There must not
609     be any local changes in the tree, otherwise the command fails
610     """
611     def __apply_patch():
612         if filename:
613             return __run('git-apply --index', [filename]) == 0
614         else:
615             try:
616                 _input('git-apply --index', sys.stdin)
617             except GitException:
618                 return False
619             return True
620
621     refresh_index()
622
623     if base:
624         orig_head = get_head()
625         switch(base)
626
627     if not __apply_patch():
628         if base:
629             switch(orig_head)
630         raise GitException, 'Patch does not apply cleanly'
631     elif base:
632         top = commit(message = 'temporary commit used for applying a patch',
633                      parents = [base])
634         switch(orig_head)
635         merge(base, orig_head, top)
636
637 def clone(repository, local_dir):
638     """Clone a remote repository. At the moment, just use the
639     'git clone' script
640     """
641     if __run('git clone', [repository, local_dir]) != 0:
642         raise GitException, 'Failed "git clone %s %s"' \
643               % (repository, local_dir)