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
29 class GitException(Exception):
39 """An author, committer, etc."""
40 def __init__(self
, name
= None, email
= None, date
= '',
42 self
.name
= self
.email
= self
.date
= None
43 if name
or email
or date
:
49 assert not (name
or email
or date
)
51 m
= re
.match(r
'^(.+)<(.+)>(.*)$', s
)
53 return [x
.strip() or None for x
in m
.groups()]
54 self
.name
, self
.email
, self
.date
= parse_desc(desc
)
55 def set_name(self
, val
):
58 def set_email(self
, val
):
61 def set_date(self
, val
):
65 if self
.name
and self
.email
:
66 return '%s <%s>' %
(self
.name
, self
.email
)
68 raise GitException
, 'not enough identity data'
71 """Handle the commit objects
73 def __init__(self
, id_hash
):
74 self
.__id_hash
= id_hash
76 lines
= _output_lines('git-cat-file commit %s' % id_hash
)
77 for i
in range(len(lines
)):
81 field
= line
.strip().split(' ', 1)
82 if field
[0] == 'tree':
83 self
.__tree
= field
[1]
84 if field
[0] == 'author':
85 self
.__author
= field
[1]
86 if field
[0] == 'committer':
87 self
.__committer
= field
[1]
88 self
.__log
= ''.join(lines
[i
+1:])
90 def get_id_hash(self
):
97 parents
= self
.get_parents()
103 def get_parents(self
):
104 return _output_lines('git-rev-list --parents --max-count=1 %s'
105 % self
.__id_hash
)[0].split()[1:]
107 def get_author(self
):
110 def get_committer(self
):
111 return self
.__committer
117 return self
.get_id_hash()
119 # dictionary of Commit objects, used to avoid multiple calls to git
126 def get_commit(id_hash
):
127 """Commit objects factory. Save/look-up them in the __commits
132 if id_hash
in __commits
:
133 return __commits
[id_hash
]
135 commit
= Commit(id_hash
)
136 __commits
[id_hash
] = commit
140 """Return the list of file conflicts
142 conflicts_file
= os
.path
.join(basedir
.get(), 'conflicts')
143 if os
.path
.isfile(conflicts_file
):
144 f
= file(conflicts_file
)
145 names
= [line
.strip() for line
in f
.readlines()]
151 def _input(cmd
, file_desc
):
152 p
= popen2
.Popen3(cmd
, True)
154 line
= file_desc
.readline()
157 p
.tochild
.write(line
)
160 raise GitException
, '%s failed (%s)' %
(str(cmd
),
161 p
.childerr
.read().strip())
163 def _input_str(cmd
, string
):
164 p
= popen2
.Popen3(cmd
, True)
165 p
.tochild
.write(string
)
168 raise GitException
, '%s failed (%s)' %
(str(cmd
),
169 p
.childerr
.read().strip())
172 p
=popen2
.Popen3(cmd
, True)
173 output
= p
.fromchild
.read()
175 raise GitException
, '%s failed (%s)' %
(str(cmd
),
176 p
.childerr
.read().strip())
179 def _output_one_line(cmd
, file_desc
= None):
180 p
=popen2
.Popen3(cmd
, True)
181 if file_desc
!= None:
182 for line
in file_desc
:
183 p
.tochild
.write(line
)
185 output
= p
.fromchild
.readline().strip()
187 raise GitException
, '%s failed (%s)' %
(str(cmd
),
188 p
.childerr
.read().strip())
191 def _output_lines(cmd
):
192 p
=popen2
.Popen3(cmd
, True)
193 lines
= p
.fromchild
.readlines()
195 raise GitException
, '%s failed (%s)' %
(str(cmd
),
196 p
.childerr
.read().strip())
199 def __run(cmd
, args
=None):
200 """__run: runs cmd using spawnvp.
202 Runs cmd using spawnvp. The shell is avoided so it won't mess up
203 our arguments. If args is very large, the command is run multiple
204 times; args is split xargs style: cmd is passed on each
205 invocation. Unlike xargs, returns immediately if any non-zero
206 return code is received.
212 for i
in range(0, len(args
)+1, 100):
213 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
218 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
219 noexclude
= True, verbose
= False):
220 """Returns a list of pairs - [status, filename]
222 if verbose
and sys
.stdout
.isatty():
223 print 'Checking for changes in the working directory...',
234 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
235 base_exclude
= ['--exclude=%s' % s for s in
236 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
237 base_exclude
.append('--exclude-per-directory=.gitignore')
239 if os
.path
.exists(exclude_file
):
240 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
244 extra_exclude
= base_exclude
= []
246 lines
= _output_lines(['git-ls-files', '--others', '--directory']
247 + base_exclude
+ extra_exclude
)
248 cache_files
+= [('?', line
.strip()) for line
in lines
]
251 conflicts
= get_conflicts()
254 cache_files
+= [('C', filename
) for filename
in conflicts
]
257 for line
in _output_lines(['git-diff-index', tree_id
, '--'] + files
):
258 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
259 if fs
[1] not in conflicts
:
260 cache_files
.append(fs
)
262 if verbose
and sys
.stdout
.isatty():
267 def local_changes(verbose
= True):
268 """Return true if there are local changes in the tree
270 return len(__tree_status(verbose
= verbose
)) != 0
276 """Verifies the HEAD and returns the SHA1 id that represents it
281 __head
= rev_parse('HEAD')
285 """Returns the name of the file pointed to by the HEAD link
287 return strip_prefix('refs/heads/',
288 _output_one_line('git-symbolic-ref HEAD'))
290 def set_head_file(ref
):
291 """Resets HEAD to point to a new ref
293 # head cache flushing is needed since we might have a different value
296 if __run('git-symbolic-ref HEAD',
297 [os
.path
.join('refs', 'heads', ref
)]) != 0:
298 raise GitException
, 'Could not set head to "%s"' % ref
301 """Sets the HEAD value
305 if not __head
or __head
!= val
:
306 if __run('git-update-ref HEAD', [val
]) != 0:
307 raise GitException
, 'Could not update HEAD to "%s".' % val
310 # only allow SHA1 hashes
311 assert(len(__head
) == 40)
313 def __clear_head_cache():
314 """Sets the __head to None so that a re-read is forced
321 """Refresh index with stat() information from the working directory.
323 __run('git-update-index -q --unmerged --refresh')
325 def rev_parse(git_id
):
326 """Parse the string and return a verified SHA1 id
329 return _output_one_line(['git-rev-parse', '--verify', git_id
])
331 raise GitException
, 'Unknown revision: %s' % git_id
333 def branch_exists(branch
):
334 """Existence check for the named branch
336 branch
= os
.path
.join('refs', 'heads', branch
)
337 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
338 if line
.strip() == branch
:
340 if re
.compile('[ |/]'+branch
+' ').search(line
):
341 raise GitException
, 'Bogus branch: %s' % line
344 def create_branch(new_branch
, tree_id
= None):
345 """Create a new branch in the git repository
347 if branch_exists(new_branch
):
348 raise GitException
, 'Branch "%s" already exists' % new_branch
350 current_head
= get_head()
351 set_head_file(new_branch
)
352 __set_head(current_head
)
354 # a checkout isn't needed if new branch points to the current head
358 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
359 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
361 def switch_branch(new_branch
):
362 """Switch to a git branch
366 if not branch_exists(new_branch
):
367 raise GitException
, 'Branch "%s" does not exist' % new_branch
369 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
371 if tree_id
!= get_head():
373 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
374 raise GitException
, 'git-read-tree failed (local changes maybe?)'
376 set_head_file(new_branch
)
378 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
379 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
381 def delete_branch(name
):
382 """Delete a git branch
384 if not branch_exists(name
):
385 raise GitException
, 'Branch "%s" does not exist' % name
386 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
389 def rename_branch(from_name
, to_name
):
390 """Rename a git branch
392 if not branch_exists(from_name
):
393 raise GitException
, 'Branch "%s" does not exist' % from_name
394 if branch_exists(to_name
):
395 raise GitException
, 'Branch "%s" already exists' % to_name
397 if get_head_file() == from_name
:
398 set_head_file(to_name
)
399 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
402 reflog_dir
= os
.path
.join(basedir
.get(), 'logs', 'refs', 'heads')
403 if os
.path
.exists(reflog_dir
) \
404 and os
.path
.exists(os
.path
.join(reflog_dir
, from_name
)):
405 rename(reflog_dir
, from_name
, to_name
)
408 """Add the files or recursively add the directory contents
410 # generate the file list
413 if not os
.path
.exists(i
):
414 raise GitException
, 'Unknown file or directory: %s' % i
417 # recursive search. We only add files
418 for root
, dirs
, local_files
in os
.walk(i
):
419 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
420 if os
.path
.isfile(name
):
421 files
.append(os
.path
.normpath(name
))
422 elif os
.path
.isfile(i
):
423 files
.append(os
.path
.normpath(i
))
425 raise GitException
, '%s is not a file or directory' % i
428 if __run('git-update-index --add --', files
):
429 raise GitException
, 'Unable to add file'
431 def rm(files
, force
= False):
432 """Remove a file from the repository
436 if os
.path
.exists(f
):
437 raise GitException
, '%s exists. Remove it first' %f
439 __run('git-update-index --remove --', files
)
442 __run('git-update-index --force-remove --', files
)
450 """Return the user information.
454 name
=config
.get('user.name')
455 email
=config
.get('user.email')
456 __user
= Person(name
, email
)
460 """Return the author information.
465 # the environment variables take priority over config
467 date
= os
.environ
['GIT_AUTHOR_DATE']
470 __author
= Person(os
.environ
['GIT_AUTHOR_NAME'],
471 os
.environ
['GIT_AUTHOR_EMAIL'],
478 """Return the author information.
483 # the environment variables take priority over config
485 date
= os
.environ
['GIT_COMMITTER_DATE']
488 __committer
= Person(os
.environ
['GIT_COMMITTER_NAME'],
489 os
.environ
['GIT_COMMITTER_EMAIL'],
495 def update_cache(files
= None, force
= False):
496 """Update the cache information for the given files
501 cache_files
= __tree_status(files
, verbose
= False)
503 # everything is up-to-date
504 if len(cache_files
) == 0:
507 # check for unresolved conflicts
508 if not force
and [x
for x
in cache_files
509 if x
[0] not in ['M', 'N', 'A', 'D']]:
510 raise GitException
, 'Updating cache failed: unresolved conflicts'
513 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
514 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
515 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
517 if add_files
and __run('git-update-index --add --', add_files
) != 0:
518 raise GitException
, 'Failed git-update-index --add'
519 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
520 raise GitException
, 'Failed git-update-index --rm'
521 if m_files
and __run('git-update-index --', m_files
) != 0:
522 raise GitException
, 'Failed git-update-index'
526 def commit(message
, files
= None, parents
= None, allowempty
= False,
527 cache_update
= True, tree_id
= None,
528 author_name
= None, author_email
= None, author_date
= None,
529 committer_name
= None, committer_email
= None):
530 """Commit the current tree to repository
537 # Get the tree status
538 if cache_update
and parents
!= []:
539 changes
= update_cache(files
)
540 if not changes
and not allowempty
:
541 raise GitException
, 'No changes to commit'
543 # get the commit message
546 elif message
[-1:] != '\n':
550 # write the index to repository
552 tree_id
= _output_one_line('git-write-tree')
559 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
561 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
563 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
565 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
567 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
568 cmd
+= 'git-commit-tree %s' % tree_id
574 commit_id
= _output_one_line(cmd
, message
)
576 __set_head(commit_id
)
580 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
581 """Apply the diff between rev1 and rev2 onto the current
582 index. This function doesn't need to raise an exception since it
583 is only used for fast-pushing a patch. If this operation fails,
584 the pushing would fall back to the three-way merge.
587 index_opt
= '--index'
594 diff_str
= diff(files
, rev1
, rev2
)
597 _input_str('git-apply %s' % index_opt
, diff_str
)
603 def merge(base
, head1
, head2
, recursive
= False):
604 """Perform a 3-way merge between base, head1 and head2 into the
611 # this operation tracks renames but it is slower (used in
612 # general when pushing or picking patches)
614 # use _output() to mask the verbose prints of the tool
615 _output('git-merge-recursive %s -- %s %s' %
(base
, head1
, head2
))
616 except GitException
, ex
:
620 # the fast case where we don't track renames (used when the
621 # distance between base and heads is small, i.e. folding or
622 # synchronising patches)
623 if __run('git-read-tree -u -m --aggressive',
624 [base
, head1
, head2
]) != 0:
625 raise GitException
, 'git-read-tree failed (local changes maybe?)'
627 # check the index for unmerged entries
629 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
631 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
635 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
637 if not path
in files
:
639 files
[path
]['1'] = ('', '')
640 files
[path
]['2'] = ('', '')
641 files
[path
]['3'] = ('', '')
643 files
[path
][stage
] = (mode
, hash)
645 if err_output
and not files
:
646 # if no unmerged files, there was probably a different type of
647 # error and we have to abort the merge
648 raise GitException
, err_output
650 # merge the unmerged files
653 # remove additional files that might be generated for some
654 # newer versions of GIT
655 for suffix
in [base
, head1
, head2
]:
658 fname
= path
+ '~' + suffix
659 if os
.path
.exists(fname
):
663 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
664 stages
['3'][1], path
, stages
['1'][0],
665 stages
['2'][0], stages
['3'][0]) != 0:
669 raise GitException
, 'GIT index merging failed (possible conflicts)'
671 def status(files
= None, modified
= False, new
= False, deleted
= False,
672 conflict
= False, unknown
= False, noexclude
= False):
673 """Show the tree status
678 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
679 all
= not (modified
or new
or deleted
or conflict
or unknown
)
694 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
696 for fs
in cache_files
:
697 if files
and not fs
[1] in files
:
700 print '%s %s' %
(fs
[0], fs
[1])
704 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
705 """Show the diff between rev1 and rev2
711 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
, '--'] + files
)
715 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
, '--'] + files
)
717 diff_str
= _output(['git-diff-index', '-p', rev1
, '--'] + files
)
722 out_fd
.write(diff_str
)
726 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
727 """Return the diffstat between rev1 and rev2
732 p
=popen2
.Popen3('git-apply --stat')
733 diff(files
, rev1
, rev2
, p
.tochild
)
735 diff_str
= p
.fromchild
.read().rstrip()
737 raise GitException
, 'git.diffstat failed'
740 def files(rev1
, rev2
):
741 """Return the files modified between rev1 and rev2
745 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
746 result
+= '%s %s\n' %
tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
748 return result
.rstrip()
750 def barefiles(rev1
, rev2
):
751 """Return the files modified between rev1 and rev2, without status info
755 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
756 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
758 return result
.rstrip()
760 def pretty_commit(commit_id
= 'HEAD'):
761 """Return a given commit (log + diff)
763 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
766 def checkout(files
= None, tree_id
= None, force
= False):
767 """Check out the given or all files
772 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
773 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
775 checkout_cmd
= 'git-checkout-index -q -u'
777 checkout_cmd
+= ' -f'
779 checkout_cmd
+= ' -a'
781 checkout_cmd
+= ' --'
783 if __run(checkout_cmd
, files
) != 0:
784 raise GitException
, 'Failed git-checkout-index'
786 def switch(tree_id
, keep
= False):
787 """Switch the tree to the given id
791 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
792 raise GitException
, 'git-read-tree failed (local changes maybe?)'
796 def reset(files
= None, tree_id
= None, check_out
= True):
797 """Revert the tree changes relative to the given tree_id. It removes
804 cache_files
= __tree_status(files
, tree_id
)
805 # files which were added but need to be removed
806 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
808 checkout(files
, tree_id
, True)
809 # checkout doesn't remove files
810 map(os
.remove
, rm_files
)
812 # if the reset refers to the whole tree, switch the HEAD as well
816 def fetch(repository
= 'origin', refspec
= None):
817 """Fetches changes from the remote repository, using 'git-fetch'
827 command
= config
.get('stgit.pullcmd')
828 if __run(command
, args
) != 0:
829 raise GitException
, 'Failed "%s %s"' %
(command
, repository
)
832 """Repack all objects into a single pack
834 __run('git-repack -a -d -f')
836 def apply_patch(filename
= None, diff
= None, base
= None,
838 """Apply a patch onto the current or given index. There must not
839 be any local changes in the tree, otherwise the command fails
851 orig_head
= get_head()
857 _input_str('git-apply --index', diff
)
862 # write the failed diff to a file
863 f
= file('.stgit-failed.patch', 'w+')
866 print >> sys
.stderr
, 'Diff written to the .stgit-failed.patch file'
871 top
= commit(message
= 'temporary commit used for applying a patch',
874 merge(base
, orig_head
, top
)
876 def clone(repository
, local_dir
):
877 """Clone a remote repository. At the moment, just use the
880 if __run('git-clone', [repository
, local_dir
]) != 0:
881 raise GitException
, 'Failed "git-clone %s %s"' \
882 %
(repository
, local_dir
)
884 def modifying_revs(files
, base_rev
):
885 """Return the revisions from the list modifying the given files
887 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
888 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]
893 def refspec_localpart(refspec
):
894 m
= re
.match('^[^:]*:([^:]*)$', refspec
)
898 raise GitException
, 'Cannot parse refspec "%s"' % line
900 def refspec_remotepart(refspec
):
901 m
= re
.match('^([^:]*):[^:]*$', refspec
)
905 raise GitException
, 'Cannot parse refspec "%s"' % line
908 def __remotes_from_config():
909 return config
.sections_matching(r
'remote\.(.*)\.url')
911 def __remotes_from_dir(dir):
912 d
= os
.path
.join(basedir
.get(), dir)
913 if os
.path
.exists(d
):
919 """Return the list of remotes in the repository
922 return Set(__remotes_from_config()) | \
923 Set(__remotes_from_dir('remotes')) | \
924 Set(__remotes_from_dir('branches'))
926 def remotes_local_branches(remote
):
927 """Returns the list of local branches fetched from given remote
931 if remote
in __remotes_from_config():
932 for line
in config
.getall('remote.%s.fetch' % remote
):
933 branches
.append(refspec_localpart(line
))
934 elif remote
in __remotes_from_dir('remotes'):
935 stream
= open(os
.path
.join(basedir
.get(), 'remotes', remote
), 'r')
937 # Only consider Pull lines
938 m
= re
.match('^Pull: (.*)\n$', line
)
940 branches
.append(refspec_localpart(m
.group(1)))
942 elif remote
in __remotes_from_dir('branches'):
943 # old-style branches only declare one branch
944 branches
.append('refs/heads/'+remote
);
946 raise GitException
, 'Unknown remote "%s"' % remote
950 def identify_remote(branchname
):
951 """Return the name for the remote to pull the given branchname
952 from, or None if we believe it is a local branch.
955 for remote
in remotes_list():
956 if branchname
in remotes_local_branches(remote
):
959 # if we get here we've found nothing, the branch is a local one
963 """Return the git id for the tip of the parent branch as left by
968 stream
= open(os
.path
.join(basedir
.get(), 'FETCH_HEAD'), "r")
970 # Only consider lines not tagged not-for-merge
971 m
= re
.match('^([^\t]*)\t\t', line
)
974 raise GitException
, "StGit does not support multiple FETCH_HEAD"
976 fetch_head
=m
.group(1)
979 # here we are sure to have a single fetch_head