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 *
27 class GitException(Exception):
36 """Handle the commit objects
38 def __init__(self
, id_hash
):
39 self
.__id_hash
= id_hash
41 lines
= _output_lines('git-cat-file commit %s' % id_hash
)
42 for i
in range(len(lines
)):
46 field
= line
.strip().split(' ', 1)
47 if field
[0] == 'tree':
48 self
.__tree
= field
[1]
49 if field
[0] == 'author':
50 self
.__author
= field
[1]
51 if field
[0] == 'committer':
52 self
.__committer
= field
[1]
53 self
.__log
= ''.join(lines
[i
+1:])
55 def get_id_hash(self
):
62 parents
= self
.get_parents()
68 def get_parents(self
):
69 return _output_lines('git-rev-list --parents --max-count=1 %s'
70 % self
.__id_hash
)[0].split()[1:]
75 def get_committer(self
):
76 return self
.__committer
81 # dictionary of Commit objects, used to avoid multiple calls to git
88 def get_commit(id_hash
):
89 """Commit objects factory. Save/look-up them in the __commits
94 if id_hash
in __commits
:
95 return __commits
[id_hash
]
97 commit
= Commit(id_hash
)
98 __commits
[id_hash
] = commit
102 """Return the list of file conflicts
104 conflicts_file
= os
.path
.join(basedir
.get(), 'conflicts')
105 if os
.path
.isfile(conflicts_file
):
106 f
= file(conflicts_file
)
107 names
= [line
.strip() for line
in f
.readlines()]
113 def _input(cmd
, file_desc
):
114 p
= popen2
.Popen3(cmd
, True)
116 line
= file_desc
.readline()
119 p
.tochild
.write(line
)
122 raise GitException
, '%s failed (%s)' %
(str(cmd
),
123 p
.childerr
.read().strip())
125 def _input_str(cmd
, string
):
126 p
= popen2
.Popen3(cmd
, True)
127 p
.tochild
.write(string
)
130 raise GitException
, '%s failed (%s)' %
(str(cmd
),
131 p
.childerr
.read().strip())
134 p
=popen2
.Popen3(cmd
, True)
135 output
= p
.fromchild
.read()
137 raise GitException
, '%s failed (%s)' %
(str(cmd
),
138 p
.childerr
.read().strip())
141 def _output_one_line(cmd
, file_desc
= None):
142 p
=popen2
.Popen3(cmd
, True)
143 if file_desc
!= None:
144 for line
in file_desc
:
145 p
.tochild
.write(line
)
147 output
= p
.fromchild
.readline().strip()
149 raise GitException
, '%s failed (%s)' %
(str(cmd
),
150 p
.childerr
.read().strip())
153 def _output_lines(cmd
):
154 p
=popen2
.Popen3(cmd
, True)
155 lines
= p
.fromchild
.readlines()
157 raise GitException
, '%s failed (%s)' %
(str(cmd
),
158 p
.childerr
.read().strip())
161 def __run(cmd
, args
=None):
162 """__run: runs cmd using spawnvp.
164 Runs cmd using spawnvp. The shell is avoided so it won't mess up
165 our arguments. If args is very large, the command is run multiple
166 times; args is split xargs style: cmd is passed on each
167 invocation. Unlike xargs, returns immediately if any non-zero
168 return code is received.
174 for i
in range(0, len(args
)+1, 100):
175 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
180 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
182 """Returns a list of pairs - [status, filename]
192 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
193 base_exclude
= ['--exclude=%s' % s for s in
194 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
195 base_exclude
.append('--exclude-per-directory=.gitignore')
197 if os
.path
.exists(exclude_file
):
198 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
202 extra_exclude
= base_exclude
= []
204 lines
= _output_lines(['git-ls-files', '--others', '--directory']
205 + base_exclude
+ extra_exclude
)
206 cache_files
+= [('?', line
.strip()) for line
in lines
]
209 conflicts
= get_conflicts()
212 cache_files
+= [('C', filename
) for filename
in conflicts
]
215 for line
in _output_lines(['git-diff-index', tree_id
, '--'] + files
):
216 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
217 if fs
[1] not in conflicts
:
218 cache_files
.append(fs
)
223 """Return true if there are local changes in the tree
225 return len(__tree_status()) != 0
231 """Verifies the HEAD and returns the SHA1 id that represents it
236 __head
= rev_parse('HEAD')
240 """Returns the name of the file pointed to by the HEAD link
242 return strip_prefix('refs/heads/',
243 _output_one_line('git-symbolic-ref HEAD'))
245 def set_head_file(ref
):
246 """Resets HEAD to point to a new ref
248 # head cache flushing is needed since we might have a different value
251 if __run('git-symbolic-ref HEAD',
252 [os
.path
.join('refs', 'heads', ref
)]) != 0:
253 raise GitException
, 'Could not set head to "%s"' % ref
256 """Sets the HEAD value
260 if not __head
or __head
!= val
:
261 if __run('git-update-ref HEAD', [val
]) != 0:
262 raise GitException
, 'Could not update HEAD to "%s".' % val
265 # only allow SHA1 hashes
266 assert(len(__head
) == 40)
268 def __clear_head_cache():
269 """Sets the __head to None so that a re-read is forced
276 """Refresh index with stat() information from the working directory.
278 __run('git-update-index -q --unmerged --refresh')
280 def rev_parse(git_id
):
281 """Parse the string and return a verified SHA1 id
284 return _output_one_line(['git-rev-parse', '--verify', git_id
])
286 raise GitException
, 'Unknown revision: %s' % git_id
288 def branch_exists(branch
):
289 """Existence check for the named branch
291 branch
= os
.path
.join('refs', 'heads', branch
)
292 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
293 if line
.strip() == branch
:
295 if re
.compile('[ |/]'+branch
+' ').search(line
):
296 raise GitException
, 'Bogus branch: %s' % line
299 def create_branch(new_branch
, tree_id
= None):
300 """Create a new branch in the git repository
302 if branch_exists(new_branch
):
303 raise GitException
, 'Branch "%s" already exists' % new_branch
305 current_head
= get_head()
306 set_head_file(new_branch
)
307 __set_head(current_head
)
309 # a checkout isn't needed if new branch points to the current head
313 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
314 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
316 def switch_branch(new_branch
):
317 """Switch to a git branch
321 if not branch_exists(new_branch
):
322 raise GitException
, 'Branch "%s" does not exist' % new_branch
324 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
326 if tree_id
!= get_head():
328 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
329 raise GitException
, 'git-read-tree failed (local changes maybe?)'
331 set_head_file(new_branch
)
333 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
334 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
336 def delete_branch(name
):
337 """Delete a git branch
339 if not branch_exists(name
):
340 raise GitException
, 'Branch "%s" does not exist' % name
341 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
344 def rename_branch(from_name
, to_name
):
345 """Rename a git branch
347 if not branch_exists(from_name
):
348 raise GitException
, 'Branch "%s" does not exist' % from_name
349 if branch_exists(to_name
):
350 raise GitException
, 'Branch "%s" already exists' % to_name
352 if get_head_file() == from_name
:
353 set_head_file(to_name
)
354 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
358 """Add the files or recursively add the directory contents
360 # generate the file list
363 if not os
.path
.exists(i
):
364 raise GitException
, 'Unknown file or directory: %s' % i
367 # recursive search. We only add files
368 for root
, dirs
, local_files
in os
.walk(i
):
369 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
370 if os
.path
.isfile(name
):
371 files
.append(os
.path
.normpath(name
))
372 elif os
.path
.isfile(i
):
373 files
.append(os
.path
.normpath(i
))
375 raise GitException
, '%s is not a file or directory' % i
378 if __run('git-update-index --add --', files
):
379 raise GitException
, 'Unable to add file'
381 def rm(files
, force
= False):
382 """Remove a file from the repository
386 if os
.path
.exists(f
):
387 raise GitException
, '%s exists. Remove it first' %f
389 __run('git-update-index --remove --', files
)
392 __run('git-update-index --force-remove --', files
)
394 def update_cache(files
= None, force
= False):
395 """Update the cache information for the given files
400 cache_files
= __tree_status(files
)
402 # everything is up-to-date
403 if len(cache_files
) == 0:
406 # check for unresolved conflicts
407 if not force
and [x
for x
in cache_files
408 if x
[0] not in ['M', 'N', 'A', 'D']]:
409 raise GitException
, 'Updating cache failed: unresolved conflicts'
412 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
413 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
414 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
416 if add_files
and __run('git-update-index --add --', add_files
) != 0:
417 raise GitException
, 'Failed git-update-index --add'
418 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
419 raise GitException
, 'Failed git-update-index --rm'
420 if m_files
and __run('git-update-index --', m_files
) != 0:
421 raise GitException
, 'Failed git-update-index'
425 def commit(message
, files
= None, parents
= None, allowempty
= False,
426 cache_update
= True, tree_id
= None,
427 author_name
= None, author_email
= None, author_date
= None,
428 committer_name
= None, committer_email
= None):
429 """Commit the current tree to repository
436 # Get the tree status
437 if cache_update
and parents
!= []:
438 changes
= update_cache(files
)
439 if not changes
and not allowempty
:
440 raise GitException
, 'No changes to commit'
442 # get the commit message
445 elif message
[-1:] != '\n':
449 # write the index to repository
451 tree_id
= _output_one_line('git-write-tree')
458 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
460 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
462 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
464 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
466 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
467 cmd
+= 'git-commit-tree %s' % tree_id
473 commit_id
= _output_one_line(cmd
, message
)
475 __set_head(commit_id
)
479 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
480 """Apply the diff between rev1 and rev2 onto the current
481 index. This function doesn't need to raise an exception since it
482 is only used for fast-pushing a patch. If this operation fails,
483 the pushing would fall back to the three-way merge.
486 index_opt
= '--index'
493 diff_str
= diff(files
, rev1
, rev2
)
496 _input_str('git-apply %s' % index_opt
, diff_str
)
502 def merge(base
, head1
, head2
):
503 """Perform a 3-way merge between base, head1 and head2 into the
507 if __run('git-read-tree -u -m --aggressive', [base
, head1
, head2
]) != 0:
508 raise GitException
, 'git-read-tree failed (local changes maybe?)'
510 # check the index for unmerged entries
512 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
514 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
518 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
520 if not path
in files
:
522 files
[path
]['1'] = ('', '')
523 files
[path
]['2'] = ('', '')
524 files
[path
]['3'] = ('', '')
526 files
[path
][stage
] = (mode
, hash)
528 # merge the unmerged files
532 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
533 stages
['3'][1], path
, stages
['1'][0],
534 stages
['2'][0], stages
['3'][0]) != 0:
538 raise GitException
, 'GIT index merging failed (possible conflicts)'
540 def status(files
= None, modified
= False, new
= False, deleted
= False,
541 conflict
= False, unknown
= False, noexclude
= False):
542 """Show the tree status
547 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
548 all
= not (modified
or new
or deleted
or conflict
or unknown
)
563 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
565 for fs
in cache_files
:
567 print '%s %s' %
(fs
[0], fs
[1])
571 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
572 """Show the diff between rev1 and rev2
578 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
, '--'] + files
)
582 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
, '--'] + files
)
584 diff_str
= _output(['git-diff-index', '-p', rev1
, '--'] + files
)
589 out_fd
.write(diff_str
)
593 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
594 """Return the diffstat between rev1 and rev2
599 p
=popen2
.Popen3('git-apply --stat')
600 diff(files
, rev1
, rev2
, p
.tochild
)
602 diff_str
= p
.fromchild
.read().rstrip()
604 raise GitException
, 'git.diffstat failed'
607 def files(rev1
, rev2
):
608 """Return the files modified between rev1 and rev2
612 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
613 result
+= '%s %s\n' %
tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
615 return result
.rstrip()
617 def barefiles(rev1
, rev2
):
618 """Return the files modified between rev1 and rev2, without status info
622 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
623 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
625 return result
.rstrip()
627 def pretty_commit(commit_id
= 'HEAD'):
628 """Return a given commit (log + diff)
630 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
633 def checkout(files
= None, tree_id
= None, force
= False):
634 """Check out the given or all files
639 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
640 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
642 checkout_cmd
= 'git-checkout-index -q -u'
644 checkout_cmd
+= ' -f'
646 checkout_cmd
+= ' -a'
648 checkout_cmd
+= ' --'
650 if __run(checkout_cmd
, files
) != 0:
651 raise GitException
, 'Failed git-checkout-index'
653 def switch(tree_id
, keep
= False):
654 """Switch the tree to the given id
658 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
659 raise GitException
, 'git-read-tree failed (local changes maybe?)'
663 def reset(files
= None, tree_id
= None, check_out
= True):
664 """Revert the tree changes relative to the given tree_id. It removes
671 cache_files
= __tree_status(files
, tree_id
)
672 # files which were added but need to be removed
673 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
675 checkout(files
, tree_id
, True)
676 # checkout doesn't remove files
677 map(os
.remove
, rm_files
)
679 # if the reset refers to the whole tree, switch the HEAD as well
683 def pull(repository
= 'origin', refspec
= None):
684 """Pull changes from the remote repository. At the moment, just
685 use the 'git-pull' command
687 # 'git-pull' updates the HEAD
694 if __run('git-pull', args
) != 0:
695 raise GitException
, 'Failed "git-pull %s"' % repository
697 def apply_patch(filename
= None, base
= None):
698 """Apply a patch onto the current or given index. There must not
699 be any local changes in the tree, otherwise the command fails
703 return __run('git-apply --index', [filename
]) == 0
706 _input('git-apply --index', sys
.stdin
)
712 orig_head
= get_head()
715 refresh_index() # needed since __apply_patch() doesn't do it
717 if not __apply_patch():
720 raise GitException
, 'Patch does not apply cleanly'
722 top
= commit(message
= 'temporary commit used for applying a patch',
725 merge(base
, orig_head
, top
)
727 def clone(repository
, local_dir
):
728 """Clone a remote repository. At the moment, just use the
731 if __run('git-clone', [repository
, local_dir
]) != 0:
732 raise GitException
, 'Failed "git-clone %s %s"' \
733 %
(repository
, local_dir
)
735 def modifying_revs(files
, base_rev
):
736 """Return the revisions from the list modifying the given files
738 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
739 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]