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