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