Compare commits

...

9 Commits
v2.34 ... v2.35

Author SHA1 Message Date
c657844efe main: Fix exitcode logging
Fixed a couple of bugs in ExitEvent logging:
- log exitcode 130 on KeyboardInterrupt
- log exitcode 1 on unhandled Exception
- log errorevent with specific reason for exit

Before this CL an exitcode of 0 would be logged, and it would be
difficult to determine the cause of non-zero exit codes

Bug: b/287105597
Change-Id: I2d34f180581f9fbd77a1c78c966ebed065223af6
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/377834
Tested-by: Jason Chang <jasonnc@google.com>
Reviewed-by: Josip Sokcevic <sokcevic@google.com>
2023-06-26 15:38:30 +00:00
1d3b4fbeec sync: Track new/existing project count
New vs existing project may be a useful measure for analyzing
sync performance.

Bug: b/287105597
Change-Id: Ibea3e90c9fe3d16fd8b863bcae22b21963a6771a
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/377574
Tested-by: Jason Chang <jasonnc@google.com>
Reviewed-by: Joanna Wang <jojwang@google.com>
2023-06-23 20:08:58 +00:00
be71c2f80f manifest: enable remove-project using path
A something.xml that gets included by two different
files, that both remove and add same shared project
to two different locations, would not work
prior to this change.

Reason is that remove killed all name keys, even
though reuse of same repo in different locations
is allowed.

Solve by adding optional attrib path to
<remove-project name="foo" path="only_this_path" />
and tweak remove-project.

Behaves as before without path, and deletes
more selectively when remove path is supplied.

As secondary feature, a project can now also be removed
by only using path, assuming a matching project name
can be found.

Change-Id: I502d9f949f5d858ddc1503846b170473f76dc8e2
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/375694
Tested-by: Fredrik de Groot <fredrik.de.groot@aptiv.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
2023-06-21 14:50:16 +00:00
696e0c48a9 update links from monorail to issuetracker
Change-Id: Ie05373aa4becc0e4d0cab74e7ea0a61eb2cc2746
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/377014
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2023-06-14 21:19:58 +00:00
b2263ba124 sync: Handle case when output isn't connected to a terminal
Currently `repo sync | tee` exits with an OSError.

Bug: https://crbug.com/gerrit/17023
Change-Id: I91ae05f1c91d374b5d57721d45af74db1b2072a5
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/376414
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2023-06-09 15:26:16 +00:00
945c006f40 sync: Update sync progress even when _sync_dict is empty
By chance, _sync_dict can be empty even though repo sync is still
working. In that case, the progress message shows incorrect info. Handle this case and fix a bug where "0 jobs" can show.

Bug: http://b/284465096
Change-Id: If915d953ba60e7cf84a6fb2d137fd6ed82abd3cc
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/375494
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Josip Sokcevic <sokcevic@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2023-05-30 20:25:00 +00:00
71122f941f sync: Handle race condition when reading active jobs
It's possible that number of jobs is more than 0 when we
check length, but in the meantime number of jobs drops to
0. In that case, we are working with float(inf) which
causes other problems

Bug: 284383869
Change-Id: I5d070d1be428f8395df7fde8ca84866db46f2100
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/375134
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Josip Sokcevic <sokcevic@google.com>
Tested-by: Josip Sokcevic <sokcevic@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
2023-05-26 15:50:20 +00:00
07a4529278 pager: set $LESS only when missing
This matches the git behavior. From [1],

> When the `LESS` environment variable is unset, Git sets it to `FRX`
> (if `LESS` environment variable is set, Git does not change it at
> all).

The default $LESS is changed from FRSX to FRX since git 2.1.0 [2]. This
change also updates the default $LESS for repo.

[1] https://git-scm.com/docs/git-config#Documentation/git-config.txt-corepager
[2] b3275838d9

