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