984b74922a539c335d2eb1cf1f02161114a41a5d
1 """Python GIT interface
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
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.
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.
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
21 import sys
, os
, popen2
, re
, gitmergeonefile
23 from stgit
import basedir
24 from stgit
.utils
import *
25 from stgit
.config
import config
28 class GitException(Exception):
37 """Handle the commit objects
39 def __init__(self
, id_hash
):
40 self
.__id_hash
= id_hash
42 lines
= _output_lines('git-cat-file commit %s' % id_hash
)
43 for i
in range(len(lines
)):
47 field
= line
.strip().split(' ', 1)
48 if field
[0] == 'tree':
49 self
.__tree
= field
[1]
50 if field
[0] == 'author':
51 self
.__author
= field
[1]
52 if field
[0] == 'committer':
53 self
.__committer
= field
[1]
54 self
.__log
= ''.join(lines
[i
+1:])
56 def get_id_hash(self
):
63 parents
= self
.get_parents()
69 def get_parents(self
):
70 return _output_lines('git-rev-list --parents --max-count=1 %s'
71 % self
.__id_hash
)[0].split()[1:]
76 def get_committer(self
):
77 return self
.__committer
83 return self
.get_id_hash()
85 # dictionary of Commit objects, used to avoid multiple calls to git
92 def get_commit(id_hash
):
93 """Commit objects factory. Save/look-up them in the __commits
98 if id_hash
in __commits
:
99 return __commits
[id_hash
]
101 commit
= Commit(id_hash
)
102 __commits
[id_hash
] = commit
106 """Return the list of file conflicts
108 conflicts_file
= os
.path
.join(basedir
.get(), 'conflicts')
109 if os
.path
.isfile(conflicts_file
):
110 f
= file(conflicts_file
)
111 names
= [line
.strip() for line
in f
.readlines()]
117 def _input(cmd
, file_desc
):
118 p
= popen2
.Popen3(cmd
, True)
120 line
= file_desc
.readline()
123 p
.tochild
.write(line
)
126 raise GitException
, '%s failed (%s)' %
(str(cmd
),
127 p
.childerr
.read().strip())
129 def _input_str(cmd
, string
):
130 p
= popen2
.Popen3(cmd
, True)
131 p
.tochild
.write(string
)
134 raise GitException
, '%s failed (%s)' %
(str(cmd
),
135 p
.childerr
.read().strip())
138 p
=popen2
.Popen3(cmd
, True)
139 output
= p
.fromchild
.read()
141 raise GitException
, '%s failed (%s)' %
(str(cmd
),
142 p
.childerr
.read().strip())
145 def _output_one_line(cmd
, file_desc
= None):
146 p
=popen2
.Popen3(cmd
, True)
147 if file_desc
!= None:
148 for line
in file_desc
:
149 p
.tochild
.write(line
)
151 output
= p
.fromchild
.readline().strip()
153 raise GitException
, '%s failed (%s)' %
(str(cmd
),
154 p
.childerr
.read().strip())
157 def _output_lines(cmd
):
158 p
=popen2
.Popen3(cmd
, True)
159 lines
= p
.fromchild
.readlines()
161 raise GitException
, '%s failed (%s)' %
(str(cmd
),
162 p
.childerr
.read().strip())
165 def __run(cmd
, args
=None):
166 """__run: runs cmd using spawnvp.
168 Runs cmd using spawnvp. The shell is avoided so it won't mess up
169 our arguments. If args is very large, the command is run multiple
170 times; args is split xargs style: cmd is passed on each
171 invocation. Unlike xargs, returns immediately if any non-zero
172 return code is received.
178 for i
in range(0, len(args
)+1, 100):
179 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
184 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
185 noexclude
= True, verbose
= False):
186 """Returns a list of pairs - [status, filename]
188 if verbose
and sys
.stdout
.isatty():
189 print 'Checking for changes in the working directory...',
200 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
201 base_exclude
= ['--exclude=%s' % s for s in
202 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
203 base_exclude
.append('--exclude-per-directory=.gitignore')
205 if os
.path
.exists(exclude_file
):
206 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
210 extra_exclude
= base_exclude
= []
212 lines
= _output_lines(['git-ls-files', '--others', '--directory']
213 + base_exclude
+ extra_exclude
)
214 cache_files
+= [('?', line
.strip()) for line
in lines
]
217 conflicts
= get_conflicts()
220 cache_files
+= [('C', filename
) for filename
in conflicts
]
223 for line
in _output_lines(['git-diff-index', tree_id
, '--'] + files
):
224 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
225 if fs
[1] not in conflicts
:
226 cache_files
.append(fs
)
228 if verbose
and sys
.stdout
.isatty():
234 """Return true if there are local changes in the tree
236 return len(__tree_status(verbose
= True)) != 0
242 """Verifies the HEAD and returns the SHA1 id that represents it
247 __head
= rev_parse('HEAD')
251 """Returns the name of the file pointed to by the HEAD link
253 return strip_prefix('refs/heads/',
254 _output_one_line('git-symbolic-ref HEAD'))
256 def set_head_file(ref
):
257 """Resets HEAD to point to a new ref
259 # head cache flushing is needed since we might have a different value
262 if __run('git-symbolic-ref HEAD',
263 [os
.path
.join('refs', 'heads', ref
)]) != 0:
264 raise GitException
, 'Could not set head to "%s"' % ref
267 """Sets the HEAD value
271 if not __head
or __head
!= val
:
272 if __run('git-update-ref HEAD', [val
]) != 0:
273 raise GitException
, 'Could not update HEAD to "%s".' % val
276 # only allow SHA1 hashes
277 assert(len(__head
) == 40)
279 def __clear_head_cache():
280 """Sets the __head to None so that a re-read is forced
287 """Refresh index with stat() information from the working directory.
289 __run('git-update-index -q --unmerged --refresh')
291 def rev_parse(git_id
):
292 """Parse the string and return a verified SHA1 id
295 return _output_one_line(['git-rev-parse', '--verify', git_id
])
297 raise GitException
, 'Unknown revision: %s' % git_id
299 def branch_exists(branch
):
300 """Existence check for the named branch
302 branch
= os
.path
.join('refs', 'heads', branch
)
303 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
304 if line
.strip() == branch
:
306 if re
.compile('[ |/]'+branch
+' ').search(line
):
307 raise GitException
, 'Bogus branch: %s' % line
310 def create_branch(new_branch
, tree_id
= None):
311 """Create a new branch in the git repository
313 if branch_exists(new_branch
):
314 raise GitException
, 'Branch "%s" already exists' % new_branch
316 current_head
= get_head()
317 set_head_file(new_branch
)
318 __set_head(current_head
)
320 # a checkout isn't needed if new branch points to the current head
324 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
325 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
327 def switch_branch(new_branch
):
328 """Switch to a git branch
332 if not branch_exists(new_branch
):
333 raise GitException
, 'Branch "%s" does not exist' % new_branch
335 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
337 if tree_id
!= get_head():
339 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
340 raise GitException
, 'git-read-tree failed (local changes maybe?)'
342 set_head_file(new_branch
)
344 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
345 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
347 def delete_branch(name
):
348 """Delete a git branch
350 if not branch_exists(name
):
351 raise GitException
, 'Branch "%s" does not exist' % name
352 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
355 def rename_branch(from_name
, to_name
):
356 """Rename a git branch
358 if not branch_exists(from_name
):
359 raise GitException
, 'Branch "%s" does not exist' % from_name
360 if branch_exists(to_name
):
361 raise GitException
, 'Branch "%s" already exists' % to_name
363 if get_head_file() == from_name
:
364 set_head_file(to_name
)
365 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
369 """Add the files or recursively add the directory contents
371 # generate the file list
374 if not os
.path
.exists(i
):
375 raise GitException
, 'Unknown file or directory: %s' % i
378 # recursive search. We only add files
379 for root
, dirs
, local_files
in os
.walk(i
):
380 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
381 if os
.path
.isfile(name
):
382 files
.append(os
.path
.normpath(name
))
383 elif os
.path
.isfile(i
):
384 files
.append(os
.path
.normpath(i
))
386 raise GitException
, '%s is not a file or directory' % i
389 if __run('git-update-index --add --', files
):
390 raise GitException
, 'Unable to add file'
392 def rm(files
, force
= False):
393 """Remove a file from the repository
397 if os
.path
.exists(f
):
398 raise GitException
, '%s exists. Remove it first' %f
400 __run('git-update-index --remove --', files
)
403 __run('git-update-index --force-remove --', files
)
405 def update_cache(files
= None, force
= False):
406 """Update the cache information for the given files
411 cache_files
= __tree_status(files
, verbose
= False)
413 # everything is up-to-date
414 if len(cache_files
) == 0:
417 # check for unresolved conflicts
418 if not force
and [x
for x
in cache_files
419 if x
[0] not in ['M', 'N', 'A', 'D']]:
420 raise GitException
, 'Updating cache failed: unresolved conflicts'
423 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
424 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
425 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
427 if add_files
and __run('git-update-index --add --', add_files
) != 0:
428 raise GitException
, 'Failed git-update-index --add'
429 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
430 raise GitException
, 'Failed git-update-index --rm'
431 if m_files
and __run('git-update-index --', m_files
) != 0:
432 raise GitException
, 'Failed git-update-index'
436 def commit(message
, files
= None, parents
= None, allowempty
= False,
437 cache_update
= True, tree_id
= None,
438 author_name
= None, author_email
= None, author_date
= None,
439 committer_name
= None, committer_email
= None):
440 """Commit the current tree to repository
447 # Get the tree status
448 if cache_update
and parents
!= []:
449 changes
= update_cache(files
)
450 if not changes
and not allowempty
:
451 raise GitException
, 'No changes to commit'
453 # get the commit message
456 elif message
[-1:] != '\n':
460 # write the index to repository
462 tree_id
= _output_one_line('git-write-tree')
469 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
471 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
473 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
475 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
477 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
478 cmd
+= 'git-commit-tree %s' % tree_id
484 commit_id
= _output_one_line(cmd
, message
)
486 __set_head(commit_id
)
490 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
491 """Apply the diff between rev1 and rev2 onto the current
492 index. This function doesn't need to raise an exception since it
493 is only used for fast-pushing a patch. If this operation fails,
494 the pushing would fall back to the three-way merge.
497 index_opt
= '--index'
504 diff_str
= diff(files
, rev1
, rev2
)
507 _input_str('git-apply %s' % index_opt
, diff_str
)
513 def merge(base
, head1
, head2
):
514 """Perform a 3-way merge between base, head1 and head2 into the
520 # use _output() to mask the verbose prints of the tool
521 _output('git-merge-recursive %s -- %s %s' %
(base
, head1
, head2
))
525 # check the index for unmerged entries
527 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
529 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
533 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
535 if not path
in files
:
537 files
[path
]['1'] = ('', '')
538 files
[path
]['2'] = ('', '')
539 files
[path
]['3'] = ('', '')
541 files
[path
][stage
] = (mode
, hash)
543 # merge the unmerged files
547 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
548 stages
['3'][1], path
, stages
['1'][0],
549 stages
['2'][0], stages
['3'][0]) != 0:
553 raise GitException
, 'GIT index merging failed (possible conflicts)'
555 def status(files
= None, modified
= False, new
= False, deleted
= False,
556 conflict
= False, unknown
= False, noexclude
= False):
557 """Show the tree status
562 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
563 all
= not (modified
or new
or deleted
or conflict
or unknown
)
578 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
580 for fs
in cache_files
:
582 print '%s %s' %
(fs
[0], fs
[1])
586 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
587 """Show the diff between rev1 and rev2
593 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
, '--'] + files
)
597 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
, '--'] + files
)
599 diff_str
= _output(['git-diff-index', '-p', rev1
, '--'] + files
)
604 out_fd
.write(diff_str
)
608 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
609 """Return the diffstat between rev1 and rev2
614 p
=popen2
.Popen3('git-apply --stat')
615 diff(files
, rev1
, rev2
, p
.tochild
)
617 diff_str
= p
.fromchild
.read().rstrip()
619 raise GitException
, 'git.diffstat failed'
622 def files(rev1
, rev2
):
623 """Return the files modified between rev1 and rev2
627 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
628 result
+= '%s %s\n' %
tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
630 return result
.rstrip()
632 def barefiles(rev1
, rev2
):
633 """Return the files modified between rev1 and rev2, without status info
637 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
638 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
640 return result
.rstrip()
642 def pretty_commit(commit_id
= 'HEAD'):
643 """Return a given commit (log + diff)
645 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
648 def checkout(files
= None, tree_id
= None, force
= False):
649 """Check out the given or all files
654 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
655 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
657 checkout_cmd
= 'git-checkout-index -q -u'
659 checkout_cmd
+= ' -f'
661 checkout_cmd
+= ' -a'
663 checkout_cmd
+= ' --'
665 if __run(checkout_cmd
, files
) != 0:
666 raise GitException
, 'Failed git-checkout-index'
668 def switch(tree_id
, keep
= False):
669 """Switch the tree to the given id
673 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
674 raise GitException
, 'git-read-tree failed (local changes maybe?)'
678 def reset(files
= None, tree_id
= None, check_out
= True):
679 """Revert the tree changes relative to the given tree_id. It removes
686 cache_files
= __tree_status(files
, tree_id
)
687 # files which were added but need to be removed
688 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
690 checkout(files
, tree_id
, True)
691 # checkout doesn't remove files
692 map(os
.remove
, rm_files
)
694 # if the reset refers to the whole tree, switch the HEAD as well
698 def pull(repository
= 'origin', refspec
= None):
699 """Pull changes from the remote repository. At the moment, just
700 use the 'git-pull' command
702 # 'git-pull' updates the HEAD
709 if __run(config
.get('stgit', 'pullcmd'), args
) != 0:
710 raise GitException
, 'Failed "git-pull %s"' % repository
713 """Repack all objects into a single pack
715 __run('git-repack -a -d -f')
717 def apply_patch(filename
= None, diff
= None, base
= None,
719 """Apply a patch onto the current or given index. There must not
720 be any local changes in the tree, otherwise the command fails
723 orig_head
= get_head()
738 _input_str('git-apply --index', diff
)
743 # write the failed diff to a file
744 f
= file('.stgit-failed.patch', 'w+')
751 top
= commit(message
= 'temporary commit used for applying a patch',
754 merge(base
, orig_head
, top
)
756 def clone(repository
, local_dir
):
757 """Clone a remote repository. At the moment, just use the
760 if __run('git-clone', [repository
, local_dir
]) != 0:
761 raise GitException
, 'Failed "git-clone %s %s"' \
762 %
(repository
, local_dir
)
764 def modifying_revs(files
, base_rev
):
765 """Return the revisions from the list modifying the given files
767 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
768 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]