1 # -*- coding: utf-8 -*-
2 """Python GIT interface"""
4 from __future__
import (absolute_import
, division
, print_function
,
11 from stgit
import basedir
12 from stgit
.compat
import environ_get
13 from stgit
.config
import config
14 from stgit
.exception
import StgException
15 from stgit
.out
import out
16 from stgit
.run
import Run
17 from stgit
.utils
import rename
, strip_prefix
20 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
22 This program is free software; you can redistribute it and/or modify
23 it under the terms of the GNU General Public License version 2 as
24 published by the Free Software Foundation.
26 This program is distributed in the hope that it will be useful,
27 but WITHOUT ANY WARRANTY; without even the implied warranty of
28 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29 GNU General Public License for more details.
31 You should have received a copy of the GNU General Public License
32 along with this program; if not, see http://www.gnu.org/licenses/.
37 class GitException(StgException
):
40 # When a subprocess has a problem, we want the exception to be a
41 # subclass of GitException.
42 class GitRunException(GitException
):
46 def __init__(self
, *cmd
):
47 """Initialise the Run object and insert the 'git' command name.
49 Run
.__init
__(self
, 'git', *cmd
)
56 """An author, committer, etc."""
57 def __init__(self
, name
= None, email
= None, date
= '',
59 self
.name
= self
.email
= self
.date
= None
60 if name
or email
or date
:
66 assert not (name
or email
or date
)
68 m
= re
.match(r
'^(.+)<(.+)>(.*)$', s
)
70 return [x
.strip() or None for x
in m
.groups()]
71 self
.name
, self
.email
, self
.date
= parse_desc(desc
)
72 def set_name(self
, val
):
75 def set_email(self
, val
):
78 def set_date(self
, val
):
82 if self
.name
and self
.email
:
83 return '%s <%s>' % (self
.name
, self
.email
)
85 raise GitException('not enough identity data')
88 """Handle the commit objects
90 def __init__(self
, id_hash
):
91 self
.__id
_hash
= id_hash
93 lines
= GRun('cat-file', 'commit', id_hash
).output_lines()
94 for i
in range(len(lines
)):
97 break # we've seen all the header fields
98 key
, val
= line
.split(' ', 1)
101 elif key
== 'author':
103 elif key
== 'committer':
104 self
.__committer
= val
106 pass # ignore other headers
107 self
.__log
= '\n'.join(lines
[i
+1:])
109 def get_id_hash(self
):
110 return self
.__id
_hash
115 def get_parent(self
):
116 parents
= self
.get_parents()
122 def get_parents(self
):
123 return GRun('rev-list', '--parents', '--max-count=1', self
.__id
_hash
124 ).output_one_line().split()[1:]
126 def get_author(self
):
129 def get_committer(self
):
130 return self
.__committer
136 return self
.get_id_hash()
138 # dictionary of Commit objects, used to avoid multiple calls to git
145 def get_commit(id_hash
):
146 """Commit objects factory. Save/look-up them in the __commits
151 if id_hash
in __commits
:
152 return __commits
[id_hash
]
154 commit
= Commit(id_hash
)
155 __commits
[id_hash
] = commit
159 """Return the list of file conflicts
162 for line
in GRun('ls-files', '-z', '--unmerged').output_lines('\0'):
163 stat
, path
= line
.split('\t', 1)
168 def ls_files(files
, tree
= 'HEAD', full_name
= True):
169 """Return the files known to GIT or raise an error otherwise. It also
170 converts the file to the full path relative the the .git directory.
177 args
.append('--with-tree=%s' % tree
)
179 args
.append('--full-name')
183 # use a set to avoid file names duplication due to different stages
184 fileset
= set(GRun('ls-files', '--error-unmatch', *args
).output_lines())
185 except GitRunException
:
186 # just hide the details of the 'git ls-files' command we use
188 'Some of the given paths are either missing or not known to GIT')
191 def parse_git_ls(lines
):
192 """Parse the output of git diff-index, diff-files, etc. Doesn't handle
193 rename/copy output, so don't feed it output generated with the -M
198 mode_a
, mode_b
, sha1_a
, sha1_b
, t
= line
.split(' ')
203 def tree_status(files
=None, tree_id
='HEAD', verbose
=False):
204 """Get the status of all changed files, or of a selected set of
205 files. Returns a list of pairs - (status, filename).
207 If 'not files', it will check all files. If 'files' is a list, it will only
208 check the files in the list.
211 out
.start('Checking for changes in the working directory')
220 conflicts
= get_conflicts()
221 cache_files
+= [('C', filename
) for filename
in conflicts
222 if not files
or filename
in files
]
223 reported_files
= set(conflicts
)
224 files_left
= [f
for f
in files
if f
not in reported_files
]
226 # files in the index. Only execute this code if no files were
227 # specified when calling the function (i.e. report all files) or
228 # files were specified but already found in the previous step
229 if not files
or files_left
:
232 args
+= ['--'] + files_left
233 diff_index_lines
= GRun('diff-index', '-z', *args
).output_lines('\0')
234 for t
, fn
in parse_git_ls(diff_index_lines
):
235 # the condition is needed in case files is emtpy and
236 # diff-index lists those already reported
237 if fn
not in reported_files
:
238 cache_files
.append((t
, fn
))
239 reported_files
.add(fn
)
240 files_left
= [f
for f
in files
if f
not in reported_files
]
242 # files in the index but changed on (or removed from) disk. Only
243 # execute this code if no files were specified when calling the
244 # function (i.e. report all files) or files were specified but
245 # already found in the previous step
246 if not files
or files_left
:
249 args
+= ['--'] + files_left
250 diff_files_lines
= GRun('diff-files', '-z', *args
).output_lines('\0')
251 for t
, fn
in parse_git_ls(diff_files_lines
):
252 # the condition is needed in case files is empty and
253 # diff-files lists those already reported
254 if fn
not in reported_files
:
255 cache_files
.append((t
, fn
))
256 reported_files
.add(fn
)
263 def local_changes(verbose
= True):
264 """Return true if there are local changes in the tree
266 return len(tree_status(verbose
= verbose
)) != 0
270 hr
= re
.compile(r
'^[0-9a-f]{40} refs/heads/(.+)$')
271 for line
in GRun('show-ref', '--heads').output_lines():
273 heads
.append(m
.group(1))
280 """Verifies the HEAD and returns the SHA1 id that represents it
285 __head
= rev_parse('HEAD')
288 class DetachedHeadException(GitException
):
290 GitException
.__init
__(self
, 'Not on any branch')
293 """Return the name of the file pointed to by the HEAD symref.
294 Throw an exception if HEAD is detached."""
297 'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD'
299 except GitRunException
:
300 raise DetachedHeadException()
302 def set_head_file(ref
):
303 """Resets HEAD to point to a new ref
305 # head cache flushing is needed since we might have a different value
309 GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref
).run()
310 except GitRunException
:
311 raise GitException('Could not set head to "%s"' % ref
)
313 def set_ref(ref
, val
):
314 """Point ref at a new commit object."""
316 GRun('update-ref', ref
, val
).run()
317 except GitRunException
:
318 raise GitException('Could not update %s to "%s".' % (ref
, val
))
320 def set_branch(branch
, val
):
321 set_ref('refs/heads/%s' % branch
, val
)
324 """Sets the HEAD value
328 if not __head
or __head
!= val
:
332 # only allow SHA1 hashes
333 assert(len(__head
) == 40)
335 def __clear_head_cache():
336 """Sets the __head to None so that a re-read is forced
343 """Refresh index with stat() information from the working directory.
345 GRun('update-index', '-q', '--unmerged', '--refresh').run()
347 def rev_parse(git_id
):
348 """Parse the string and return a verified SHA1 id
351 return GRun('rev-parse', '--verify', git_id
352 ).discard_stderr().output_one_line()
353 except GitRunException
:
354 raise GitException('Unknown revision: %s' % git_id
)
363 def branch_exists(branch
):
364 return ref_exists('refs/heads/%s' % branch
)
366 def create_branch(new_branch
, tree_id
= None):
367 """Create a new branch in the git repository
369 if branch_exists(new_branch
):
370 raise GitException('Branch "%s" already exists' % new_branch
)
372 current_head_file
= get_head_file()
373 current_head
= get_head()
374 set_head_file(new_branch
)
375 __set_head(current_head
)
377 # a checkout isn't needed if new branch points to the current head
382 # Tree switching failed. Revert the head file
383 set_head_file(current_head_file
)
384 delete_branch(new_branch
)
387 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
388 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
390 def switch_branch(new_branch
):
391 """Switch to a git branch
395 if not branch_exists(new_branch
):
396 raise GitException('Branch "%s" does not exist' % new_branch
)
398 tree_id
= rev_parse('refs/heads/%s^{commit}' % new_branch
)
399 if tree_id
!= get_head():
402 GRun('read-tree', '-u', '-m', get_head(), tree_id
).run()
403 except GitRunException
:
404 raise GitException('read-tree failed (local changes maybe?)')
406 set_head_file(new_branch
)
408 if os
.path
.isfile(os
.path
.join(basedir
.get(), 'MERGE_HEAD')):
409 os
.remove(os
.path
.join(basedir
.get(), 'MERGE_HEAD'))
412 if not ref_exists(ref
):
413 raise GitException('%s does not exist' % ref
)
414 sha1
= GRun('show-ref', '-s', ref
).output_one_line()
416 GRun('update-ref', '-d', ref
, sha1
).run()
417 except GitRunException
:
418 raise GitException('Failed to delete ref %s' % ref
)
420 def delete_branch(name
):
421 delete_ref('refs/heads/%s' % name
)
423 def rename_ref(from_ref
, to_ref
):
424 if not ref_exists(from_ref
):
425 raise GitException('"%s" does not exist' % from_ref
)
426 if ref_exists(to_ref
):
427 raise GitException('"%s" already exists' % to_ref
)
429 sha1
= GRun('show-ref', '-s', from_ref
).output_one_line()
431 GRun('update-ref', to_ref
, sha1
, '0'*40).run()
432 except GitRunException
:
433 raise GitException('Failed to create new ref %s' % to_ref
)
435 GRun('update-ref', '-d', from_ref
, sha1
).run()
436 except GitRunException
:
437 raise GitException('Failed to delete ref %s' % from_ref
)
439 def rename_branch(from_name
, to_name
):
440 """Rename a git branch."""
441 rename_ref('refs/heads/%s' % from_name
, 'refs/heads/%s' % to_name
)
443 if get_head_file() == from_name
:
444 set_head_file(to_name
)
445 except DetachedHeadException
:
446 pass # detached HEAD, so the renamee can't be the current branch
447 reflog_dir
= os
.path
.join(basedir
.get(), 'logs', 'refs', 'heads')
448 if os
.path
.exists(reflog_dir
) \
449 and os
.path
.exists(os
.path
.join(reflog_dir
, from_name
)):
450 rename(reflog_dir
, from_name
, to_name
)
458 """Return the user information.
462 name
=config
.get('user.name')
463 email
=config
.get('user.email')
464 __user
= Person(name
, email
)
468 """Return the author information.
472 # the environment variables take priority over config
473 name
= environ_get('GIT_AUTHOR_NAME')
474 email
= environ_get('GIT_AUTHOR_EMAIL')
475 if name
is None or email
is None:
478 date
= environ_get('GIT_AUTHOR_DATE', '')
479 __author
= Person(name
, email
, date
)
483 """Return the author information.
487 # the environment variables take priority over config
488 name
= environ_get('GIT_COMMITTER_NAME')
489 email
= environ_get('GIT_COMMITTER_EMAIL')
490 if name
is None or email
is None:
493 date
= environ_get('GIT_COMMITTER_DATE', '')
494 __committer
= Person(name
, email
, date
)
497 def update_cache(files
= None, force
= False):
498 """Update the cache information for the given files
500 cache_files
= tree_status(files
, verbose
= False)
502 # everything is up-to-date
503 if len(cache_files
) == 0:
506 # check for unresolved conflicts
507 if not force
and [x
for x
in cache_files
508 if x
[0] not in ['M', 'N', 'A', 'D']]:
509 raise GitException('Updating cache failed: unresolved conflicts')
512 add_files
= [x
[1] for x
in cache_files
if x
[0] in ['N', 'A']]
513 rm_files
= [x
[1] for x
in cache_files
if x
[0] in ['D']]
514 m_files
= [x
[1] for x
in cache_files
if x
[0] in ['M']]
516 GRun('update-index', '--add', '--').xargs(add_files
)
517 GRun('update-index', '--force-remove', '--').xargs(rm_files
)
518 GRun('update-index', '--').xargs(m_files
)
522 def commit(message
, files
= None, parents
= None, allowempty
= False,
523 cache_update
= True, tree_id
= None, set_head
= False,
524 author_name
= None, author_email
= None, author_date
= None,
525 committer_name
= None, committer_email
= None):
526 """Commit the current tree to repository
531 # Get the tree status
532 if cache_update
and parents
!= []:
533 changes
= update_cache(files
)
534 if not changes
and not allowempty
:
535 raise GitException('No changes to commit')
537 # get the commit message
540 elif message
[-1:] != '\n':
543 # write the index to repository
545 tree_id
= GRun('write-tree').output_one_line()
551 env
['GIT_AUTHOR_NAME'] = author_name
553 env
['GIT_AUTHOR_EMAIL'] = author_email
555 env
['GIT_AUTHOR_DATE'] = author_date
557 env
['GIT_COMMITTER_NAME'] = committer_name
559 env
['GIT_COMMITTER_EMAIL'] = committer_email
560 commit_id
= GRun('commit-tree', tree_id
,
561 *sum([['-p', p
] for p
in parents
], [])
562 ).env(env
).raw_input(message
).output_one_line()
564 __set_head(commit_id
)
568 def apply_diff(rev1
, rev2
, check_index
= True, files
= None):
569 """Apply the diff between rev1 and rev2 onto the current
570 index. This function doesn't need to raise an exception since it
571 is only used for fast-pushing a patch. If this operation fails,
572 the pushing would fall back to the three-way merge.
575 index_opt
= ['--index']
582 diff_str
= diff(files
, rev1
, rev2
)
585 GRun('apply', *index_opt
).raw_input(
586 diff_str
).discard_stderr().no_output()
587 except GitRunException
:
592 stages_re
= re
.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
594 def merge_recursive(base
, head1
, head2
):
595 """Perform a 3-way merge between base, head1 and head2 into the
599 p
= GRun('merge-recursive', base
, '--', head1
, head2
).env(
600 { 'GITHEAD_%s' % base
: 'ancestor',
601 'GITHEAD_%s' % head1
: 'current',
602 'GITHEAD_%s' % head2
: 'patched'}).returns([0, 1])
603 output
= p
.output_lines()
605 # There were conflicts
606 if config
.getbool('stgit.autoimerge'):
609 conflicts
= [l
for l
in output
if l
.startswith('CONFLICT')]
611 raise GitException("%d conflict(s)" % len(conflicts
))
613 def mergetool(files
= ()):
614 """Invoke 'git mergetool' to resolve any outstanding conflicts. If 'not
615 files', all the files in an unmerged state will be processed."""
616 GRun('mergetool', *list(files
)).returns([0, 1]).run()
617 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
619 conflicts
= ['CONFLICT ' + f
for f
in get_conflicts()]
622 raise GitException("%d conflict(s)" % len(conflicts
))
624 def diff(files
= None, rev1
= 'HEAD', rev2
= None, diff_flags
= [],
626 """Show the diff between rev1 and rev2
630 if binary
and '--binary' not in diff_flags
:
631 diff_flags
= diff_flags
+ ['--binary']
634 return GRun('diff-tree', '-p',
635 *(diff_flags
+ [rev1
, rev2
, '--'] + files
)).raw_output()
639 return GRun('diff-index', '-p', '-R',
640 *(diff_flags
+ [rev2
, '--'] + files
)).raw_output()
642 return GRun('diff-index', '-p',
643 *(diff_flags
+ [rev1
, '--'] + files
)).raw_output()
647 def files(rev1
, rev2
, diff_flags
= []):
648 """Return the files modified between rev1 and rev2
652 for line
in GRun('diff-tree', *(diff_flags
+ ['-r', rev1
, rev2
])
654 result
.append('%s %s' % tuple(line
.split(' ', 4)[-1].split('\t', 1)))
656 return '\n'.join(result
)
658 def barefiles(rev1
, rev2
):
659 """Return the files modified between rev1 and rev2, without status info
663 for line
in GRun('diff-tree', '-r', rev1
, rev2
).output_lines():
664 result
.append(line
.split(' ', 4)[-1].split('\t', 1)[-1])
666 return '\n'.join(result
)
668 def pretty_commit(commit_id
= 'HEAD', flags
= []):
669 """Return a given commit (log + diff)
671 return GRun('show', *(flags
+ [commit_id
])).raw_output()
673 def checkout(tree_id
):
674 """Check out the given tree_id
677 GRun('read-tree', '--reset', '-u', tree_id
).run()
678 except GitRunException
:
679 raise GitException('Failed "git read-tree" --reset %s' % tree_id
)
681 def switch(tree_id
, keep
= False):
682 """Switch the tree to the given id
685 # only update the index while keeping the local changes
686 GRun('read-tree', tree_id
).run()
690 GRun('read-tree', '-u', '-m', get_head(), tree_id
).run()
691 except GitRunException
:
692 raise GitException('read-tree failed (local changes maybe?)')
696 def reset(tree_id
= None):
697 """Revert the tree changes relative to the given tree_id. It removes
707 def fetch(repository
= 'origin', refspec
= None):
708 """Fetches changes from the remote repository, using 'git fetch'
718 command
= config
.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
719 config
.get('stgit.fetchcmd')
720 Run(*(command
.split() + args
)).run()
722 def pull(repository
= 'origin', refspec
= None):
723 """Fetches changes from the remote repository, using 'git pull'
733 command
= config
.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
734 config
.get('stgit.pullcmd')
735 Run(*(command
.split() + args
)).run()
737 def rebase(tree_id
= None):
738 """Rebase the current tree to the give tree_id. The tree_id
739 argument may be something other than a GIT id if an external
742 command
= config
.get('branch.%s.stgit.rebasecmd' % get_head_file()) \
743 or config
.get('stgit.rebasecmd')
749 raise GitException('Default rebasing requires a commit id')
751 # clear the HEAD cache as the custom rebase command will update it
753 Run(*(command
.split() + args
)).run()
759 """Repack all objects into a single pack
761 GRun('repack', '-a', '-d', '-f').run()
763 def apply_patch(filename
= None, diff
= None, base
= None,
764 reject
= False, strip
= None):
765 """Apply a patch onto the current or given index. There must not
766 be any local changes in the tree, otherwise the command fails
770 with io
.open(filename
, encoding
='utf-8') as f
:
773 diff
= sys
.stdin
.read()
776 orig_head
= get_head()
781 cmd
= ['apply', '--index']
784 if strip
is not None:
785 cmd
+= ['-p%s' % (strip
,)]
787 GRun(*cmd
).raw_input(diff
).no_output()
788 except GitRunException
:
791 raise GitException('Diff does not apply cleanly')
794 top
= commit(message
= 'temporary commit used for applying a patch',
797 merge_recursive(base
, orig_head
, top
)
800 def modifying_revs(files
, base_rev
, head_rev
):
801 """Return the revisions from the list modifying the given files."""
802 return GRun('rev-list', '%s..%s' % (base_rev
, head_rev
), '--', *files
805 def refspec_localpart(refspec
):
806 m
= re
.match('^[^:]*:([^:]*)$', refspec
)
810 raise GitException('Cannot parse refspec "%s"' % refspec
)
812 def __remotes_from_config():
813 return config
.sections_matching(r
'remote\.(.*)\.url')
815 def __remotes_from_dir(dir):
816 d
= os
.path
.join(basedir
.get(), dir)
817 if os
.path
.exists(d
):
823 """Return the list of remotes in the repository
825 return (set(__remotes_from_config())
826 |
set(__remotes_from_dir('remotes'))
827 |
set(__remotes_from_dir('branches')))
829 def remotes_local_branches(remote
):
830 """Returns the list of local branches fetched from given remote
834 if remote
in __remotes_from_config():
835 for line
in config
.getall('remote.%s.fetch' % remote
):
836 branches
.append(refspec_localpart(line
))
837 elif remote
in __remotes_from_dir('remotes'):
838 stream
= open(os
.path
.join(basedir
.get(), 'remotes', remote
), 'r')
840 # Only consider Pull lines
841 m
= re
.match('^Pull: (.*)\n$', line
)
843 branches
.append(refspec_localpart(m
.group(1)))
845 elif remote
in __remotes_from_dir('branches'):
846 # old-style branches only declare one branch
847 branches
.append('refs/heads/'+remote
)
849 raise GitException('Unknown remote "%s"' % remote
)
853 def identify_remote(branchname
):
854 """Return the name for the remote to pull the given branchname
855 from, or None if we believe it is a local branch.
858 for remote
in remotes_list():
859 if branchname
in remotes_local_branches(remote
):
862 # if we get here we've found nothing, the branch is a local one
866 """Return the git id for the tip of the parent branch as left by
871 stream
= open(os
.path
.join(basedir
.get(), 'FETCH_HEAD'), "r")
873 # Only consider lines not tagged not-for-merge
874 m
= re
.match('^([^\t]*)\t\t', line
)
877 raise GitException('StGit does not support multiple FETCH_HEAD')
879 fetch_head
=m
.group(1)
883 out
.warn('No for-merge remote head found in FETCH_HEAD')
885 # here we are sure to have a single fetch_head
889 """Return a list of all refs in the current repository.
892 return [line
.split()[1] for line
in GRun('show-ref').output_lines()]