chiark / gitweb /
Make 'stg pull' use 'git pull' directly
[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         for i in range(len(lines)):
49             line = lines[i]
50             if line == '\n':
51                 break
52             field = line.strip().split(' ', 1)
53             if field[0] == 'tree':
54                 self.__tree = field[1]
55             elif field[0] == 'parent':
56                 self.__parent = field[1]
57             if field[0] == 'author':
58                 self.__author = field[1]
59             if field[0] == 'comitter':
60                 self.__committer = field[1]
61         self.__log = ''.join(lines[i:])
62
63     def get_id_hash(self):
64         return self.__id_hash
65
66     def get_tree(self):
67         return self.__tree
68
69     def get_parent(self):
70         return self.__parent
71
72     def get_author(self):
73         return self.__author
74
75     def get_committer(self):
76         return self.__committer
77
78
79 #
80 # Functions
81 #
82 def get_conflicts():
83     """Return the list of file conflicts
84     """
85     conflicts_file = os.path.join(base_dir, 'conflicts')
86     if os.path.isfile(conflicts_file):
87         f = file(conflicts_file)
88         names = [line.strip() for line in f.readlines()]
89         f.close()
90         return names
91     else:
92         return None
93
94 def _input(cmd, file_desc):
95     p = popen2.Popen3(cmd)
96     for line in file_desc:
97         p.tochild.write(line)
98     p.tochild.close()
99     if p.wait():
100         raise GitException, '%s failed' % str(cmd)
101
102 def _output(cmd):
103     p=popen2.Popen3(cmd)
104     string = p.fromchild.read()
105     if p.wait():
106         raise GitException, '%s failed' % str(cmd)
107     return string
108
109 def _output_one_line(cmd):
110     p=popen2.Popen3(cmd)
111     string = p.fromchild.readline().strip()
112     if p.wait():
113         raise GitException, '%s failed' % str(cmd)
114     return string
115
116 def _output_lines(cmd):
117     p=popen2.Popen3(cmd)
118     lines = p.fromchild.readlines()
119     if p.wait():
120         raise GitException, '%s failed' % str(cmd)
121     return lines
122
123 def __run(cmd, args=None):
124     """__run: runs cmd using spawnvp.
125
126     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
127     our arguments.  If args is very large, the command is run multiple
128     times; args is split xargs style: cmd is passed on each
129     invocation.  Unlike xargs, returns immediately if any non-zero
130     return code is received.  
131     """
132     
133     args_l=cmd.split()
134     if args is None:
135         args = []
136     for i in range(0, len(args)+1, 100):
137         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
138     if r:
139         return r
140     return 0
141
142 def __check_base_dir():
143     return os.path.isdir(base_dir)
144
145 def __tree_status(files = [], tree_id = 'HEAD', unknown = False):
146     """Returns a list of pairs - [status, filename]
147     """
148     os.system('git-update-cache --refresh > /dev/null')
149
150     cache_files = []
151
152     # unknown files
153     if unknown:
154         exclude_file = os.path.join(base_dir, 'exclude')
155         extra_exclude = []
156         if os.path.exists(exclude_file):
157             extra_exclude.append('--exclude-from=%s' % exclude_file)
158         lines = _output_lines(['git-ls-files', '--others',
159                         '--exclude=*.[ao]', '--exclude=.*'
160                         '--exclude=TAGS', '--exclude=tags', '--exclude=*~',
161                         '--exclude=#*'] + extra_exclude)
162         cache_files += [('?', line.strip()) for line in lines]
163
164     # conflicted files
165     conflicts = get_conflicts()
166     if not conflicts:
167         conflicts = []
168     cache_files += [('C', filename) for filename in conflicts]
169
170     # the rest
171     for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
172         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
173         if fs[1] not in conflicts:
174             cache_files.append(fs)
175
176     return cache_files
177
178 def local_changes():
179     """Return true if there are local changes in the tree
180     """
181     return len(__tree_status()) != 0
182
183 def get_head():
184     """Returns a string representing the HEAD
185     """
186     return read_string(head_link)
187
188 def get_head_file():
189     """Returns the name of the file pointed to by the HEAD link
190     """
191     # valid link
192     if os.path.islink(head_link) and os.path.isfile(head_link):
193         return os.path.basename(os.readlink(head_link))
194     else:
195         raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
196
197 def __set_head(val):
198     """Sets the HEAD value
199     """
200     write_string(head_link, val)
201
202 def add(names):
203     """Add the files or recursively add the directory contents
204     """
205     # generate the file list
206     files = []
207     for i in names:
208         if not os.path.exists(i):
209             raise GitException, 'Unknown file or directory: %s' % i
210
211         if os.path.isdir(i):
212             # recursive search. We only add files
213             for root, dirs, local_files in os.walk(i):
214                 for name in [os.path.join(root, f) for f in local_files]:
215                     if os.path.isfile(name):
216                         files.append(os.path.normpath(name))
217         elif os.path.isfile(i):
218             files.append(os.path.normpath(i))
219         else:
220             raise GitException, '%s is not a file or directory' % i
221
222     if files:
223         if __run('git-update-cache --add --', files):
224             raise GitException, 'Unable to add file'
225
226 def rm(files, force = False):
227     """Remove a file from the repository
228     """
229     if force:
230         git_opt = '--force-remove'
231     else:
232         git_opt = '--remove'
233
234     if not force:
235         for f in files:
236             if os.path.exists(f):
237                 raise GitException, '%s exists. Remove it first' %f
238         if files:
239             __run('git-update-cache --remove --', files)
240     else:
241         if files:
242             __run('git-update-cache --force-remove --', files)
243
244 def update_cache(files):
245     """Update the cache information for the given files
246     """
247     files_here = []
248     files_gone = []
249
250     for f in files:
251         if os.path.exists(f):
252             files_here.append(f)
253         else:
254             files_gone.append(f)
255
256     if files_here:
257         __run('git-update-cache --', files_here)
258     if files_gone:
259         __run('git-update-cache --remove --', files_gone)
260
261 def commit(message, files = [], parents = [], allowempty = False,
262            author_name = None, author_email = None, author_date = None,
263            committer_name = None, committer_email = None):
264     """Commit the current tree to repository
265     """
266     first = (parents == [])
267
268     # Get the tree status
269     if not first:
270         cache_files = __tree_status(files)
271
272     if not first and len(cache_files) == 0 and not allowempty:
273         raise GitException, 'No changes to commit'
274
275     # check for unresolved conflicts
276     if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'A', 'D'],
277                                 cache_files)) != 0:
278         raise GitException, 'Commit failed: unresolved conflicts'
279
280     # get the commit message
281     f = file('.commitmsg', 'w+')
282     if message[-1] == '\n':
283         f.write(message)
284     else:
285         print >> f, message
286     f.close()
287
288     # update the cache
289     if not first:
290         add_files=[]
291         rm_files=[]
292         m_files=[]
293         for f in cache_files:
294             if f[0] in ['N', 'A']:
295                 add_files.append(f[1])
296             elif f[0] == 'D':
297                 rm_files.append(f[1])
298             else:
299                 m_files.append(f[1])
300
301     if add_files:
302         if __run('git-update-cache --add --', add_files):
303             raise GitException, 'Failed git-update-cache --add'
304     if rm_files:
305         if __run('git-update-cache --force-remove --', rm_files):
306             raise GitException, 'Failed git-update-cache --rm'
307     if m_files:
308         if __run('git-update-cache --', m_files):
309             raise GitException, 'Failed git-update-cache'
310
311     # write the index to repository
312     tree_id = _output_one_line('git-write-tree')
313
314     # the commit
315     cmd = ''
316     if author_name:
317         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
318     if author_email:
319         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
320     if author_date:
321         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
322     if committer_name:
323         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
324     if committer_email:
325         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
326     cmd += 'git-commit-tree %s' % tree_id
327
328     # get the parents
329     for p in parents:
330         cmd += ' -p %s' % p
331
332     cmd += ' < .commitmsg'
333
334     commit_id = _output_one_line(cmd)
335     __set_head(commit_id)
336     os.remove('.commitmsg')
337
338     return commit_id
339
340 def merge(base, head1, head2):
341     """Perform a 3-way merge between base, head1 and head2 into the
342     local tree
343     """
344     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
345         raise GitException, 'git-read-tree failed (local changes maybe?)'
346
347     # this can fail if there are conflicts
348     if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
349         raise GitException, 'git-merge-cache failed (possible conflicts)'
350
351     # this should not fail
352     if os.system('git-checkout-cache -f -a') != 0:
353         raise GitException, 'Failed git-checkout-cache'
354
355 def status(files = [], modified = False, new = False, deleted = False,
356            conflict = False, unknown = False):
357     """Show the tree status
358     """
359     cache_files = __tree_status(files, unknown = True)
360     all = not (modified or new or deleted or conflict or unknown)
361
362     if not all:
363         filestat = []
364         if modified:
365             filestat.append('M')
366         if new:
367             filestat.append('A')
368             filestat.append('N')
369         if deleted:
370             filestat.append('D')
371         if conflict:
372             filestat.append('C')
373         if unknown:
374             filestat.append('?')
375         cache_files = filter(lambda x: x[0] in filestat, cache_files)
376
377     for fs in cache_files:
378         if all:
379             print '%s %s' % (fs[0], fs[1])
380         else:
381             print '%s' % fs[1]
382
383 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
384     """Show the diff between rev1 and rev2
385     """
386     os.system('git-update-cache --refresh > /dev/null')
387
388     if rev2:
389         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
390     else:
391         diff_str = _output(['git-diff-cache', '-p', rev1] + files)
392
393     if out_fd:
394         out_fd.write(diff_str)
395     else:
396         return diff_str
397
398 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
399     """Return the diffstat between rev1 and rev2
400     """
401
402     os.system('git-update-cache --refresh > /dev/null')
403     p=popen2.Popen3('git-apply --stat')
404     diff(files, rev1, rev2, p.tochild)
405     p.tochild.close()
406     str = p.fromchild.read().rstrip()
407     if p.wait():
408         raise GitException, 'git.diffstat failed'
409     return str
410
411 def files(rev1, rev2):
412     """Return the files modified between rev1 and rev2
413     """
414     os.system('git-update-cache --refresh > /dev/null')
415
416     str = ''
417     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
418         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
419
420     return str.rstrip()
421
422 def checkout(files = [], tree_id = None, force = False):
423     """Check out the given or all files
424     """
425     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
426         raise GitException, 'Failed git-read-tree -m %s' % tree_id
427
428     checkout_cmd = 'git-checkout-cache -q -u'
429     if force:
430         checkout_cmd += ' -f'
431     if len(files) == 0:
432         checkout_cmd += ' -a'
433     else:
434         checkout_cmd += ' --'
435
436     if __run(checkout_cmd, files) != 0:
437         raise GitException, 'Failed git-checkout-cache'
438
439 def switch(tree_id):
440     """Switch the tree to the given id
441     """
442     to_delete = filter(lambda x: x[0] in ['N', 'A'],
443                        __tree_status(tree_id = tree_id))
444
445     checkout(tree_id = tree_id, force = True)
446     __set_head(tree_id)
447
448     # checkout doesn't remove files
449     for fs in to_delete:
450         os.remove(fs[1])
451
452 def pull(location, head = None, tag = None):
453     """Fetch changes from the remote repository. At the moment, just
454     use the 'git fetch' scripts
455     """
456     args = [location]
457     if head:
458         args += [head]
459     elif tag:
460         args += ['tag', tag]
461
462     if __run('git pull', args) != 0:
463         raise GitException, 'Failed "git fetch %s"' % location
464
465 def apply_patch(filename = None):
466     """Apply a patch onto the current index. There must not be any
467     local changes in the tree, otherwise the command fails
468     """
469     os.system('git-update-cache --refresh > /dev/null')
470
471     if filename:
472         if __run('git-apply --index', [filename]) != 0:
473             raise GitException, 'Patch does not apply cleanly'
474     else:
475         _input('git-apply --index', sys.stdin)
476
477 def clone(repository, local_dir):
478     """Clone a remote repository. At the moment, just use the
479     'git clone' script
480     """
481     if __run('git clone', [repository, local_dir]) != 0:
482         raise GitException, 'Failed "git clone %s %s"' \
483               % (repository, local_dir)