| OLD | NEW |
| 1 # Copyright (c) 2009 The Chromium Authors. All rights reserved. | 1 # Copyright (c) 2009 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 4 | 4 |
| 5 """Gclient-specific SCM-specific operations.""" | 5 """Gclient-specific SCM-specific operations.""" |
| 6 | 6 |
| 7 import logging | 7 import logging |
| 8 import os | 8 import os |
| 9 import posixpath | 9 import posixpath |
| 10 import re | 10 import re |
| (...skipping 181 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 192 rev_type = "branch" | 192 rev_type = "branch" |
| 193 elif revision.startswith('origin/'): | 193 elif revision.startswith('origin/'): |
| 194 # For compatability with old naming, translate 'origin' to 'refs/heads' | 194 # For compatability with old naming, translate 'origin' to 'refs/heads' |
| 195 revision = revision.replace('origin/', 'refs/heads/') | 195 revision = revision.replace('origin/', 'refs/heads/') |
| 196 rev_type = "branch" | 196 rev_type = "branch" |
| 197 else: | 197 else: |
| 198 # hash is also a tag, only make a distinction at checkout | 198 # hash is also a tag, only make a distinction at checkout |
| 199 rev_type = "hash" | 199 rev_type = "hash" |
| 200 | 200 |
| 201 if not os.path.exists(self.checkout_path): | 201 if not os.path.exists(self.checkout_path): |
| 202 self._Clone(rev_type, revision, url, options.verbose) | 202 self._Clone(revision, url, options.verbose) |
| 203 files = self._Run(['ls-files']).split() | 203 files = self._Run(['ls-files']).split() |
| 204 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) | 204 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) |
| 205 if not verbose: | 205 if not verbose: |
| 206 # Make the output a little prettier. It's nice to have some whitespace | 206 # Make the output a little prettier. It's nice to have some whitespace |
| 207 # between projects when cloning. | 207 # between projects when cloning. |
| 208 print "" | 208 print "" |
| 209 return | 209 return |
| 210 | 210 |
| 211 if not os.path.exists(os.path.join(self.checkout_path, '.git')): | 211 if not os.path.exists(os.path.join(self.checkout_path, '.git')): |
| 212 raise gclient_utils.Error('\n____ %s%s\n' | 212 raise gclient_utils.Error('\n____ %s%s\n' |
| 213 '\tPath is not a git repo. No .git dir.\n' | 213 '\tPath is not a git repo. No .git dir.\n' |
| 214 '\tTo resolve:\n' | 214 '\tTo resolve:\n' |
| 215 '\t\trm -rf %s\n' | 215 '\t\trm -rf %s\n' |
| 216 '\tAnd run gclient sync again\n' | 216 '\tAnd run gclient sync again\n' |
| 217 % (self.relpath, rev_str, self.relpath)) | 217 % (self.relpath, rev_str, self.relpath)) |
| 218 | 218 |
| 219 cur_branch = self._GetCurrentBranch() | 219 cur_branch = self._GetCurrentBranch() |
| 220 | 220 |
| 221 # Check if we are in a rebase conflict | |
| 222 if cur_branch is None: | |
| 223 raise gclient_utils.Error('\n____ %s%s\n' | |
| 224 '\tAlready in a conflict, i.e. (no branch).\n' | |
| 225 '\tFix the conflict and run gclient again.\n' | |
| 226 '\tOr to abort run:\n\t\tgit-rebase --abort\n' | |
| 227 '\tSee man git-rebase for details.\n' | |
| 228 % (self.relpath, rev_str)) | |
| 229 | |
| 230 # Cases: | 221 # Cases: |
| 231 # 1) current branch based on a hash (could be git-svn) | 222 # 0) HEAD is detached. Probably from our initial clone. |
| 232 # - try to rebase onto the new upstream (hash or branch) | 223 # - make sure HEAD is contained by a named ref, then update. |
| 233 # 2) current branch based on a remote branch with local committed changes, | 224 # Cases 1-4. HEAD is a branch. |
| 234 # but the DEPS file switched to point to a hash | 225 # 1) current branch is not tracking a remote branch (could be git-svn) |
| 226 # - try to rebase onto the new hash or branch |
| 227 # 2) current branch is tracking a remote branch with local committed |
| 228 # changes, but the DEPS file switched to point to a hash |
| 235 # - rebase those changes on top of the hash | 229 # - rebase those changes on top of the hash |
| 236 # 3) current branch based on a remote with or without changes, no switch | 230 # 3) current branch is tracking a remote branch w/or w/out changes, |
| 231 # no switch |
| 237 # - see if we can FF, if not, prompt the user for rebase, merge, or stop | 232 # - see if we can FF, if not, prompt the user for rebase, merge, or stop |
| 238 # 4) current branch based on a remote, switches to a new remote | 233 # 4) current branch is tracking a remote branch, switches to a different |
| 234 # remote branch |
| 239 # - exit | 235 # - exit |
| 240 | 236 |
| 241 # GetUpstreamBranch returns something like 'refs/remotes/origin/master' for | 237 # GetUpstreamBranch returns something like 'refs/remotes/origin/master' for |
| 242 # a tracking branch | 238 # a tracking branch |
| 243 # or 'master' if not a tracking branch (it's based on a specific rev/hash) | 239 # or 'master' if not a tracking branch (it's based on a specific rev/hash) |
| 244 # or it returns None if it couldn't find an upstream | 240 # or it returns None if it couldn't find an upstream |
| 245 upstream_branch = scm.GIT.GetUpstreamBranch(self.checkout_path) | 241 if cur_branch is None: |
| 246 if not upstream_branch or not upstream_branch.startswith('refs/remotes'): | 242 upstream_branch = None |
| 247 current_type = "hash" | 243 current_type = "detached" |
| 248 logging.debug("Current branch is based off a specific rev and is not " | 244 logging.debug("Detached HEAD") |
| 249 "tracking an upstream.") | |
| 250 elif upstream_branch.startswith('refs/remotes'): | |
| 251 current_type = "branch" | |
| 252 else: | 245 else: |
| 253 raise gclient_utils.Error('Invalid Upstream') | 246 upstream_branch = scm.GIT.GetUpstreamBranch(self.checkout_path) |
| 247 if not upstream_branch or not upstream_branch.startswith('refs/remotes'): |
| 248 current_type = "hash" |
| 249 logging.debug("Current branch is not tracking an upstream (remote)" |
| 250 " branch.") |
| 251 elif upstream_branch.startswith('refs/remotes'): |
| 252 current_type = "branch" |
| 253 else: |
| 254 raise gclient_utils.Error('Invalid Upstream: %s' % upstream_branch) |
| 254 | 255 |
| 255 # Update the remotes first so we have all the refs. | 256 # Update the remotes first so we have all the refs. |
| 256 for _ in range(10): | 257 for _ in range(10): |
| 257 try: | 258 try: |
| 258 remote_output, remote_err = scm.GIT.Capture( | 259 remote_output, remote_err = scm.GIT.Capture( |
| 259 ['remote'] + verbose + ['update'], | 260 ['remote'] + verbose + ['update'], |
| 260 self.checkout_path, | 261 self.checkout_path, |
| 261 print_error=False) | 262 print_error=False) |
| 262 break | 263 break |
| 263 except gclient_utils.CheckCallError: | 264 except gclient_utils.CheckCallError: |
| 264 # Hackish but at that point, git is known to work so just checking for | 265 # Hackish but at that point, git is known to work so just checking for |
| 265 # 502 in stderr should be fine. | 266 # 502 in stderr should be fine. |
| 266 if '502' in e.stderr: | 267 if '502' in e.stderr: |
| 267 print str(e) | 268 print str(e) |
| 268 print "Sleeping 15 seconds and retrying..." | 269 print "Sleeping 15 seconds and retrying..." |
| 269 time.sleep(15) | 270 time.sleep(15) |
| 270 continue | 271 continue |
| 271 raise | 272 raise |
| 272 | 273 |
| 273 if verbose: | 274 if verbose: |
| 274 print remote_output.strip() | 275 print remote_output.strip() |
| 275 # git remote update prints to stderr when used with --verbose | 276 # git remote update prints to stderr when used with --verbose |
| 276 print remote_err.strip() | 277 print remote_err.strip() |
| 277 | 278 |
| 278 # This is a big hammer, debatable if it should even be here... | 279 # This is a big hammer, debatable if it should even be here... |
| 279 if options.force or options.reset: | 280 if options.force or options.reset: |
| 280 self._Run(['reset', '--hard', 'HEAD'], redirect_stdout=False) | 281 self._Run(['reset', '--hard', 'HEAD'], redirect_stdout=False) |
| 281 | 282 |
| 282 if current_type == 'hash': | 283 if current_type == 'detached': |
| 284 # case 0 |
| 285 self._CheckClean(rev_str) |
| 286 self._CheckDetachedHead(rev_str) |
| 287 self._Run(['checkout', '--quiet', '%s^0' % revision]) |
| 288 if not printed_path: |
| 289 print("\n_____ %s%s" % (self.relpath, rev_str)) |
| 290 elif current_type == 'hash': |
| 283 # case 1 | 291 # case 1 |
| 284 if scm.GIT.IsGitSvn(self.checkout_path) and upstream_branch is not None: | 292 if scm.GIT.IsGitSvn(self.checkout_path) and upstream_branch is not None: |
| 285 # Our git-svn branch (upstream_branch) is our upstream | 293 # Our git-svn branch (upstream_branch) is our upstream |
| 286 self._AttemptRebase(upstream_branch, files, verbose=options.verbose, | 294 self._AttemptRebase(upstream_branch, files, verbose=options.verbose, |
| 287 newbase=revision, printed_path=printed_path) | 295 newbase=revision, printed_path=printed_path) |
| 288 printed_path = True | 296 printed_path = True |
| 289 else: | 297 else: |
| 290 # Can't find a merge-base since we don't know our upstream. That makes | 298 # Can't find a merge-base since we don't know our upstream. That makes |
| 291 # this command VERY likely to produce a rebase failure. For now we | 299 # this command VERY likely to produce a rebase failure. For now we |
| 292 # assume origin is our upstream since that's what the old behavior was. | 300 # assume origin is our upstream since that's what the old behavior was. |
| (...skipping 80 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 373 if merge_err: | 381 if merge_err: |
| 374 print "Merge produced error output:\n%s" % merge_err.strip() | 382 print "Merge produced error output:\n%s" % merge_err.strip() |
| 375 if not verbose: | 383 if not verbose: |
| 376 # Make the output a little prettier. It's nice to have some | 384 # Make the output a little prettier. It's nice to have some |
| 377 # whitespace between projects when syncing. | 385 # whitespace between projects when syncing. |
| 378 print "" | 386 print "" |
| 379 | 387 |
| 380 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) | 388 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) |
| 381 | 389 |
| 382 # If the rebase generated a conflict, abort and ask user to fix | 390 # If the rebase generated a conflict, abort and ask user to fix |
| 383 if self._GetCurrentBranch() is None: | 391 if self._IsRebasing(): |
| 384 raise gclient_utils.Error('\n____ %s%s\n' | 392 raise gclient_utils.Error('\n____ %s%s\n' |
| 385 '\nConflict while rebasing this branch.\n' | 393 '\nConflict while rebasing this branch.\n' |
| 386 'Fix the conflict and run gclient again.\n' | 394 'Fix the conflict and run gclient again.\n' |
| 387 'See man git-rebase for details.\n' | 395 'See man git-rebase for details.\n' |
| 388 % (self.relpath, rev_str)) | 396 % (self.relpath, rev_str)) |
| 389 | 397 |
| 390 if verbose: | 398 if verbose: |
| 391 print "Checked out revision %s" % self.revinfo(options, (), None) | 399 print "Checked out revision %s" % self.revinfo(options, (), None) |
| 392 | 400 |
| 393 def revert(self, options, args, file_list): | 401 def revert(self, options, args, file_list): |
| (...skipping 40 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 434 self._Run(['diff', '--name-status', merge_base], redirect_stdout=False) | 442 self._Run(['diff', '--name-status', merge_base], redirect_stdout=False) |
| 435 files = self._Run(['diff', '--name-only', merge_base]).split() | 443 files = self._Run(['diff', '--name-only', merge_base]).split() |
| 436 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) | 444 file_list.extend([os.path.join(self.checkout_path, f) for f in files]) |
| 437 | 445 |
| 438 def FullUrlForRelativeUrl(self, url): | 446 def FullUrlForRelativeUrl(self, url): |
| 439 # Strip from last '/' | 447 # Strip from last '/' |
| 440 # Equivalent to unix basename | 448 # Equivalent to unix basename |
| 441 base_url = self.url | 449 base_url = self.url |
| 442 return base_url[:base_url.rfind('/')] + url | 450 return base_url[:base_url.rfind('/')] + url |
| 443 | 451 |
| 444 def _Clone(self, rev_type, revision, url, verbose=False): | 452 def _Clone(self, revision, url, verbose=False): |
| 445 """Clone a git repository from the given URL. | 453 """Clone a git repository from the given URL. |
| 446 | 454 |
| 447 Once we've cloned the repo, we checkout a working branch based off the | 455 Once we've cloned the repo, we checkout a working branch if the specified |
| 448 specified revision.""" | 456 revision is a branch head. If it is a tag or a specific commit, then we |
| 457 leave HEAD detached as it makes future updates simpler -- in this case the |
| 458 user should first create a new branch or switch to an existing branch before |
| 459 making changes in the repo.""" |
| 449 if not verbose: | 460 if not verbose: |
| 450 # git clone doesn't seem to insert a newline properly before printing | 461 # git clone doesn't seem to insert a newline properly before printing |
| 451 # to stdout | 462 # to stdout |
| 452 print "" | 463 print "" |
| 453 | 464 |
| 454 clone_cmd = ['clone'] | 465 clone_cmd = ['clone'] |
| 466 if revision.startswith('refs/heads/'): |
| 467 clone_cmd.extend(['-b', revision.replace('refs/heads/', '')]) |
| 468 detach_head = False |
| 469 else: |
| 470 clone_cmd.append('--no-checkout') |
| 471 detach_head = True |
| 455 if verbose: | 472 if verbose: |
| 456 clone_cmd.append('--verbose') | 473 clone_cmd.append('--verbose') |
| 457 clone_cmd.extend([url, self.checkout_path]) | 474 clone_cmd.extend([url, self.checkout_path]) |
| 458 | 475 |
| 459 for _ in range(3): | 476 for _ in range(3): |
| 460 try: | 477 try: |
| 461 self._Run(clone_cmd, cwd=self._root_dir, redirect_stdout=False) | 478 self._Run(clone_cmd, cwd=self._root_dir, redirect_stdout=False) |
| 462 break | 479 break |
| 463 except gclient_utils.Error, e: | 480 except gclient_utils.Error, e: |
| 464 # TODO(maruel): Hackish, should be fixed by moving _Run() to | 481 # TODO(maruel): Hackish, should be fixed by moving _Run() to |
| 465 # CheckCall(). | 482 # CheckCall(). |
| 466 # Too bad we don't have access to the actual output. | 483 # Too bad we don't have access to the actual output. |
| 467 # We should check for "transfer closed with NNN bytes remaining to | 484 # We should check for "transfer closed with NNN bytes remaining to |
| 468 # read". In the meantime, just make sure .git exists. | 485 # read". In the meantime, just make sure .git exists. |
| 469 if (e.args[0] == 'git command clone returned 128' and | 486 if (e.args[0] == 'git command clone returned 128' and |
| 470 os.path.exists(os.path.join(self.checkout_path, '.git'))): | 487 os.path.exists(os.path.join(self.checkout_path, '.git'))): |
| 471 print str(e) | 488 print str(e) |
| 472 print "Retrying..." | 489 print "Retrying..." |
| 473 continue | 490 continue |
| 474 raise e | 491 raise e |
| 475 | 492 |
| 476 if rev_type == "branch": | 493 if detach_head: |
| 477 short_rev = revision.replace('refs/heads/', '') | 494 # Squelch git's very verbose detached HEAD warning and use our own |
| 478 new_branch = revision.replace('heads', 'remotes/origin') | 495 self._Run(['checkout', '--quiet', '%s^0' % revision]) |
| 479 elif revision.startswith('refs/tags/'): | 496 print \ |
| 480 short_rev = revision.replace('refs/tags/', '') | 497 "Checked out %s to a detached HEAD. Before making any commits\n" \ |
| 481 new_branch = revision | 498 "in this repo, you should use 'git checkout <branch>' to switch to\n" \ |
| 482 else: | 499 "an existing branch or use 'git checkout origin -b <branch>' to\n" \ |
| 483 # revision is a specific sha1 hash | 500 "create a new branch for your work." % revision |
| 484 short_rev = revision | |
| 485 new_branch = revision | |
| 486 | |
| 487 cur_branch = self._GetCurrentBranch() | |
| 488 if cur_branch != short_rev: | |
| 489 self._Run(['checkout', '-b', short_rev, new_branch], | |
| 490 redirect_stdout=False) | |
| 491 | 501 |
| 492 def _AttemptRebase(self, upstream, files, verbose=False, newbase=None, | 502 def _AttemptRebase(self, upstream, files, verbose=False, newbase=None, |
| 493 branch=None, printed_path=False): | 503 branch=None, printed_path=False): |
| 494 """Attempt to rebase onto either upstream or, if specified, newbase.""" | 504 """Attempt to rebase onto either upstream or, if specified, newbase.""" |
| 495 files.extend(self._Run(['diff', upstream, '--name-only']).split()) | 505 files.extend(self._Run(['diff', upstream, '--name-only']).split()) |
| 496 revision = upstream | 506 revision = upstream |
| 497 if newbase: | 507 if newbase: |
| 498 revision = newbase | 508 revision = newbase |
| 499 if not printed_path: | 509 if not printed_path: |
| 500 print "\n_____ %s : Attempting rebase onto %s..." % (self.relpath, | 510 print "\n_____ %s : Attempting rebase onto %s..." % (self.relpath, |
| (...skipping 62 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 563 # Make the output a little prettier. It's nice to have some | 573 # Make the output a little prettier. It's nice to have some |
| 564 # whitespace between projects when syncing. | 574 # whitespace between projects when syncing. |
| 565 print "" | 575 print "" |
| 566 | 576 |
| 567 def _CheckMinVersion(self, min_version): | 577 def _CheckMinVersion(self, min_version): |
| 568 (ok, current_version) = scm.GIT.AssertVersion(min_version) | 578 (ok, current_version) = scm.GIT.AssertVersion(min_version) |
| 569 if not ok: | 579 if not ok: |
| 570 raise gclient_utils.Error('git version %s < minimum required %s' % | 580 raise gclient_utils.Error('git version %s < minimum required %s' % |
| 571 (current_version, min_version)) | 581 (current_version, min_version)) |
| 572 | 582 |
| 583 def _IsRebasing(self): |
| 584 # Check for any of REBASE-i/REBASE-m/REBASE/AM. Unfortunately git doesn't |
| 585 # have a plumbing command to determine whether a rebase is in progress, so |
| 586 # for now emualate (more-or-less) git-rebase.sh / git-completion.bash |
| 587 g = os.path.join(self.checkout_path, '.git') |
| 588 return ( |
| 589 os.path.isdir(os.path.join(g, "rebase-merge")) or |
| 590 os.path.isdir(os.path.join(g, "rebase-apply"))) |
| 591 |
| 592 def _CheckClean(self, rev_str): |
| 593 # Make sure the tree is clean; see git-rebase.sh for reference |
| 594 try: |
| 595 scm.GIT.Capture(['update-index', '--ignore-submodules', '--refresh'], |
| 596 self.checkout_path, print_error=False) |
| 597 except gclient_utils.CheckCallError, e: |
| 598 raise gclient_utils.Error('\n____ %s%s\n' |
| 599 '\tYou have unstaged changes.\n' |
| 600 '\tPlease commit, stash, or reset.\n' |
| 601 % (self.relpath, rev_str)) |
| 602 try: |
| 603 scm.GIT.Capture(['diff-index', '--cached', '--name-status', '-r', |
| 604 '--ignore-submodules', 'HEAD', '--'], self.checkout_path, |
| 605 print_error=False) |
| 606 except gclient_utils.CheckCallError, e: |
| 607 raise gclient_utils.Error('\n____ %s%s\n' |
| 608 '\tYour index contains uncommitted changes\n' |
| 609 '\tPlease commit, stash, or reset.\n' |
| 610 % (self.relpath, rev_str)) |
| 611 |
| 612 def _CheckDetachedHead(self, rev_str): |
| 613 # HEAD is detached. Make sure it is safe to move away from (i.e., it is |
| 614 # reference by a commit). If not, error out -- most likely a rebase is |
| 615 # in progress, try to detect so we can give a better error. |
| 616 try: |
| 617 out, err = scm.GIT.Capture( |
| 618 ['name-rev', '--no-undefined', 'HEAD'], |
| 619 self.checkout_path, |
| 620 print_error=False) |
| 621 except gclient_utils.CheckCallError, e: |
| 622 # Commit is not contained by any rev. See if the user is rebasing: |
| 623 if self._IsRebasing(): |
| 624 # Punt to the user |
| 625 raise gclient_utils.Error('\n____ %s%s\n' |
| 626 '\tAlready in a conflict, i.e. (no branch).\n' |
| 627 '\tFix the conflict and run gclient again.\n' |
| 628 '\tOr to abort run:\n\t\tgit-rebase --abort\n' |
| 629 '\tSee man git-rebase for details.\n' |
| 630 % (self.relpath, rev_str)) |
| 631 # Let's just save off the commit so we can proceed. |
| 632 name = "saved-by-gclient-" + self._Run(["rev-parse", "--short", "HEAD"]) |
| 633 self._Run(["branch", name]) |
| 634 print ("\n_____ found an unreferenced commit and saved it as '%s'" % name) |
| 635 |
| 573 def _GetCurrentBranch(self): | 636 def _GetCurrentBranch(self): |
| 574 # Returns name of current branch | 637 # Returns name of current branch or None for detached HEAD |
| 575 # Returns None if inside a (no branch) | 638 branch = self._Run(['rev-parse', '--abbrev-ref=strict', 'HEAD']) |
| 576 tokens = self._Run(['branch']).split() | 639 if branch == 'HEAD': |
| 577 branch = tokens[tokens.index('*') + 1] | |
| 578 if branch == '(no': | |
| 579 return None | 640 return None |
| 580 return branch | 641 return branch |
| 581 | 642 |
| 582 def _Run(self, args, cwd=None, redirect_stdout=True): | 643 def _Run(self, args, cwd=None, redirect_stdout=True): |
| 583 # TODO(maruel): Merge with Capture or better gclient_utils.CheckCall(). | 644 # TODO(maruel): Merge with Capture or better gclient_utils.CheckCall(). |
| 584 if cwd is None: | 645 if cwd is None: |
| 585 cwd = self.checkout_path | 646 cwd = self.checkout_path |
| 586 stdout = None | 647 stdout = None |
| 587 if redirect_stdout: | 648 if redirect_stdout: |
| 588 stdout = subprocess.PIPE | 649 stdout = subprocess.PIPE |
| (...skipping 270 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 859 print("\n________ couldn't run \'%s\' in \'%s\':\nThe directory " | 920 print("\n________ couldn't run \'%s\' in \'%s\':\nThe directory " |
| 860 "does not exist." | 921 "does not exist." |
| 861 % (' '.join(command), path)) | 922 % (' '.join(command), path)) |
| 862 # There's no file list to retrieve. | 923 # There's no file list to retrieve. |
| 863 else: | 924 else: |
| 864 scm.SVN.RunAndGetFileList(options, command, path, file_list) | 925 scm.SVN.RunAndGetFileList(options, command, path, file_list) |
| 865 | 926 |
| 866 def FullUrlForRelativeUrl(self, url): | 927 def FullUrlForRelativeUrl(self, url): |
| 867 # Find the forth '/' and strip from there. A bit hackish. | 928 # Find the forth '/' and strip from there. A bit hackish. |
| 868 return '/'.join(self.url.split('/')[:4]) + url | 929 return '/'.join(self.url.split('/')[:4]) + url |
| OLD | NEW |