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)