Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 # coding=utf8 | 1 # coding=utf8 |
| 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 """Manages a project checkout. | 5 """Manages a project checkout. |
| 6 | 6 |
| 7 Includes support for svn, git-svn and git. | 7 Includes support for svn, git-svn and git. |
| 8 """ | 8 """ |
| 9 | 9 |
| 10 import ConfigParser | 10 import ConfigParser |
| (...skipping 113 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 124 """Checks out a clean copy of the tree and removes any local modification. | 124 """Checks out a clean copy of the tree and removes any local modification. |
| 125 | 125 |
| 126 This function shouldn't throw unless the remote repository is inaccessible, | 126 This function shouldn't throw unless the remote repository is inaccessible, |
| 127 there is no free disk space or hard issues like that. | 127 there is no free disk space or hard issues like that. |
| 128 | 128 |
| 129 Args: | 129 Args: |
| 130 revision: The revision it should sync to, SCM specific. | 130 revision: The revision it should sync to, SCM specific. |
| 131 """ | 131 """ |
| 132 raise NotImplementedError() | 132 raise NotImplementedError() |
| 133 | 133 |
| 134 def apply_patch(self, patches, post_processors=None, verbose=False): | 134 def apply_patch(self, patches, post_processors=None, verbose=False, |
| 135 revert=False): | |
| 135 """Applies a patch and returns the list of modified files. | 136 """Applies a patch and returns the list of modified files. |
| 136 | 137 |
| 137 This function should throw patch.UnsupportedPatchFormat or | 138 This function should throw patch.UnsupportedPatchFormat or |
| 138 PatchApplicationFailed when relevant. | 139 PatchApplicationFailed when relevant. |
| 139 | 140 |
| 140 Args: | 141 Args: |
| 141 patches: patch.PatchSet object. | 142 patches: patch.PatchSet object. |
| 142 """ | 143 """ |
| 143 raise NotImplementedError() | 144 raise NotImplementedError() |
| 144 | 145 |
| (...skipping 13 matching lines...) Expand all Loading... | |
| 158 | 159 |
| 159 class RawCheckout(CheckoutBase): | 160 class RawCheckout(CheckoutBase): |
| 160 """Used to apply a patch locally without any intent to commit it. | 161 """Used to apply a patch locally without any intent to commit it. |
| 161 | 162 |
| 162 To be used by the try server. | 163 To be used by the try server. |
| 163 """ | 164 """ |
| 164 def prepare(self, revision): | 165 def prepare(self, revision): |
| 165 """Stubbed out.""" | 166 """Stubbed out.""" |
| 166 pass | 167 pass |
| 167 | 168 |
| 168 def apply_patch(self, patches, post_processors=None, verbose=False): | 169 def apply_patch(self, patches, post_processors=None, verbose=False, |
| 170 unused_revert=False): | |
| 169 """Ignores svn properties.""" | 171 """Ignores svn properties.""" |
| 170 post_processors = post_processors or self.post_processors or [] | 172 post_processors = post_processors or self.post_processors or [] |
| 171 for p in patches: | 173 for p in patches: |
| 172 stdout = [] | 174 stdout = [] |
| 173 try: | 175 try: |
| 174 filepath = os.path.join(self.project_path, p.filename) | 176 filepath = os.path.join(self.project_path, p.filename) |
| 175 if p.is_delete: | 177 if p.is_delete: |
| 176 os.remove(filepath) | 178 os.remove(filepath) |
| 177 stdout.append('Deleted.') | 179 stdout.append('Deleted.') |
| 178 else: | 180 else: |
| (...skipping 163 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 342 assert bool(self.commit_user) >= bool(self.commit_pwd) | 344 assert bool(self.commit_user) >= bool(self.commit_pwd) |
| 343 | 345 |
| 344 def prepare(self, revision): | 346 def prepare(self, revision): |
| 345 # Will checkout if the directory is not present. | 347 # Will checkout if the directory is not present. |
| 346 assert self.svn_url | 348 assert self.svn_url |
| 347 if not os.path.isdir(self.project_path): | 349 if not os.path.isdir(self.project_path): |
| 348 logging.info('Checking out %s in %s' % | 350 logging.info('Checking out %s in %s' % |
| 349 (self.project_name, self.project_path)) | 351 (self.project_name, self.project_path)) |
| 350 return self._revert(revision) | 352 return self._revert(revision) |
| 351 | 353 |
| 352 def apply_patch(self, patches, post_processors=None, verbose=False): | 354 def apply_patch(self, patches, post_processors=None, verbose=False, |
| 355 unused_revert=False): | |
|
M-A Ruel
2013/09/02 20:35:09
Keep revert=False for compatibility.
Why not:
ass
rmistry
2013/09/03 12:40:36
Yes will do in a separate CL, see the comment belo
| |
| 353 post_processors = post_processors or self.post_processors or [] | 356 post_processors = post_processors or self.post_processors or [] |
| 354 for p in patches: | 357 for p in patches: |
| 355 stdout = [] | 358 stdout = [] |
| 356 try: | 359 try: |
| 357 filepath = os.path.join(self.project_path, p.filename) | 360 filepath = os.path.join(self.project_path, p.filename) |
| 358 # It is important to use credentials=False otherwise credentials could | 361 # It is important to use credentials=False otherwise credentials could |
| 359 # leak in the error message. Credentials are not necessary here for the | 362 # leak in the error message. Credentials are not necessary here for the |
| 360 # following commands anyway. | 363 # following commands anyway. |
| 361 if p.is_delete: | 364 if p.is_delete: |
| 362 stdout.append(self._check_output_svn( | 365 stdout.append(self._check_output_svn( |
| (...skipping 180 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 543 # the order specified. | 546 # the order specified. |
| 544 try: | 547 try: |
| 545 out = self._check_output_svn( | 548 out = self._check_output_svn( |
| 546 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)]) | 549 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)]) |
| 547 except subprocess.CalledProcessError: | 550 except subprocess.CalledProcessError: |
| 548 return None | 551 return None |
| 549 # Ignore the '----' lines. | 552 # Ignore the '----' lines. |
| 550 return len([l for l in out.splitlines() if l.startswith('r')]) - 1 | 553 return len([l for l in out.splitlines() if l.startswith('r')]) - 1 |
| 551 | 554 |
| 552 | 555 |
| 553 class GitCheckoutBase(CheckoutBase): | 556 class GitCheckout(CheckoutBase): |
| 554 """Base class for git checkout. Not to be used as-is.""" | 557 """Manages a git checkout.""" |
| 555 def __init__(self, root_dir, project_name, remote_branch, | 558 def __init__(self, root_dir, project_name, remote_branch, git_url, |
| 556 post_processors=None): | 559 commit_user, post_processors=None): |
| 557 super(GitCheckoutBase, self).__init__( | 560 assert git_url |
| 558 root_dir, project_name, post_processors) | 561 assert commit_user |
| 559 # There is no reason to not hardcode it. | 562 super(GitCheckout, self).__init__(root_dir, project_name, post_processors) |
| 563 self.git_url = git_url | |
| 564 self.commit_user = commit_user | |
| 565 self.remote_branch = remote_branch | |
| 566 # The working branch where patches will be applied. It will track the | |
| 567 # remote branch. | |
| 568 self.working_branch = 'working_branch' | |
| 569 # There is no reason to not hardcode origin. | |
| 560 self.remote = 'origin' | 570 self.remote = 'origin' |
| 561 self.remote_branch = remote_branch | |
| 562 self.working_branch = 'working_branch' | |
| 563 | 571 |
| 564 def prepare(self, revision): | 572 def prepare(self, revision): |
| 565 """Resets the git repository in a clean state. | 573 """Resets the git repository in a clean state. |
| 566 | 574 |
| 567 Checks it out if not present and deletes the working branch. | 575 Checks it out if not present and deletes the working branch. |
| 568 """ | 576 """ |
| 569 assert self.remote_branch | 577 assert self.remote_branch |
| 570 assert os.path.isdir(self.project_path) | 578 |
| 571 self._check_call_git(['reset', '--hard', '--quiet']) | 579 if not os.path.isdir(self.project_path): |
| 580 # Clone the repo if the directory is not present. | |
| 581 logging.info('Checking out %s in %s' % | |
| 582 (self.project_name, self.project_path)) | |
| 583 self._check_call_git( | |
| 584 ['clone', self.git_url, '-b', self.remote_branch, self.project_path], | |
| 585 cwd=None, timeout=FETCH_TIMEOUT) | |
| 586 else: | |
| 587 # Throw away all uncommitted changes in the existing checkout. | |
| 588 self._check_call_git(['checkout', self.remote_branch]) | |
| 589 self._check_call_git( | |
| 590 ['reset', '--hard', '--quiet', | |
| 591 '%s/%s' % (self.remote, self.remote_branch)]) | |
| 592 | |
| 572 if revision: | 593 if revision: |
| 573 try: | 594 try: |
| 595 # Look if the commit hash already exist. If so, we can skip a | |
| 596 # 'git fetch' call. | |
| 574 revision = self._check_output_git(['rev-parse', revision]) | 597 revision = self._check_output_git(['rev-parse', revision]) |
| 575 except subprocess.CalledProcessError: | 598 except subprocess.CalledProcessError: |
| 576 self._check_call_git( | 599 self._check_call_git( |
| 577 ['fetch', self.remote, self.remote_branch, '--quiet']) | 600 ['fetch', self.remote, self.remote_branch, '--quiet']) |
| 578 revision = self._check_output_git(['rev-parse', revision]) | 601 revision = self._check_output_git(['rev-parse', revision]) |
| 579 self._check_call_git(['checkout', '--force', '--quiet', revision]) | 602 self._check_call_git(['checkout', '--force', '--quiet', revision]) |
| 580 else: | 603 else: |
| 581 branches, active = self._branches() | 604 branches, active = self._branches() |
| 582 if active != 'master': | 605 if active != 'master': |
| 583 self._check_call_git(['checkout', '--force', '--quiet', 'master']) | 606 self._check_call_git(['checkout', '--force', '--quiet', 'master']) |
| 584 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet']) | 607 self._sync_remote_branch() |
| 608 | |
| 585 if self.working_branch in branches: | 609 if self.working_branch in branches: |
| 586 self._call_git(['branch', '-D', self.working_branch]) | 610 self._call_git(['branch', '-D', self.working_branch]) |
| 611 return self._get_revision() | |
| 587 | 612 |
| 588 def apply_patch(self, patches, post_processors=None, verbose=False): | 613 def _sync_remote_branch(self): |
| 589 """Applies a patch on 'working_branch' and switch to it. | 614 """Sync the remote branch.""" |
| 615 # We do a 'git pull origin master:refs/remotes/origin/master' instead of | |
| 616 # 'git pull origin master' because from the manpage for git-pull: | |
| 617 # A parameter <ref> without a colon is equivalent to <ref>: when | |
| 618 # pulling/fetching, so it merges <ref> into the current branch without | |
| 619 # storing the remote branch anywhere locally. | |
| 620 self._check_call_git( | |
| 621 ['pull', self.remote, | |
| 622 '%s:refs/remotes/%s/%s' % (self.remote_branch, self.remote, | |
|
M-A Ruel
2013/09/02 20:35:09
I think it'd be more readable if you made it a nam
rmistry
2013/09/03 12:40:36
Done.
| |
| 623 self.remote_branch), | |
| 624 '--quiet']) | |
| 625 | |
| 626 def _get_revision(self): | |
| 627 """Gets the current revision from the local branch.""" | |
| 628 return self._check_output_git(['rev-parse', 'HEAD']).strip() | |
| 629 | |
| 630 def apply_patch(self, patches, post_processors=None, verbose=False, | |
| 631 revert=False): | |
| 632 """Applies a patch on 'working_branch' and switches to it. | |
| 590 | 633 |
| 591 Also commits the changes on the local branch. | 634 Also commits the changes on the local branch. |
| 592 | 635 |
| 593 Ignores svn properties and raise an exception on unexpected ones. | 636 Ignores svn properties and raise an exception on unexpected ones. |
| 594 """ | 637 """ |
| 595 post_processors = post_processors or self.post_processors or [] | 638 post_processors = post_processors or self.post_processors or [] |
| 596 # It this throws, the checkout is corrupted. Maybe worth deleting it and | 639 # It this throws, the checkout is corrupted. Maybe worth deleting it and |
| 597 # trying again? | 640 # trying again? |
| 598 if self.remote_branch: | 641 if self.remote_branch: |
| 599 self._check_call_git( | 642 self._check_call_git( |
| 600 ['checkout', '-b', self.working_branch, | 643 ['checkout', '-b', self.working_branch, '-t', self.remote_branch, |
| 601 '%s/%s' % (self.remote, self.remote_branch), '--quiet']) | 644 '--quiet']) |
| 645 | |
| 602 for index, p in enumerate(patches): | 646 for index, p in enumerate(patches): |
| 603 stdout = [] | 647 stdout = [] |
| 604 try: | 648 try: |
| 605 filepath = os.path.join(self.project_path, p.filename) | 649 filepath = os.path.join(self.project_path, p.filename) |
| 606 if p.is_delete: | 650 if p.is_delete: |
| 607 if (not os.path.exists(filepath) and | 651 if (not os.path.exists(filepath) and |
| 608 any(p1.source_filename == p.filename for p1 in patches[0:index])): | 652 any(p1.source_filename == p.filename for p1 in patches[0:index])): |
| 609 # The file was already deleted if a prior patch with file rename | 653 # The file was already deleted if a prior patch with file rename |
| 610 # was already processed because 'git apply' did it for us. | 654 # was already processed because 'git apply' did it for us. |
| 611 pass | 655 pass |
| (...skipping 12 matching lines...) Expand all Loading... | |
| 624 f.write(content) | 668 f.write(content) |
| 625 stdout.append('Added binary file %d bytes' % len(content)) | 669 stdout.append('Added binary file %d bytes' % len(content)) |
| 626 cmd = ['add', p.filename] | 670 cmd = ['add', p.filename] |
| 627 if verbose: | 671 if verbose: |
| 628 cmd.append('--verbose') | 672 cmd.append('--verbose') |
| 629 stdout.append(self._check_output_git(cmd)) | 673 stdout.append(self._check_output_git(cmd)) |
| 630 else: | 674 else: |
| 631 # No need to do anything special with p.is_new or if not | 675 # No need to do anything special with p.is_new or if not |
| 632 # p.diff_hunks. git apply manages all that already. | 676 # p.diff_hunks. git apply manages all that already. |
| 633 cmd = ['apply', '--index', '-p%s' % p.patchlevel] | 677 cmd = ['apply', '--index', '-p%s' % p.patchlevel] |
| 678 if revert: | |
|
M-A Ruel
2013/09/02 20:35:09
You mean "reversed"? Is that really needed?
rmistry
2013/09/03 12:40:36
Context behind the revert parameters:
I am also wo
| |
| 679 cmd.append('-R') | |
| 634 if verbose: | 680 if verbose: |
| 635 cmd.append('--verbose') | 681 cmd.append('--verbose') |
| 636 stdout.append(self._check_output_git(cmd, stdin=p.get(True))) | 682 stdout.append(self._check_output_git(cmd, stdin=p.get(True))) |
| 637 for name, value in p.svn_properties: | 683 for name, value in p.svn_properties: |
| 638 # Ignore some known auto-props flags through .subversion/config, | 684 # Ignore some known auto-props flags through .subversion/config, |
| 639 # bails out on the other ones. | 685 # bails out on the other ones. |
| 640 # TODO(maruel): Read ~/.subversion/config and detect the rules that | 686 # TODO(maruel): Read ~/.subversion/config and detect the rules that |
| 641 # applies here to figure out if the property will be correctly | 687 # applies here to figure out if the property will be correctly |
| 642 # handled. | 688 # handled. |
| 643 stdout.append('Property %s=%s' % (name, value)) | 689 stdout.append('Property %s=%s' % (name, value)) |
| (...skipping 16 matching lines...) Expand all Loading... | |
| 660 'While running %s;\n%s%s' % ( | 706 'While running %s;\n%s%s' % ( |
| 661 ' '.join(e.cmd), | 707 ' '.join(e.cmd), |
| 662 align_stdout(stdout), | 708 align_stdout(stdout), |
| 663 align_stdout([getattr(e, 'stdout', '')]))) | 709 align_stdout([getattr(e, 'stdout', '')]))) |
| 664 # Once all the patches are processed and added to the index, commit the | 710 # Once all the patches are processed and added to the index, commit the |
| 665 # index. | 711 # index. |
| 666 cmd = ['commit', '-m', 'Committed patch'] | 712 cmd = ['commit', '-m', 'Committed patch'] |
| 667 if verbose: | 713 if verbose: |
| 668 cmd.append('--verbose') | 714 cmd.append('--verbose') |
| 669 self._check_call_git(cmd) | 715 self._check_call_git(cmd) |
| 670 # TODO(maruel): Weirdly enough they don't match, need to investigate. | 716 found_files = self._check_output_git( |
| 671 #found_files = self._check_output_git( | 717 ['diff', '%s/%s' % (self.remote, self.remote_branch), |
| 672 # ['diff', 'master', '--name-only']).splitlines(False) | 718 '--name-only']).splitlines(False) |
| 673 #assert sorted(patches.filenames) == sorted(found_files), ( | 719 assert sorted(patches.filenames) == sorted(found_files), ( |
| 674 # sorted(out), sorted(found_files)) | 720 sorted(patches.filenames), sorted(found_files)) |
| 675 | 721 |
| 676 def commit(self, commit_message, user): | 722 def commit(self, commit_message, user): |
| 677 """Updates the commit message. | 723 """Commits, updates the commit message and pushes.""" |
| 724 assert isinstance(commit_message, unicode) | |
| 725 | |
| 726 commit_cmd = ['commit', '--amend', '-m', commit_message] | |
| 727 if user and user != self.commit_user: | |
| 728 # We do not have the first or last name of the user, grab the username | |
| 729 # from the email and call it the original author's name. | |
| 730 name = user.split('@')[0] | |
| 731 commit_cmd.extend(['--author', '%s <%s>' % (name, user)]) | |
| 732 self._check_call_git(commit_cmd) | |
| 678 | 733 |
| 679 Subclass needs to dcommit or push. | 734 # Push to the remote repository. |
| 680 """ | 735 self._check_call_git( |
| 681 assert isinstance(commit_message, unicode) | 736 ['push', 'origin', '%s:%s' % (self.working_branch, self.remote_branch), |
| 682 self._check_call_git(['commit', '--amend', '-m', commit_message]) | 737 '--force', '--quiet']) |
| 683 return self._check_output_git(['rev-parse', 'HEAD']).strip() | 738 # Get the revision after the push. |
| 739 revision = self._get_revision() | |
| 740 # Switch back to the remote_branch and sync it. | |
| 741 self._check_call_git(['checkout', self.remote_branch]) | |
| 742 self._sync_remote_branch() | |
| 743 # Delete the working branch since we are done with it. | |
| 744 self._check_call_git(['branch', '-D', self.working_branch]) | |
| 745 | |
| 746 return revision | |
| 684 | 747 |
| 685 def _check_call_git(self, args, **kwargs): | 748 def _check_call_git(self, args, **kwargs): |
| 686 kwargs.setdefault('cwd', self.project_path) | 749 kwargs.setdefault('cwd', self.project_path) |
| 687 kwargs.setdefault('stdout', self.VOID) | 750 kwargs.setdefault('stdout', self.VOID) |
| 688 kwargs.setdefault('timeout', GLOBAL_TIMEOUT) | 751 kwargs.setdefault('timeout', GLOBAL_TIMEOUT) |
| 689 return subprocess2.check_call_out(['git'] + args, **kwargs) | 752 return subprocess2.check_call_out(['git'] + args, **kwargs) |
| 690 | 753 |
| 691 def _call_git(self, args, **kwargs): | 754 def _call_git(self, args, **kwargs): |
| 692 """Like check_call but doesn't throw on failure.""" | 755 """Like check_call but doesn't throw on failure.""" |
| 693 kwargs.setdefault('cwd', self.project_path) | 756 kwargs.setdefault('cwd', self.project_path) |
| (...skipping 26 matching lines...) Expand all Loading... | |
| 720 # Revision range is ]rev1, rev2] and ordering matters. | 783 # Revision range is ]rev1, rev2] and ordering matters. |
| 721 try: | 784 try: |
| 722 out = self._check_output_git( | 785 out = self._check_output_git( |
| 723 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)]) | 786 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)]) |
| 724 except subprocess.CalledProcessError: | 787 except subprocess.CalledProcessError: |
| 725 return None | 788 return None |
| 726 return len(out.splitlines()) | 789 return len(out.splitlines()) |
| 727 | 790 |
| 728 def _fetch_remote(self): | 791 def _fetch_remote(self): |
| 729 """Fetches the remote without rebasing.""" | 792 """Fetches the remote without rebasing.""" |
| 730 raise NotImplementedError() | 793 # git fetch is always verbose even with -q, so redirect its output. |
| 731 | |
| 732 | |
| 733 class GitCheckout(GitCheckoutBase): | |
| 734 """Git checkout implementation.""" | |
| 735 def _fetch_remote(self): | |
| 736 # git fetch is always verbose even with -q -q so redirect its output. | |
| 737 self._check_output_git(['fetch', self.remote, self.remote_branch], | 794 self._check_output_git(['fetch', self.remote, self.remote_branch], |
| 738 timeout=FETCH_TIMEOUT) | 795 timeout=FETCH_TIMEOUT) |
| 739 | 796 |
| 740 | 797 |
| 741 class ReadOnlyCheckout(object): | 798 class ReadOnlyCheckout(object): |
| 742 """Converts a checkout into a read-only one.""" | 799 """Converts a checkout into a read-only one.""" |
| 743 def __init__(self, checkout, post_processors=None): | 800 def __init__(self, checkout, post_processors=None): |
| 744 super(ReadOnlyCheckout, self).__init__() | 801 super(ReadOnlyCheckout, self).__init__() |
| 745 self.checkout = checkout | 802 self.checkout = checkout |
| 746 self.post_processors = (post_processors or []) + ( | 803 self.post_processors = (post_processors or []) + ( |
| 747 self.checkout.post_processors or []) | 804 self.checkout.post_processors or []) |
| 748 | 805 |
| 749 def prepare(self, revision): | 806 def prepare(self, revision): |
| 750 return self.checkout.prepare(revision) | 807 return self.checkout.prepare(revision) |
| 751 | 808 |
| 752 def get_settings(self, key): | 809 def get_settings(self, key): |
| 753 return self.checkout.get_settings(key) | 810 return self.checkout.get_settings(key) |
| 754 | 811 |
| 755 def apply_patch(self, patches, post_processors=None, verbose=False): | 812 def apply_patch(self, patches, post_processors=None, verbose=False, |
| 813 revert=False): | |
| 756 return self.checkout.apply_patch( | 814 return self.checkout.apply_patch( |
| 757 patches, post_processors or self.post_processors, verbose) | 815 patches, post_processors or self.post_processors, verbose, revert) |
| 758 | 816 |
| 759 def commit(self, message, user): # pylint: disable=R0201 | 817 def commit(self, message, user): # pylint: disable=R0201 |
| 760 logging.info('Would have committed for %s with message: %s' % ( | 818 logging.info('Would have committed for %s with message: %s' % ( |
| 761 user, message)) | 819 user, message)) |
| 762 return 'FAKE' | 820 return 'FAKE' |
| 763 | 821 |
| 764 def revisions(self, rev1, rev2): | 822 def revisions(self, rev1, rev2): |
| 765 return self.checkout.revisions(rev1, rev2) | 823 return self.checkout.revisions(rev1, rev2) |
| 766 | 824 |
| 767 @property | 825 @property |
| 768 def project_name(self): | 826 def project_name(self): |
| 769 return self.checkout.project_name | 827 return self.checkout.project_name |
| 770 | 828 |
| 771 @property | 829 @property |
| 772 def project_path(self): | 830 def project_path(self): |
| 773 return self.checkout.project_path | 831 return self.checkout.project_path |
| OLD | NEW |