Home | History | Annotate | Download | only in common_lib
      1 """
      2 Module with abstraction layers to revision control systems.
      3 
      4 With this library, autotest developers can handle source code checkouts and
      5 updates on both client as well as server code.
      6 """
      7 
      8 import os, warnings, logging
      9 import error, utils
     10 from autotest_lib.client.bin import os_dep
     11 
     12 
     13 class RevisionControlError(Exception):
     14     """Local exception to be raised by code in this file."""
     15 
     16 
     17 class GitError(RevisionControlError):
     18     """Exceptions raised for general git errors."""
     19 
     20 
     21 class GitCloneError(GitError):
     22     """Exceptions raised for git clone errors."""
     23 
     24 
     25 class GitFetchError(GitError):
     26     """Exception raised for git fetch errors."""
     27 
     28 
     29 class GitPullError(GitError):
     30     """Exception raised for git pull errors."""
     31 
     32 
     33 class GitResetError(GitError):
     34     """Exception raised for git reset errors."""
     35 
     36 
     37 class GitCommitError(GitError):
     38     """Exception raised for git commit errors."""
     39 
     40 
     41 class GitPushError(GitError):
     42     """Exception raised for git push errors."""
     43 
     44 
     45 class GitRepo(object):
     46     """
     47     This class represents a git repo.
     48 
     49     It is used to pull down a local copy of a git repo, check if the local
     50     repo is up-to-date, if not update.  It delegates the install to
     51     implementation classes.
     52     """
     53 
     54     def __init__(self, repodir, giturl=None, weburl=None, abs_work_tree=None):
     55         """
     56         Initialized reposotory.
     57 
     58         @param repodir: destination repo directory.
     59         @param giturl: master repo git url.
     60         @param weburl: a web url for the master repo.
     61         @param abs_work_tree: work tree of the git repo. In the
     62             absence of a work tree git manipulations will occur
     63             in the current working directory for non bare repos.
     64             In such repos the -git-dir option should point to
     65             the .git directory and -work-tree should point to
     66             the repos working tree.
     67         Note: a bare reposotory is one which contains all the
     68         working files (the tree) and the other wise hidden files
     69         (.git) in the same directory. This class assumes non-bare
     70         reposotories.
     71         """
     72         if repodir is None:
     73             raise ValueError('You must provide a path that will hold the'
     74                              'git repository')
     75         self.repodir = utils.sh_escape(repodir)
     76         self._giturl = giturl
     77         if weburl is not None:
     78             warnings.warn("Param weburl: You are no longer required to provide "
     79                           "a web URL for your git repos", DeprecationWarning)
     80 
     81         # path to .git dir
     82         self.gitpath = utils.sh_escape(os.path.join(self.repodir,'.git'))
     83 
     84         # Find git base command. If not found, this will throw an exception
     85         self.git_base_cmd = os_dep.command('git')
     86         self.work_tree = abs_work_tree
     87 
     88         # default to same remote path as local
     89         self._build = os.path.dirname(self.repodir)
     90 
     91 
     92     @property
     93     def giturl(self):
     94         """
     95         A giturl is necessary to perform certain actions (clone, pull, fetch)
     96         but not others (like diff).
     97         """
     98         if self._giturl is None:
     99             raise ValueError('Unsupported operation -- this object was not'
    100                              'constructed with a git URL.')
    101         return self._giturl
    102 
    103 
    104     def gen_git_cmd_base(self):
    105         """
    106         The command we use to run git cannot be set. It is reconstructed
    107         on each access from it's component variables. This is it's getter.
    108         """
    109         # base git command , pointing to gitpath git dir
    110         gitcmdbase = '%s --git-dir=%s' % (self.git_base_cmd,
    111                                           self.gitpath)
    112         if self.work_tree:
    113             gitcmdbase += ' --work-tree=%s' % self.work_tree
    114         return gitcmdbase
    115 
    116 
    117     def _run(self, command, timeout=None, ignore_status=False):
    118         """
    119         Auxiliary function to run a command, with proper shell escaping.
    120 
    121         @param timeout: Timeout to run the command.
    122         @param ignore_status: Whether we should supress error.CmdError
    123                 exceptions if the command did return exit code !=0 (True), or
    124                 not supress them (False).
    125         """
    126         return utils.run(r'%s' % (utils.sh_escape(command)),
    127                          timeout, ignore_status)
    128 
    129 
    130     def gitcmd(self, cmd, ignore_status=False, error_class=None,
    131                error_msg=None):
    132         """
    133         Wrapper for a git command.
    134 
    135         @param cmd: Git subcommand (ex 'clone').
    136         @param ignore_status: If True, ignore the CmdError raised by the
    137                 underlying command runner. NB: Passing in an error_class
    138                 impiles ignore_status=True.
    139         @param error_class: When ignore_status is False, optional error
    140                 error class to log and raise in case of errors. Must be a
    141                 (sub)type of GitError.
    142         @param error_msg: When passed with error_class, used as a friendly
    143                 error message.
    144         """
    145         # TODO(pprabhu) Get rid of the ignore_status argument.
    146         # Now that we support raising custom errors, we always want to get a
    147         # return code from the command execution, instead of an exception.
    148         ignore_status = ignore_status or error_class is not None
    149         cmd = '%s %s' % (self.gen_git_cmd_base(), cmd)
    150         rv = self._run(cmd, ignore_status=ignore_status)
    151         if rv.exit_status != 0 and error_class is not None:
    152             logging.error('git command failed: %s: %s',
    153                           cmd, error_msg if error_msg is not None else '')
    154             logging.error(rv.stderr)
    155             raise error_class(error_msg if error_msg is not None
    156                               else rv.stderr)
    157 
    158         return rv
    159 
    160 
    161     def clone(self, remote_branch=None, shallow=False):
    162         """
    163         Clones a repo using giturl and repodir.
    164 
    165         Since we're cloning the master repo we don't have a work tree yet,
    166         make sure the getter of the gitcmd doesn't think we do by setting
    167         work_tree to None.
    168 
    169         @param remote_branch: Specify the remote branch to clone. None if to
    170                               clone master branch.
    171         @param shallow: If True, do a shallow clone.
    172 
    173         @raises GitCloneError: if cloning the master repo fails.
    174         """
    175         logging.info('Cloning git repo %s', self.giturl)
    176         cmd = 'clone %s %s ' % (self.giturl, self.repodir)
    177         if remote_branch:
    178             cmd += '-b %s' % remote_branch
    179         if shallow:
    180             cmd += '--depth 1'
    181         abs_work_tree = self.work_tree
    182         self.work_tree = None
    183         try:
    184             rv = self.gitcmd(cmd, True)
    185             if rv.exit_status != 0:
    186                 logging.error(rv.stderr)
    187                 raise GitCloneError('Failed to clone git url', rv)
    188             else:
    189                 logging.info(rv.stdout)
    190         finally:
    191             self.work_tree = abs_work_tree
    192 
    193 
    194     def pull(self, rebase=False):
    195         """
    196         Pulls into repodir using giturl.
    197 
    198         @param rebase: If true forces git pull to perform a rebase instead of a
    199                         merge.
    200         @raises GitPullError: if pulling from giturl fails.
    201         """
    202         logging.info('Updating git repo %s', self.giturl)
    203         cmd = 'pull '
    204         if rebase:
    205             cmd += '--rebase '
    206         cmd += self.giturl
    207 
    208         rv = self.gitcmd(cmd, True)
    209         if rv.exit_status != 0:
    210             logging.error(rv.stderr)
    211             e_msg = 'Failed to pull git repo data'
    212             raise GitPullError(e_msg, rv)
    213 
    214 
    215     def commit(self, msg='default'):
    216         """
    217         Commit changes to repo with the supplied commit msg.
    218 
    219         @param msg: A message that goes with the commit.
    220         """
    221         rv = self.gitcmd('commit -a -m \'%s\'' % msg)
    222         if rv.exit_status != 0:
    223             logging.error(rv.stderr)
    224             raise GitCommitError('Unable to commit', rv)
    225 
    226 
    227     def upload_cl(self, remote, remote_branch, local_ref='HEAD', draft=False,
    228                   dryrun=False):
    229         """
    230         Upload the change.
    231 
    232         @param remote: The git remote to upload the CL.
    233         @param remote_branch: The remote branch to upload the CL.
    234         @param local_ref: The local ref to upload.
    235         @param draft: Whether to upload the CL as a draft.
    236         @param dryrun: Whether the upload operation is a dryrun.
    237 
    238         @return: Git command result stderr.
    239         """
    240         remote_refspec = (('refs/drafts/%s' if draft else 'refs/for/%s') %
    241                           remote_branch)
    242         return self.push(remote, local_ref, remote_refspec, dryrun=dryrun)
    243 
    244 
    245     def push(self, remote, local_refspec, remote_refspec, dryrun=False):
    246         """
    247         Push the change.
    248 
    249         @param remote: The git remote to push the CL.
    250         @param local_ref: The local ref to push.
    251         @param remote_refspec: The remote ref to push to.
    252         @param dryrun: Whether the upload operation is a dryrun.
    253 
    254         @return: Git command result stderr.
    255         """
    256         cmd = 'push %s %s:%s' % (remote, local_refspec, remote_refspec)
    257 
    258         if dryrun:
    259             logging.info('Would run push command: %s.', cmd)
    260             return
    261 
    262         rv = self.gitcmd(cmd)
    263         if rv.exit_status != 0:
    264             logging.error(rv.stderr)
    265             raise GitPushError('Unable to push', rv)
    266 
    267         # The CL url is in the result stderr (not stdout)
    268         return rv.stderr
    269 
    270 
    271     def reset(self, branch_or_sha):
    272         """
    273         Reset repo to the given branch or git sha.
    274 
    275         @param branch_or_sha: Name of a local or remote branch or git sha.
    276 
    277         @raises GitResetError if operation fails.
    278         """
    279         self.gitcmd('reset --hard %s' % branch_or_sha,
    280                     error_class=GitResetError,
    281                     error_msg='Failed to reset to %s' % branch_or_sha)
    282 
    283 
    284     def reset_head(self):
    285         """
    286         Reset repo to HEAD@{0} by running git reset --hard HEAD.
    287 
    288         TODO(pprabhu): cleanup. Use reset.
    289 
    290         @raises GitResetError: if we fails to reset HEAD.
    291         """
    292         logging.info('Resetting head on repo %s', self.repodir)
    293         rv = self.gitcmd('reset --hard HEAD')
    294         if rv.exit_status != 0:
    295             logging.error(rv.stderr)
    296             e_msg = 'Failed to reset HEAD'
    297             raise GitResetError(e_msg, rv)
    298 
    299 
    300     def fetch_remote(self):
    301         """
    302         Fetches all files from the remote but doesn't reset head.
    303 
    304         @raises GitFetchError: if we fail to fetch all files from giturl.
    305         """
    306         logging.info('fetching from repo %s', self.giturl)
    307         rv = self.gitcmd('fetch --all')
    308         if rv.exit_status != 0:
    309             logging.error(rv.stderr)
    310             e_msg = 'Failed to fetch from %s' % self.giturl
    311             raise GitFetchError(e_msg, rv)
    312 
    313 
    314     def reinit_repo_at(self, remote_branch):
    315         """
    316         Does all it can to ensure that the repo is at remote_branch.
    317 
    318         This will try to be nice and detect any local changes and bail early.
    319         OTOH, if it finishes successfully, it'll blow away anything and
    320         everything so that local repo reflects the upstream branch requested.
    321 
    322         @param remote_branch: branch to check out.
    323         """
    324         if not self.is_repo_initialized():
    325             self.clone()
    326 
    327         # Play nice. Detect any local changes and bail.
    328         # Re-stat all files before comparing index. This is needed for
    329         # diff-index to work properly in cases when the stat info on files is
    330         # stale. (e.g., you just untarred the whole git folder that you got from
    331         # Alice)
    332         rv = self.gitcmd('update-index --refresh -q',
    333                          error_class=GitError,
    334                          error_msg='Failed to refresh index.')
    335         rv = self.gitcmd(
    336                 'diff-index --quiet HEAD --',
    337                 error_class=GitError,
    338                 error_msg='Failed to check for local changes.')
    339         if rv.stdout:
    340             logging.error(rv.stdout)
    341             e_msg = 'Local checkout dirty. (%s)'
    342             raise GitError(e_msg % rv.stdout)
    343 
    344         # Play the bad cop. Destroy everything in your path.
    345         # Don't trust the existing repo setup at all (so don't trust the current
    346         # config, current branches / remotes etc).
    347         self.gitcmd('config remote.origin.url %s' % self.giturl,
    348                     error_class=GitError,
    349                     error_msg='Failed to set origin.')
    350         self.gitcmd('checkout -f',
    351                     error_class=GitError,
    352                     error_msg='Failed to checkout.')
    353         self.gitcmd('clean -qxdf',
    354                     error_class=GitError,
    355                     error_msg='Failed to clean.')
    356         self.fetch_remote()
    357         self.reset('origin/%s' % remote_branch)
    358 
    359 
    360     def get(self, **kwargs):
    361         """
    362         This method overrides baseclass get so we can do proper git
    363         clone/pulls, and check for updated versions.  The result of
    364         this method will leave an up-to-date version of git repo at
    365         'giturl' in 'repodir' directory to be used by build/install
    366         methods.
    367 
    368         @param kwargs: Dictionary of parameters to the method get.
    369         """
    370         if not self.is_repo_initialized():
    371             # this is your first time ...
    372             self.clone()
    373         elif self.is_out_of_date():
    374             # exiting repo, check if we're up-to-date
    375             self.pull()
    376         else:
    377             logging.info('repo up-to-date')
    378 
    379         # remember where the source is
    380         self.source_material = self.repodir
    381 
    382 
    383     def get_local_head(self):
    384         """
    385         Get the top commit hash of the current local git branch.
    386 
    387         @return: Top commit hash of local git branch
    388         """
    389         cmd = 'log --pretty=format:"%H" -1'
    390         l_head_cmd = self.gitcmd(cmd)
    391         return l_head_cmd.stdout.strip()
    392 
    393 
    394     def get_remote_head(self):
    395         """
    396         Get the top commit hash of the current remote git branch.
    397 
    398         @return: Top commit hash of remote git branch
    399         """
    400         cmd1 = 'remote show'
    401         origin_name_cmd = self.gitcmd(cmd1)
    402         cmd2 = 'log --pretty=format:"%H" -1 ' + origin_name_cmd.stdout.strip()
    403         r_head_cmd = self.gitcmd(cmd2)
    404         return r_head_cmd.stdout.strip()
    405 
    406 
    407     def is_out_of_date(self):
    408         """
    409         Return whether this branch is out of date with regards to remote branch.
    410 
    411         @return: False, if the branch is outdated, True if it is current.
    412         """
    413         local_head = self.get_local_head()
    414         remote_head = self.get_remote_head()
    415 
    416         # local is out-of-date, pull
    417         if local_head != remote_head:
    418             return True
    419 
    420         return False
    421 
    422 
    423     def is_repo_initialized(self):
    424         """
    425         Return whether the git repo was already initialized.
    426 
    427         Counts objects in .git directory, since these will exist even if the
    428         repo is empty. Assumes non-bare reposotories like the rest of this file.
    429 
    430         @return: True if the repo is initialized.
    431         """
    432         cmd = 'count-objects'
    433         rv = self.gitcmd(cmd, True)
    434         if rv.exit_status == 0:
    435             return True
    436 
    437         return False
    438 
    439 
    440     def get_latest_commit_hash(self):
    441         """
    442         Get the commit hash of the latest commit in the repo.
    443 
    444         We don't raise an exception if no commit hash was found as
    445         this could be an empty repository. The caller should notice this
    446         methods return value and raise one appropriately.
    447 
    448         @return: The first commit hash if anything has been committed.
    449         """
    450         cmd = 'rev-list -n 1 --all'
    451         rv = self.gitcmd(cmd, True)
    452         if rv.exit_status == 0:
    453             return rv.stdout
    454         return None
    455 
    456 
    457     def is_repo_empty(self):
    458         """
    459         Checks for empty but initialized repos.
    460 
    461         eg: we clone an empty master repo, then don't pull
    462         after the master commits.
    463 
    464         @return True if the repo has no commits.
    465         """
    466         if self.get_latest_commit_hash():
    467             return False
    468         return True
    469 
    470 
    471     def get_revision(self):
    472         """
    473         Return current HEAD commit id
    474         """
    475         if not self.is_repo_initialized():
    476             self.get()
    477 
    478         cmd = 'rev-parse --verify HEAD'
    479         gitlog = self.gitcmd(cmd, True)
    480         if gitlog.exit_status != 0:
    481             logging.error(gitlog.stderr)
    482             raise error.CmdError('Failed to find git sha1 revision', gitlog)
    483         else:
    484             return gitlog.stdout.strip('\n')
    485 
    486 
    487     def checkout(self, remote, local=None):
    488         """
    489         Check out the git commit id, branch, or tag given by remote.
    490 
    491         Optional give the local branch name as local.
    492 
    493         @param remote: Remote commit hash
    494         @param local: Local commit hash
    495         @note: For git checkout tag git version >= 1.5.0 is required
    496         """
    497         if not self.is_repo_initialized():
    498             self.get()
    499 
    500         assert(isinstance(remote, basestring))
    501         if local:
    502             cmd = 'checkout -b %s %s' % (local, remote)
    503         else:
    504             cmd = 'checkout %s' % (remote)
    505         gitlog = self.gitcmd(cmd, True)
    506         if gitlog.exit_status != 0:
    507             logging.error(gitlog.stderr)
    508             raise error.CmdError('Failed to checkout git branch', gitlog)
    509         else:
    510             logging.info(gitlog.stdout)
    511 
    512 
    513     def get_branch(self, all=False, remote_tracking=False):
    514         """
    515         Show the branches.
    516 
    517         @param all: List both remote-tracking branches and local branches (True)
    518                 or only the local ones (False).
    519         @param remote_tracking: Lists the remote-tracking branches.
    520         """
    521         if not self.is_repo_initialized():
    522             self.get()
    523 
    524         cmd = 'branch --no-color'
    525         if all:
    526             cmd = " ".join([cmd, "-a"])
    527         if remote_tracking:
    528             cmd = " ".join([cmd, "-r"])
    529 
    530         gitlog = self.gitcmd(cmd, True)
    531         if gitlog.exit_status != 0:
    532             logging.error(gitlog.stderr)
    533             raise error.CmdError('Failed to get git branch', gitlog)
    534         elif all or remote_tracking:
    535             return gitlog.stdout.strip('\n')
    536         else:
    537             branch = [b[2:] for b in gitlog.stdout.split('\n')
    538                       if b.startswith('*')][0]
    539             return branch
    540 
    541 
    542     def status(self, short=True):
    543         """
    544         Return the current status of the git repo.
    545 
    546         @param short: Whether to give the output in the short-format.
    547         """
    548         cmd = 'status'
    549 
    550         if short:
    551             cmd += ' -s'
    552 
    553         gitlog = self.gitcmd(cmd, True)
    554         if gitlog.exit_status != 0:
    555             logging.error(gitlog.stderr)
    556             raise error.CmdError('Failed to get git status', gitlog)
    557         else:
    558             return gitlog.stdout.strip('\n')
    559 
    560 
    561     def config(self, option_name):
    562         """
    563         Return the git config value for the given option name.
    564 
    565         @option_name: The name of the git option to get.
    566         """
    567         cmd = 'config ' + option_name
    568         gitlog = self.gitcmd(cmd)
    569 
    570         if gitlog.exit_status != 0:
    571             logging.error(gitlog.stderr)
    572             raise error.CmdError('Failed to get git config %', option_name)
    573         else:
    574             return gitlog.stdout.strip('\n')
    575 
    576 
    577     def remote(self):
    578         """
    579         Return repository git remote name.
    580         """
    581         gitlog = self.gitcmd('remote')
    582 
    583         if gitlog.exit_status != 0:
    584             logging.error(gitlog.stderr)
    585             raise error.CmdError('Failed to run git remote.')
    586         else:
    587             return gitlog.stdout.strip('\n')
    588