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