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