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
)
43 for i
in range(len(lines
)):
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:])
58 def get_id_hash(self
):
65 return self
.__parents
[0]
67 def get_parents(self
):
73 def get_committer(self
):
74 return self
.__committer
79 # dictionary of Commit objects, used to avoid multiple calls to git
86 def get_commit(id_hash
):
87 """Commit objects factory. Save/look-up them in the __commits
92 if id_hash
in __commits
:
93 return __commits
[id_hash
]
95 commit
= Commit(id_hash
)
96 __commits
[id_hash
] = commit
100 """Return the list of file conflicts
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()]
111 def _input(cmd
, file_desc
):
112 p
= popen2
.Popen3(cmd
, True)
114 line
= file_desc
.readline()
117 p
.tochild
.write(line
)
120 raise GitException
, '%s failed (%s)' %
(str(cmd
),
121 p
.childerr
.read().strip())
123 def _input_str(cmd
, string
):
124 p
= popen2
.Popen3(cmd
, True)
125 p
.tochild
.write(string
)
128 raise GitException
, '%s failed (%s)' %
(str(cmd
),
129 p
.childerr
.read().strip())
132 p
=popen2
.Popen3(cmd
, True)
133 output
= p
.fromchild
.read()
135 raise GitException
, '%s failed (%s)' %
(str(cmd
),
136 p
.childerr
.read().strip())
139 def _output_one_line(cmd
, file_desc
= None):
140 p
=popen2
.Popen3(cmd
, True)
141 if file_desc
!= None:
142 for line
in file_desc
:
143 p
.tochild
.write(line
)
145 output
= p
.fromchild
.readline().strip()
147 raise GitException
, '%s failed (%s)' %
(str(cmd
),
148 p
.childerr
.read().strip())
151 def _output_lines(cmd
):
152 p
=popen2
.Popen3(cmd
, True)
153 lines
= p
.fromchild
.readlines()
155 raise GitException
, '%s failed (%s)' %
(str(cmd
),
156 p
.childerr
.read().strip())
159 def __run(cmd
, args
=None):
160 """__run: runs cmd using spawnvp.
162 Runs cmd using spawnvp. The shell is avoided so it won't mess up
163 our arguments. If args is very large, the command is run multiple
164 times; args is split xargs style: cmd is passed on each
165 invocation. Unlike xargs, returns immediately if any non-zero
166 return code is received.
172 for i
in range(0, len(args
)+1, 100):
173 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
178 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
180 """Returns a list of pairs - [status, filename]
190 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
191 base_exclude
= ['--exclude=%s' % s for s in
192 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
193 base_exclude
.append('--exclude-per-directory=.gitignore')
195 if os
.path
.exists(exclude_file
):
196 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
200 extra_exclude
= base_exclude
= []
202 lines
= _output_lines(['git-ls-files', '--others', '--directory']
203 + base_exclude
+ extra_exclude
)
204 cache_files
+= [('?', line
.strip()) for line
in lines
]
207 conflicts
= get_conflicts()
210 cache_files
+= [('C', filename
) for filename
in conflicts
]
213 for line
in _output_lines(['git-diff-index', tree_id
, '--'] + files
):
214 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
215 if fs
[1] not in conflicts
:
216 cache_files
.append(fs
)
221 """Return true if there are local changes in the tree
223 return len(__tree_status()) != 0
229 """Verifies the HEAD and returns the SHA1 id that represents it
234 __head
= rev_parse('HEAD')
238 """Returns the name of the file pointed to by the HEAD link
240 return strip_prefix('refs/heads/',
241 _output_one_line('git-symbolic-ref HEAD'))
243 def set_head_file(ref
):
244 """Resets HEAD to point to a new ref
246 # head cache flushing is needed since we might have a different value
249 if __run('git-symbolic-ref HEAD',
250 [os
.path
.join('refs', 'heads', ref
)]) != 0:
251 raise GitException
, 'Could not set head to "%s"' % ref
254 """Sets the HEAD value
258 if not __head
or __head
!= val
:
259 if __run('git-update-ref HEAD', [val
]) != 0:
260 raise GitException
, 'Could not update HEAD to "%s".' % val
263 # only allow SHA1 hashes
264 assert(len(__head
) == 40)
266 def __clear_head_cache():
267 """Sets the __head to None so that a re-read is forced
274 """Refresh index with stat() information from the working directory.
276 __run('git-update-index -q --unmerged --refresh')
278 def rev_parse(git_id
):
279 """Parse the string and return a verified SHA1 id
282 return _output_one_line(['git-rev-parse', '--verify', git_id
])
284 raise GitException
, 'Unknown revision: %s' % git_id
286 def branch_exists(branch
):
287 """Existence check for the named branch
289 branch
= os
.path
.join('refs', 'heads', branch
)
290 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
291 if line
.strip() == branch
:
293 if re
.compile('[ |/]'+branch
+' ').search(line
):
294 raise GitException
, 'Bogus branch: %s' % line
297 def create_branch(new_branch
, tree_id
= None):
298 """Create a new branch in the git repository
300 if branch_exists(new_branch
):
301 raise GitException
, 'Branch "%s" already exists' % new_branch
303 current_head
= get_head()
304 set_head_file(new_branch
)
305 __set_head(current_head
)
307 # a checkout isn't needed if new branch points to the current head
311 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
312 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
314 def switch_branch(new_branch
):
315 """Switch to a git branch
319 if not branch_exists(new_branch
):
320 raise GitException
, 'Branch "%s" does not exist' % new_branch
322 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
324 if tree_id
!= get_head():
326 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
327 raise GitException
, 'git-read-tree failed (local changes maybe?)'
329 set_head_file(new_branch
)
331 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
332 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
334 def delete_branch(name
):
335 """Delete a git branch
337 if not branch_exists(name
):
338 raise GitException
, 'Branch "%s" does not exist' % name
339 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
342 def rename_branch(from_name
, to_name
):
343 """Rename a git branch
345 if not branch_exists(from_name
):
346 raise GitException
, 'Branch "%s" does not exist' % from_name
347 if branch_exists(to_name
):
348 raise GitException
, 'Branch "%s" already exists' % to_name
350 if get_head_file() == from_name
:
351 set_head_file(to_name
)
352 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
356 """Add the files or recursively add the directory contents
358 # generate the file list
361 if not os
.path
.exists(i
):
362 raise GitException
, 'Unknown file or directory: %s' % i
365 # recursive search. We only add files
366 for root
, dirs
, local_files
in os
.walk(i
):
367 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
368 if os
.path
.isfile(name
):
369 files
.append(os
.path
.normpath(name
))
370 elif os
.path
.isfile(i
):
371 files
.append(os
.path
.normpath(i
))
373 raise GitException
, '%s is not a file or directory' % i
376 if __run('git-update-index --add --', files
):
377 raise GitException
, 'Unable to add file'
379 def rm(files
, force
= False):
380 """Remove a file from the repository
384 if os
.path
.exists(f
):
385 raise GitException
, '%s exists. Remove it first' %f
387 __run('git-update-index --remove --', files
)
390 __run('git-update-index --force-remove --', files
)
392 def update_cache(files
= None, force
= False):
393 """Update the cache information for the given files
398 cache_files
= __tree_status(files
)
400 # everything is up-to-date
401 if len(cache_files
) == 0:
404 # check for unresolved conflicts
405 if not force
and [x
for x
in cache_files
406 if x
[0] not in ['M', 'N', 'A', 'D']]:
407 raise GitException
, 'Updating cache failed: unresolved conflicts'
410 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
411 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
412 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
414 if add_files
and __run('git-update-index --add --', add_files
) != 0:
415 raise GitException
, 'Failed git-update-index --add'
416 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
417 raise GitException
, 'Failed git-update-index --rm'
418 if m_files
and __run('git-update-index --', m_files
) != 0:
419 raise GitException
, 'Failed git-update-index'
423 def commit(message
, files
= None, parents
= None, allowempty
= False,
424 cache_update
= True, tree_id
= None,
425 author_name
= None, author_email
= None, author_date
= None,
426 committer_name
= None, committer_email
= None):
427 """Commit the current tree to repository
434 # Get the tree status
435 if cache_update
and parents
!= []:
436 changes
= update_cache(files
)
437 if not changes
and not allowempty
:
438 raise GitException
, 'No changes to commit'
440 # get the commit message
441 if message
[-1:] != '\n':
445 # write the index to repository
447 tree_id
= _output_one_line('git-write-tree')
454 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
456 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
458 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
460 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
462 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
463 cmd
+= 'git-commit-tree %s' % tree_id
469 commit_id
= _output_one_line(cmd
, message
)
471 __set_head(commit_id
)
475 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
476 """Apply the diff between rev1 and rev2 onto the current
477 index. This function doesn't need to raise an exception since it
478 is only used for fast-pushing a patch. If this operation fails,
479 the pushing would fall back to the three-way merge.
482 index_opt
= '--index'
489 diff_str
= diff(files
, rev1
, rev2
)
492 _input_str('git-apply %s' % index_opt
, diff_str
)
498 def merge(base
, head1
, head2
):
499 """Perform a 3-way merge between base, head1 and head2 into the
503 if __run('git-read-tree -u -m --aggressive', [base
, head1
, head2
]) != 0:
504 raise GitException
, 'git-read-tree failed (local changes maybe?)'
506 # check the index for unmerged entries
508 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
510 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
514 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
516 if not path
in files
:
518 files
[path
]['1'] = ('', '')
519 files
[path
]['2'] = ('', '')
520 files
[path
]['3'] = ('', '')
522 files
[path
][stage
] = (mode
, hash)
524 # merge the unmerged files
528 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
529 stages
['3'][1], path
, stages
['1'][0],
530 stages
['2'][0], stages
['3'][0]) != 0:
534 raise GitException
, 'GIT index merging failed (possible conflicts)'
536 def status(files
= None, modified
= False, new
= False, deleted
= False,
537 conflict
= False, unknown
= False, noexclude
= False):
538 """Show the tree status
543 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
544 all
= not (modified
or new
or deleted
or conflict
or unknown
)
559 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
561 for fs
in cache_files
:
563 print '%s %s' %
(fs
[0], fs
[1])
567 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
568 """Show the diff between rev1 and rev2
574 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
, '--'] + files
)
578 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
, '--'] + files
)
580 diff_str
= _output(['git-diff-index', '-p', rev1
, '--'] + files
)
585 out_fd
.write(diff_str
)
589 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
590 """Return the diffstat between rev1 and rev2
595 p
=popen2
.Popen3('git-apply --stat')
596 diff(files
, rev1
, rev2
, p
.tochild
)
598 diff_str
= p
.fromchild
.read().rstrip()
600 raise GitException
, 'git.diffstat failed'
603 def files(rev1
, rev2
):
604 """Return the files modified between rev1 and rev2
608 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
609 result
+= '%s %s\n' %
tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
611 return result
.rstrip()
613 def barefiles(rev1
, rev2
):
614 """Return the files modified between rev1 and rev2, without status info
618 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
619 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
621 return result
.rstrip()
623 def pretty_commit(commit_id
= 'HEAD'):
624 """Return a given commit (log + diff)
626 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
629 def checkout(files
= None, tree_id
= None, force
= False):
630 """Check out the given or all files
635 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
636 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
638 checkout_cmd
= 'git-checkout-index -q -u'
640 checkout_cmd
+= ' -f'
642 checkout_cmd
+= ' -a'
644 checkout_cmd
+= ' --'
646 if __run(checkout_cmd
, files
) != 0:
647 raise GitException
, 'Failed git-checkout-index'
650 """Switch the tree to the given id
653 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
654 raise GitException
, 'git-read-tree failed (local changes maybe?)'
658 def reset(files
= None, tree_id
= None, check_out
= True):
659 """Revert the tree changes relative to the given tree_id. It removes
666 cache_files
= __tree_status(files
, tree_id
)
667 # files which were added but need to be removed
668 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
670 checkout(files
, tree_id
, True)
671 # checkout doesn't remove files
672 map(os
.remove
, rm_files
)
674 # if the reset refers to the whole tree, switch the HEAD as well
678 def pull(repository
= 'origin', refspec
= None):
679 """Pull changes from the remote repository. At the moment, just
680 use the 'git-pull' command
682 # 'git-pull' updates the HEAD
689 if __run('git-pull', args
) != 0:
690 raise GitException
, 'Failed "git-pull %s"' % repository
692 def apply_patch(filename
= None, base
= None):
693 """Apply a patch onto the current or given index. There must not
694 be any local changes in the tree, otherwise the command fails
698 return __run('git-apply --index', [filename
]) == 0
701 _input('git-apply --index', sys
.stdin
)
707 orig_head
= get_head()
710 refresh_index() # needed since __apply_patch() doesn't do it
712 if not __apply_patch():
715 raise GitException
, 'Patch does not apply cleanly'
717 top
= commit(message
= 'temporary commit used for applying a patch',
720 merge(base
, orig_head
, top
)
722 def clone(repository
, local_dir
):
723 """Clone a remote repository. At the moment, just use the
726 if __run('git-clone', [repository
, local_dir
]) != 0:
727 raise GitException
, 'Failed "git-clone %s %s"' \
728 %
(repository
, local_dir
)
730 def modifying_revs(files
, base_rev
):
731 """Return the revisions from the list modifying the given files
733 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
734 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]