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