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