1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5import os 6import sys 7 8from patman import settings 9from u_boot_pylib import command 10from u_boot_pylib import terminal 11 12# True to use --no-decorate - we check this in setup() 13use_no_decorate = True 14 15 16def log_cmd(commit_range, git_dir=None, oneline=False, reverse=False, 17 count=None): 18 """Create a command to perform a 'git log' 19 20 Args: 21 commit_range: Range expression to use for log, None for none 22 git_dir: Path to git repository (None to use default) 23 oneline: True to use --oneline, else False 24 reverse: True to reverse the log (--reverse) 25 count: Number of commits to list, or None for no limit 26 Return: 27 List containing command and arguments to run 28 """ 29 cmd = ['git'] 30 if git_dir: 31 cmd += ['--git-dir', git_dir] 32 cmd += ['--no-pager', 'log', '--no-color'] 33 if oneline: 34 cmd.append('--oneline') 35 if use_no_decorate: 36 cmd.append('--no-decorate') 37 if reverse: 38 cmd.append('--reverse') 39 if count is not None: 40 cmd.append('-n%d' % count) 41 if commit_range: 42 cmd.append(commit_range) 43 44 # Add this in case we have a branch with the same name as a directory. 45 # This avoids messages like this, for example: 46 # fatal: ambiguous argument 'test': both revision and filename 47 cmd.append('--') 48 return cmd 49 50 51def count_commits_to_branch(branch): 52 """Returns number of commits between HEAD and the tracking branch. 53 54 This looks back to the tracking branch and works out the number of commits 55 since then. 56 57 Args: 58 branch: Branch to count from (None for current branch) 59 60 Return: 61 Number of patches that exist on top of the branch 62 """ 63 if branch: 64 us, msg = get_upstream('.git', branch) 65 rev_range = '%s..%s' % (us, branch) 66 else: 67 rev_range = '@{upstream}..' 68 pipe = [log_cmd(rev_range, oneline=True)] 69 result = command.run_pipe(pipe, capture=True, capture_stderr=True, 70 oneline=True, raise_on_error=False) 71 if result.return_code: 72 raise ValueError('Failed to determine upstream: %s' % 73 result.stderr.strip()) 74 patch_count = len(result.stdout.splitlines()) 75 return patch_count 76 77 78def name_revision(commit_hash): 79 """Gets the revision name for a commit 80 81 Args: 82 commit_hash: Commit hash to look up 83 84 Return: 85 Name of revision, if any, else None 86 """ 87 pipe = ['git', 'name-rev', commit_hash] 88 stdout = command.run_pipe([pipe], capture=True, oneline=True).stdout 89 90 # We expect a commit, a space, then a revision name 91 name = stdout.split(' ')[1].strip() 92 return name 93 94 95def guess_upstream(git_dir, branch): 96 """Tries to guess the upstream for a branch 97 98 This lists out top commits on a branch and tries to find a suitable 99 upstream. It does this by looking for the first commit where 100 'git name-rev' returns a plain branch name, with no ! or ^ modifiers. 101 102 Args: 103 git_dir: Git directory containing repo 104 branch: Name of branch 105 106 Returns: 107 Tuple: 108 Name of upstream branch (e.g. 'upstream/master') or None if none 109 Warning/error message, or None if none 110 """ 111 pipe = [log_cmd(branch, git_dir=git_dir, oneline=True, count=100)] 112 result = command.run_pipe(pipe, capture=True, capture_stderr=True, 113 raise_on_error=False) 114 if result.return_code: 115 return None, "Branch '%s' not found" % branch 116 for line in result.stdout.splitlines()[1:]: 117 commit_hash = line.split(' ')[0] 118 name = name_revision(commit_hash) 119 if '~' not in name and '^' not in name: 120 if name.startswith('remotes/'): 121 name = name[8:] 122 return name, "Guessing upstream as '%s'" % name 123 return None, "Cannot find a suitable upstream for branch '%s'" % branch 124 125 126def get_upstream(git_dir, branch): 127 """Returns the name of the upstream for a branch 128 129 Args: 130 git_dir: Git directory containing repo 131 branch: Name of branch 132 133 Returns: 134 Tuple: 135 Name of upstream branch (e.g. 'upstream/master') or None if none 136 Warning/error message, or None if none 137 """ 138 try: 139 remote = command.output_one_line('git', '--git-dir', git_dir, 'config', 140 'branch.%s.remote' % branch) 141 merge = command.output_one_line('git', '--git-dir', git_dir, 'config', 142 'branch.%s.merge' % branch) 143 except Exception: 144 upstream, msg = guess_upstream(git_dir, branch) 145 return upstream, msg 146 147 if remote == '.': 148 return merge, None 149 elif remote and merge: 150 # Drop the initial refs/heads from merge 151 leaf = merge.split('/', maxsplit=2)[2:] 152 return '%s/%s' % (remote, '/'.join(leaf)), None 153 else: 154 raise ValueError("Cannot determine upstream branch for branch " 155 "'%s' remote='%s', merge='%s'" 156 % (branch, remote, merge)) 157 158 159def get_range_in_branch(git_dir, branch, include_upstream=False): 160 """Returns an expression for the commits in the given branch. 161 162 Args: 163 git_dir: Directory containing git repo 164 branch: Name of branch 165 Return: 166 Expression in the form 'upstream..branch' which can be used to 167 access the commits. If the branch does not exist, returns None. 168 """ 169 upstream, msg = get_upstream(git_dir, branch) 170 if not upstream: 171 return None, msg 172 rstr = '%s%s..%s' % (upstream, '~' if include_upstream else '', branch) 173 return rstr, msg 174 175 176def count_commits_in_range(git_dir, range_expr): 177 """Returns the number of commits in the given range. 178 179 Args: 180 git_dir: Directory containing git repo 181 range_expr: Range to check 182 Return: 183 Number of patches that exist in the supplied range or None if none 184 were found 185 """ 186 pipe = [log_cmd(range_expr, git_dir=git_dir, oneline=True)] 187 result = command.run_pipe(pipe, capture=True, capture_stderr=True, 188 raise_on_error=False) 189 if result.return_code: 190 return None, "Range '%s' not found or is invalid" % range_expr 191 patch_count = len(result.stdout.splitlines()) 192 return patch_count, None 193 194 195def count_commits_in_branch(git_dir, branch, include_upstream=False): 196 """Returns the number of commits in the given branch. 197 198 Args: 199 git_dir: Directory containing git repo 200 branch: Name of branch 201 Return: 202 Number of patches that exist on top of the branch, or None if the 203 branch does not exist. 204 """ 205 range_expr, msg = get_range_in_branch(git_dir, branch, include_upstream) 206 if not range_expr: 207 return None, msg 208 return count_commits_in_range(git_dir, range_expr) 209 210 211def count_commits(commit_range): 212 """Returns the number of commits in the given range. 213 214 Args: 215 commit_range: Range of commits to count (e.g. 'HEAD..base') 216 Return: 217 Number of patches that exist on top of the branch 218 """ 219 pipe = [log_cmd(commit_range, oneline=True), 220 ['wc', '-l']] 221 stdout = command.run_pipe(pipe, capture=True, oneline=True).stdout 222 patch_count = int(stdout) 223 return patch_count 224 225 226def checkout(commit_hash, git_dir=None, work_tree=None, force=False): 227 """Checkout the selected commit for this build 228 229 Args: 230 commit_hash: Commit hash to check out 231 """ 232 pipe = ['git'] 233 if git_dir: 234 pipe.extend(['--git-dir', git_dir]) 235 if work_tree: 236 pipe.extend(['--work-tree', work_tree]) 237 pipe.append('checkout') 238 if force: 239 pipe.append('-f') 240 pipe.append(commit_hash) 241 result = command.run_pipe([pipe], capture=True, raise_on_error=False, 242 capture_stderr=True) 243 if result.return_code != 0: 244 raise OSError('git checkout (%s): %s' % (pipe, result.stderr)) 245 246 247def clone(git_dir, output_dir): 248 """Checkout the selected commit for this build 249 250 Args: 251 commit_hash: Commit hash to check out 252 """ 253 pipe = ['git', 'clone', git_dir, '.'] 254 result = command.run_pipe([pipe], capture=True, cwd=output_dir, 255 capture_stderr=True) 256 if result.return_code != 0: 257 raise OSError('git clone: %s' % result.stderr) 258 259 260def fetch(git_dir=None, work_tree=None): 261 """Fetch from the origin repo 262 263 Args: 264 commit_hash: Commit hash to check out 265 """ 266 pipe = ['git'] 267 if git_dir: 268 pipe.extend(['--git-dir', git_dir]) 269 if work_tree: 270 pipe.extend(['--work-tree', work_tree]) 271 pipe.append('fetch') 272 result = command.run_pipe([pipe], capture=True, capture_stderr=True) 273 if result.return_code != 0: 274 raise OSError('git fetch: %s' % result.stderr) 275 276 277def check_worktree_is_available(git_dir): 278 """Check if git-worktree functionality is available 279 280 Args: 281 git_dir: The repository to test in 282 283 Returns: 284 True if git-worktree commands will work, False otherwise. 285 """ 286 pipe = ['git', '--git-dir', git_dir, 'worktree', 'list'] 287 result = command.run_pipe([pipe], capture=True, capture_stderr=True, 288 raise_on_error=False) 289 return result.return_code == 0 290 291 292def add_worktree(git_dir, output_dir, commit_hash=None): 293 """Create and checkout a new git worktree for this build 294 295 Args: 296 git_dir: The repository to checkout the worktree from 297 output_dir: Path for the new worktree 298 commit_hash: Commit hash to checkout 299 """ 300 # We need to pass --detach to avoid creating a new branch 301 pipe = ['git', '--git-dir', git_dir, 'worktree', 'add', '.', '--detach'] 302 if commit_hash: 303 pipe.append(commit_hash) 304 result = command.run_pipe([pipe], capture=True, cwd=output_dir, 305 capture_stderr=True) 306 if result.return_code != 0: 307 raise OSError('git worktree add: %s' % result.stderr) 308 309 310def prune_worktrees(git_dir): 311 """Remove administrative files for deleted worktrees 312 313 Args: 314 git_dir: The repository whose deleted worktrees should be pruned 315 """ 316 pipe = ['git', '--git-dir', git_dir, 'worktree', 'prune'] 317 result = command.run_pipe([pipe], capture=True, capture_stderr=True) 318 if result.return_code != 0: 319 raise OSError('git worktree prune: %s' % result.stderr) 320 321 322def create_patches(branch, start, count, ignore_binary, series, signoff=True): 323 """Create a series of patches from the top of the current branch. 324 325 The patch files are written to the current directory using 326 git format-patch. 327 328 Args: 329 branch: Branch to create patches from (None for current branch) 330 start: Commit to start from: 0=HEAD, 1=next one, etc. 331 count: number of commits to include 332 ignore_binary: Don't generate patches for binary files 333 series: Series object for this series (set of patches) 334 Return: 335 Filename of cover letter (None if none) 336 List of filenames of patch files 337 """ 338 cmd = ['git', 'format-patch', '-M'] 339 if signoff: 340 cmd.append('--signoff') 341 if ignore_binary: 342 cmd.append('--no-binary') 343 if series.get('cover'): 344 cmd.append('--cover-letter') 345 prefix = series.GetPatchPrefix() 346 if prefix: 347 cmd += ['--subject-prefix=%s' % prefix] 348 brname = branch or 'HEAD' 349 cmd += ['%s~%d..%s~%d' % (brname, start + count, brname, start)] 350 351 stdout = command.run_list(cmd) 352 files = stdout.splitlines() 353 354 # We have an extra file if there is a cover letter 355 if series.get('cover'): 356 return files[0], files[1:] 357 else: 358 return None, files 359 360 361def build_email_list(in_list, tag=None, alias=None, warn_on_error=True): 362 """Build a list of email addresses based on an input list. 363 364 Takes a list of email addresses and aliases, and turns this into a list 365 of only email address, by resolving any aliases that are present. 366 367 If the tag is given, then each email address is prepended with this 368 tag and a space. If the tag starts with a minus sign (indicating a 369 command line parameter) then the email address is quoted. 370 371 Args: 372 in_list: List of aliases/email addresses 373 tag: Text to put before each address 374 alias: Alias dictionary 375 warn_on_error: True to raise an error when an alias fails to match, 376 False to just print a message. 377 378 Returns: 379 List of email addresses 380 381 >>> alias = {} 382 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 383 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 384 >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>'] 385 >>> alias['boys'] = ['fred', ' john'] 386 >>> alias['all'] = ['fred ', 'john', ' mary '] 387 >>> build_email_list(['john', 'mary'], None, alias) 388 ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>'] 389 >>> build_email_list(['john', 'mary'], '--to', alias) 390 ['--to "j.bloggs@napier.co.nz"', \ 391'--to "Mary Poppins <m.poppins@cloud.net>"'] 392 >>> build_email_list(['john', 'mary'], 'Cc', alias) 393 ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>'] 394 """ 395 quote = '"' if tag and tag[0] == '-' else '' 396 raw = [] 397 for item in in_list: 398 raw += lookup_email(item, alias, warn_on_error=warn_on_error) 399 result = [] 400 for item in raw: 401 if item not in result: 402 result.append(item) 403 if tag: 404 return ['%s %s%s%s' % (tag, quote, email, quote) for email in result] 405 return result 406 407 408def check_suppress_cc_config(): 409 """Check if sendemail.suppresscc is configured correctly. 410 411 Returns: 412 True if the option is configured correctly, False otherwise. 413 """ 414 suppresscc = command.output_one_line( 415 'git', 'config', 'sendemail.suppresscc', raise_on_error=False) 416 417 # Other settings should be fine. 418 if suppresscc == 'all' or suppresscc == 'cccmd': 419 col = terminal.Color() 420 421 print((col.build(col.RED, "error") + 422 ": git config sendemail.suppresscc set to %s\n" 423 % (suppresscc)) + 424 " patman needs --cc-cmd to be run to set the cc list.\n" + 425 " Please run:\n" + 426 " git config --unset sendemail.suppresscc\n" + 427 " Or read the man page:\n" + 428 " git send-email --help\n" + 429 " and set an option that runs --cc-cmd\n") 430 return False 431 432 return True 433 434 435def email_patches(series, cover_fname, args, dry_run, warn_on_error, cc_fname, 436 self_only=False, alias=None, in_reply_to=None, thread=False, 437 smtp_server=None, get_maintainer_script=None): 438 """Email a patch series. 439 440 Args: 441 series: Series object containing destination info 442 cover_fname: filename of cover letter 443 args: list of filenames of patch files 444 dry_run: Just return the command that would be run 445 warn_on_error: True to print a warning when an alias fails to match, 446 False to ignore it. 447 cc_fname: Filename of Cc file for per-commit Cc 448 self_only: True to just email to yourself as a test 449 in_reply_to: If set we'll pass this to git as --in-reply-to. 450 Should be a message ID that this is in reply to. 451 thread: True to add --thread to git send-email (make 452 all patches reply to cover-letter or first patch in series) 453 smtp_server: SMTP server to use to send patches 454 get_maintainer_script: File name of script to get maintainers emails 455 456 Returns: 457 Git command that was/would be run 458 459 # For the duration of this doctest pretend that we ran patman with ./patman 460 >>> _old_argv0 = sys.argv[0] 461 >>> sys.argv[0] = './patman' 462 463 >>> alias = {} 464 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 465 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 466 >>> alias['mary'] = ['m.poppins@cloud.net'] 467 >>> alias['boys'] = ['fred', ' john'] 468 >>> alias['all'] = ['fred ', 'john', ' mary '] 469 >>> alias[os.getenv('USER')] = ['this-is-me@me.com'] 470 >>> series = {} 471 >>> series['to'] = ['fred'] 472 >>> series['cc'] = ['mary'] 473 >>> email_patches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 474 False, alias) 475 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 476"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" cover p1 p2' 477 >>> email_patches(series, None, ['p1'], True, True, 'cc-fname', False, \ 478 alias) 479 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 480"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" p1' 481 >>> series['cc'] = ['all'] 482 >>> email_patches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 483 True, alias) 484 'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \ 485send --cc-cmd cc-fname" cover p1 p2' 486 >>> email_patches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 487 False, alias) 488 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 489"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \ 490"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" cover p1 p2' 491 492 # Restore argv[0] since we clobbered it. 493 >>> sys.argv[0] = _old_argv0 494 """ 495 to = build_email_list(series.get('to'), '--to', alias, warn_on_error) 496 if not to: 497 git_config_to = command.output('git', 'config', 'sendemail.to', 498 raise_on_error=False) 499 if not git_config_to: 500 print("No recipient.\n" 501 "Please add something like this to a commit\n" 502 "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n" 503 "Or do something like this\n" 504 "git config sendemail.to u-boot@lists.denx.de") 505 return 506 cc = build_email_list(list(set(series.get('cc')) - set(series.get('to'))), 507 '--cc', alias, warn_on_error) 508 if self_only: 509 to = build_email_list([os.getenv('USER')], '--to', 510 alias, warn_on_error) 511 cc = [] 512 cmd = ['git', 'send-email', '--annotate'] 513 if smtp_server: 514 cmd.append('--smtp-server=%s' % smtp_server) 515 if in_reply_to: 516 cmd.append('--in-reply-to="%s"' % in_reply_to) 517 if thread: 518 cmd.append('--thread') 519 520 cmd += to 521 cmd += cc 522 cmd += ['--cc-cmd', '"%s send --cc-cmd %s"' % (sys.argv[0], cc_fname)] 523 if cover_fname: 524 cmd.append(cover_fname) 525 cmd += args 526 cmdstr = ' '.join(cmd) 527 if not dry_run: 528 os.system(cmdstr) 529 return cmdstr 530 531 532def lookup_email(lookup_name, alias=None, warn_on_error=True, level=0): 533 """If an email address is an alias, look it up and return the full name 534 535 TODO: Why not just use git's own alias feature? 536 537 Args: 538 lookup_name: Alias or email address to look up 539 alias: Dictionary containing aliases (None to use settings default) 540 warn_on_error: True to print a warning when an alias fails to match, 541 False to ignore it. 542 543 Returns: 544 tuple: 545 list containing a list of email addresses 546 547 Raises: 548 OSError if a recursive alias reference was found 549 ValueError if an alias was not found 550 551 >>> alias = {} 552 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 553 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 554 >>> alias['mary'] = ['m.poppins@cloud.net'] 555 >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz'] 556 >>> alias['all'] = ['fred ', 'john', ' mary '] 557 >>> alias['loop'] = ['other', 'john', ' mary '] 558 >>> alias['other'] = ['loop', 'john', ' mary '] 559 >>> lookup_email('mary', alias) 560 ['m.poppins@cloud.net'] 561 >>> lookup_email('arthur.wellesley@howe.ro.uk', alias) 562 ['arthur.wellesley@howe.ro.uk'] 563 >>> lookup_email('boys', alias) 564 ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz'] 565 >>> lookup_email('all', alias) 566 ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net'] 567 >>> lookup_email('odd', alias) 568 Alias 'odd' not found 569 [] 570 >>> lookup_email('loop', alias) 571 Traceback (most recent call last): 572 ... 573 OSError: Recursive email alias at 'other' 574 >>> lookup_email('odd', alias, warn_on_error=False) 575 [] 576 >>> # In this case the loop part will effectively be ignored. 577 >>> lookup_email('loop', alias, warn_on_error=False) 578 Recursive email alias at 'other' 579 Recursive email alias at 'john' 580 Recursive email alias at 'mary' 581 ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net'] 582 """ 583 if not alias: 584 alias = settings.alias 585 lookup_name = lookup_name.strip() 586 if '@' in lookup_name: # Perhaps a real email address 587 return [lookup_name] 588 589 lookup_name = lookup_name.lower() 590 col = terminal.Color() 591 592 out_list = [] 593 if level > 10: 594 msg = "Recursive email alias at '%s'" % lookup_name 595 if warn_on_error: 596 raise OSError(msg) 597 else: 598 print(col.build(col.RED, msg)) 599 return out_list 600 601 if lookup_name: 602 if lookup_name not in alias: 603 msg = "Alias '%s' not found" % lookup_name 604 if warn_on_error: 605 print(col.build(col.RED, msg)) 606 return out_list 607 for item in alias[lookup_name]: 608 todo = lookup_email(item, alias, warn_on_error, level + 1) 609 for new_item in todo: 610 if new_item not in out_list: 611 out_list.append(new_item) 612 613 return out_list 614 615 616def get_top_level(): 617 """Return name of top-level directory for this git repo. 618 619 Returns: 620 Full path to git top-level directory 621 622 This test makes sure that we are running tests in the right subdir 623 624 >>> os.path.realpath(os.path.dirname(__file__)) == \ 625 os.path.join(get_top_level(), 'tools', 'patman') 626 True 627 """ 628 return command.output_one_line('git', 'rev-parse', '--show-toplevel') 629 630 631def get_alias_file(): 632 """Gets the name of the git alias file. 633 634 Returns: 635 Filename of git alias file, or None if none 636 """ 637 fname = command.output_one_line('git', 'config', 'sendemail.aliasesfile', 638 raise_on_error=False) 639 if not fname: 640 return None 641 642 fname = os.path.expanduser(fname.strip()) 643 if os.path.isabs(fname): 644 return fname 645 646 return os.path.join(get_top_level(), fname) 647 648 649def get_default_user_name(): 650 """Gets the user.name from .gitconfig file. 651 652 Returns: 653 User name found in .gitconfig file, or None if none 654 """ 655 uname = command.output_one_line('git', 'config', '--global', '--includes', 'user.name') 656 return uname 657 658 659def get_default_user_email(): 660 """Gets the user.email from the global .gitconfig file. 661 662 Returns: 663 User's email found in .gitconfig file, or None if none 664 """ 665 uemail = command.output_one_line('git', 'config', '--global', '--includes', 'user.email') 666 return uemail 667 668 669def get_default_subject_prefix(): 670 """Gets the format.subjectprefix from local .git/config file. 671 672 Returns: 673 Subject prefix found in local .git/config file, or None if none 674 """ 675 sub_prefix = command.output_one_line( 676 'git', 'config', 'format.subjectprefix', raise_on_error=False) 677 678 return sub_prefix 679 680 681def setup(): 682 """Set up git utils, by reading the alias files.""" 683 # Check for a git alias file also 684 global use_no_decorate 685 686 alias_fname = get_alias_file() 687 if alias_fname: 688 settings.ReadGitAliases(alias_fname) 689 cmd = log_cmd(None, count=0) 690 use_no_decorate = (command.run_pipe([cmd], raise_on_error=False) 691 .return_code == 0) 692 693 694def get_head(): 695 """Get the hash of the current HEAD 696 697 Returns: 698 Hash of HEAD 699 """ 700 return command.output_one_line('git', 'show', '-s', '--pretty=format:%H') 701 702 703if __name__ == "__main__": 704 import doctest 705 706 doctest.testmod() 707