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