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