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