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