• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2Module with abstraction layers to revision control systems.
3
4With this library, autotest developers can handle source code checkouts and
5updates on both client as well as server code.
6"""
7
8import os, warnings, logging
9import error, utils
10from autotest_lib.client.bin import os_dep
11
12
13class RevisionControlError(Exception):
14    """Local exception to be raised by code in this file."""
15
16
17class GitError(RevisionControlError):
18    """Exceptions raised for general git errors."""
19
20
21class GitCloneError(GitError):
22    """Exceptions raised for git clone errors."""
23
24
25class GitFetchError(GitError):
26    """Exception raised for git fetch errors."""
27
28
29class GitPullError(GitError):
30    """Exception raised for git pull errors."""
31
32
33class GitResetError(GitError):
34    """Exception raised for git reset errors."""
35
36
37class GitCommitError(GitError):
38    """Exception raised for git commit errors."""
39
40
41class GitPushError(GitError):
42    """Exception raised for git push errors."""
43
44
45class 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