Cache the HEAD value
[stgit] / stgit / git.py
... / ...
CommitLineData
1"""Python GIT interface
2"""
3
4__copyright__ = """
5Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7This program is free software; you can redistribute it and/or modify
8it under the terms of the GNU General Public License version 2 as
9published by the Free Software Foundation.
10
11This program is distributed in the hope that it will be useful,
12but WITHOUT ANY WARRANTY; without even the implied warranty of
13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14GNU General Public License for more details.
15
16You should have received a copy of the GNU General Public License
17along with this program; if not, write to the Free Software
18Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19"""
20
21import sys, os, glob, popen2
22
23from stgit.utils import *
24
25# git exception class
26class GitException(Exception):
27 pass
28
29
30# Different start-up variables read from the environment
31if 'GIT_DIR' in os.environ:
32 base_dir = os.environ['GIT_DIR']
33else:
34 base_dir = '.git'
35
36head_link = os.path.join(base_dir, 'HEAD')
37
38#
39# Classes
40#
41class 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#
91def 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
102def 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
114def _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
125def _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
132def _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
143def _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
150def __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
169def __check_base_dir():
170 return os.path.isdir(base_dir)
171
172def __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
212def local_changes():
213 """Return true if there are local changes in the tree
214 """
215 return len(__tree_status()) != 0
216
217# HEAD value cached
218__head = None
219
220def get_head():
221 """Verifies the HEAD and returns the SHA1 id that represents it
222 """
223 global __head
224
225 if not __head:
226 __head = rev_parse('HEAD')
227 return __head
228
229def get_head_file():
230 """Returns the name of the file pointed to by the HEAD link
231 """
232 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
233
234def __set_head(val):
235 """Sets the HEAD value
236 """
237 global __head
238
239 __head = val
240 if __run('git-update-ref HEAD', [val]) != 0:
241 raise GitException, 'Could not update HEAD to "%s".' % val
242
243def rev_parse(git_id):
244 """Parse the string and return a verified SHA1 id
245 """
246 return _output_one_line(['git-rev-parse', '--verify', git_id])
247
248def add(names):
249 """Add the files or recursively add the directory contents
250 """
251 # generate the file list
252 files = []
253 for i in names:
254 if not os.path.exists(i):
255 raise GitException, 'Unknown file or directory: %s' % i
256
257 if os.path.isdir(i):
258 # recursive search. We only add files
259 for root, dirs, local_files in os.walk(i):
260 for name in [os.path.join(root, f) for f in local_files]:
261 if os.path.isfile(name):
262 files.append(os.path.normpath(name))
263 elif os.path.isfile(i):
264 files.append(os.path.normpath(i))
265 else:
266 raise GitException, '%s is not a file or directory' % i
267
268 if files:
269 if __run('git-update-index --add --', files):
270 raise GitException, 'Unable to add file'
271
272def rm(files, force = False):
273 """Remove a file from the repository
274 """
275 if force:
276 git_opt = '--force-remove'
277 else:
278 git_opt = '--remove'
279
280 if not force:
281 for f in files:
282 if os.path.exists(f):
283 raise GitException, '%s exists. Remove it first' %f
284 if files:
285 __run('git-update-index --remove --', files)
286 else:
287 if files:
288 __run('git-update-index --force-remove --', files)
289
290def update_cache(files = [], force = False):
291 """Update the cache information for the given files
292 """
293 cache_files = __tree_status(files)
294
295 # everything is up-to-date
296 if len(cache_files) == 0:
297 return False
298
299 # check for unresolved conflicts
300 if not force and [x for x in cache_files
301 if x[0] not in ['M', 'N', 'A', 'D']]:
302 raise GitException, 'Updating cache failed: unresolved conflicts'
303
304 # update the cache
305 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
306 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
307 m_files = [x[1] for x in cache_files if x[0] in ['M']]
308
309 if add_files and __run('git-update-index --add --', add_files) != 0:
310 raise GitException, 'Failed git-update-index --add'
311 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
312 raise GitException, 'Failed git-update-index --rm'
313 if m_files and __run('git-update-index --', m_files) != 0:
314 raise GitException, 'Failed git-update-index'
315
316 return True
317
318def commit(message, files = [], parents = [], allowempty = False,
319 cache_update = True, tree_id = None,
320 author_name = None, author_email = None, author_date = None,
321 committer_name = None, committer_email = None):
322 """Commit the current tree to repository
323 """
324 # Get the tree status
325 if cache_update and parents != []:
326 changes = update_cache(files)
327 if not changes and not allowempty:
328 raise GitException, 'No changes to commit'
329
330 # get the commit message
331 if message[-1:] != '\n':
332 message += '\n'
333
334 must_switch = True
335 # write the index to repository
336 if tree_id == None:
337 tree_id = _output_one_line('git-write-tree')
338 else:
339 must_switch = False
340
341 # the commit
342 cmd = ''
343 if author_name:
344 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
345 if author_email:
346 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
347 if author_date:
348 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
349 if committer_name:
350 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
351 if committer_email:
352 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
353 cmd += 'git-commit-tree %s' % tree_id
354
355 # get the parents
356 for p in parents:
357 cmd += ' -p %s' % p
358
359 commit_id = _output_one_line(cmd, message)
360 if must_switch:
361 __set_head(commit_id)
362
363 return commit_id
364
365def apply_diff(rev1, rev2):
366 """Apply the diff between rev1 and rev2 onto the current
367 index. This function doesn't need to raise an exception since it
368 is only used for fast-pushing a patch. If this operation fails,
369 the pushing would fall back to the three-way merge.
370 """
371 return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
372 % (rev1, rev2)) == 0
373
374def merge(base, head1, head2):
375 """Perform a 3-way merge between base, head1 and head2 into the
376 local tree
377 """
378 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
379 raise GitException, 'git-read-tree failed (local changes maybe?)'
380
381 # this can fail if there are conflicts
382 if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
383 raise GitException, 'git-merge-cache failed (possible conflicts)'
384
385def status(files = [], modified = False, new = False, deleted = False,
386 conflict = False, unknown = False, noexclude = False):
387 """Show the tree status
388 """
389 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
390 all = not (modified or new or deleted or conflict or unknown)
391
392 if not all:
393 filestat = []
394 if modified:
395 filestat.append('M')
396 if new:
397 filestat.append('A')
398 filestat.append('N')
399 if deleted:
400 filestat.append('D')
401 if conflict:
402 filestat.append('C')
403 if unknown:
404 filestat.append('?')
405 cache_files = [x for x in cache_files if x[0] in filestat]
406
407 for fs in cache_files:
408 if all:
409 print '%s %s' % (fs[0], fs[1])
410 else:
411 print '%s' % fs[1]
412
413def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
414 """Show the diff between rev1 and rev2
415 """
416
417 if rev2:
418 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
419 else:
420 os.system('git-update-index --refresh > /dev/null')
421 diff_str = _output(['git-diff-index', '-p', rev1] + files)
422
423 if out_fd:
424 out_fd.write(diff_str)
425 else:
426 return diff_str
427
428def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
429 """Return the diffstat between rev1 and rev2
430 """
431
432 p=popen2.Popen3('git-apply --stat')
433 diff(files, rev1, rev2, p.tochild)
434 p.tochild.close()
435 str = p.fromchild.read().rstrip()
436 if p.wait():
437 raise GitException, 'git.diffstat failed'
438 return str
439
440def files(rev1, rev2):
441 """Return the files modified between rev1 and rev2
442 """
443
444 str = ''
445 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
446 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
447
448 return str.rstrip()
449
450def barefiles(rev1, rev2):
451 """Return the files modified between rev1 and rev2, without status info
452 """
453
454 str = ''
455 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
456 str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
457
458 return str.rstrip()
459
460def checkout(files = [], tree_id = None, force = False):
461 """Check out the given or all files
462 """
463 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
464 raise GitException, 'Failed git-read-tree -m %s' % tree_id
465
466 checkout_cmd = 'git-checkout-index -q -u'
467 if force:
468 checkout_cmd += ' -f'
469 if len(files) == 0:
470 checkout_cmd += ' -a'
471 else:
472 checkout_cmd += ' --'
473
474 if __run(checkout_cmd, files) != 0:
475 raise GitException, 'Failed git-checkout-index'
476
477def switch(tree_id):
478 """Switch the tree to the given id
479 """
480 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
481 raise GitException, 'git-read-tree failed (local changes maybe?)'
482
483 __set_head(tree_id)
484
485def reset(tree_id = None):
486 """Revert the tree changes relative to the given tree_id. It removes
487 any local changes
488 """
489 if not tree_id:
490 tree_id = get_head()
491
492 cache_files = __tree_status(tree_id = tree_id)
493 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
494
495 checkout(tree_id = tree_id, force = True)
496 __set_head(tree_id)
497
498 # checkout doesn't remove files
499 map(os.remove, rm_files)
500
501def pull(repository = 'origin', refspec = None):
502 """Pull changes from the remote repository. At the moment, just
503 use the 'git pull' command
504 """
505 args = [repository]
506 if refspec:
507 args.append(refspec)
508
509 if __run('git pull', args) != 0:
510 raise GitException, 'Failed "git pull %s"' % repository
511
512def apply_patch(filename = None):
513 """Apply a patch onto the current index. There must not be any
514 local changes in the tree, otherwise the command fails
515 """
516 os.system('git-update-index --refresh > /dev/null')
517
518 if filename:
519 if __run('git-apply --index', [filename]) != 0:
520 raise GitException, 'Patch does not apply cleanly'
521 else:
522 _input('git-apply --index', sys.stdin)
523
524def clone(repository, local_dir):
525 """Clone a remote repository. At the moment, just use the
526 'git clone' script
527 """
528 if __run('git clone', [repository, local_dir]) != 0:
529 raise GitException, 'Failed "git clone %s %s"' \
530 % (repository, local_dir)