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
)
123 p
=popen2
.Popen3(cmd
, True)
124 output
= p
.fromchild
.read()
126 raise GitException
, '%s failed' % str
(cmd
)
129 def _output_one_line(cmd
, file_desc
= None):
130 p
=popen2
.Popen3(cmd
, True)
131 if file_desc
!= None:
132 for line
in file_desc
:
133 p
.tochild
.write(line
)
135 output
= p
.fromchild
.readline().strip()
137 raise GitException
, '%s failed' % str
(cmd
)
140 def _output_lines(cmd
):
141 p
=popen2
.Popen3(cmd
, True)
142 lines
= p
.fromchild
.readlines()
144 raise GitException
, '%s failed' % str
(cmd
)
147 def __run(cmd
, args
=None):
148 """__run: runs cmd using spawnvp.
150 Runs cmd using spawnvp. The shell is avoided so it won't mess up
151 our arguments. If args is very large, the command is run multiple
152 times; args is split xargs style: cmd is passed on each
153 invocation. Unlike xargs, returns immediately if any non-zero
154 return code is received.
160 for i
in range(0, len(args
)+1, 100):
161 r
=os
.spawnvp(os
.P_WAIT
, args_l
[0], args_l
+ args
[i
:min(i
+100, len(args
))])
166 def __tree_status(files
= None, tree_id
= 'HEAD', unknown
= False,
168 """Returns a list of pairs - [status, filename]
178 exclude_file
= os
.path
.join(basedir
.get(), 'info', 'exclude')
179 base_exclude
= ['--exclude=%s' % s for s in
180 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
181 base_exclude
.append('--exclude-per-directory=.gitignore')
183 if os
.path
.exists(exclude_file
):
184 extra_exclude
= ['--exclude-from=%s' % exclude_file
]
188 extra_exclude
= base_exclude
= []
190 lines
= _output_lines(['git-ls-files', '--others', '--directory']
191 + base_exclude
+ extra_exclude
)
192 cache_files
+= [('?', line
.strip()) for line
in lines
]
195 conflicts
= get_conflicts()
198 cache_files
+= [('C', filename
) for filename
in conflicts
]
201 for line
in _output_lines(['git-diff-index', tree_id
] + files
):
202 fs
= tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
203 if fs
[1] not in conflicts
:
204 cache_files
.append(fs
)
209 """Return true if there are local changes in the tree
211 return len(__tree_status()) != 0
217 """Verifies the HEAD and returns the SHA1 id that represents it
222 __head
= rev_parse('HEAD')
226 """Returns the name of the file pointed to by the HEAD link
228 return os
.path
.basename(_output_one_line('git-symbolic-ref HEAD'))
230 def set_head_file(ref
):
231 """Resets HEAD to point to a new ref
233 # head cache flushing is needed since we might have a different value
236 if __run('git-symbolic-ref HEAD', [ref
]) != 0:
237 raise GitException
, 'Could not set head to "%s"' % ref
240 """Sets the HEAD value
244 if not __head
or __head
!= val
:
245 if __run('git-update-ref HEAD', [val
]) != 0:
246 raise GitException
, 'Could not update HEAD to "%s".' % val
249 # only allow SHA1 hashes
250 assert(len(__head
) == 40)
252 def __clear_head_cache():
253 """Sets the __head to None so that a re-read is forced
260 """Refresh index with stat() information from the working directory.
262 __run('git-update-index -q --unmerged --refresh')
264 def rev_parse(git_id
):
265 """Parse the string and return a verified SHA1 id
268 return _output_one_line(['git-rev-parse', '--verify', git_id
])
270 raise GitException
, 'Unknown revision: %s' % git_id
272 def branch_exists(branch
):
273 """Existence check for the named branch
275 for line
in _output_lines('git-rev-parse --symbolic --all 2>&1'):
276 if line
.strip() == branch
:
278 if re
.compile('[ |/]'+branch
+' ').search(line
):
279 raise GitException
, 'Bogus branch: %s' % line
282 def create_branch(new_branch
, tree_id
= None):
283 """Create a new branch in the git repository
285 new_head
= os
.path
.join('refs', 'heads', new_branch
)
286 if branch_exists(new_head
):
287 raise GitException
, 'Branch "%s" already exists' % new_branch
289 current_head
= get_head()
290 set_head_file(new_head
)
291 __set_head(current_head
)
293 # a checkout isn't needed if new branch points to the current head
297 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
298 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
300 def switch_branch(name
):
301 """Switch to a git branch
305 new_head
= os
.path
.join('refs', 'heads', name
)
306 if not branch_exists(new_head
):
307 raise GitException
, 'Branch "%s" does not exist' % name
309 tree_id
= rev_parse(new_head
+ '^{commit}')
310 if tree_id
!= get_head():
312 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
313 raise GitException
, 'git-read-tree failed (local changes maybe?)'
315 set_head_file(new_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 delete_branch(name
):
321 """Delete a git branch
323 branch_head
= os
.path
.join('refs', 'heads', name
)
324 if not branch_exists(branch_head
):
325 raise GitException
, 'Branch "%s" does not exist' % name
326 os
.remove(os
.path
.join(basedir
.get(), branch_head
))
328 def rename_branch(from_name
, to_name
):
329 """Rename a git branch
331 from_head
= os
.path
.join('refs', 'heads', from_name
)
332 if not branch_exists(from_head
):
333 raise GitException
, 'Branch "%s" does not exist' % from_name
334 to_head
= os
.path
.join('refs', 'heads', to_name
)
335 if branch_exists(to_head
):
336 raise GitException
, 'Branch "%s" already exists' % to_name
338 if get_head_file() == from_name
:
339 set_head_file(to_head
)
340 os
.rename(os
.path
.join(basedir
.get(), from_head
), \
341 os
.path
.join(basedir
.get(), to_head
))
344 """Add the files or recursively add the directory contents
346 # generate the file list
349 if not os
.path
.exists(i
):
350 raise GitException
, 'Unknown file or directory: %s' % i
353 # recursive search. We only add files
354 for root
, dirs
, local_files
in os
.walk(i
):
355 for name
in [os
.path
.join(root
, f
) for f
in local_files
]:
356 if os
.path
.isfile(name
):
357 files
.append(os
.path
.normpath(name
))
358 elif os
.path
.isfile(i
):
359 files
.append(os
.path
.normpath(i
))
361 raise GitException
, '%s is not a file or directory' % i
364 if __run('git-update-index --add --', files
):
365 raise GitException
, 'Unable to add file'
367 def rm(files
, force
= False):
368 """Remove a file from the repository
372 if os
.path
.exists(f
):
373 raise GitException
, '%s exists. Remove it first' %f
375 __run('git-update-index --remove --', files
)
378 __run('git-update-index --force-remove --', files
)
380 def update_cache(files
= None, force
= False):
381 """Update the cache information for the given files
386 cache_files
= __tree_status(files
)
388 # everything is up-to-date
389 if len(cache_files
) == 0:
392 # check for unresolved conflicts
393 if not force
and [x
for x
in cache_files
394 if x
[0] not in ['M', 'N', 'A', 'D']]:
395 raise GitException
, 'Updating cache failed: unresolved conflicts'
398 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
399 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
400 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
402 if add_files
and __run('git-update-index --add --', add_files
) != 0:
403 raise GitException
, 'Failed git-update-index --add'
404 if rm_files
and __run('git-update-index --force-remove --', rm_files
) != 0:
405 raise GitException
, 'Failed git-update-index --rm'
406 if m_files
and __run('git-update-index --', m_files
) != 0:
407 raise GitException
, 'Failed git-update-index'
411 def commit(message
, files
= None, parents
= None, allowempty
= False,
412 cache_update
= True, tree_id
= None,
413 author_name
= None, author_email
= None, author_date
= None,
414 committer_name
= None, committer_email
= None):
415 """Commit the current tree to repository
422 # Get the tree status
423 if cache_update
and parents
!= []:
424 changes
= update_cache(files
)
425 if not changes
and not allowempty
:
426 raise GitException
, 'No changes to commit'
428 # get the commit message
429 if message
[-1:] != '\n':
433 # write the index to repository
435 tree_id
= _output_one_line('git-write-tree')
442 cmd
+= 'GIT_AUTHOR_NAME="%s" ' % author_name
444 cmd
+= 'GIT_AUTHOR_EMAIL="%s" ' % author_email
446 cmd
+= 'GIT_AUTHOR_DATE="%s" ' % author_date
448 cmd
+= 'GIT_COMMITTER_NAME="%s" ' % committer_name
450 cmd
+= 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
451 cmd
+= 'git-commit-tree %s' % tree_id
457 commit_id
= _output_one_line(cmd
, message
)
459 __set_head(commit_id
)
463 def apply_diff(rev1
, rev2
, check_index
= True):
464 """Apply the diff between rev1 and rev2 onto the current
465 index. This function doesn't need to raise an exception since it
466 is only used for fast-pushing a patch. If this operation fails,
467 the pushing would fall back to the three-way merge.
470 index_opt
= '--index'
473 cmd
= 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
474 %
(rev1
, rev2
, index_opt
)
476 return os
.system(cmd
) == 0
478 def merge(base
, head1
, head2
):
479 """Perform a 3-way merge between base, head1 and head2 into the
483 if __run('git-read-tree -u -m --aggressive', [base
, head1
, head2
]) != 0:
484 raise GitException
, 'git-read-tree failed (local changes maybe?)'
486 # check the index for unmerged entries
488 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
490 for line
in _output('git-ls-files --unmerged --stage -z').split('\0'):
494 mode
, hash, stage
, path
= stages_re
.findall(line
)[0]
496 if not path
in files
:
498 files
[path
]['1'] = ('', '')
499 files
[path
]['2'] = ('', '')
500 files
[path
]['3'] = ('', '')
502 files
[path
][stage
] = (mode
, hash)
504 # merge the unmerged files
508 if gitmergeonefile
.merge(stages
['1'][1], stages
['2'][1],
509 stages
['3'][1], path
, stages
['1'][0],
510 stages
['2'][0], stages
['3'][0]) != 0:
514 raise GitException
, 'GIT index merging failed (possible conflicts)'
516 def status(files
= None, modified
= False, new
= False, deleted
= False,
517 conflict
= False, unknown
= False, noexclude
= False):
518 """Show the tree status
523 cache_files
= __tree_status(files
, unknown
= True, noexclude
= noexclude
)
524 all
= not (modified
or new
or deleted
or conflict
or unknown
)
539 cache_files
= [x
for x
in cache_files
if x
[0] in filestat
]
541 for fs
in cache_files
:
543 print '%s %s' %
(fs
[0], fs
[1])
547 def diff(files
= None, rev1
= 'HEAD', rev2
= None, out_fd
= None):
548 """Show the diff between rev1 and rev2
554 diff_str
= _output(['git-diff-tree', '-p', rev1
, rev2
] + files
)
558 diff_str
= _output(['git-diff-index', '-p', '-R', rev2
] + files
)
560 diff_str
= _output(['git-diff-index', '-p', rev1
] + files
)
565 out_fd
.write(diff_str
)
569 def diffstat(files
= None, rev1
= 'HEAD', rev2
= None):
570 """Return the diffstat between rev1 and rev2
575 p
=popen2
.Popen3('git-apply --stat')
576 diff(files
, rev1
, rev2
, p
.tochild
)
578 diff_str
= p
.fromchild
.read().rstrip()
580 raise GitException
, 'git.diffstat failed'
583 def files(rev1
, rev2
):
584 """Return the files modified between rev1 and rev2
588 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
589 result
+= '%s %s\n' %
tuple(line
.rstrip().split(' ',4)[-1].split('\t',1))
591 return result
.rstrip()
593 def barefiles(rev1
, rev2
):
594 """Return the files modified between rev1 and rev2, without status info
598 for line
in _output_lines('git-diff-tree -r %s %s' %
(rev1
, rev2
)):
599 result
+= '%s\n' % line
.rstrip().split(' ',4)[-1].split('\t',1)[-1]
601 return result
.rstrip()
603 def pretty_commit(commit_id
= 'HEAD'):
604 """Return a given commit (log + diff)
606 return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
609 def checkout(files
= None, tree_id
= None, force
= False):
610 """Check out the given or all files
615 if tree_id
and __run('git-read-tree --reset', [tree_id
]) != 0:
616 raise GitException
, 'Failed git-read-tree --reset %s' % tree_id
618 checkout_cmd
= 'git-checkout-index -q -u'
620 checkout_cmd
+= ' -f'
622 checkout_cmd
+= ' -a'
624 checkout_cmd
+= ' --'
626 if __run(checkout_cmd
, files
) != 0:
627 raise GitException
, 'Failed git-checkout-index'
630 """Switch the tree to the given id
633 if __run('git-read-tree -u -m', [get_head(), tree_id
]) != 0:
634 raise GitException
, 'git-read-tree failed (local changes maybe?)'
638 def reset(files
= None, tree_id
= None, check_out
= True):
639 """Revert the tree changes relative to the given tree_id. It removes
646 cache_files
= __tree_status(files
, tree_id
)
647 # files which were added but need to be removed
648 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['A']]
650 checkout(files
, tree_id
, True)
651 # checkout doesn't remove files
652 map(os
.remove
, rm_files
)
654 # if the reset refers to the whole tree, switch the HEAD as well
658 def pull(repository
= 'origin', refspec
= None):
659 """Pull changes from the remote repository. At the moment, just
660 use the 'git-pull' command
662 # 'git-pull' updates the HEAD
669 if __run('git-pull', args
) != 0:
670 raise GitException
, 'Failed "git-pull %s"' % repository
672 def apply_patch(filename
= None, base
= None):
673 """Apply a patch onto the current or given index. There must not
674 be any local changes in the tree, otherwise the command fails
678 return __run('git-apply --index', [filename
]) == 0
681 _input('git-apply --index', sys
.stdin
)
687 orig_head
= get_head()
690 refresh_index() # needed since __apply_patch() doesn't do it
692 if not __apply_patch():
695 raise GitException
, 'Patch does not apply cleanly'
697 top
= commit(message
= 'temporary commit used for applying a patch',
700 merge(base
, orig_head
, top
)
702 def clone(repository
, local_dir
):
703 """Clone a remote repository. At the moment, just use the
706 if __run('git-clone', [repository
, local_dir
]) != 0:
707 raise GitException
, 'Failed "git-clone %s %s"' \
708 %
(repository
, local_dir
)
710 def modifying_revs(files
, base_rev
):
711 """Return the revisions from the list modifying the given files
713 cmd
= ['git-rev-list', '%s..' % base_rev
, '--']
714 revs
= [line
.strip() for line
in _output_lines(cmd
+ files
)]