chiark / gitweb /
Cache the HEAD value
[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     if id_hash in __commits:
96         return __commits[id_hash]
97     else:
98         commit = Commit(id_hash)
99         __commits[id_hash] = commit
100         return commit
101
102 def get_conflicts():
103     """Return the list of file conflicts
104     """
105     conflicts_file = os.path.join(base_dir, 'conflicts')
106     if os.path.isfile(conflicts_file):
107         f = file(conflicts_file)
108         names = [line.strip() for line in f.readlines()]
109         f.close()
110         return names
111     else:
112         return None
113
114 def _input(cmd, file_desc):
115     p = popen2.Popen3(cmd)
116     while True:
117         line = file_desc.readline()
118         if not line:
119             break
120         p.tochild.write(line)
121     p.tochild.close()
122     if p.wait():
123         raise GitException, '%s failed' % str(cmd)
124
125 def _output(cmd):
126     p=popen2.Popen3(cmd)
127     string = p.fromchild.read()
128     if p.wait():
129         raise GitException, '%s failed' % str(cmd)
130     return string
131
132 def _output_one_line(cmd, file_desc = None):
133     p=popen2.Popen3(cmd)
134     if file_desc != None:
135         for line in file_desc:
136             p.tochild.write(line)
137         p.tochild.close()
138     string = p.fromchild.readline().strip()
139     if p.wait():
140         raise GitException, '%s failed' % str(cmd)
141     return string
142
143 def _output_lines(cmd):
144     p=popen2.Popen3(cmd)
145     lines = p.fromchild.readlines()
146     if p.wait():
147         raise GitException, '%s failed' % str(cmd)
148     return lines
149
150 def __run(cmd, args=None):
151     """__run: runs cmd using spawnvp.
152
153     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
154     our arguments.  If args is very large, the command is run multiple
155     times; args is split xargs style: cmd is passed on each
156     invocation.  Unlike xargs, returns immediately if any non-zero
157     return code is received.  
158     """
159     
160     args_l=cmd.split()
161     if args is None:
162         args = []
163     for i in range(0, len(args)+1, 100):
164         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
165     if r:
166         return r
167     return 0
168
169 def __check_base_dir():
170     return os.path.isdir(base_dir)
171
172 def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
173                   noexclude = True):
174     """Returns a list of pairs - [status, filename]
175     """
176     os.system('git-update-index --refresh > /dev/null')
177
178     cache_files = []
179
180     # unknown files
181     if unknown:
182         exclude_file = os.path.join(base_dir, 'info', 'exclude')
183         base_exclude = ['--exclude=%s' % s for s in
184                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
185         base_exclude.append('--exclude-per-directory=.gitignore')
186
187         if os.path.exists(exclude_file):
188             extra_exclude = ['--exclude-from=%s' % exclude_file]
189         else:
190             extra_exclude = []
191         if noexclude:
192             extra_exclude = base_exclude = []
193
194         lines = _output_lines(['git-ls-files', '--others'] + base_exclude
195                         + extra_exclude)
196         cache_files += [('?', line.strip()) for line in lines]
197
198     # conflicted files
199     conflicts = get_conflicts()
200     if not conflicts:
201         conflicts = []
202     cache_files += [('C', filename) for filename in conflicts]
203
204     # the rest
205     for line in _output_lines(['git-diff-index', '-r', tree_id] + files):
206         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
207         if fs[1] not in conflicts:
208             cache_files.append(fs)
209
210     return cache_files
211
212 def local_changes():
213     """Return true if there are local changes in the tree
214     """
215     return len(__tree_status()) != 0
216
217 # HEAD value cached
218 __head = None
219
220 def get_head():
221     """Verifies the HEAD and returns the SHA1 id that represents it
222     """
223     global __head
224
225     if not __head:
226         __head = rev_parse('HEAD')
227     return __head
228
229 def get_head_file():
230     """Returns the name of the file pointed to by the HEAD link
231     """
232     return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
233
234 def __set_head(val):
235     """Sets the HEAD value
236     """
237     global __head
238
239     __head = val
240     if __run('git-update-ref HEAD', [val]) != 0:
241         raise GitException, 'Could not update HEAD to "%s".' % val
242
243 def rev_parse(git_id):
244     """Parse the string and return a verified SHA1 id
245     """
246     return _output_one_line(['git-rev-parse', '--verify', git_id])
247
248 def add(names):
249     """Add the files or recursively add the directory contents
250     """
251     # generate the file list
252     files = []
253     for i in names:
254         if not os.path.exists(i):
255             raise GitException, 'Unknown file or directory: %s' % i
256
257         if os.path.isdir(i):
258             # recursive search. We only add files
259             for root, dirs, local_files in os.walk(i):
260                 for name in [os.path.join(root, f) for f in local_files]:
261                     if os.path.isfile(name):
262                         files.append(os.path.normpath(name))
263         elif os.path.isfile(i):
264             files.append(os.path.normpath(i))
265         else:
266             raise GitException, '%s is not a file or directory' % i
267
268     if files:
269         if __run('git-update-index --add --', files):
270             raise GitException, 'Unable to add file'
271
272 def rm(files, force = False):
273     """Remove a file from the repository
274     """
275     if force:
276         git_opt = '--force-remove'
277     else:
278         git_opt = '--remove'
279
280     if not force:
281         for f in files:
282             if os.path.exists(f):
283                 raise GitException, '%s exists. Remove it first' %f
284         if files:
285             __run('git-update-index --remove --', files)
286     else:
287         if files:
288             __run('git-update-index --force-remove --', files)
289
290 def update_cache(files = [], force = False):
291     """Update the cache information for the given files
292     """
293     cache_files = __tree_status(files)
294
295     # everything is up-to-date
296     if len(cache_files) == 0:
297         return False
298
299     # check for unresolved conflicts
300     if not force and [x for x in cache_files
301                       if x[0] not in ['M', 'N', 'A', 'D']]:
302         raise GitException, 'Updating cache failed: unresolved conflicts'
303
304     # update the cache
305     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
306     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
307     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
308
309     if add_files and __run('git-update-index --add --', add_files) != 0:
310         raise GitException, 'Failed git-update-index --add'
311     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
312         raise GitException, 'Failed git-update-index --rm'
313     if m_files and __run('git-update-index --', m_files) != 0:
314         raise GitException, 'Failed git-update-index'
315
316     return True
317
318 def commit(message, files = [], parents = [], allowempty = False,
319            cache_update = True, tree_id = None,
320            author_name = None, author_email = None, author_date = None,
321            committer_name = None, committer_email = None):
322     """Commit the current tree to repository
323     """
324     # Get the tree status
325     if cache_update and parents != []:
326         changes = update_cache(files)
327         if not changes and not allowempty:
328             raise GitException, 'No changes to commit'
329
330     # get the commit message
331     if message[-1:] != '\n':
332         message += '\n'
333
334     must_switch = True
335     # write the index to repository
336     if tree_id == None:
337         tree_id = _output_one_line('git-write-tree')
338     else:
339         must_switch = False
340
341     # the commit
342     cmd = ''
343     if author_name:
344         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
345     if author_email:
346         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
347     if author_date:
348         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
349     if committer_name:
350         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
351     if committer_email:
352         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
353     cmd += 'git-commit-tree %s' % tree_id
354
355     # get the parents
356     for p in parents:
357         cmd += ' -p %s' % p
358
359     commit_id = _output_one_line(cmd, message)
360     if must_switch:
361         __set_head(commit_id)
362
363     return commit_id
364
365 def apply_diff(rev1, rev2):
366     """Apply the diff between rev1 and rev2 onto the current
367     index. This function doesn't need to raise an exception since it
368     is only used for fast-pushing a patch. If this operation fails,
369     the pushing would fall back to the three-way merge.
370     """
371     return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
372                      % (rev1, rev2)) == 0
373
374 def merge(base, head1, head2):
375     """Perform a 3-way merge between base, head1 and head2 into the
376     local tree
377     """
378     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
379         raise GitException, 'git-read-tree failed (local changes maybe?)'
380
381     # this can fail if there are conflicts
382     if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
383         raise GitException, 'git-merge-cache failed (possible conflicts)'
384
385 def status(files = [], modified = False, new = False, deleted = False,
386            conflict = False, unknown = False, noexclude = False):
387     """Show the tree status
388     """
389     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
390     all = not (modified or new or deleted or conflict or unknown)
391
392     if not all:
393         filestat = []
394         if modified:
395             filestat.append('M')
396         if new:
397             filestat.append('A')
398             filestat.append('N')
399         if deleted:
400             filestat.append('D')
401         if conflict:
402             filestat.append('C')
403         if unknown:
404             filestat.append('?')
405         cache_files = [x for x in cache_files if x[0] in filestat]
406
407     for fs in cache_files:
408         if all:
409             print '%s %s' % (fs[0], fs[1])
410         else:
411             print '%s' % fs[1]
412
413 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
414     """Show the diff between rev1 and rev2
415     """
416
417     if rev2:
418         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
419     else:
420         os.system('git-update-index --refresh > /dev/null')
421         diff_str = _output(['git-diff-index', '-p', rev1] + files)
422
423     if out_fd:
424         out_fd.write(diff_str)
425     else:
426         return diff_str
427
428 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
429     """Return the diffstat between rev1 and rev2
430     """
431
432     p=popen2.Popen3('git-apply --stat')
433     diff(files, rev1, rev2, p.tochild)
434     p.tochild.close()
435     str = p.fromchild.read().rstrip()
436     if p.wait():
437         raise GitException, 'git.diffstat failed'
438     return str
439
440 def files(rev1, rev2):
441     """Return the files modified between rev1 and rev2
442     """
443
444     str = ''
445     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
446         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
447
448     return str.rstrip()
449
450 def barefiles(rev1, rev2):
451     """Return the files modified between rev1 and rev2, without status info
452     """
453
454     str = ''
455     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
456         str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
457
458     return str.rstrip()
459
460 def checkout(files = [], tree_id = None, force = False):
461     """Check out the given or all files
462     """
463     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
464         raise GitException, 'Failed git-read-tree -m %s' % tree_id
465
466     checkout_cmd = 'git-checkout-index -q -u'
467     if force:
468         checkout_cmd += ' -f'
469     if len(files) == 0:
470         checkout_cmd += ' -a'
471     else:
472         checkout_cmd += ' --'
473
474     if __run(checkout_cmd, files) != 0:
475         raise GitException, 'Failed git-checkout-index'
476
477 def switch(tree_id):
478     """Switch the tree to the given id
479     """
480     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
481         raise GitException, 'git-read-tree failed (local changes maybe?)'
482
483     __set_head(tree_id)
484
485 def reset(tree_id = None):
486     """Revert the tree changes relative to the given tree_id. It removes
487     any local changes
488     """
489     if not tree_id:
490         tree_id = get_head()
491
492     cache_files = __tree_status(tree_id = tree_id)
493     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
494
495     checkout(tree_id = tree_id, force = True)
496     __set_head(tree_id)
497
498     # checkout doesn't remove files
499     map(os.remove, rm_files)
500
501 def pull(repository = 'origin', refspec = None):
502     """Pull changes from the remote repository. At the moment, just
503     use the 'git pull' command
504     """
505     args = [repository]
506     if refspec:
507         args.append(refspec)
508
509     if __run('git pull', args) != 0:
510         raise GitException, 'Failed "git pull %s"' % repository
511
512 def apply_patch(filename = None):
513     """Apply a patch onto the current index. There must not be any
514     local changes in the tree, otherwise the command fails
515     """
516     os.system('git-update-index --refresh > /dev/null')
517
518     if filename:
519         if __run('git-apply --index', [filename]) != 0:
520             raise GitException, 'Patch does not apply cleanly'
521     else:
522         _input('git-apply --index', sys.stdin)
523
524 def clone(repository, local_dir):
525     """Clone a remote repository. At the moment, just use the
526     'git clone' script
527     """
528     if __run('git clone', [repository, local_dir]) != 0:
529         raise GitException, 'Failed "git clone %s %s"' \
530               % (repository, local_dir)