Cache the base_dir value for subsequent calls
[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, popen2
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27 pass
28
29
30
31 #
32 # Classes
33 #
34 class Commit:
35 """Handle the commit objects
36 """
37 def __init__(self, id_hash):
38 self.__id_hash = id_hash
39
40 lines = _output_lines('git-cat-file commit %s' % id_hash)
41 self.__parents = []
42 for i in range(len(lines)):
43 line = lines[i]
44 if line == '\n':
45 break
46 field = line.strip().split(' ', 1)
47 if field[0] == 'tree':
48 self.__tree = field[1]
49 elif field[0] == 'parent':
50 self.__parents.append(field[1])
51 if field[0] == 'author':
52 self.__author = field[1]
53 if field[0] == 'committer':
54 self.__committer = field[1]
55 self.__log = ''.join(lines[i+1:])
56
57 def get_id_hash(self):
58 return self.__id_hash
59
60 def get_tree(self):
61 return self.__tree
62
63 def get_parent(self):
64 return self.__parents[0]
65
66 def get_parents(self):
67 return self.__parents
68
69 def get_author(self):
70 return self.__author
71
72 def get_committer(self):
73 return self.__committer
74
75 def get_log(self):
76 return self.__log
77
78 # dictionary of Commit objects, used to avoid multiple calls to git
79 __commits = dict()
80
81 #
82 # Functions
83 #
84
85 # GIT_DIR value cached
86 __base_dir = None
87
88 def get_base_dir():
89 """Different start-up variables read from the environment
90 """
91 global __base_dir
92
93 if not __base_dir:
94 if 'GIT_DIR' in os.environ:
95 __base_dir = os.environ['GIT_DIR']
96 else:
97 __base_dir = _output_one_line('git-rev-parse --git-dir')
98
99 return __base_dir
100
101 def get_commit(id_hash):
102 """Commit objects factory. Save/look-up them in the __commits
103 dictionary
104 """
105 global __commits
106
107 if id_hash in __commits:
108 return __commits[id_hash]
109 else:
110 commit = Commit(id_hash)
111 __commits[id_hash] = commit
112 return commit
113
114 def get_conflicts():
115 """Return the list of file conflicts
116 """
117 conflicts_file = os.path.join(get_base_dir(), 'conflicts')
118 if os.path.isfile(conflicts_file):
119 f = file(conflicts_file)
120 names = [line.strip() for line in f.readlines()]
121 f.close()
122 return names
123 else:
124 return None
125
126 def _input(cmd, file_desc):
127 p = popen2.Popen3(cmd, True)
128 while True:
129 line = file_desc.readline()
130 if not line:
131 break
132 p.tochild.write(line)
133 p.tochild.close()
134 if p.wait():
135 raise GitException, '%s failed' % str(cmd)
136
137 def _output(cmd):
138 p=popen2.Popen3(cmd, True)
139 output = p.fromchild.read()
140 if p.wait():
141 raise GitException, '%s failed' % str(cmd)
142 return output
143
144 def _output_one_line(cmd, file_desc = None):
145 p=popen2.Popen3(cmd, True)
146 if file_desc != None:
147 for line in file_desc:
148 p.tochild.write(line)
149 p.tochild.close()
150 output = p.fromchild.readline().strip()
151 if p.wait():
152 raise GitException, '%s failed' % str(cmd)
153 return output
154
155 def _output_lines(cmd):
156 p=popen2.Popen3(cmd, True)
157 lines = p.fromchild.readlines()
158 if p.wait():
159 raise GitException, '%s failed' % str(cmd)
160 return lines
161
162 def __run(cmd, args=None):
163 """__run: runs cmd using spawnvp.
164
165 Runs cmd using spawnvp. The shell is avoided so it won't mess up
166 our arguments. If args is very large, the command is run multiple
167 times; args is split xargs style: cmd is passed on each
168 invocation. Unlike xargs, returns immediately if any non-zero
169 return code is received.
170 """
171
172 args_l=cmd.split()
173 if args is None:
174 args = []
175 for i in range(0, len(args)+1, 100):
176 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
177 if r:
178 return r
179 return 0
180
181 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
182 noexclude = True):
183 """Returns a list of pairs - [status, filename]
184 """
185 refresh_index()
186
187 if not files:
188 files = []
189 cache_files = []
190
191 # unknown files
192 if unknown:
193 exclude_file = os.path.join(get_base_dir(), 'info', 'exclude')
194 base_exclude = ['--exclude=%s' % s for s in
195 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
196 base_exclude.append('--exclude-per-directory=.gitignore')
197
198 if os.path.exists(exclude_file):
199 extra_exclude = ['--exclude-from=%s' % exclude_file]
200 else:
201 extra_exclude = []
202 if noexclude:
203 extra_exclude = base_exclude = []
204
205 lines = _output_lines(['git-ls-files', '--others'] + base_exclude
206 + extra_exclude)
207 cache_files += [('?', line.strip()) for line in lines]
208
209 # conflicted files
210 conflicts = get_conflicts()
211 if not conflicts:
212 conflicts = []
213 cache_files += [('C', filename) for filename in conflicts]
214
215 # the rest
216 for line in _output_lines(['git-diff-index', tree_id] + files):
217 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
218 if fs[1] not in conflicts:
219 cache_files.append(fs)
220
221 return cache_files
222
223 def local_changes():
224 """Return true if there are local changes in the tree
225 """
226 return len(__tree_status()) != 0
227
228 # HEAD value cached
229 __head = None
230
231 def get_head():
232 """Verifies the HEAD and returns the SHA1 id that represents it
233 """
234 global __head
235
236 if not __head:
237 __head = rev_parse('HEAD')
238 return __head
239
240 def get_head_file():
241 """Returns the name of the file pointed to by the HEAD link
242 """
243 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
244
245 def set_head_file(ref):
246 """Resets HEAD to point to a new ref
247 """
248 # head cache flushing is needed since we might have a different value
249 # in the new head
250 __clear_head_cache()
251 if __run('git-symbolic-ref HEAD', [ref]) != 0:
252 raise GitException, 'Could not set head to "%s"' % ref
253
254 def __set_head(val):
255 """Sets the HEAD value
256 """
257 global __head
258
259 if not __head or __head != val:
260 if __run('git-update-ref HEAD', [val]) != 0:
261 raise GitException, 'Could not update HEAD to "%s".' % val
262 __head = val
263
264 # only allow SHA1 hashes
265 assert(len(__head) == 40)
266
267 def __clear_head_cache():
268 """Sets the __head to None so that a re-read is forced
269 """
270 global __head
271
272 __head = None
273
274 def refresh_index():
275 """Refresh index with stat() information from the working directory.
276 """
277 __run('git-update-index -q --unmerged --refresh')
278
279 def rev_parse(git_id):
280 """Parse the string and return a verified SHA1 id
281 """
282 try:
283 return _output_one_line(['git-rev-parse', '--verify', git_id])
284 except GitException:
285 raise GitException, 'Unknown revision: %s' % git_id
286
287 def branch_exists(branch):
288 """Existence check for the named branch
289 """
290 for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
291 if line.strip() == branch:
292 return True
293 return False
294
295 def create_branch(new_branch, tree_id = None):
296 """Create a new branch in the git repository
297 """
298 new_head = os.path.join('refs', 'heads', new_branch)
299 if branch_exists(new_head):
300 raise GitException, 'Branch "%s" already exists' % new_branch
301
302 current_head = get_head()
303 set_head_file(new_head)
304 __set_head(current_head)
305
306 # a checkout isn't needed if new branch points to the current head
307 if tree_id:
308 switch(tree_id)
309
310 if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
311 os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
312
313 def switch_branch(name):
314 """Switch to a git branch
315 """
316 global __head
317
318 new_head = os.path.join('refs', 'heads', name)
319 if not branch_exists(new_head):
320 raise GitException, 'Branch "%s" does not exist' % name
321
322 tree_id = rev_parse(new_head + '^{commit}')
323 if tree_id != get_head():
324 refresh_index()
325 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
326 raise GitException, 'git-read-tree failed (local changes maybe?)'
327 __head = tree_id
328 set_head_file(new_head)
329
330 if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
331 os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
332
333 def delete_branch(name):
334 """Delete a git branch
335 """
336 branch_head = os.path.join('refs', 'heads', name)
337 if not branch_exists(branch_head):
338 raise GitException, 'Branch "%s" does not exist' % name
339 os.remove(os.path.join(get_base_dir(), branch_head))
340
341 def rename_branch(from_name, to_name):
342 """Rename a git branch
343 """
344 from_head = os.path.join('refs', 'heads', from_name)
345 if not branch_exists(from_head):
346 raise GitException, 'Branch "%s" does not exist' % from_name
347 to_head = os.path.join('refs', 'heads', to_name)
348 if branch_exists(to_head):
349 raise GitException, 'Branch "%s" already exists' % to_name
350
351 if get_head_file() == from_name:
352 set_head_file(to_head)
353 os.rename(os.path.join(get_base_dir(), from_head), \
354 os.path.join(get_base_dir(), to_head))
355
356 def add(names):
357 """Add the files or recursively add the directory contents
358 """
359 # generate the file list
360 files = []
361 for i in names:
362 if not os.path.exists(i):
363 raise GitException, 'Unknown file or directory: %s' % i
364
365 if os.path.isdir(i):
366 # recursive search. We only add files
367 for root, dirs, local_files in os.walk(i):
368 for name in [os.path.join(root, f) for f in local_files]:
369 if os.path.isfile(name):
370 files.append(os.path.normpath(name))
371 elif os.path.isfile(i):
372 files.append(os.path.normpath(i))
373 else:
374 raise GitException, '%s is not a file or directory' % i
375
376 if files:
377 if __run('git-update-index --add --', files):
378 raise GitException, 'Unable to add file'
379
380 def rm(files, force = False):
381 """Remove a file from the repository
382 """
383 if not force:
384 for f in files:
385 if os.path.exists(f):
386 raise GitException, '%s exists. Remove it first' %f
387 if files:
388 __run('git-update-index --remove --', files)
389 else:
390 if files:
391 __run('git-update-index --force-remove --', files)
392
393 def update_cache(files = None, force = False):
394 """Update the cache information for the given files
395 """
396 if not files:
397 files = []
398
399 cache_files = __tree_status(files)
400
401 # everything is up-to-date
402 if len(cache_files) == 0:
403 return False
404
405 # check for unresolved conflicts
406 if not force and [x for x in cache_files
407 if x[0] not in ['M', 'N', 'A', 'D']]:
408 raise GitException, 'Updating cache failed: unresolved conflicts'
409
410 # update the cache
411 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
412 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
413 m_files = [x[1] for x in cache_files if x[0] in ['M']]
414
415 if add_files and __run('git-update-index --add --', add_files) != 0:
416 raise GitException, 'Failed git-update-index --add'
417 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
418 raise GitException, 'Failed git-update-index --rm'
419 if m_files and __run('git-update-index --', m_files) != 0:
420 raise GitException, 'Failed git-update-index'
421
422 return True
423
424 def commit(message, files = None, parents = None, allowempty = False,
425 cache_update = True, tree_id = None,
426 author_name = None, author_email = None, author_date = None,
427 committer_name = None, committer_email = None):
428 """Commit the current tree to repository
429 """
430 if not files:
431 files = []
432 if not parents:
433 parents = []
434
435 # Get the tree status
436 if cache_update and parents != []:
437 changes = update_cache(files)
438 if not changes and not allowempty:
439 raise GitException, 'No changes to commit'
440
441 # get the commit message
442 if message[-1:] != '\n':
443 message += '\n'
444
445 must_switch = True
446 # write the index to repository
447 if tree_id == None:
448 tree_id = _output_one_line('git-write-tree')
449 else:
450 must_switch = False
451
452 # the commit
453 cmd = ''
454 if author_name:
455 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
456 if author_email:
457 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
458 if author_date:
459 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
460 if committer_name:
461 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
462 if committer_email:
463 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
464 cmd += 'git-commit-tree %s' % tree_id
465
466 # get the parents
467 for p in parents:
468 cmd += ' -p %s' % p
469
470 commit_id = _output_one_line(cmd, message)
471 if must_switch:
472 __set_head(commit_id)
473
474 return commit_id
475
476 def apply_diff(rev1, rev2, check_index = True):
477 """Apply the diff between rev1 and rev2 onto the current
478 index. This function doesn't need to raise an exception since it
479 is only used for fast-pushing a patch. If this operation fails,
480 the pushing would fall back to the three-way merge.
481 """
482 if check_index:
483 index_opt = '--index'
484 else:
485 index_opt = ''
486 cmd = 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
487 % (rev1, rev2, index_opt)
488
489 return os.system(cmd) == 0
490
491 def merge(base, head1, head2):
492 """Perform a 3-way merge between base, head1 and head2 into the
493 local tree
494 """
495 refresh_index()
496 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
497 raise GitException, 'git-read-tree failed (local changes maybe?)'
498
499 # this can fail if there are conflicts
500 if __run('git-merge-index -o -q gitmergeonefile.py -a') != 0:
501 raise GitException, 'git-merge-index failed (possible conflicts)'
502
503 def status(files = None, modified = False, new = False, deleted = False,
504 conflict = False, unknown = False, noexclude = False):
505 """Show the tree status
506 """
507 if not files:
508 files = []
509
510 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
511 all = not (modified or new or deleted or conflict or unknown)
512
513 if not all:
514 filestat = []
515 if modified:
516 filestat.append('M')
517 if new:
518 filestat.append('A')
519 filestat.append('N')
520 if deleted:
521 filestat.append('D')
522 if conflict:
523 filestat.append('C')
524 if unknown:
525 filestat.append('?')
526 cache_files = [x for x in cache_files if x[0] in filestat]
527
528 for fs in cache_files:
529 if all:
530 print '%s %s' % (fs[0], fs[1])
531 else:
532 print '%s' % fs[1]
533
534 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
535 """Show the diff between rev1 and rev2
536 """
537 if not files:
538 files = []
539
540 if rev1 and rev2:
541 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
542 elif rev1 or rev2:
543 refresh_index()
544 if rev2:
545 diff_str = _output(['git-diff-index', '-p', '-R', rev2] + files)
546 else:
547 diff_str = _output(['git-diff-index', '-p', rev1] + files)
548 else:
549 diff_str = ''
550
551 if out_fd:
552 out_fd.write(diff_str)
553 else:
554 return diff_str
555
556 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
557 """Return the diffstat between rev1 and rev2
558 """
559 if not files:
560 files = []
561
562 p=popen2.Popen3('git-apply --stat')
563 diff(files, rev1, rev2, p.tochild)
564 p.tochild.close()
565 diff_str = p.fromchild.read().rstrip()
566 if p.wait():
567 raise GitException, 'git.diffstat failed'
568 return diff_str
569
570 def files(rev1, rev2):
571 """Return the files modified between rev1 and rev2
572 """
573
574 result = ''
575 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
576 result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
577
578 return result.rstrip()
579
580 def barefiles(rev1, rev2):
581 """Return the files modified between rev1 and rev2, without status info
582 """
583
584 result = ''
585 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
586 result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
587
588 return result.rstrip()
589
590 def checkout(files = None, tree_id = None, force = False):
591 """Check out the given or all files
592 """
593 if not files:
594 files = []
595
596 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
597 raise GitException, 'Failed git-read-tree -m %s' % tree_id
598
599 checkout_cmd = 'git-checkout-index -q -u'
600 if force:
601 checkout_cmd += ' -f'
602 if len(files) == 0:
603 checkout_cmd += ' -a'
604 else:
605 checkout_cmd += ' --'
606
607 if __run(checkout_cmd, files) != 0:
608 raise GitException, 'Failed git-checkout-index'
609
610 def switch(tree_id):
611 """Switch the tree to the given id
612 """
613 refresh_index()
614 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
615 raise GitException, 'git-read-tree failed (local changes maybe?)'
616
617 __set_head(tree_id)
618
619 def reset(files = None, tree_id = None):
620 """Revert the tree changes relative to the given tree_id. It removes
621 any local changes
622 """
623 if not tree_id:
624 tree_id = get_head()
625
626 checkout(files, tree_id, True)
627
628 # if the reset refers to the whole tree, switch the HEAD as well
629 if not files:
630 __set_head(tree_id)
631
632 def pull(repository = 'origin', refspec = None):
633 """Pull changes from the remote repository. At the moment, just
634 use the 'git pull' command
635 """
636 # 'git pull' updates the HEAD
637 __clear_head_cache()
638
639 args = [repository]
640 if refspec:
641 args.append(refspec)
642
643 if __run('git pull', args) != 0:
644 raise GitException, 'Failed "git pull %s"' % repository
645
646 def apply_patch(filename = None, base = None):
647 """Apply a patch onto the current or given index. There must not
648 be any local changes in the tree, otherwise the command fails
649 """
650 def __apply_patch():
651 if filename:
652 return __run('git-apply --index', [filename]) == 0
653 else:
654 try:
655 _input('git-apply --index', sys.stdin)
656 except GitException:
657 return False
658 return True
659
660 if base:
661 orig_head = get_head()
662 switch(base)
663 else:
664 refresh_index() # needed since __apply_patch() doesn't do it
665
666 if not __apply_patch():
667 if base:
668 switch(orig_head)
669 raise GitException, 'Patch does not apply cleanly'
670 elif base:
671 top = commit(message = 'temporary commit used for applying a patch',
672 parents = [base])
673 switch(orig_head)
674 merge(base, orig_head, top)
675
676 def clone(repository, local_dir):
677 """Clone a remote repository. At the moment, just use the
678 'git clone' script
679 """
680 if __run('git clone', [repository, local_dir]) != 0:
681 raise GitException, 'Failed "git clone %s %s"' \
682 % (repository, local_dir)
683
684 def modifying_revs(files, base_rev):
685 """Return the revisions from the list modifying the given files
686 """
687 cmd = ['git-rev-list', '%s..' % base_rev, '--']
688 revs = [line.strip() for line in _output_lines(cmd + files)]
689
690 return revs