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