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