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' % str(cmd
)
122 def _input_str(cmd
, string
):
123 p
= popen2
.Popen3(cmd
, True)
124 p
.tochild
.write(string
)
127 raise GitException
, '%s failed' % str(cmd
)
130 p
=popen2
.Popen3(cmd
, True)
131 output
= p
.fromchild
.read()
133 raise GitException
, '%s failed' % str(cmd
)
136 def _output_one_line(cmd
, file_desc
= None):
137 p
=popen2
.Popen3(cmd
, True)
138 if file_desc
!= None:
139 for line
in file_desc
:
140 p
.tochild
.write(line
)
142 output
= p
.fromchild
.readline().strip()
144 raise GitException
, '%s failed' % str(cmd
)
147 def _output_lines(cmd
):
148 p
=popen2
.Popen3(cmd
, True)
149 lines
= p
.fromchild
.readlines()
151 raise GitException
, '%s failed' % str(cmd
)
154 def __run(cmd
, args
=None):
155 """__run: runs cmd using spawnvp.
157 Runs cmd using spawnvp. The shell is avoided so it won't mess up
158 our arguments. If args is very large, the command is run multiple
159 times; args is split xargs style: cmd is passed on each
160 invocation. Unlike xargs, returns immediately if any non-zero
161 return code is received.
167 for i
in range(0, len(args
)+1, 100):
168 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
173 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
175 """Returns a list of pairs - [status, filename]
185 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
186 base_exclude
= ['--exclude=%s' % s
for s
in
187 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
188 base_exclude
.append('--exclude-per-directory=.gitignore')
190 if os
.path
.exists(exclude_file
):
191 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
195 extra_exclude
= base_exclude
= []
197 lines
= _output_lines(['git-ls-files', '--others', '--directory']
198 + base_exclude
+ extra_exclude
)
199 cache_files
+= [('?', line
.strip()) for line
in lines
]
202 conflicts
= get_conflicts()
205 cache_files
+= [('C', filename
) for filename
in conflicts
]
208 for line
in _output_lines(['git-diff-index', tree_id
] + files
):
209 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
210 if fs
[1] not in conflicts
:
211 cache_files
.append(fs
)
216 """Return true if there are local changes in the tree
218 return len(__tree_status()) != 0
224 """Verifies the HEAD and returns the SHA1 id that represents it
229 __head
= rev_parse('HEAD')
233 """Returns the name of the file pointed to by the HEAD link
235 return strip_prefix('refs/heads/',
236 _output_one_line('git-symbolic-ref HEAD'))
238 def set_head_file(ref
):
239 """Resets HEAD to point to a new ref
241 # head cache flushing is needed since we might have a different value
244 if __run('git-symbolic-ref HEAD',
245 [os
.path
.join('refs', 'heads', ref
)]) != 0:
246 raise GitException
, 'Could not set head to "%s"' % ref
249 """Sets the HEAD value
253 if not __head
or __head
!= val
:
254 if __run('git-update-ref HEAD', [val
]) != 0:
255 raise GitException
, 'Could not update HEAD to "%s".' % val
258 # only allow SHA1 hashes
259 assert(len(__head
) == 40)
261 def __clear_head_cache():
262 """Sets the __head to None so that a re-read is forced
269 """Refresh index with stat() information from the working directory.
271 __run('git-update-index -q --unmerged --refresh')
273 def rev_parse(git_id
):
274 """Parse the string and return a verified SHA1 id
277 return _output_one_line(['git-rev-parse', '--verify', git_id
])
279 raise GitException
, 'Unknown revision: %s' % git_id
281 def branch_exists(branch
):
282 """Existence check for the named branch
284 branch
= os
.path
.join('refs', 'heads', branch
)
285 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
286 if line
.strip() == branch
:
288 if re
.compile('[ |/]'+branch
+' ').search(line
):
289 raise GitException
, 'Bogus branch: %s' % line
292 def create_branch(new_branch
, tree_id
= None):
293 """Create a new branch in the git repository
295 if branch_exists(new_branch
):
296 raise GitException
, 'Branch "%s" already exists' % new_branch
298 current_head
= get_head()
299 set_head_file(new_branch
)
300 __set_head(current_head
)
302 # a checkout isn't needed if new branch points to the current head
306 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
307 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
309 def switch_branch(new_branch
):
310 """Switch to a git branch
314 if not branch_exists(new_branch
):
315 raise GitException
, 'Branch "%s" does not exist' % new_branch
317 tree_id
= rev_parse(os
.path
.join('refs', 'heads', new_branch
)
319 if tree_id
!= get_head():
321 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
322 raise GitException
, 'git-read-tree failed (local changes maybe?)'
324 set_head_file(new_branch
)
326 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
327 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
329 def delete_branch(name
):
330 """Delete a git branch
332 if not branch_exists(name
):
333 raise GitException
, 'Branch "%s" does not exist' % name
334 remove_file_and_dirs(os
.path
.join(basedir
.get(), 'refs', 'heads'),
337 def rename_branch(from_name
, to_name
):
338 """Rename a git branch
340 if not branch_exists(from_name
):
341 raise GitException
, 'Branch "%s" does not exist' % from_name
342 if branch_exists(to_name
):
343 raise GitException
, 'Branch "%s" already exists' % to_name
345 if get_head_file() == from_name
:
346 set_head_file(to_name
)
347 rename(os
.path
.join(basedir
.get(), 'refs', 'heads'),
351 """Add the files or recursively add the directory contents
353 # generate the file list
356 if not os
.path
.exists(i
):
357 raise GitException
, 'Unknown file or directory: %s' % i
360 # recursive search. We only add files
361 for root
, dirs
, local_files
in os
.walk(i
):
362 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
363 if os
.path
.isfile(name
):
364 files
.append(os
.path
.normpath(name
))
365 elif os
.path
.isfile(i
):
366 files
.append(os
.path
.normpath(i
))
368 raise GitException
, '%s is not a file or directory' % i
371 if __run('git-update-index --add --', files
):
372 raise GitException
, 'Unable to add file'
374 def rm(files
, force
= False):
375 """Remove a file from the repository
379 if os
.path
.exists(f
):
380 raise GitException
, '%s exists. Remove it first' %f
382 __run('git-update-index --remove --', files
)
385 __run('git-update-index --force-remove --', files
)
387 def update_cache(files
= None, force
= False):
388 """Update the cache information for the given files
393 cache_files
= __tree_status(files
)
395 # everything is up-to-date
396 if len(cache_files
) == 0:
399 # check for unresolved conflicts
400 if not force
and [x
for x
in cache_files
401 if x
[0] not in ['M', 'N', 'A', 'D']]:
402 raise GitException
, 'Updating cache failed: unresolved conflicts'
405 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
406 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
407 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
409 if add_files
and __run('git-update-index --add --', add_files
) != 0:
410 raise GitException
, 'Failed git-update-index --add'
411 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
412 raise GitException
, 'Failed git-update-index --rm'
413 if m_files
and __run('git-update-index --', m_files
) != 0:
414 raise GitException
, 'Failed git-update-index'
418 def commit(message
, files
= None, parents
= None, allowempty
= False,
419 cache_update
= True, tree_id
= None,
420 author_name
= None, author_email
= None, author_date
= None,
421 committer_name
= None, committer_email
= None):
422 """Commit the current tree to repository
429 # Get the tree status
430 if cache_update
and parents
!= []:
431 changes
= update_cache(files
)
432 if not changes
and not allowempty
:
433 raise GitException
, 'No changes to commit'
435 # get the commit message
436 if message
[-1:] != '\n':
440 # write the index to repository
442 tree_id
= _output_one_line('git-write-tree')
449 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
451 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
453 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
455 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
457 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
458 cmd
+= 'git-commit-tree %s' % tree_id
464 commit_id
= _output_one_line(cmd
, message
)
466 __set_head(commit_id
)
470 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
471 """Apply the diff between rev1 and rev2 onto the current
472 index. This function doesn't need to raise an exception since it
473 is only used for fast-pushing a patch. If this operation fails,
474 the pushing would fall back to the three-way merge.
477 index_opt
= '--index'
484 diff_str
= diff(files
, rev1
, rev2
)
487 _input_str('git-apply %s' % index_opt
, diff_str
)
493 def merge(base
, head1
, head2
):
494 """Perform a 3-way merge between base, head1 and head2 into the
498 if __run('git-read-tree -u -m --aggressive', [base
, head1
, head2
]) != 0:
499 raise GitException
, 'git-read-tree failed (local changes maybe?)'
501 # check the index for unmerged entries
503 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
505 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
509 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
511 if not path
in files
:
513 files
[path
]['1'] = ('', '')
514 files
[path
]['2'] = ('', '')
515 files
[path
]['3'] = ('', '')
517 files
[path
][stage
] = (mode
, hash)
519 # merge the unmerged files
523 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
524 stages
['3'][1], path
, stages
['1'][0],
525 stages
['2'][0], stages
['3'][0]) != 0:
529 raise GitException
, 'GIT index merging failed (possible conflicts)'
531 def status(files
= None, modified
= False, new
= False, deleted
= False,
532 conflict
= False, unknown
= False, noexclude
= False):
533 """Show the tree status
538 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
539 all
= not (modified
or new
or deleted
or conflict
or unknown
)
554 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
556 for fs
in cache_files
:
558 print '%s %s' % (fs
[0], fs
[1])
562 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
563 """Show the diff between rev1 and rev2
569 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
] + files
)
573 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
] + files
)
575 diff_str
= _output(['git-diff-index', '-p', rev1
] + files
)
580 out_fd
.write(diff_str
)
584 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
585 """Return the diffstat between rev1 and rev2
590 p
=popen2
.Popen3('git-apply --stat')
591 diff(files
, rev1
, rev2
, p
.tochild
)
593 diff_str
= p
.fromchild
.read().rstrip()
595 raise GitException
, 'git.diffstat failed'
598 def files(rev1
, rev2
):
599 """Return the files modified between rev1 and rev2
603 for line
in _output_lines('git-diff-tree -r %s %s' % (rev1
, rev2
)):
604 result
+= '%s %s\n' % tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
606 return result
.rstrip()
608 def barefiles(rev1
, rev2
):
609 """Return the files modified between rev1 and rev2, without status info
613 for line
in _output_lines('git-diff-tree -r %s %s' % (rev1
, rev2
)):
614 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
616 return result
.rstrip()
618 def pretty_commit(commit_id
= 'HEAD'):
619 """Return a given commit (log + diff)
621 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
624 def checkout(files
= None, tree_id
= None, force
= False):
625 """Check out the given or all files
630 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
631 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
633 checkout_cmd
= 'git-checkout-index -q -u'
635 checkout_cmd
+= ' -f'
637 checkout_cmd
+= ' -a'
639 checkout_cmd
+= ' --'
641 if __run(checkout_cmd
, files
) != 0:
642 raise GitException
, 'Failed git-checkout-index'
645 """Switch the tree to the given id
648 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
649 raise GitException
, 'git-read-tree failed (local changes maybe?)'
653 def reset(files
= None, tree_id
= None, check_out
= True):
654 """Revert the tree changes relative to the given tree_id. It removes
661 cache_files
= __tree_status(files
, tree_id
)
662 # files which were added but need to be removed
663 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
665 checkout(files
, tree_id
, True)
666 # checkout doesn't remove files
667 map(os
.remove
, rm_files
)
669 # if the reset refers to the whole tree, switch the HEAD as well
673 def pull(repository
= 'origin', refspec
= None):
674 """Pull changes from the remote repository. At the moment, just
675 use the 'git-pull' command
677 # 'git-pull' updates the HEAD
684 if __run('git-pull', args
) != 0:
685 raise GitException
, 'Failed "git-pull %s"' % repository
687 def apply_patch(filename
= None, base
= None):
688 """Apply a patch onto the current or given index. There must not
689 be any local changes in the tree, otherwise the command fails
693 return __run('git-apply --index', [filename
]) == 0
696 _input('git-apply --index', sys
.stdin
)
702 orig_head
= get_head()
705 refresh_index() # needed since __apply_patch() doesn't do it
707 if not __apply_patch():
710 raise GitException
, 'Patch does not apply cleanly'
712 top
= commit(message
= 'temporary commit used for applying a patch',
715 merge(base
, orig_head
, top
)
717 def clone(repository
, local_dir
):
718 """Clone a remote repository. At the moment, just use the
721 if __run('git-clone', [repository
, local_dir
]) != 0:
722 raise GitException
, 'Failed "git-clone %s %s"' \
723 % (repository
, local_dir
)
725 def modifying_revs(files
, base_rev
):
726 """Return the revisions from the list modifying the given files
728 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
729 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]