chiark / gitweb /
Add git method for switching to a different branch
[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, glob, 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)
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)
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)
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)
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     os.system('git-update-index --refresh > /dev/null')
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     if __run('git-symbolic-ref HEAD', [ref]) != 0:
240         raise GitException, 'Could not set head to "%s"' % ref
241
242 def __set_head(val):
243     """Sets the HEAD value
244     """
245     global __head
246
247     if not __head or __head != val:
248         if __run('git-update-ref HEAD', [val]) != 0:
249             raise GitException, 'Could not update HEAD to "%s".' % val
250         __head = val
251
252 def __clear_head_cache():
253     """Sets the __head to None so that a re-read is forced
254     """
255     global __head
256
257     __head = None
258
259 def rev_parse(git_id):
260     """Parse the string and return a verified SHA1 id
261     """
262     try:
263         return _output_one_line(['git-rev-parse', '--verify', git_id])
264     except GitException:
265         raise GitException, 'Unknown revision: %s' % git_id
266
267 def branch_exists(branch):
268     """Existance check for the named branch
269     """
270     for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
271         if line.strip() == branch:
272             return True
273     return False
274
275 def create_branch(new_branch, tree_id = None):
276     """Create a new branch in the git repository
277     """
278     new_head = os.path.join('refs', 'heads', new_branch)
279     if branch_exists(new_head):
280         raise GitException, 'Branch "%s" already exists' % new_branch
281
282     current_head = get_head()
283     set_head_file(new_head)
284     __set_head(current_head)
285
286     # a checkout isn't needed if new branch points to the current head
287     if tree_id:
288         git.switch(tree_id)
289
290     if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
291         os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
292
293 def switch_branch(name):
294     """Switch to a git branch
295     """
296     new_head = os.path.join('refs', 'heads', name)
297     if not branch_exists(new_head):
298         raise GitException, 'Branch "%s" does not exist' % name
299
300     tree_id = rev_parse(new_head + '^0')
301     if tree_id != get_head():
302         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
303             raise GitException, 'git-read-tree failed (local changes maybe?)'
304         __head = tree_id
305     set_head_file(new_head)
306
307     if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
308         os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
309
310 def add(names):
311     """Add the files or recursively add the directory contents
312     """
313     # generate the file list
314     files = []
315     for i in names:
316         if not os.path.exists(i):
317             raise GitException, 'Unknown file or directory: %s' % i
318
319         if os.path.isdir(i):
320             # recursive search. We only add files
321             for root, dirs, local_files in os.walk(i):
322                 for name in [os.path.join(root, f) for f in local_files]:
323                     if os.path.isfile(name):
324                         files.append(os.path.normpath(name))
325         elif os.path.isfile(i):
326             files.append(os.path.normpath(i))
327         else:
328             raise GitException, '%s is not a file or directory' % i
329
330     if files:
331         if __run('git-update-index --add --', files):
332             raise GitException, 'Unable to add file'
333
334 def rm(files, force = False):
335     """Remove a file from the repository
336     """
337     if force:
338         git_opt = '--force-remove'
339     else:
340         git_opt = '--remove'
341
342     if not force:
343         for f in files:
344             if os.path.exists(f):
345                 raise GitException, '%s exists. Remove it first' %f
346         if files:
347             __run('git-update-index --remove --', files)
348     else:
349         if files:
350             __run('git-update-index --force-remove --', files)
351
352 def update_cache(files = [], force = False):
353     """Update the cache information for the given files
354     """
355     cache_files = __tree_status(files)
356
357     # everything is up-to-date
358     if len(cache_files) == 0:
359         return False
360
361     # check for unresolved conflicts
362     if not force and [x for x in cache_files
363                       if x[0] not in ['M', 'N', 'A', 'D']]:
364         raise GitException, 'Updating cache failed: unresolved conflicts'
365
366     # update the cache
367     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
368     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
369     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
370
371     if add_files and __run('git-update-index --add --', add_files) != 0:
372         raise GitException, 'Failed git-update-index --add'
373     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
374         raise GitException, 'Failed git-update-index --rm'
375     if m_files and __run('git-update-index --', m_files) != 0:
376         raise GitException, 'Failed git-update-index'
377
378     return True
379
380 def commit(message, files = [], parents = [], allowempty = False,
381            cache_update = True, tree_id = None,
382            author_name = None, author_email = None, author_date = None,
383            committer_name = None, committer_email = None):
384     """Commit the current tree to repository
385     """
386     # Get the tree status
387     if cache_update and parents != []:
388         changes = update_cache(files)
389         if not changes and not allowempty:
390             raise GitException, 'No changes to commit'
391
392     # get the commit message
393     if message[-1:] != '\n':
394         message += '\n'
395
396     must_switch = True
397     # write the index to repository
398     if tree_id == None:
399         tree_id = _output_one_line('git-write-tree')
400     else:
401         must_switch = False
402
403     # the commit
404     cmd = ''
405     if author_name:
406         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
407     if author_email:
408         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
409     if author_date:
410         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
411     if committer_name:
412         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
413     if committer_email:
414         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
415     cmd += 'git-commit-tree %s' % tree_id
416
417     # get the parents
418     for p in parents:
419         cmd += ' -p %s' % p
420
421     commit_id = _output_one_line(cmd, message)
422     if must_switch:
423         __set_head(commit_id)
424
425     return commit_id
426
427 def apply_diff(rev1, rev2):
428     """Apply the diff between rev1 and rev2 onto the current
429     index. This function doesn't need to raise an exception since it
430     is only used for fast-pushing a patch. If this operation fails,
431     the pushing would fall back to the three-way merge.
432     """
433     return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
434                      % (rev1, rev2)) == 0
435
436 def merge(base, head1, head2):
437     """Perform a 3-way merge between base, head1 and head2 into the
438     local tree
439     """
440     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
441         raise GitException, 'git-read-tree failed (local changes maybe?)'
442
443     # this can fail if there are conflicts
444     if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
445         raise GitException, 'git-merge-cache failed (possible conflicts)'
446
447 def status(files = [], modified = False, new = False, deleted = False,
448            conflict = False, unknown = False, noexclude = False):
449     """Show the tree status
450     """
451     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
452     all = not (modified or new or deleted or conflict or unknown)
453
454     if not all:
455         filestat = []
456         if modified:
457             filestat.append('M')
458         if new:
459             filestat.append('A')
460             filestat.append('N')
461         if deleted:
462             filestat.append('D')
463         if conflict:
464             filestat.append('C')
465         if unknown:
466             filestat.append('?')
467         cache_files = [x for x in cache_files if x[0] in filestat]
468
469     for fs in cache_files:
470         if all:
471             print '%s %s' % (fs[0], fs[1])
472         else:
473             print '%s' % fs[1]
474
475 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
476     """Show the diff between rev1 and rev2
477     """
478
479     if rev2:
480         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
481     else:
482         os.system('git-update-index --refresh > /dev/null')
483         diff_str = _output(['git-diff-index', '-p', rev1] + files)
484
485     if out_fd:
486         out_fd.write(diff_str)
487     else:
488         return diff_str
489
490 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
491     """Return the diffstat between rev1 and rev2
492     """
493
494     p=popen2.Popen3('git-apply --stat')
495     diff(files, rev1, rev2, p.tochild)
496     p.tochild.close()
497     str = p.fromchild.read().rstrip()
498     if p.wait():
499         raise GitException, 'git.diffstat failed'
500     return str
501
502 def files(rev1, rev2):
503     """Return the files modified between rev1 and rev2
504     """
505
506     str = ''
507     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
508         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
509
510     return str.rstrip()
511
512 def barefiles(rev1, rev2):
513     """Return the files modified between rev1 and rev2, without status info
514     """
515
516     str = ''
517     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
518         str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
519
520     return str.rstrip()
521
522 def checkout(files = [], tree_id = None, force = False):
523     """Check out the given or all files
524     """
525     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
526         raise GitException, 'Failed git-read-tree -m %s' % tree_id
527
528     checkout_cmd = 'git-checkout-index -q -u'
529     if force:
530         checkout_cmd += ' -f'
531     if len(files) == 0:
532         checkout_cmd += ' -a'
533     else:
534         checkout_cmd += ' --'
535
536     if __run(checkout_cmd, files) != 0:
537         raise GitException, 'Failed git-checkout-index'
538
539 def switch(tree_id):
540     """Switch the tree to the given id
541     """
542     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
543         raise GitException, 'git-read-tree failed (local changes maybe?)'
544
545     __set_head(tree_id)
546
547 def reset(tree_id = None):
548     """Revert the tree changes relative to the given tree_id. It removes
549     any local changes
550     """
551     if not tree_id:
552         tree_id = get_head()
553
554     cache_files = __tree_status(tree_id = tree_id)
555     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
556
557     checkout(tree_id = tree_id, force = True)
558     __set_head(tree_id)
559
560     # checkout doesn't remove files
561     map(os.remove, rm_files)
562
563 def pull(repository = 'origin', refspec = None):
564     """Pull changes from the remote repository. At the moment, just
565     use the 'git pull' command
566     """
567     # 'git pull' updates the HEAD
568     __clear_head_cache()
569
570     args = [repository]
571     if refspec:
572         args.append(refspec)
573
574     if __run('git pull', args) != 0:
575         raise GitException, 'Failed "git pull %s"' % repository
576
577 def apply_patch(filename = None, base = None):
578     """Apply a patch onto the current or given index. There must not
579     be any local changes in the tree, otherwise the command fails
580     """
581     def __apply_patch():
582         if filename:
583             return __run('git-apply --index', [filename]) == 0
584         else:
585             try:
586                 _input('git-apply --index', sys.stdin)
587             except GitException:
588                 return False
589             return True
590
591     os.system('git-update-index --refresh > /dev/null')
592
593     if base:
594         orig_head = get_head()
595         switch(base)
596
597     if not __apply_patch():
598         if base:
599             switch(orig_head)
600         raise GitException, 'Patch does not apply cleanly'
601     elif base:
602         top = commit(message = 'temporary commit used for applying a patch',
603                      parents = [base])
604         switch(orig_head)
605         merge(base, orig_head, top)
606
607 def clone(repository, local_dir):
608     """Clone a remote repository. At the moment, just use the
609     'git clone' script
610     """
611     if __run('git clone', [repository, local_dir]) != 0:
612         raise GitException, 'Failed "git clone %s %s"' \
613               % (repository, local_dir)