Bug: https://crbug.com/gerrit/16973
Change-Id: I64ccaa7b034fdb6a92c10025e47f5d07e85e6451
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/374894
Reviewed-by: Chih-Hsuan Yen <x5f4qvj3w3ge2tiq@chyen.cc>
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Chih-Hsuan Yen <x5f4qvj3w3ge2tiq@chyen.cc>
2023-05-26 14:39:09 +00:00
17833322d9 Add envar to replace shallow clones with partial
An investigation go/git-repo-shallow shows a number of problems
when doing a shallow git fetch/clone. This change introduces an
environment variable REPO_ALLOW_SHALLOW. When this environment variable
is set to 1 during a repo init or repo sync all shallow git fetch
commands are replaced with partial fetch commands. Any shallow
repository needing update is unshallowed. This behavior continues until
a subsequent repo sync command is run with REPO_ALLOW_SHALLOW set to 1.

Bug: b/274340522
Change-Id: I1c3188270629359e52449788897d9d4988ebf280
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/374754
Reviewed-by: Josip Sokcevic <sokcevic@google.com>
Tested-by: Jason Chang <jasonnc@google.com>
2023-05-25 22:37:04 +00:00
13 changed files with 208 additions and 36 deletions

View File

@ -8,7 +8,7 @@ that you can put anywhere in your path.
* Homepage: <https://gerrit.googlesource.com/git-repo/>
* Mailing list: [repo-discuss on Google Groups][repo-discuss]
* Bug reports: <https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo>
* Bug reports: <https://issues.gerritcodereview.com/issues?q=is:open%20componentid:1370071>
* Source: <https://gerrit.googlesource.com/git-repo/>
* Overview: <https://source.android.com/source/developing.html>
* Docs: <https://source.android.com/source/using-repo.html>
@ -50,6 +50,6 @@ $ chmod a+rx ~/.bin/repo
```
[new-bug]: https://bugs.chromium.org/p/gerrit/issues/entry?template=Repo+tool+issue
[issue tracker]: https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo
[new-bug]: https://issues.gerritcodereview.com/issues/new?component=1370071
[issue tracker]: https://issues.gerritcodereview.com/issues?q=is:open%20componentid:1370071
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss

View File

@ -109,7 +109,8 @@ following DTD:
<!ATTLIST extend-project upstream CDATA #IMPLIED>
<!ELEMENT remove-project EMPTY>
<!ATTLIST remove-project name CDATA #REQUIRED>
<!ATTLIST remove-project name CDATA #IMPLIED>
<!ATTLIST remove-project path CDATA #IMPLIED>
<!ATTLIST remove-project optional CDATA #IMPLIED>
<!ELEMENT repo-hooks EMPTY>
@ -473,7 +474,7 @@ of the repo client.
### Element remove-project
Deletes the named project from the internal manifest table, possibly
Deletes a project from the internal manifest table, possibly
allowing a subsequent project element in the same manifest file to
replace the project with a different source.
@ -481,6 +482,17 @@ This element is mostly useful in a local manifest file, where
the user can remove a project, and possibly replace it with their
own definition.
The project `name` or project `path` can be used to specify the remove target
meaning one of them is required. If only name is specified, all
projects with that name are removed.
If both name and path are specified, only projects with the same name and
path are removed, meaning projects with the same name but in other
locations are kept.
If only path is specified, a matching project is removed regardless of its
name. Logic otherwise behaves like both are specified.
Attribute `optional`: Set to true to ignore remove-project elements with no
matching `project` element.

33
main.py
View File

@ -25,6 +25,7 @@ import netrc
import optparse
import os
import shlex
import signal
import sys
import textwrap
import time
@ -95,6 +96,7 @@ else:
file=sys.stderr,
)
KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
global_options = optparse.OptionParser(
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
@ -374,7 +376,11 @@ class _Repo(object):
git_trace2_event_log.StartEvent()
git_trace2_event_log.CommandEvent(name="repo", subcommands=[name])
try:
def execute_command_helper():
"""
Execute the subcommand.
"""
nonlocal result
cmd.CommonValidateOptions(copts, cargs)
cmd.ValidateOptions(copts, cargs)
@ -409,6 +415,23 @@ class _Repo(object):
if hasattr(copts, "manifest_branch"):
child_argv.extend(["--manifest-branch", spec.revision])
result = self._Run(name, gopts, child_argv) or result
def execute_command():
"""
Execute the command and log uncaught exceptions.
"""
try:
execute_command_helper()
except (KeyboardInterrupt, SystemExit, Exception) as e:
ok = isinstance(e, SystemExit) and not e.code
if not ok:
exception_name = type(e).__name__
git_trace2_event_log.ErrorEvent(
f"RepoExitError:{exception_name}")
raise
try:
execute_command()
except (
DownloadError,
ManifestInvalidRevisionError,
@ -448,6 +471,12 @@ class _Repo(object):
if e.code:
result = e.code
raise
except KeyboardInterrupt:
result = KEYBOARD_INTERRUPT_EXIT
raise
except Exception:
result = 1
raise
finally:
finish = time.time()
elapsed = finish - start
@ -813,7 +842,7 @@ def _Main(argv):
result = repo._Run(name, gopts, argv) or 0
except KeyboardInterrupt:
print("aborted by user", file=sys.stderr)
result = 1
result = KEYBOARD_INTERRUPT_EXIT
except ManifestParseError as mpe:
print("fatal: %s" % mpe, file=sys.stderr)
result = 1

View File

@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
.TH REPO "1" "November 2022" "repo" "Repo Manual"
.TH REPO "1" "June 2023" "repo" "Repo Manual"
.SH NAME
repo \- repository management tool built on top of git
.SH SYNOPSIS
@ -137,4 +137,4 @@ version
Display the version of repo
.PP
See 'repo help <command>' for more information on a specific command.
Bug reports: https://bugs.chromium.org/p/gerrit/issues/entry?template=Repo+tool+issue
Bug reports: https://issues.gerritcodereview.com/issues/new?component=1370071

View File

@ -981,6 +981,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
return self.manifestProject.clone_filter
return None
@property
def CloneFilterForDepth(self):
if self.manifestProject.clone_filter_for_depth:
return self.manifestProject.clone_filter_for_depth
return None
@property
def PartialCloneExclude(self):
exclude = self.manifest.manifestProject.partial_clone_exclude or ""
@ -1529,22 +1535,45 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
self._contactinfo = ContactInfo(bugurl)
if node.nodeName == "remove-project":
name = self._reqatt(node, "name")
name = node.getAttribute("name")
path = node.getAttribute("path")
if name in self._projects:
for p in self._projects[name]:
# Name or path needed.
if not name and not path:
raise ManifestParseError(
"remove-project must have name and/or path"
)
removed_project = ""
# Find and remove projects based on name and/or path.
for projname, projects in list(self._projects.items()):
for p in projects:
if name == projname and not path:
del self._paths[p.relpath]
if not removed_project:
del self._projects[name]
removed_project = name
elif path == p.relpath and (
name == projname or not name
):
self._projects[projname].remove(p)
del self._paths[p.relpath]
removed_project = p.name
# If the manifest removes the hooks project, treat it as if
# it deleted
# the repo-hooks element too.
if repo_hooks_project == name:
# it deleted the repo-hooks element too.
if (
removed_project
and removed_project not in self._projects
and repo_hooks_project == removed_project
):
repo_hooks_project = None
elif not XmlBool(node, "optional", False):
if not removed_project and not XmlBool(node, "optional", False):
raise ManifestParseError(
"remove-project element specifies non-existent "
"project: %s" % name
"project: %s" % node.toxml()
)
# Store repo hooks project information.

View File

@ -118,7 +118,10 @@ def _BecomePager(pager):
# available versions of 'less', a better 'more'.
_a, _b, _c = select.select([0], [], [0])
os.environ["LESS"] = "FRSX"
# This matches the behavior of git, which sets $LESS to `FRX` if it is not
# set. See:
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-corepager
os.environ.setdefault("LESS", "FRX")
try:
os.execvp(pager, [pager])

View File

@ -23,7 +23,7 @@ except ImportError:
from repo_trace import IsTraceToStderr
_NOT_TTY = not os.isatty(2)
_TTY = sys.stderr.isatty()
# This will erase all content in the current line (wherever the cursor is).
# It does not move the cursor, so this is usually followed by \r to move to
@ -97,7 +97,8 @@ class Progress(object):
self._start = time.time()
self._show = not delay
self._units = units
self._elide = elide
self._elide = elide and _TTY
# Only show the active jobs section if we run more than one in parallel.
self._show_jobs = False
self._active = 0
@ -129,7 +130,7 @@ class Progress(object):
def _write(self, s):
s = "\r" + s
if self._elide:
col = os.get_terminal_size().columns
col = os.get_terminal_size(sys.stderr.fileno()).columns
if len(s) > col:
s = s[: col - 1] + ".."
sys.stderr.write(s)
@ -157,7 +158,7 @@ class Progress(object):
msg = self._last_msg
self._last_msg = msg
if _NOT_TTY or IsTraceToStderr():
if not _TTY or IsTraceToStderr():
return
elapsed_sec = time.time() - self._start
@ -199,7 +200,7 @@ class Progress(object):
def end(self):
self._update_event.set()
if _NOT_TTY or IsTraceToStderr() or not self._show:
if not _TTY or IsTraceToStderr() or not self._show:
return
duration = duration_str(time.time() - self._start)

View File

@ -1186,6 +1186,7 @@ class Project(object):
ssh_proxy=None,
clone_filter=None,
partial_clone_exclude=set(),
clone_filter_for_depth=None,
):
"""Perform only the network IO portion of the sync process.
Local working directory/branch state is not affected.
@ -1295,6 +1296,10 @@ class Project(object):
else:
depth = self.manifest.manifestProject.depth
if depth and clone_filter_for_depth:
depth = None
clone_filter = clone_filter_for_depth
# See if we can skip the network fetch entirely.
remote_fetched = False
if not (
@ -3884,6 +3889,11 @@ class ManifestProject(MetaProject):
"""Partial clone exclude string"""
return self.config.GetString("repo.partialcloneexclude")
@property
def clone_filter_for_depth(self):
"""Replace shallow clone with partial clone."""
return self.config.GetString("repo.clonefilterfordepth")
@property
def manifest_platform(self):
"""The --platform argument from `repo init`."""
@ -3961,6 +3971,7 @@ class ManifestProject(MetaProject):
manifest_name=spec.manifestName,
this_manifest_only=True,
outer_manifest=False,
clone_filter_for_depth=mp.clone_filter_for_depth,
)
def Sync(
@ -3991,6 +4002,7 @@ class ManifestProject(MetaProject):
tags="",
this_manifest_only=False,
outer_manifest=True,
clone_filter_for_depth=None,
):
"""Sync the manifest and all submanifests.
@ -4035,6 +4047,8 @@ class ManifestProject(MetaProject):
current sub manifest.
outer_manifest: a boolean, whether to start at the outermost
manifest.
clone_filter_for_depth: a string, when specified replaces shallow
clones with partial.
Returns:
a boolean, whether the sync was successful.
@ -4297,6 +4311,9 @@ class ManifestProject(MetaProject):
file=sys.stderr,
)
if clone_filter_for_depth is not None:
self.ConfigureCloneFilterForDepth(clone_filter_for_depth)
if use_superproject is not None:
self.config.SetBoolean("repo.superproject", use_superproject)
@ -4311,6 +4328,7 @@ class ManifestProject(MetaProject):
submodules=submodules,
clone_filter=clone_filter,
partial_clone_exclude=self.manifest.PartialCloneExclude,
clone_filter_for_depth=self.manifest.CloneFilterForDepth,
).success
if not success:
r = self.GetRemote()
@ -4415,6 +4433,18 @@ class ManifestProject(MetaProject):
return True
def ConfigureCloneFilterForDepth(self, clone_filter_for_depth):
"""Configure clone filter to replace shallow clones.
Args:
clone_filter_for_depth: a string or None, e.g. 'blob:none' will
disable shallow clones and replace with partial clone. None will
enable shallow clones.
"""
self.config.SetString(
"repo.clonefilterfordepth", clone_filter_for_depth
)
def _ConfigureDepth(self, depth):
"""Configure the depth we'll sync down.

4
repo
View File

@ -146,10 +146,10 @@ REPO_REV = os.environ.get('REPO_REV')
if not REPO_REV:
REPO_REV = 'stable'
# URL to file bug reports for repo tool issues.
BUG_URL = 'https://bugs.chromium.org/p/gerrit/issues/entry?template=Repo+tool+issue'
BUG_URL = 'https://issues.gerritcodereview.com/issues/new?component=1370071'
# increment this whenever we make important changes to this script
VERSION = (2, 32)
VERSION = (2, 35)
# increment this if the MAINTAINER_KEYS block is modified
KEYRING_VERSION = (2, 3)

View File

@ -40,7 +40,7 @@ setuptools.setup(
long_description_content_type="text/plain",
url="https://gerrit.googlesource.com/git-repo/",
project_urls={
"Bug Tracker": "https://bugs.chromium.org/p/gerrit/issues/list?q=component:Applications%3Erepo", # noqa: E501
"Bug Tracker": "https://issues.gerritcodereview.com/issues?q=is:open%20componentid:1370071", # noqa: E501
},
# https://pypi.org/classifiers/
classifiers=[

View File

@ -20,6 +20,8 @@ from command import InteractiveCommand, MirrorSafeCommand
from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD
from wrapper import Wrapper
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
class Init(InteractiveCommand, MirrorSafeCommand):
COMMON = True
@ -125,6 +127,9 @@ to update the working directory files.
# manifest project is special and is created when instantiating the
# manifest which happens before we parse options.
self.manifest.manifestProject.clone_depth = opt.manifest_depth
clone_filter_for_depth = (
"blob:none" if (_REPO_ALLOW_SHALLOW == "0") else None
)
if not self.manifest.manifestProject.Sync(
manifest_url=opt.manifest_url,
manifest_branch=opt.manifest_branch,
@ -140,6 +145,7 @@ to update the working directory files.
partial_clone=opt.partial_clone,
clone_filter=opt.clone_filter,
partial_clone_exclude=opt.partial_clone_exclude,
clone_filter_for_depth=clone_filter_for_depth,
clone_bundle=opt.clone_bundle,
git_lfs=opt.git_lfs,
use_superproject=opt.use_superproject,

View File

@ -79,6 +79,8 @@ _ONE_DAY_S = 24 * 60 * 60
_REPO_AUTO_GC = "REPO_AUTO_GC"
_AUTO_GC = os.environ.get(_REPO_AUTO_GC) == "1"
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
class _FetchOneResult(NamedTuple):
"""_FetchOne return value.
@ -638,6 +640,7 @@ later is required to fix a server side protocol bug.
ssh_proxy=self.ssh_proxy,
clone_filter=project.manifest.CloneFilter,
partial_clone_exclude=project.manifest.PartialCloneExclude,
clone_filter_for_depth=project.manifest.CloneFilterForDepth,
)
success = sync_result.success
remote_fetched = sync_result.remote_fetched
@ -674,18 +677,22 @@ later is required to fix a server side protocol bug.
cls.ssh_proxy = ssh_proxy
def _GetSyncProgressMessage(self):
if len(self._sync_dict) == 0:
return None
earliest_time = float("inf")
earliest_proj = None
for project, t in self._sync_dict.items():
items = self._sync_dict.items()
for project, t in items:
if t < earliest_time:
earliest_time = t
earliest_proj = project
if not earliest_proj:
# This function is called when sync is still running but in some
# cases (by chance), _sync_dict can contain no entries. Return some
# text to indicate that sync is still working.
return "..working.."
elapsed = time.time() - earliest_time
jobs = jobs_str(len(self._sync_dict))
jobs = jobs_str(len(items))
return f"{jobs} | {elapsed_str(elapsed)} {earliest_proj}"
def _Fetch(self, projects, opt, err_event, ssh_proxy):
@ -1440,6 +1447,7 @@ later is required to fix a server side protocol bug.
submodules=mp.manifest.HasSubmodules,
clone_filter=mp.manifest.CloneFilter,
partial_clone_exclude=mp.manifest.PartialCloneExclude,
clone_filter_for_depth=mp.manifest.CloneFilterForDepth,
)
finish = time.time()
self.event_log.AddSync(
@ -1589,6 +1597,15 @@ later is required to fix a server side protocol bug.
_PostRepoUpgrade(manifest, quiet=opt.quiet)
mp = manifest.manifestProject
if _REPO_ALLOW_SHALLOW is not None:
if _REPO_ALLOW_SHALLOW == "1":
mp.ConfigureCloneFilterForDepth(None)
elif (
_REPO_ALLOW_SHALLOW == "0" and mp.clone_filter_for_depth is None
):
mp.ConfigureCloneFilterForDepth("blob:none")
if opt.mp_update:
self._UpdateAllManifestProjects(opt, mp, manifest_name)
else:
@ -1659,6 +1676,13 @@ later is required to fix a server side protocol bug.
err_update_projects = False
err_update_linkfiles = False
# Log the repo projects by existing and new.
existing = [x for x in all_projects if x.Exists]
mp.config.SetString("repo.existingprojectcount", str(len(existing)))
mp.config.SetString(
"repo.newprojectcount", str(len(all_projects) - len(existing))
)
self._fetch_times = _FetchTimes(manifest)
if not opt.local_only:
with multiprocessing.Manager() as manager:

View File

@ -996,6 +996,44 @@ class RemoveProjectElementTests(ManifestParseTestCase):
)
self.assertEqual(manifest.projects, [])
def test_remove_using_path_attrib(self):
manifest = self.getXmlManifest(
"""
<manifest>
<remote name="default-remote" fetch="http://localhost" />
<default remote="default-remote" revision="refs/heads/main" />
<project name="project1" path="tests/path1" />
<project name="project1" path="tests/path2" />
<project name="project2" />
<project name="project3" />
<project name="project4" path="tests/path3" />
<project name="project4" path="tests/path4" />
<project name="project5" />
<project name="project6" path="tests/path6" />
<remove-project name="project1" path="tests/path2" />
<remove-project name="project3" />
<remove-project name="project4" />
<remove-project path="project5" />
<remove-project path="tests/path6" />
</manifest>
"""
)
found_proj1_path1 = False
found_proj2 = False
for proj in manifest.projects:
if proj.name == "project1":
found_proj1_path1 = True
self.assertEqual(proj.relpath, "tests/path1")
if proj.name == "project2":
found_proj2 = True
self.assertNotEqual(proj.name, "project3")
self.assertNotEqual(proj.name, "project4")
self.assertNotEqual(proj.name, "project5")
self.assertNotEqual(proj.name, "project6")
self.assertTrue(found_proj1_path1)
self.assertTrue(found_proj2)
class ExtendProjectElementTests(ManifestParseTestCase):
"""Tests for <extend-project>."""