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