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,
186 """Returns a list of pairs - [status, filename]
196 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
197 base_exclude
= ['--exclude=%s' % s
for s
in
198 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
199 base_exclude
.append('--exclude-per-directory=.gitignore')
201 if os
.path
.exists(exclude_file
):
202 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
206 extra_exclude
= base_exclude
= []
208 lines
= _output_lines(['git-ls-files', '--others', '--directory']
209 + base_exclude
+ extra_exclude
)
210 cache_files
+= [('?', line
.strip()) for line
in lines
]
213 conflicts
= get_conflicts()
216 cache_files
+= [('C', filename
) for filename
in conflicts
]
219 for line
in _output_lines(['git-diff-index', tree_id
, '--'] + files
):
220 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
221 if fs
[1] not in conflicts
:
222 cache_files
.append(fs
)
227 """Return true if there are local changes in the tree
229 return len(__tree_status()) != 0
235 """Verifies the HEAD and returns the SHA1 id that represents it
240 __head
= rev_parse('HEAD')
244 """Returns the name of the file pointed to by the HEAD link
246 return strip_prefix('refs/heads/',
247 _output_one_line('git-symbolic-ref HEAD'))
249 def set_head_file(ref
):
250 """Resets HEAD to point to a new ref
252 # head cache flushing is needed since we might have a different value
255 if __run('git-symbolic-ref HEAD',
256 [os
.path
.join('refs', 'heads', ref
)]) != 0:
257 raise GitException
, 'Could not set head to "%s"' % ref
260 """Sets the HEAD value
264 if not __head
or __head
!= val
:
265 if __run('git-update-ref HEAD', [val
]) != 0:
266 raise GitException
, 'Could not update HEAD to "%s".' % val
269 # only allow SHA1 hashes
270 assert(len(__head
) == 40)
272 def __clear_head_cache():
273 """Sets the __head to None so that a re-read is forced
280 """Refresh index with stat() information from the working directory.
282 __run('git-update-index -q --unmerged --refresh')
284 def rev_parse(git_id
):
285 """Parse the string and return a verified SHA1 id
288 return _output_one_line(['git-rev-parse', '--verify', git_id
])
290 raise GitException
, 'Unknown revision: %s' % git_id
292 def branch_exists(branch
):
293 """Existence check for the named branch
295 branch
= os
.path
.join('refs', 'heads', branch
)
296 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
297 if line
.strip() == branch
:
299 if re
.compile('[ |/]'+branch
+' ').search(line
):
300 raise GitException
, 'Bogus branch: %s' % line
303 def create_branch(new_branch
, tree_id
= None):
304 """Create a new branch in the git repository
306 if branch_exists(new_branch
):
307 raise GitException
, 'Branch "%s" already exists' % new_branch
309 current_head
= get_head()
310 set_head_file(new_branch
)
311 __set_head(current_head
)
313 # a checkout isn't needed if new branch points to the current head
317 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
318 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
320 def switch_branch(new_branch
):
321 """Switch to a git branch
325 if not branch_exists(new_branch
):
326 raise GitException
, 'Branch "%s" does not exist' % new_branch
328 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
330 if tree_id
!= get_head():
332 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
333 raise GitException
, 'git-read-tree failed (local changes maybe?)'
335 set_head_file(new_branch
)
337 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
338 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
340 def delete_branch(name
):
341 """Delete a git branch
343 if not branch_exists(name
):
344 raise GitException
, 'Branch "%s" does not exist' % name
345 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
348 def rename_branch(from_name
, to_name
):
349 """Rename a git branch
351 if not branch_exists(from_name
):
352 raise GitException
, 'Branch "%s" does not exist' % from_name
353 if branch_exists(to_name
):
354 raise GitException
, 'Branch "%s" already exists' % to_name
356 if get_head_file() == from_name
:
357 set_head_file(to_name
)
358 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
362 """Add the files or recursively add the directory contents
364 # generate the file list
367 if not os
.path
.exists(i
):
368 raise GitException
, 'Unknown file or directory: %s' % i
371 # recursive search. We only add files
372 for root
, dirs
, local_files
in os
.walk(i
):
373 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
374 if os
.path
.isfile(name
):
375 files
.append(os
.path
.normpath(name
))
376 elif os
.path
.isfile(i
):
377 files
.append(os
.path
.normpath(i
))
379 raise GitException
, '%s is not a file or directory' % i
382 if __run('git-update-index --add --', files
):
383 raise GitException
, 'Unable to add file'
385 def rm(files
, force
= False):
386 """Remove a file from the repository
390 if os
.path
.exists(f
):
391 raise GitException
, '%s exists. Remove it first' %f
393 __run('git-update-index --remove --', files
)
396 __run('git-update-index --force-remove --', files
)
398 def update_cache(files
= None, force
= False):
399 """Update the cache information for the given files
404 cache_files
= __tree_status(files
)
406 # everything is up-to-date
407 if len(cache_files
) == 0:
410 # check for unresolved conflicts
411 if not force
and [x
for x
in cache_files
412 if x
[0] not in ['M', 'N', 'A', 'D']]:
413 raise GitException
, 'Updating cache failed: unresolved conflicts'
416 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
417 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
418 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
420 if add_files
and __run('git-update-index --add --', add_files
) != 0:
421 raise GitException
, 'Failed git-update-index --add'
422 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
423 raise GitException
, 'Failed git-update-index --rm'
424 if m_files
and __run('git-update-index --', m_files
) != 0:
425 raise GitException
, 'Failed git-update-index'
429 def commit(message
, files
= None, parents
= None, allowempty
= False,
430 cache_update
= True, tree_id
= None,
431 author_name
= None, author_email
= None, author_date
= None,
432 committer_name
= None, committer_email
= None):
433 """Commit the current tree to repository
440 # Get the tree status
441 if cache_update
and parents
!= []:
442 changes
= update_cache(files
)
443 if not changes
and not allowempty
:
444 raise GitException
, 'No changes to commit'
446 # get the commit message
449 elif message
[-1:] != '\n':
453 # write the index to repository
455 tree_id
= _output_one_line('git-write-tree')
462 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
464 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
466 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
468 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
470 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
471 cmd
+= 'git-commit-tree %s' % tree_id
477 commit_id
= _output_one_line(cmd
, message
)
479 __set_head(commit_id
)
483 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
484 """Apply the diff between rev1 and rev2 onto the current
485 index. This function doesn't need to raise an exception since it
486 is only used for fast-pushing a patch. If this operation fails,
487 the pushing would fall back to the three-way merge.
490 index_opt
= '--index'
497 diff_str
= diff(files
, rev1
, rev2
)
500 _input_str('git-apply %s' % index_opt
, diff_str
)
506 def merge(base
, head1
, head2
):
507 """Perform a 3-way merge between base, head1 and head2 into the
511 if __run('git-read-tree -u -m --aggressive', [base
, head1
, head2
]) != 0:
512 raise GitException
, 'git-read-tree failed (local changes maybe?)'
514 # check the index for unmerged entries
516 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
518 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
522 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
524 if not path
in files
:
526 files
[path
]['1'] = ('', '')
527 files
[path
]['2'] = ('', '')
528 files
[path
]['3'] = ('', '')
530 files
[path
][stage
] = (mode
, hash)
532 # merge the unmerged files
536 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
537 stages
['3'][1], path
, stages
['1'][0],
538 stages
['2'][0], stages
['3'][0]) != 0:
542 raise GitException
, 'GIT index merging failed (possible conflicts)'
544 def status(files
= None, modified
= False, new
= False, deleted
= False,
545 conflict
= False, unknown
= False, noexclude
= False):
546 """Show the tree status
551 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
552 all
= not (modified
or new
or deleted
or conflict
or unknown
)
567 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
569 for fs
in cache_files
:
571 print '%s %s' % (fs
[0], fs
[1])
575 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
576 """Show the diff between rev1 and rev2
582 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
, '--'] + files
)
586 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
, '--'] + files
)
588 diff_str
= _output(['git-diff-index', '-p', rev1
, '--'] + files
)
593 out_fd
.write(diff_str
)
597 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
598 """Return the diffstat between rev1 and rev2
603 p
=popen2
.Popen3('git-apply --stat')
604 diff(files
, rev1
, rev2
, p
.tochild
)
606 diff_str
= p
.fromchild
.read().rstrip()
608 raise GitException
, 'git.diffstat failed'
611 def files(rev1
, rev2
):
612 """Return the files modified between rev1 and rev2
616 for line
in _output_lines('git-diff-tree -r %s %s' % (rev1
, rev2
)):
617 result
+= '%s %s\n' % tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
619 return result
.rstrip()
621 def barefiles(rev1
, rev2
):
622 """Return the files modified between rev1 and rev2, without status info
626 for line
in _output_lines('git-diff-tree -r %s %s' % (rev1
, rev2
)):
627 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
629 return result
.rstrip()
631 def pretty_commit(commit_id
= 'HEAD'):
632 """Return a given commit (log + diff)
634 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
637 def checkout(files
= None, tree_id
= None, force
= False):
638 """Check out the given or all files
643 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
644 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
646 checkout_cmd
= 'git-checkout-index -q -u'
648 checkout_cmd
+= ' -f'
650 checkout_cmd
+= ' -a'
652 checkout_cmd
+= ' --'
654 if __run(checkout_cmd
, files
) != 0:
655 raise GitException
, 'Failed git-checkout-index'
657 def switch(tree_id
, keep
= False):
658 """Switch the tree to the given id
662 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
663 raise GitException
, 'git-read-tree failed (local changes maybe?)'
667 def reset(files
= None, tree_id
= None, check_out
= True):
668 """Revert the tree changes relative to the given tree_id. It removes
675 cache_files
= __tree_status(files
, tree_id
)
676 # files which were added but need to be removed
677 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
679 checkout(files
, tree_id
, True)
680 # checkout doesn't remove files
681 map(os
.remove
, rm_files
)
683 # if the reset refers to the whole tree, switch the HEAD as well
687 def pull(repository
= 'origin', refspec
= None):
688 """Pull changes from the remote repository. At the moment, just
689 use the 'git-pull' command
691 # 'git-pull' updates the HEAD
698 if __run(config
.get('stgit', 'pullcmd'), args
) != 0:
699 raise GitException
, 'Failed "git-pull %s"' % repository
701 def apply_patch(filename
= None, base
= None):
702 """Apply a patch onto the current or given index. There must not
703 be any local changes in the tree, otherwise the command fails
707 return __run('git-apply --index', [filename
]) == 0
710 _input('git-apply --index', sys
.stdin
)
716 orig_head
= get_head()
719 refresh_index() # needed since __apply_patch() doesn't do it
721 if not __apply_patch():
724 raise GitException
, 'Patch does not apply cleanly'
726 top
= commit(message
= 'temporary commit used for applying a patch',
729 merge(base
, orig_head
, top
)
731 def clone(repository
, local_dir
):
732 """Clone a remote repository. At the moment, just use the
735 if __run('git-clone', [repository
, local_dir
]) != 0:
736 raise GitException
, 'Failed "git-clone %s %s"' \
737 % (repository
, local_dir
)
739 def modifying_revs(files
, base_rev
):
740 """Return the revisions from the list modifying the given files
742 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
743 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]