From e75587e782a1192b78530e73ab120b066354e8e3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 10:50:43 +0000 Subject: [PATCH 1/2] adopt-project.py: an `org/repo` `--source` too long for the disk is refused as a path is, naming no GitHub repository and offering no url, so `--source <300 a's>/Thing` is no longer offered `https://github.com/<300 a's>/Thing`, which can never clone (#1003) `plan --source <300 a's>/Thing` is refused `source-unreadable`, "cannot be read (File name too long)" (#970). It also said "whether it names the path `./<300 a's>/Thing` here, or only the GitHub repository `<300 a's>/Thing`, cannot be told", and offered `https://github.com/<300 a's>/Thing`. Neither can be meant: a part longer than a file name may be is longer than GitHub lets an owner (39) or a repository (100) be named, so that url can never clone. `_source_there` now refuses an `ENAMETOOLONG` as it refuses a path: "so whether a repository is there cannot be told", with the cure for a cause other than permission, and no url. The same holds for the `github.com/` and `www.github.com/` spellings, and for a long repository part beside a `./` directory. Any other refused `stat` of a name (a permission, an I/O error) still names both readings and offers the url. Part (a) of #1003, a source or recorded clone directory this user cannot enter, already landed on main in #1072 (see #918). It is measured again here and not changed. Closes #1003 Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 13 ++- tests/test_adopt_unsearchable_source.py | 101 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 556dbc53..b6a21286 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1989,11 +1989,20 @@ def _source_there(spec: str, candidate: Path, name: str | None) -> bool: (`acme/Atlas` beside an unsearchable `./acme`) was cloned from GitHub, where `source-ambiguous` refuses it once the path can be seen. Which of the two it names cannot be told, so neither is read, and the url is - offered for the repository, as `source-ambiguous` offers it.""" + offered for the repository, as `source-ambiguous` offers it. + + A NAME TOO LONG FOR THE DISK (`ENAMETOOLONG`) NAMES NO GITHUB REPOSITORY + EITHER (#1003), and is refused as a path is, with no url. A part of it + is longer than a file system lets a file name be, which on any this tool + runs on is more than GitHub lets an owner (`GITHUB_OWNER_MAX`) or a + repository (`GITHUB_NAME_MAX`) be named. `--source <300 a's>/Thing` was + "whether it names the path ... here, or only the GitHub repository ..., + cannot be told", when neither can be, and was offered + `https://github.com/<300 a's>/Thing`, which can never clone.""" try: return _is_there(candidate) except OSError as exc: - if name is None: + if name is None or exc.errno == errno.ENAMETOOLONG: raise _source_unreadable(f"--source {spec!r}", exc, "whether a repository is there") from exc here = _spelled("./" + spec.rstrip("/")) diff --git a/tests/test_adopt_unsearchable_source.py b/tests/test_adopt_unsearchable_source.py index a742f5be..34ad3c93 100644 --- a/tests/test_adopt_unsearchable_source.py +++ b/tests/test_adopt_unsearchable_source.py @@ -341,6 +341,107 @@ def test_an_org_repo_beside_an_unsearchable_directory_is_not_sent_to_github( assert not (tmp_path / "work" / "source-readonly").exists() +# --- an `org/repo` spelling too long for the disk (#1003) -------------------- + +#: A part of a spelling longer than a file system lets a file name be, and +#: so longer than GitHub lets an owner or a repository be named +#: (`GITHUB_OWNER_MAX`, `GITHUB_NAME_MAX`): `stat` is `File name too long`. +LONG = "a" * 300 +TOO_LONG = f"cannot be read ({os.strerror(errno.ENAMETOOLONG)})" +needs_a_name_too_long = pytest.mark.skipif( + sys.platform == "win32", + reason="`File name too long` (ENAMETOOLONG) is a POSIX `stat`'s answer " + "to a name over the file system's limit; Windows answers it with " + "errors of its own") + + +def too_long_here(path: Path) -> None: + """`stat` of `path`, relative to the directory the test runs in, is + refused with `File name too long`, asserted so a file system that took + the name would not pass a test here for the wrong reason.""" + with pytest.raises(OSError) as raised: + path.stat() + assert raised.value.errno == errno.ENAMETOOLONG, raised.value + + +@needs_a_name_too_long +@pytest.mark.parametrize(("spec", "holder", "name"), [ + pytest.param(f"{LONG}/{PROJECT}", None, f"{LONG}/{PROJECT}", id="owner"), + pytest.param(f"{ORG}/{LONG}", ORG, f"{ORG}/{LONG}", id="repository"), + pytest.param(f"github.com/{LONG}/{PROJECT}", "github.com", + f"{LONG}/{PROJECT}", id="bare-github-url"), + pytest.param(f"www.github.com/{LONG}/{PROJECT}", "www.github.com", + f"{LONG}/{PROJECT}", id="www-github-url"), +]) +def test_a_spelling_too_long_for_the_disk_is_offered_no_url( + adopter, monkeypatch, tmp_path, spec, holder, name): + """`--source <300 a's>/Thing` is `source-unreadable`, `File name too + long` (#430's slice H), and was told "whether it names the path ... + here, or only the GitHub repository ..., cannot be told", with + `https://github.com/<300 a's>/Thing` offered: a url that can never clone, + for a name GitHub gives no owner or repository (#1003). It is refused + as a path too long is, and offered no url. `holder` is the directory + the spelling's first part names here, made so the `stat` reaches the + long part; `name` is the GitHub repository the spelling names.""" + monkeypatch.chdir(tmp_path) + if holder is not None: + (tmp_path / holder).mkdir() + too_long_here(Path(spec)) + + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(spec) + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert refusal.detail == (f"--source {spec!r}: {TOO_LONG}, so whether a " + "repository is there cannot be told") + assert refusal.remediation.endswith( + "which no `chmod` puts right: put right what it names. Then run the " + "same command again."), refusal.remediation + assert "https://" not in refusal.remediation + + # The control: the same spelling, its `stat` refused for another reason + # than its length (an I/O error), still leaves both readings open and + # offers the url. Only a name too long loses it. + def refusing(self, *args, **kwargs): + raise OSError(errno.EIO, os.strerror(errno.EIO), str(self)) + + monkeypatch.setattr(Path, "stat", refusing) + with pytest.raises(adopter.Refusal) as control: + adopter._source_spec(spec) + assert control.value.code == "source-unreadable" + assert (f"or only the GitHub repository `{name}`, cannot be told" + in control.value.detail) + assert (f"pass `https://github.com/{name}` to clone the repository from " + "GitHub") in control.value.remediation + + +@needs_a_name_too_long +def test_plan_of_a_spelling_too_long_for_the_disk_offers_no_url(tmp_path): + """The same, as `plan` is run: `source-unreadable`, exit 2, naming no + GitHub repository and offering no url; no plan is written, nothing is + made and `gh` is never asked.""" + here = tmp_path / "here" + here.mkdir() + spec = f"{LONG}/{PROJECT}" + plan = tmp_path / "adoption-plan.yaml" + env, log = recording_gh(tmp_path / "gh-bin") + + proc = run_script(ADOPT, "plan", "--source", spec, *PLANNED, "--out", + str(plan), "--work-dir", str(tmp_path / "work"), + cwd=here, env=env) + + assert proc.returncode == 2, said(proc) + assert (f"{UNREADABLE}--source {spec!r}: {TOO_LONG}, so whether a " + "repository is there cannot be told\n") in proc.stderr + assert "GitHub repository" not in said(proc) + assert "https://" not in said(proc) + assert_no_false_answer(said(proc)) + assert not plan.exists() + assert not (tmp_path / "work").exists() + assert log.read_bytes() == b"" + + # --- a recorded source this user cannot reach (:5735) ------------------------ def test_a_recorded_clone_whose_stat_is_refused_is_not_cloned_again( From c01c4d73d808ef2a55c3d68ee201cad50d87244a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 15:27:49 +0000 Subject: [PATCH 2/2] adopt-project.py: an `org/repo` `--source` whose `stat` is refused is offered its url only where GitHub can hold the name, so `testorg/<300 a's>` beside an unsearchable `./testorg` is offered no url, and `testorg/Thing` through a link too long keeps `https://github.com/testorg/Thing` (#1003) RV1122's SHOULD-1. #1122's first head refused a name as a path, with no url, where its `stat` was `File name too long`. It asked the errno, not the name, and that went wrong both ways: - `testorg/<300 a's>` beside a `./testorg` at mode 000 stops at `Permission denied` before the long part, and was still offered `https://github.com/testorg/<300 a's>`, which can never clone. - `testorg/Thing`, with `./testorg` a symlink to a name too long, is `File name too long` because of the link's destination. It lost the url of a repository GitHub can hold, which main offered. `_source_there` now decides by GitHub's limits, `GITHUB_OWNER_MAX` (39) and `GITHUB_NAME_MAX` (100), which the file already imports. An owner or a repository over them is refused as a path is, whatever reason the operating system gave, with the cure for that reason and no url. Any other name keeps both readings and its url. A part too long for the disk is over both limits, so `<300 a's>/Thing` keeps the first head's answer. The four controls now refuse a name as long as GitHub allows (an owner of 39, a repository of 100) with an I/O error, and expect its url. New rows: a name one over each limit, or too long for the disk, refused with an I/O error or a permission, offers no url (10 rows, in-process); the symlink keeps its url; and `plan` beside a mode-000 `./testorg` offers none. An owner of 40 to 255 characters, or a repository of 101 to 255, with nothing on disk is still handed to `gh`; see #1129. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 24 +-- tests/test_adopt_unsearchable_source.py | 187 ++++++++++++++++++++---- 2 files changed, 175 insertions(+), 36 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index b6a21286..793ad991 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1991,18 +1991,24 @@ def _source_there(spec: str, candidate: Path, name: str | None) -> bool: the two it names cannot be told, so neither is read, and the url is offered for the repository, as `source-ambiguous` offers it. - A NAME TOO LONG FOR THE DISK (`ENAMETOOLONG`) NAMES NO GITHUB REPOSITORY - EITHER (#1003), and is refused as a path is, with no url. A part of it - is longer than a file system lets a file name be, which on any this tool - runs on is more than GitHub lets an owner (`GITHUB_OWNER_MAX`) or a - repository (`GITHUB_NAME_MAX`) be named. `--source <300 a's>/Thing` was - "whether it names the path ... here, or only the GitHub repository ..., - cannot be told", when neither can be, and was offered - `https://github.com/<300 a's>/Thing`, which can never clone.""" + A NAME GITHUB CANNOT HOLD NAMES NO GITHUB REPOSITORY EITHER (#1003): an + owner over `GITHUB_OWNER_MAX` characters, or a repository over + `GITHUB_NAME_MAX`. It is refused as a path is, with no url, whatever + reason the operating system gave. `--source <300 a's>/Thing`, `File + name too long`, was "whether it names the path ... here, or only the + GitHub repository ..., cannot be told", and was offered + `https://github.com/<300 a's>/Thing`, which can never clone; and so was + `testorg/<300 a's>` beside a `./testorg` this user cannot search, whose + `stat` stops at `Permission denied` before it reaches the long part. The + name decides, never the reason: `testorg/Thing`, with `./testorg` a + symlink to a name too long, is `File name too long` as well, and names + a repository GitHub can hold, so its url is offered.""" try: return _is_there(candidate) except OSError as exc: - if name is None or exc.errno == errno.ENAMETOOLONG: + owner, _, repo = (name or "").partition("/") + if (name is None or len(owner) > GITHUB_OWNER_MAX + or len(repo) > GITHUB_NAME_MAX): raise _source_unreadable(f"--source {spec!r}", exc, "whether a repository is there") from exc here = _spelled("./" + spec.rstrip("/")) diff --git a/tests/test_adopt_unsearchable_source.py b/tests/test_adopt_unsearchable_source.py index 34ad3c93..c016e7b2 100644 --- a/tests/test_adopt_unsearchable_source.py +++ b/tests/test_adopt_unsearchable_source.py @@ -341,13 +341,19 @@ def test_an_org_repo_beside_an_unsearchable_directory_is_not_sent_to_github( assert not (tmp_path / "work" / "source-readonly").exists() -# --- an `org/repo` spelling too long for the disk (#1003) -------------------- +# --- an `org/repo` spelling GitHub cannot hold (#1003) ----------------------- #: A part of a spelling longer than a file system lets a file name be, and #: so longer than GitHub lets an owner or a repository be named #: (`GITHUB_OWNER_MAX`, `GITHUB_NAME_MAX`): `stat` is `File name too long`. LONG = "a" * 300 TOO_LONG = f"cannot be read ({os.strerror(errno.ENAMETOOLONG)})" +#: What GitHub keeps an owner's name and a repository's to, as +#: tests/test_github_name_limits.py writes them. +OWNER_MAX, NAME_MAX = 39, 100 +#: The end of `source-unreadable`'s remedy for a cause other than permission. +OTHER_CURE = ("which no `chmod` puts right: put right what it names. Then run " + "the same command again") needs_a_name_too_long = pytest.mark.skipif( sys.platform == "win32", reason="`File name too long` (ENAMETOOLONG) is a POSIX `stat`'s answer " @@ -364,25 +370,53 @@ def too_long_here(path: Path) -> None: assert raised.value.errno == errno.ENAMETOOLONG, raised.value +def refused_for(monkeypatch, code: int) -> None: + """`Path.stat` refused with `code` for every path, as the operating + system refuses it: `EACCES` is a `PermissionError`, `EIO` an `OSError`. + In-process, so it runs on every platform and as root.""" + def refusing(self, *args, **kwargs): + raise OSError(code, os.strerror(code), str(self)) + + monkeypatch.setattr(Path, "stat", refusing) + + +def assert_both_readings_and_the_url(adopter, spec: str, name: str) -> None: + """`--source spec`, its `stat` refused, names a repository GitHub can + hold: `source-unreadable` leaves both readings open and offers the url, + as `source-ambiguous` offers it (#430).""" + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(spec) + refusal = raised.value + assert refusal.code == "source-unreadable" + assert (f"or only the GitHub repository `{name}`, cannot be told" + in refusal.detail), refusal.detail + assert (f"pass `https://github.com/{name}` to clone the repository from " + "GitHub.") in refusal.remediation, refusal.remediation + + @needs_a_name_too_long -@pytest.mark.parametrize(("spec", "holder", "name"), [ - pytest.param(f"{LONG}/{PROJECT}", None, f"{LONG}/{PROJECT}", id="owner"), - pytest.param(f"{ORG}/{LONG}", ORG, f"{ORG}/{LONG}", id="repository"), - pytest.param(f"github.com/{LONG}/{PROJECT}", "github.com", - f"{LONG}/{PROJECT}", id="bare-github-url"), - pytest.param(f"www.github.com/{LONG}/{PROJECT}", "www.github.com", - f"{LONG}/{PROJECT}", id="www-github-url"), +@pytest.mark.parametrize(("written", "holder", "names", "limit"), [ + pytest.param("{}/" + PROJECT, None, "{}/" + PROJECT, OWNER_MAX, + id="owner"), + pytest.param(ORG + "/{}", ORG, ORG + "/{}", NAME_MAX, id="repository"), + pytest.param("github.com/{}/" + PROJECT, "github.com", "{}/" + PROJECT, + OWNER_MAX, id="bare-github-url"), + pytest.param("www.github.com/{}/" + PROJECT, "www.github.com", + "{}/" + PROJECT, OWNER_MAX, id="www-github-url"), ]) def test_a_spelling_too_long_for_the_disk_is_offered_no_url( - adopter, monkeypatch, tmp_path, spec, holder, name): + adopter, monkeypatch, tmp_path, written, holder, names, limit): """`--source <300 a's>/Thing` is `source-unreadable`, `File name too long` (#430's slice H), and was told "whether it names the path ... here, or only the GitHub repository ..., cannot be told", with `https://github.com/<300 a's>/Thing` offered: a url that can never clone, for a name GitHub gives no owner or repository (#1003). It is refused - as a path too long is, and offered no url. `holder` is the directory - the spelling's first part names here, made so the `stat` reaches the - long part; `name` is the GitHub repository the spelling names.""" + as a path too long is, and offered no url. `written` spells the source + with `{}` for the long part, and `names` the GitHub repository it names; + `limit` is what GitHub keeps that part to. `holder` is the directory the + spelling's first part names here, made so the `stat` reaches the long + part.""" + spec = written.format(LONG) monkeypatch.chdir(tmp_path) if holder is not None: (tmp_path / holder).mkdir() @@ -395,25 +429,61 @@ def test_a_spelling_too_long_for_the_disk_is_offered_no_url( assert refusal.code == "source-unreadable" assert refusal.detail == (f"--source {spec!r}: {TOO_LONG}, so whether a " "repository is there cannot be told") - assert refusal.remediation.endswith( - "which no `chmod` puts right: put right what it names. Then run the " - "same command again."), refusal.remediation + assert refusal.remediation.endswith(OTHER_CURE + "."), refusal.remediation assert "https://" not in refusal.remediation - # The control: the same spelling, its `stat` refused for another reason - # than its length (an I/O error), still leaves both readings open and - # offers the url. Only a name too long loses it. - def refusing(self, *args, **kwargs): - raise OSError(errno.EIO, os.strerror(errno.EIO), str(self)) + # The control: the part as long as GitHub lets it be, its `stat` refused + # for another reason (an I/O error), names a repository GitHub can hold, + # so both readings stay open and its url is offered. + refused_for(monkeypatch, errno.EIO) + assert_both_readings_and_the_url(adopter, written.format("a" * limit), + names.format("a" * limit)) + + +@pytest.mark.parametrize("code", [errno.EIO, errno.EACCES], + ids=["EIO", "EACCES"]) +@pytest.mark.parametrize(("written", "names", "limit", "length"), [ + pytest.param("{}/" + PROJECT, "{}/" + PROJECT, OWNER_MAX, OWNER_MAX + 1, + id="owner-one-over"), + pytest.param("{}/" + PROJECT, "{}/" + PROJECT, OWNER_MAX, len(LONG), + id="owner-too-long-for-the-disk"), + pytest.param(ORG + "/{}", ORG + "/{}", NAME_MAX, NAME_MAX + 1, + id="repository-one-over"), + pytest.param(ORG + "/{}", ORG + "/{}", NAME_MAX, len(LONG), + id="repository-too-long-for-the-disk"), + pytest.param("github.com/{}/" + PROJECT, "{}/" + PROJECT, OWNER_MAX, + OWNER_MAX + 1, id="bare-github-url-owner-one-over"), +]) +def test_a_name_github_cannot_hold_is_offered_no_url_whatever_the_reason( + adopter, monkeypatch, tmp_path, code, written, names, limit, length): + """An owner over 39 characters, or a repository over 100, is no name + GitHub holds (#1003), whatever the operating system refused the `stat` + for. #1122's first head read the reason, not the name: only `File name + too long` lost the url, so `testorg/<300 a's>` beside a `./testorg` this + user cannot search, which stops at `Permission denied` before the long + part, was offered `https://github.com/testorg/<300 a's>`, which can never + clone. Now it is refused as a path is, with the cure for its cause and + no url. The control: the part as long as GitHub lets it be, refused + alike, keeps both readings and its url.""" + monkeypatch.chdir(tmp_path) + refused_for(monkeypatch, code) + spec = written.format("a" * length) - monkeypatch.setattr(Path, "stat", refusing) - with pytest.raises(adopter.Refusal) as control: + with pytest.raises(adopter.Refusal) as raised: adopter._source_spec(spec) - assert control.value.code == "source-unreadable" - assert (f"or only the GitHub repository `{name}`, cannot be told" - in control.value.detail) - assert (f"pass `https://github.com/{name}` to clone the repository from " - "GitHub") in control.value.remediation + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert refusal.detail == (f"--source {spec!r}: cannot be read " + f"({os.strerror(code)}), so whether a " + "repository is there cannot be told") + assert refusal.remediation.endswith( + (CURE if code == errno.EACCES else OTHER_CURE) + "." + ), refusal.remediation + assert "https://" not in refusal.remediation + + assert_both_readings_and_the_url(adopter, written.format("a" * limit), + names.format("a" * limit)) @needs_a_name_too_long @@ -442,6 +512,69 @@ def test_plan_of_a_spelling_too_long_for_the_disk_offers_no_url(tmp_path): assert log.read_bytes() == b"" +@needs_a_name_too_long +@needs_a_symlink +def test_a_name_github_can_hold_keeps_its_url_through_a_link_too_long( + adopter, monkeypatch, tmp_path): + """`testorg/Thing`, with `./testorg` a symlink to a name too long: the + `stat` is `File name too long`, from the link's destination and not from + the spelling, which names a repository GitHub can hold. #1122's first + head read the reason and refused it as a path, with no url; it names + both readings again, as main did, and offers + `https://github.com/testorg/Thing`.""" + monkeypatch.chdir(tmp_path) + os.symlink(LONG, ORG) + too_long_here(Path(REPOSITORY)) + + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(REPOSITORY) + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert refusal.detail == ( + f"--source {REPOSITORY!r}: {TOO_LONG}, so whether it names the path " + f"`./{REPOSITORY}` here, or only the GitHub repository " + f"`{REPOSITORY}`, cannot be told") + assert refusal.remediation.endswith( + f"{OTHER_CURE}; or pass `{URL}` to clone the repository from GitHub." + ), refusal.remediation + + +@needs_a_mode +def test_plan_of_a_name_github_cannot_hold_in_an_unsearchable_place( + tmp_path): + """`plan --source testorg/<300 a's>` beside a `./testorg` at mode 000: + the `stat` stops at `Permission denied` before it reaches the long part, + and #1122's first head, which read the reason and not the name, offered + `https://github.com/testorg/<300 a's>`, a url that can never clone + (#1003). Now `source-unreadable` as a path is, with the cure for a + permission, naming no GitHub repository and offering no url; no plan is + written, nothing is made and `gh` is never asked.""" + here = tmp_path / "here" + (here / ORG).mkdir(parents=True) + spec = f"{ORG}/{LONG}" + plan = tmp_path / "adoption-plan.yaml" + env, log = recording_gh(tmp_path / "gh-bin") + + with unsearchable(here / ORG): + with pytest.raises(PermissionError): + (here / spec).stat() + proc = run_script(ADOPT, "plan", "--source", spec, *PLANNED, "--out", + str(plan), "--work-dir", str(tmp_path / "work"), + cwd=here, env=env) + + assert proc.returncode == 2, said(proc) + assert (f"{UNREADABLE}--source {spec!r}: {DENIED}, so whether a " + "repository is there cannot be told\n") in proc.stderr + assert CURE in proc.stderr + assert "GitHub repository" not in said(proc) + assert "https://" not in said(proc) + assert_no_false_answer(said(proc)) + assert not plan.exists() + assert not (tmp_path / "work").exists() + assert log.read_bytes() == b"" + + # --- a recorded source this user cannot reach (:5735) ------------------------ def test_a_recorded_clone_whose_stat_is_refused_is_not_cloned_again(