| """ |
| Module with abstraction layers to revision control systems. |
| |
| With this library, autotest developers can handle source code checkouts and |
| updates on both client as well as server code. |
| """ |
| |
| import os, warnings, logging |
| import error, utils |
| from autotest_lib.client.bin import os_dep |
| |
| |
| class RevisionControlError(Exception): |
| """Local exception to be raised by code in this file.""" |
| |
| |
| class GitError(RevisionControlError): |
| """Exceptions raised for general git errors.""" |
| |
| |
| class GitCloneError(GitError): |
| """Exceptions raised for git clone errors.""" |
| |
| |
| class GitFetchError(GitError): |
| """Exception raised for git fetch errors.""" |
| |
| |
| class GitPullError(GitError): |
| """Exception raised for git pull errors.""" |
| |
| |
| class GitResetError(GitError): |
| """Exception raised for git reset errors.""" |
| |
| |
| class GitCommitError(GitError): |
| """Exception raised for git commit errors.""" |
| |
| |
| class GitPushError(GitError): |
| """Exception raised for git push errors.""" |
| |
| |
| class GitRepo(object): |
| """ |
| This class represents a git repo. |
| |
| It is used to pull down a local copy of a git repo, check if the local |
| repo is up-to-date, if not update. It delegates the install to |
| implementation classes. |
| """ |
| |
| def __init__(self, repodir, giturl=None, weburl=None, abs_work_tree=None): |
| """ |
| Initialized reposotory. |
| |
| @param repodir: destination repo directory. |
| @param giturl: master repo git url. |
| @param weburl: a web url for the master repo. |
| @param abs_work_tree: work tree of the git repo. In the |
| absence of a work tree git manipulations will occur |
| in the current working directory for non bare repos. |
| In such repos the -git-dir option should point to |
| the .git directory and -work-tree should point to |
| the repos working tree. |
| Note: a bare reposotory is one which contains all the |
| working files (the tree) and the other wise hidden files |
| (.git) in the same directory. This class assumes non-bare |
| reposotories. |
| """ |
| if repodir is None: |
| raise ValueError('You must provide a path that will hold the' |
| 'git repository') |
| self.repodir = utils.sh_escape(repodir) |
| self._giturl = giturl |
| if weburl is not None: |
| warnings.warn("Param weburl: You are no longer required to provide " |
| "a web URL for your git repos", DeprecationWarning) |
| |
| # path to .git dir |
| self.gitpath = utils.sh_escape(os.path.join(self.repodir,'.git')) |
| |
| # Find git base command. If not found, this will throw an exception |
| self.git_base_cmd = os_dep.command('git') |
| self.work_tree = abs_work_tree |
| |
| # default to same remote path as local |
| self._build = os.path.dirname(self.repodir) |
| |
| |
| @property |
| def giturl(self): |
| """ |
| A giturl is necessary to perform certain actions (clone, pull, fetch) |
| but not others (like diff). |
| """ |
| if self._giturl is None: |
| raise ValueError('Unsupported operation -- this object was not' |
| 'constructed with a git URL.') |
| return self._giturl |
| |
| |
| def gen_git_cmd_base(self): |
| """ |
| The command we use to run git cannot be set. It is reconstructed |
| on each access from it's component variables. This is it's getter. |
| """ |
| # base git command , pointing to gitpath git dir |
| gitcmdbase = '%s --git-dir=%s' % (self.git_base_cmd, |
| self.gitpath) |
| if self.work_tree: |
| gitcmdbase += ' --work-tree=%s' % self.work_tree |
| return gitcmdbase |
| |
| |
| def _run(self, command, timeout=None, ignore_status=False): |
| """ |
| Auxiliary function to run a command, with proper shell escaping. |
| |
| @param timeout: Timeout to run the command. |
| @param ignore_status: Whether we should supress error.CmdError |
| exceptions if the command did return exit code !=0 (True), or |
| not supress them (False). |
| """ |
| return utils.run(r'%s' % (utils.sh_escape(command)), |
| timeout, ignore_status) |
| |
| |
| def gitcmd(self, cmd, ignore_status=False, error_class=None, |
| error_msg=None): |
| """ |
| Wrapper for a git command. |
| |
| @param cmd: Git subcommand (ex 'clone'). |
| @param ignore_status: If True, ignore the CmdError raised by the |
| underlying command runner. NB: Passing in an error_class |
| impiles ignore_status=True. |
| @param error_class: When ignore_status is False, optional error |
| error class to log and raise in case of errors. Must be a |
| (sub)type of GitError. |
| @param error_msg: When passed with error_class, used as a friendly |
| error message. |
| """ |
| # TODO(pprabhu) Get rid of the ignore_status argument. |
| # Now that we support raising custom errors, we always want to get a |
| # return code from the command execution, instead of an exception. |
| ignore_status = ignore_status or error_class is not None |
| cmd = '%s %s' % (self.gen_git_cmd_base(), cmd) |
| rv = self._run(cmd, ignore_status=ignore_status) |
| if rv.exit_status != 0 and error_class is not None: |
| logging.error('git command failed: %s: %s', |
| cmd, error_msg if error_msg is not None else '') |
| logging.error(rv.stderr) |
| raise error_class(error_msg if error_msg is not None |
| else rv.stderr) |
| |
| return rv |
| |
| |
| def clone(self, remote_branch=None, shallow=False): |
| """ |
| Clones a repo using giturl and repodir. |
| |
| Since we're cloning the master repo we don't have a work tree yet, |
| make sure the getter of the gitcmd doesn't think we do by setting |
| work_tree to None. |
| |
| @param remote_branch: Specify the remote branch to clone. None if to |
| clone master branch. |
| @param shallow: If True, do a shallow clone. |
| |
| @raises GitCloneError: if cloning the master repo fails. |
| """ |
| logging.info('Cloning git repo %s', self.giturl) |
| cmd = 'clone %s %s ' % (self.giturl, self.repodir) |
| if remote_branch: |
| cmd += '-b %s' % remote_branch |
| if shallow: |
| cmd += '--depth 1' |
| abs_work_tree = self.work_tree |
| self.work_tree = None |
| try: |
| rv = self.gitcmd(cmd, True) |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| raise GitCloneError('Failed to clone git url', rv) |
| else: |
| logging.info(rv.stdout) |
| finally: |
| self.work_tree = abs_work_tree |
| |
| |
| def pull(self, rebase=False): |
| """ |
| Pulls into repodir using giturl. |
| |
| @param rebase: If true forces git pull to perform a rebase instead of a |
| merge. |
| @raises GitPullError: if pulling from giturl fails. |
| """ |
| logging.info('Updating git repo %s', self.giturl) |
| cmd = 'pull ' |
| if rebase: |
| cmd += '--rebase ' |
| cmd += self.giturl |
| |
| rv = self.gitcmd(cmd, True) |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| e_msg = 'Failed to pull git repo data' |
| raise GitPullError(e_msg, rv) |
| |
| |
| def commit(self, msg='default'): |
| """ |
| Commit changes to repo with the supplied commit msg. |
| |
| @param msg: A message that goes with the commit. |
| """ |
| rv = self.gitcmd('commit -a -m \'%s\'' % msg) |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| raise GitCommitError('Unable to commit', rv) |
| |
| |
| def upload_cl(self, remote, remote_branch, local_ref='HEAD', draft=False, |
| dryrun=False): |
| """ |
| Upload the change. |
| |
| @param remote: The git remote to upload the CL. |
| @param remote_branch: The remote branch to upload the CL. |
| @param local_ref: The local ref to upload. |
| @param draft: Whether to upload the CL as a draft. |
| @param dryrun: Whether the upload operation is a dryrun. |
| |
| @return: Git command result stderr. |
| """ |
| remote_refspec = (('refs/drafts/%s' if draft else 'refs/for/%s') % |
| remote_branch) |
| return self.push(remote, local_ref, remote_refspec, dryrun=dryrun) |
| |
| |
| def push(self, remote, local_refspec, remote_refspec, dryrun=False): |
| """ |
| Push the change. |
| |
| @param remote: The git remote to push the CL. |
| @param local_ref: The local ref to push. |
| @param remote_refspec: The remote ref to push to. |
| @param dryrun: Whether the upload operation is a dryrun. |
| |
| @return: Git command result stderr. |
| """ |
| cmd = 'push %s %s:%s' % (remote, local_refspec, remote_refspec) |
| |
| if dryrun: |
| logging.info('Would run push command: %s.', cmd) |
| return |
| |
| rv = self.gitcmd(cmd) |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| raise GitPushError('Unable to push', rv) |
| |
| # The CL url is in the result stderr (not stdout) |
| return rv.stderr |
| |
| |
| def reset(self, branch_or_sha): |
| """ |
| Reset repo to the given branch or git sha. |
| |
| @param branch_or_sha: Name of a local or remote branch or git sha. |
| |
| @raises GitResetError if operation fails. |
| """ |
| self.gitcmd('reset --hard %s' % branch_or_sha, |
| error_class=GitResetError, |
| error_msg='Failed to reset to %s' % branch_or_sha) |
| |
| |
| def reset_head(self): |
| """ |
| Reset repo to HEAD@{0} by running git reset --hard HEAD. |
| |
| TODO(pprabhu): cleanup. Use reset. |
| |
| @raises GitResetError: if we fails to reset HEAD. |
| """ |
| logging.info('Resetting head on repo %s', self.repodir) |
| rv = self.gitcmd('reset --hard HEAD') |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| e_msg = 'Failed to reset HEAD' |
| raise GitResetError(e_msg, rv) |
| |
| |
| def fetch_remote(self): |
| """ |
| Fetches all files from the remote but doesn't reset head. |
| |
| @raises GitFetchError: if we fail to fetch all files from giturl. |
| """ |
| logging.info('fetching from repo %s', self.giturl) |
| rv = self.gitcmd('fetch --all') |
| if rv.exit_status != 0: |
| logging.error(rv.stderr) |
| e_msg = 'Failed to fetch from %s' % self.giturl |
| raise GitFetchError(e_msg, rv) |
| |
| |
| def reinit_repo_at(self, remote_branch): |
| """ |
| Does all it can to ensure that the repo is at remote_branch. |
| |
| This will try to be nice and detect any local changes and bail early. |
| OTOH, if it finishes successfully, it'll blow away anything and |
| everything so that local repo reflects the upstream branch requested. |
| |
| @param remote_branch: branch to check out. |
| """ |
| if not self.is_repo_initialized(): |
| self.clone() |
| |
| # Play nice. Detect any local changes and bail. |
| # Re-stat all files before comparing index. This is needed for |
| # diff-index to work properly in cases when the stat info on files is |
| # stale. (e.g., you just untarred the whole git folder that you got from |
| # Alice) |
| rv = self.gitcmd('update-index --refresh -q', |
| error_class=GitError, |
| error_msg='Failed to refresh index.') |
| rv = self.gitcmd( |
| 'diff-index --quiet HEAD --', |
| error_class=GitError, |
| error_msg='Failed to check for local changes.') |
| if rv.stdout: |
| logging.error(rv.stdout) |
| e_msg = 'Local checkout dirty. (%s)' |
| raise GitError(e_msg % rv.stdout) |
| |
| # Play the bad cop. Destroy everything in your path. |
| # Don't trust the existing repo setup at all (so don't trust the current |
| # config, current branches / remotes etc). |
| self.gitcmd('config remote.origin.url %s' % self.giturl, |
| error_class=GitError, |
| error_msg='Failed to set origin.') |
| self.gitcmd('checkout -f', |
| error_class=GitError, |
| error_msg='Failed to checkout.') |
| self.gitcmd('clean -qxdf', |
| error_class=GitError, |
| error_msg='Failed to clean.') |
| self.fetch_remote() |
| self.reset('origin/%s' % remote_branch) |
| |
| |
| def get(self, **kwargs): |
| """ |
| This method overrides baseclass get so we can do proper git |
| clone/pulls, and check for updated versions. The result of |
| this method will leave an up-to-date version of git repo at |
| 'giturl' in 'repodir' directory to be used by build/install |
| methods. |
| |
| @param kwargs: Dictionary of parameters to the method get. |
| """ |
| if not self.is_repo_initialized(): |
| # this is your first time ... |
| self.clone() |
| elif self.is_out_of_date(): |
| # exiting repo, check if we're up-to-date |
| self.pull() |
| else: |
| logging.info('repo up-to-date') |
| |
| # remember where the source is |
| self.source_material = self.repodir |
| |
| |
| def get_local_head(self): |
| """ |
| Get the top commit hash of the current local git branch. |
| |
| @return: Top commit hash of local git branch |
| """ |
| cmd = 'log --pretty=format:"%H" -1' |
| l_head_cmd = self.gitcmd(cmd) |
| return l_head_cmd.stdout.strip() |
| |
| |
| def get_remote_head(self): |
| """ |
| Get the top commit hash of the current remote git branch. |
| |
| @return: Top commit hash of remote git branch |
| """ |
| cmd1 = 'remote show' |
| origin_name_cmd = self.gitcmd(cmd1) |
| cmd2 = 'log --pretty=format:"%H" -1 ' + origin_name_cmd.stdout.strip() |
| r_head_cmd = self.gitcmd(cmd2) |
| return r_head_cmd.stdout.strip() |
| |
| |
| def is_out_of_date(self): |
| """ |
| Return whether this branch is out of date with regards to remote branch. |
| |
| @return: False, if the branch is outdated, True if it is current. |
| """ |
| local_head = self.get_local_head() |
| remote_head = self.get_remote_head() |
| |
| # local is out-of-date, pull |
| if local_head != remote_head: |
| return True |
| |
| return False |
| |
| |
| def is_repo_initialized(self): |
| """ |
| Return whether the git repo was already initialized. |
| |
| Counts objects in .git directory, since these will exist even if the |
| repo is empty. Assumes non-bare reposotories like the rest of this file. |
| |
| @return: True if the repo is initialized. |
| """ |
| cmd = 'count-objects' |
| rv = self.gitcmd(cmd, True) |
| if rv.exit_status == 0: |
| return True |
| |
| return False |
| |
| |
| def get_latest_commit_hash(self): |
| """ |
| Get the commit hash of the latest commit in the repo. |
| |
| We don't raise an exception if no commit hash was found as |
| this could be an empty repository. The caller should notice this |
| methods return value and raise one appropriately. |
| |
| @return: The first commit hash if anything has been committed. |
| """ |
| cmd = 'rev-list -n 1 --all' |
| rv = self.gitcmd(cmd, True) |
| if rv.exit_status == 0: |
| return rv.stdout |
| return None |
| |
| |
| def is_repo_empty(self): |
| """ |
| Checks for empty but initialized repos. |
| |
| eg: we clone an empty master repo, then don't pull |
| after the master commits. |
| |
| @return True if the repo has no commits. |
| """ |
| if self.get_latest_commit_hash(): |
| return False |
| return True |
| |
| |
| def get_revision(self): |
| """ |
| Return current HEAD commit id |
| """ |
| if not self.is_repo_initialized(): |
| self.get() |
| |
| cmd = 'rev-parse --verify HEAD' |
| gitlog = self.gitcmd(cmd, True) |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to find git sha1 revision', gitlog) |
| else: |
| return gitlog.stdout.strip('\n') |
| |
| |
| def checkout(self, remote, local=None): |
| """ |
| Check out the git commit id, branch, or tag given by remote. |
| |
| Optional give the local branch name as local. |
| |
| @param remote: Remote commit hash |
| @param local: Local commit hash |
| @note: For git checkout tag git version >= 1.5.0 is required |
| """ |
| if not self.is_repo_initialized(): |
| self.get() |
| |
| assert(isinstance(remote, basestring)) |
| if local: |
| cmd = 'checkout -b %s %s' % (local, remote) |
| else: |
| cmd = 'checkout %s' % (remote) |
| gitlog = self.gitcmd(cmd, True) |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to checkout git branch', gitlog) |
| else: |
| logging.info(gitlog.stdout) |
| |
| |
| def get_branch(self, all=False, remote_tracking=False): |
| """ |
| Show the branches. |
| |
| @param all: List both remote-tracking branches and local branches (True) |
| or only the local ones (False). |
| @param remote_tracking: Lists the remote-tracking branches. |
| """ |
| if not self.is_repo_initialized(): |
| self.get() |
| |
| cmd = 'branch --no-color' |
| if all: |
| cmd = " ".join([cmd, "-a"]) |
| if remote_tracking: |
| cmd = " ".join([cmd, "-r"]) |
| |
| gitlog = self.gitcmd(cmd, True) |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to get git branch', gitlog) |
| elif all or remote_tracking: |
| return gitlog.stdout.strip('\n') |
| else: |
| branch = [b[2:] for b in gitlog.stdout.split('\n') |
| if b.startswith('*')][0] |
| return branch |
| |
| |
| def status(self, short=True): |
| """ |
| Return the current status of the git repo. |
| |
| @param short: Whether to give the output in the short-format. |
| """ |
| cmd = 'status' |
| |
| if short: |
| cmd += ' -s' |
| |
| gitlog = self.gitcmd(cmd, True) |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to get git status', gitlog) |
| else: |
| return gitlog.stdout.strip('\n') |
| |
| |
| def config(self, option_name): |
| """ |
| Return the git config value for the given option name. |
| |
| @option_name: The name of the git option to get. |
| """ |
| cmd = 'config ' + option_name |
| gitlog = self.gitcmd(cmd) |
| |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to get git config %', option_name) |
| else: |
| return gitlog.stdout.strip('\n') |
| |
| |
| def remote(self): |
| """ |
| Return repository git remote name. |
| """ |
| gitlog = self.gitcmd('remote') |
| |
| if gitlog.exit_status != 0: |
| logging.error(gitlog.stderr) |
| raise error.CmdError('Failed to run git remote.') |
| else: |
| return gitlog.stdout.strip('\n') |