From e75587e782a1192b78530e73ab120b066354e8e3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 10:50:43 +0000 Subject: [PATCH 1/6] 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/6] 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( From 900f77af92ff61f38652d8289241f4a0875df83d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:52:18 +0000 Subject: [PATCH 3/6] adopt-project.py: `_github_name` reads no owner over 39 characters or repository over 100, so `plan --source <50 b's>/Thing` never asks `gh` for a name GitHub cannot hold, no remedy offers `--org <50 b's>`, and `check` and `execute` never clone such a record again (#1129) GitHub gives an owner at most GITHUB_OWNER_MAX (39) characters and a repository at most GITHUB_NAME_MAX (100). `_github_name` now gives no name past either, counted once the `.git` is dropped, so every reader of it answers alike: `_source_spec` reads such a spelling as a path (in place where it is one, `source-unresolvable` where it is not), `_repository_in_url` refuses a url of it, and `_recorded_repository` and `_held_repository` read a plan's record of it as naming no repository. Stacked on #1122 (fix/1003-adopt-unenterable-source): its guard on the two limits in `_source_there` is folded into this one question, and its three `github.com/` rows move to the new file, since such a url is now refused before any `stat`. New rows: tests/test_adopt_source_github_limits.py (48). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 51 ++- tests/test_adopt_source_github_limits.py | 481 +++++++++++++++++++++++ tests/test_adopt_unsearchable_source.py | 10 +- 3 files changed, 522 insertions(+), 20 deletions(-) create mode 100644 tests/test_adopt_source_github_limits.py diff --git a/adopt-project.py b/adopt-project.py index 793ad991..e03bfaf8 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1929,7 +1929,26 @@ def _github_name(written: str) -> str | None: `source-unclonable`, with a remedy about `gh auth status`. GitHub reads a repository's name without regard to case, and so does a disk that ignores case, where `source-readonly/.GIT` is `source-readonly/.git`; - so the trailing `.git` is dropped in any case too.""" + so the trailing `.git` is dropped in any case too. + + NOR IS EITHER PART LONGER THAN GITHUB LETS IT BE (#1129): an owner of + more than `GITHUB_OWNER_MAX` characters, the longest name GitHub gives + a user or an organisation, or a repository of more than + `GITHUB_NAME_MAX`, counted once the `.git` is dropped. Such a spelling + names nothing GitHub holds, and every reader of this answer now says + so alike. On main `plan --source <50 b's>/Thing`, with nothing there, + was handed to `gh repo clone`; with `--org testorg` it was refused + `adopt-not-the-source` and told to pass `--org <50 b's>`, which + `execute` refuses; and a plan that recorded it as `source.repository` + was cloned from it again by `check` and `execute`, or held to it with + the same `--org` for a remedy. Now it is a path, read in place where it + is one, as any other spelling that names no `owner/repo` is + (`_source_spec`), and refused where it is not; a url of it names no + `owner/repo` on GitHub (`_repository_in_url`); a plan's record of it + names no repository (`_recorded_repository`, `_held_repository`); and + beside a directory this user cannot search it is offered no url + (`_source_there`, #1003). Asked of the name and not of the disk, it + holds on Windows as well, where no error is `File name too long`.""" segments = [part for part in written.split("/") if part] if len(segments) != 2: return None @@ -1940,6 +1959,8 @@ def _github_name(written: str) -> str | None: GITHUB_SEGMENT_RE.fullmatch(part) and part not in (".", "..") for part in (owner, repo)): return None + if len(owner) > GITHUB_OWNER_MAX or len(repo) > GITHUB_NAME_MAX: + return None return f"{owner}/{repo}" @@ -1993,22 +2014,24 @@ def _source_there(spec: str, candidate: Path, name: str | None) -> bool: 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.""" + `GITHUB_NAME_MAX`. `_github_name` gives no `name` for it (#1129), the + one question every reader of a name asks, so here it is refused as a + path is, with no url, whatever reason the operating system gave; and a + `github.com/` spelling of it never reaches here, since + `_repository_in_url` refuses it first, as naming no `owner/repo`. + `--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: - owner, _, repo = (name or "").partition("/") - if (name is None or len(owner) > GITHUB_OWNER_MAX - or len(repo) > GITHUB_NAME_MAX): + if name is None: 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_source_github_limits.py b/tests/test_adopt_source_github_limits.py new file mode 100644 index 00000000..efeaa57d --- /dev/null +++ b/tests/test_adopt_source_github_limits.py @@ -0,0 +1,481 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`adopt-project.py`: an `org/repo` GitHub cannot hold names no GitHub +repository, for every reader of the name (#1129, RV1122's NIT-1 on #1122). + +GitHub gives a user or an organisation a name of at most 39 characters +(`GITHUB_OWNER_MAX`) and a repository one of at most 100 (`GITHUB_NAME_MAX`), +as `tests/test_github_name_limits.py` holds them. `_github_name`, the one +reading of an `owner/repo`, asked neither. AT #1122's HEAD c01c4d73 +(2026-10-22), Python 3.12.3, uid 1000, with a `gh` first on PATH that logs +each call and refuses it, in an empty directory: + +- `plan --source <50 b's>/Thing --org <50 b's>` asked `gh repo clone <50 + b's>/Thing` (1 call), and so did an owner of 40, a repository of 101 (or + of 101 and `.git`), both at once, and `https://github.com/<40 c's>/Thing`; +- `plan --source <50 b's>/Thing --org testorg` was refused + `adopt-not-the-source` and told to run `plan` again with `--org <50 b's>`, + which `execute` refuses as `adopt-org-too-long`; +- a plan whose clone is gone and whose `source.repository` records such a + name was cloned from it again by `check` and `execute`; a plan whose + source names no repository was held to it, with the same `--org` for a + remedy; and `--source ` given to either was cloned as `plan` clones it; +- a directory `./<40 c's>/Thing` here was `source-ambiguous`, as if the + spelling also named a repository on GitHub. + +Now `_github_name` gives no name for an owner over 39 or a repository over +100, counted once the `.git` is dropped, and every reader says so alike: the +spelling is a path, read in place where it is one and `source-unresolvable` +where it is not; a url of it names no `owner/repo` on github.com; and a plan's +record of it names no repository. `gh` is never asked. The rows at each limit +are the controls: still GitHub's names, still asked of `gh`. + +A `github.com/` SPELLING PAST THE LIMITS NEVER REACHES THE DISK. #1122 refused +`github.com/<300 a's>/Thing` beside a `./github.com`, and `github.com/<40 a's> +/Thing` whose `stat` was refused, in `_source_there`, as a path is, with no +url. `_repository_in_url` refuses each first now, as a url that names no +`owner/repo`; those rows of tests/test_adopt_unsearchable_source.py are here, +with that answer. + +POSIX ONLY. The `gh` each child runs is a `/bin/sh` script that logs its +arguments (`recording_gh`), put ahead of the suite's own guard; nothing here +reaches GitHub, and an `execute` is given `--local-remote-dir` throughout. +""" + +from __future__ import annotations + +import errno +import sys +from pathlib import Path + +import pytest + +from conftest import (ADOPT, git, load_without_bytecode, make_source_repo, + run_script) +from test_adopt_project_is_the_source import edited +from test_adopt_temp_work_roots import ( + ORG, PROJECT, REPOSITORY, SMALL_TREE, needs_filter_repo, recorded, +) +from test_adopt_unsearchable_source import ( + LONG, assert_both_readings_and_the_url, planned_locally, recording_gh, + refused_for, said, too_long_here, +) + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="the `gh` that logs each call is a /bin/sh script, and a part " + "too long for the disk is a POSIX `stat`'s `File name too long`") + + +@pytest.fixture(scope="module") +def adopter(): + return load_without_bytecode(ADOPT, "adopt_source_github_limits") + +#: 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 +OWNER_AT, OWNER_OVER = "c" * OWNER_MAX, "c" * (OWNER_MAX + 1) +NAME_AT, NAME_OVER = "r" * NAME_MAX, "r" * (NAME_MAX + 1) +#: The issue's own owner. +ISSUE_OWNER = "b" * 50 +URL = "https://github.com/" +#: How `_source_spec` refuses a spelling that is neither, and how +#: `_repository_in_url` refuses a url that names no `owner/repo`. +NEITHER = "is neither a path that exists nor an `org/repo` name" +NO_NAME_ON_GITHUB = "names no `owner/repo` on github.com" +UNRESOLVABLE = "REFUSED source-unresolvable: " + +#: (`--source`, the owner and the repository GitHub would be asked for). +#: Each is a name GitHub can hold: `gh repo clone` is asked for it. +TAKEN = [ + pytest.param(f"{OWNER_AT}/{PROJECT}", OWNER_AT, PROJECT, id="owner-39"), + pytest.param(f"{ORG}/{NAME_AT}", ORG, NAME_AT, id="repository-100"), + pytest.param(f"{ORG}/{NAME_AT}.git", ORG, NAME_AT, + id="repository-100-dot-git"), + pytest.param(f"{OWNER_AT}/{NAME_AT}", OWNER_AT, NAME_AT, id="both-at"), + pytest.param(f"{URL}{OWNER_AT}/{PROJECT}", OWNER_AT, PROJECT, + id="url-owner-39"), + pytest.param(f"github.com/{ORG}/{NAME_AT}", ORG, NAME_AT, + id="bare-url-repository-100"), +] +#: (`--source`, the owner and repository it spells, how it is refused). +#: Each is past a limit GitHub keeps: no `gh` call is made. +REFUSED = [ + pytest.param(f"{OWNER_OVER}/{PROJECT}", OWNER_OVER, PROJECT, NEITHER, + id="owner-40"), + pytest.param(f"{ISSUE_OWNER}/{PROJECT}", ISSUE_OWNER, PROJECT, NEITHER, + id="owner-50"), + pytest.param(f"{ORG}/{NAME_OVER}", ORG, NAME_OVER, NEITHER, + id="repository-101"), + pytest.param(f"{ORG}/{NAME_OVER}.git", ORG, NAME_OVER, NEITHER, + id="repository-101-dot-git"), + pytest.param(f"{OWNER_OVER}/{NAME_OVER}", OWNER_OVER, NAME_OVER, NEITHER, + id="both-over"), + pytest.param(f"{URL}{OWNER_OVER}/{PROJECT}", OWNER_OVER, PROJECT, + NO_NAME_ON_GITHUB, id="url-owner-40"), + pytest.param(f"{URL}{ORG}/{NAME_OVER}.git", ORG, NAME_OVER, + NO_NAME_ON_GITHUB, id="url-repository-101"), + pytest.param(f"github.com/{OWNER_OVER}/{PROJECT}", OWNER_OVER, PROJECT, + NO_NAME_ON_GITHUB, id="bare-url-owner-40"), + pytest.param(f"www.github.com/{ORG}/{NAME_OVER}", ORG, NAME_OVER, + NO_NAME_ON_GITHUB, id="www-url-repository-101"), +] + + +def cloned_by_gh(owner: str, repo: str, work: Path) -> str: + """The one line the logging `gh` keeps for `plan`'s clone of + `/` into `work`.""" + return (f"repo clone {owner}/{repo} {work / 'source-readonly' / repo} " + "-- --quiet\n") + + +def planned(here: Path, tmp_path: Path, spec: str, project: str, org: str, + env: dict): + """`plan --source --project --org ` in a child run + in `here`, with its own plan file and work directory under `tmp_path`.""" + return run_script(ADOPT, "plan", "--source", spec, "--project", project, + "--org", org, "--elected-by", "Test Human", + "--elected-on", "2026-09-02", "--out", + str(tmp_path / "adoption-plan.yaml"), "--work-dir", + str(tmp_path / "work"), cwd=here, env=env) + + +# --- `plan --source`, with nothing here ------------------------------------- + +@pytest.mark.parametrize(("spec", "owner", "repo"), TAKEN) +def test_plan_asks_gh_for_a_name_github_can_hold(tmp_path, spec, owner, + repo): + """The control: an owner of 39 and a repository of 100, as typed or in + a url, are GitHub's names, and `gh repo clone` is asked for each, once, + with the `.git` dropped. The logging `gh` refuses it, so `plan` ends in + `source-unclonable` naming the repository.""" + here = tmp_path / "here" + here.mkdir() + env, log = recording_gh(tmp_path / "gh-bin") + + proc = planned(here, tmp_path, spec, repo, owner, env) + + assert log.read_text(encoding="utf-8") == cloned_by_gh( + owner, repo, tmp_path / "work"), said(proc) + assert proc.returncode == 2, said(proc) + assert (f"REFUSED source-unclonable: could not clone {owner}/{repo}: " + in proc.stderr), said(proc) + + +@pytest.mark.parametrize(("spec", "owner", "repo", "refused"), REFUSED) +def test_plan_never_asks_gh_for_a_name_github_cannot_hold( + tmp_path, spec, owner, repo, refused): + """The issue's first case: `plan --source <50 b's>/Thing --org <50 b's>` + with nothing here asked `gh repo clone` for a name GitHub cannot hold, + and so did an owner of 40, a repository of 101 and both, as typed or in + a url. Now each names no GitHub repository: refused + `source-unresolvable`, exit 2, with no `gh` call, no plan and no work + directory. `--org` and `--project` are the spelling's own, so no other + refusal comes first.""" + here = tmp_path / "here" + here.mkdir() + env, log = recording_gh(tmp_path / "gh-bin") + + proc = planned(here, tmp_path, spec, repo, owner, env) + + assert log.read_bytes() == b"", said(proc) + assert proc.returncode == 2, said(proc) + assert f"{UNRESOLVABLE}--source {spec!r} {refused}\n" in proc.stderr, \ + said(proc) + assert "cloning " not in said(proc) + assert not (tmp_path / "adoption-plan.yaml").exists() + assert not (tmp_path / "work").exists() + + +@pytest.mark.parametrize(("written", "holder", "length", "code"), [ + pytest.param("github.com/{}/" + PROJECT, "github.com", len(LONG), None, + id="bare-url-too-long-for-the-disk"), + pytest.param("www.github.com/{}/" + PROJECT, "www.github.com", + len(LONG), None, id="www-url-too-long-for-the-disk"), + pytest.param("github.com/{}/" + PROJECT, None, OWNER_MAX + 1, errno.EIO, + id="bare-url-owner-40-EIO"), + pytest.param("github.com/{}/" + PROJECT, None, OWNER_MAX + 1, + errno.EACCES, id="bare-url-owner-40-EACCES"), +]) +def test_a_github_url_past_the_limits_is_refused_before_any_stat( + adopter, monkeypatch, tmp_path, written, holder, length, code): + """`github.com/<300 a's>/Thing` beside a `./github.com`, its `stat` + `File name too long`, and `github.com/<40 a's>/Thing`, its `stat` + refused for an I/O error or a permission: #1122 refused each in + `_source_there`, as a path is, with no url. The spelling is a url, and + names no `owner/repo` GitHub can hold, so `_repository_in_url` refuses + it before the disk is asked, as it refuses any url that names none: + `source-unresolvable`, with no url. The control: at GitHub's limit, + its `stat` refused alike, it names a repository, both readings stay + open and its url is offered (#430).""" + monkeypatch.chdir(tmp_path) + spec = written.format("a" * length) + if holder is not None: + (tmp_path / holder).mkdir() + too_long_here(Path(spec)) + else: + refused_for(monkeypatch, code) + + with pytest.raises(adopter.Refusal) as raised: + adopter._source_spec(spec) + + refusal = raised.value + assert (refusal.code, refusal.detail) == ( + "source-unresolvable", f"--source {spec!r} {NO_NAME_ON_GITHUB}") + assert refusal.remediation == ("Remediation: pass a local clone's path, " + "or `org/repo`.") + + refused_for(monkeypatch, code or errno.EIO) + assert_both_readings_and_the_url( + adopter, written.format("a" * OWNER_MAX), + f"{'a' * OWNER_MAX}/{PROJECT}") + + +# --- the remedy that names `--org` ------------------------------------------ + +@pytest.mark.parametrize(("owner", "repo"), [ + pytest.param(OWNER_OVER, PROJECT, id="owner-40"), + pytest.param(ISSUE_OWNER, PROJECT, id="owner-50"), + pytest.param(OWNER_OVER, NAME_OVER, id="both-over"), +]) +def test_no_remedy_offers_an_org_github_cannot_hold(tmp_path, owner, repo): + """`plan --source <50 b's>/Thing --org testorg` was refused + `adopt-not-the-source`, and told to run `plan` again with `--org <50 + b's>`, a name `execute` refuses as `adopt-org-too-long`. The spelling + names no GitHub repository now, so it is not the source's name either: + `source-unresolvable`, and no `--org` is offered. The control: an owner + of 39 is still the source's own organisation, and the remedy names it.""" + here = tmp_path / "here" + here.mkdir() + env, log = recording_gh(tmp_path / "gh-bin") + spec = f"{owner}/{repo}" + + proc = planned(here, tmp_path, spec, PROJECT, ORG, env) + + assert f"--org {owner}" not in said(proc), said(proc) + assert "adopt-not-the-source" not in said(proc) + assert proc.returncode == 2, said(proc) + assert f"{UNRESOLVABLE}--source {spec!r} {NEITHER}\n" in proc.stderr + assert log.read_bytes() == b"" + + held = repo[:NAME_MAX] + control = planned(here, tmp_path, f"{OWNER_AT}/{held}", PROJECT, ORG, + env) + assert control.returncode == 2, said(control) + assert "REFUSED adopt-not-the-source: " in control.stderr + assert (f"Run `plan` again with `--project {held} --org {OWNER_AT}`, the " + "source's own name,") in control.stderr, said(control) + assert log.read_bytes() == b"" + + +# --- `plan --source`, with a directory of that spelling here ---------------- + +@pytest.mark.parametrize(("owner", "repo"), [ + pytest.param(OWNER_OVER, PROJECT, id="owner-40"), + pytest.param(ORG, NAME_OVER, id="repository-101"), +]) +def test_a_directory_github_cannot_name_is_read_in_place(tmp_path, owner, + repo): + """`./<40 c's>/Thing`, a repository here: `--source <40 c's>/Thing` was + `source-ambiguous`, naming a GitHub repository that cannot exist. It + names only the path, which is read in place, as any spelling that names + no `owner/repo` is: the plan is written, recording that directory and + no repository, and `gh` is never asked. The control: at GitHub's limit + the same spelling still names both, and is refused.""" + here = tmp_path / "here" + source = make_source_repo(here / owner / repo, tree=SMALL_TREE, edits=()) + env, log = recording_gh(tmp_path / "gh-bin") + spec = f"{owner}/{repo}" + + proc = planned(here, tmp_path, spec, PROJECT, ORG, env) + + assert proc.returncode == 0, said(proc) + assert "source-ambiguous" not in said(proc) + record = recorded(tmp_path / "adoption-plan.yaml") + assert record["local_path"] == str(source.resolve()), record + assert record["repository"] is None, record + assert record["cloned"] is False, record + assert log.read_bytes() == b"" + + at = (OWNER_AT, repo) if owner == OWNER_OVER else (owner, NAME_AT) + make_source_repo(here.joinpath(*at), tree=SMALL_TREE, edits=()) + control = planned(here, tmp_path / "control", "/".join(at), PROJECT, ORG, + env) + assert control.returncode == 2, said(control) + assert ("REFUSED source-ambiguous: --source '{0}' names both the path " + "`./{0}` here and the GitHub repository `{0}`").format( + "/".join(at)) in control.stderr, said(control) + assert log.read_bytes() == b"" + + +# --- `check` and `execute`: the plan's record, and `--source` --------------- + +#: (the name, the owner, the repository), at GitHub's limits or past them. +AT_THE_LIMITS = [ + pytest.param(f"{OWNER_AT}/{PROJECT}", OWNER_AT, PROJECT, id="owner-39"), + pytest.param(f"{ORG}/{NAME_AT}", ORG, NAME_AT, id="repository-100"), +] +PAST_THE_LIMITS = [ + pytest.param(f"{OWNER_OVER}/{PROJECT}", OWNER_OVER, PROJECT, + id="owner-40"), + pytest.param(f"{ORG}/{NAME_OVER}", ORG, NAME_OVER, id="repository-101"), + pytest.param(f"{OWNER_OVER}/{NAME_OVER}", OWNER_OVER, NAME_OVER, + id="both-over"), +] +VERBS = pytest.mark.parametrize("verb", ["check", "execute"]) + + +def recorded_clone_gone(tmp_path: Path, name: str) -> Path: + """A plan recording its source as a clone `plan` made (`cloned: true`) + of `name`, `source.repository`, with that clone gone: the plan `plan` + wrote for a local source whose `origin` is `testorg/Thing`, its record + edited, and the source moved away. Returns the plan.""" + source = make_source_repo(tmp_path / "clone" / PROJECT, tree=SMALL_TREE, + edits=()) + git("remote", "add", "origin", f"{URL}{REPOSITORY}.git", cwd=source) + plan = planned_locally(tmp_path, source, cloned=True) + edited(plan, (f" repository: {REPOSITORY}", f" repository: {name}")) + source.parent.rename(tmp_path / "moved") + return plan + + +def run_verb(verb: str, plan: Path, tmp_path: Path, env: dict, *more: str): + """`check` or `execute --yes` of `plan` in a child, with its own work + directory and `--local-remote-dir`, so nothing is asked of GitHub but + what the logging `gh` records.""" + return run_script(ADOPT, verb, "--plan", str(plan), "--work-dir", + str(tmp_path / f"{verb}-work"), "--local-remote-dir", + str(tmp_path / "remotes"), *more, + *(["--yes"] if verb == "execute" else []), env=env) + + +@VERBS +@pytest.mark.parametrize(("name", "owner", "repo"), AT_THE_LIMITS) +def test_a_recorded_name_github_can_hold_is_cloned_again(tmp_path, verb, + name, owner, repo): + """The control: the plan's clone is gone, and its `source.repository` + is a name GitHub can hold, so `check` and `execute` clone it again, as + `plan` cloned it: the NOTE says so, and `gh` is asked once.""" + plan = recorded_clone_gone(tmp_path, name) + env, log = recording_gh(tmp_path / "gh-bin") + + proc = run_verb(verb, plan, tmp_path, env) + + assert log.read_text(encoding="utf-8") == cloned_by_gh( + owner, repo, tmp_path / f"{verb}-work"), said(proc) + assert proc.returncode == 2, said(proc) + assert (f"so its `source.repository`, {name}, is cloned again from " + "GitHub, as `plan` cloned it.") in proc.stdout, said(proc) + assert f"REFUSED source-unclonable: could not clone {name}: " in \ + proc.stderr, said(proc) + + +@VERBS +@pytest.mark.parametrize(("name", "owner", "repo"), PAST_THE_LIMITS) +def test_a_recorded_name_github_cannot_hold_is_never_cloned_again( + tmp_path, verb, name, owner, repo): + """The plan's clone is gone, and its `source.repository` names an owner + over 39 characters or a repository over 100: `check` and `execute` said + it "is cloned again from GitHub" and asked `gh repo clone` for it. It + names no repository on GitHub, so it is refused as any record that + names no `owner/repo` is, and `gh` is never asked.""" + plan = recorded_clone_gone(tmp_path, name) + env, log = recording_gh(tmp_path / "gh-bin") + + proc = run_verb(verb, plan, tmp_path, env) + + assert log.read_bytes() == b"", said(proc) + assert proc.returncode == 2, said(proc) + assert (f"{UNRESOLVABLE}the plan's `source.repository`, {name}, names no " + "`owner/repo` on github.com\n") in proc.stderr, said(proc) + assert "cloned again" not in said(proc) + assert not (tmp_path / f"{verb}-work" / "source-readonly").exists() + assert not (tmp_path / "remotes").exists() + + +@VERBS +@pytest.mark.parametrize(("name", "owner", "repo"), + AT_THE_LIMITS + PAST_THE_LIMITS) +def test_check_and_execute_read_a_source_name_as_plan_reads_it( + tmp_path, verb, name, owner, repo): + """`--source /` given to `check` or `execute` is read as + `plan` reads it: at GitHub's limits it is cloned (`gh` asked once, and + refused); past them it names no repository and is refused, with no + `gh` call. The plan's own clone is gone, so `--source` is the way + on.""" + plan = recorded_clone_gone(tmp_path, REPOSITORY) + env, log = recording_gh(tmp_path / "gh-bin") + + proc = run_verb(verb, plan, tmp_path, env, "--source", name) + + assert proc.returncode == 2, said(proc) + if len(owner) <= OWNER_MAX and len(repo) <= NAME_MAX: + assert log.read_text(encoding="utf-8") == cloned_by_gh( + owner, repo, tmp_path / f"{verb}-work"), said(proc) + assert f"REFUSED source-unclonable: could not clone {name}: " in \ + proc.stderr, said(proc) + else: + assert log.read_bytes() == b"", said(proc) + assert f"{UNRESOLVABLE}--source {name!r} {NEITHER}\n" in \ + proc.stderr, said(proc) + + +# --- `check` and `execute`: the record a source with no `origin` is held to - + +def held_to_the_record(tmp_path: Path, name: str) -> Path: + """The plan for a local source whose `origin` was `testorg/Thing`, with + `origin` then removed and the plan's `source.repository` edited to + `name`: the plan is held to its record (#716). Returns the plan.""" + source = make_source_repo(tmp_path / PROJECT, tree=SMALL_TREE, edits=()) + git("remote", "add", "origin", f"{URL}{REPOSITORY}.git", cwd=source) + plan = planned_locally(tmp_path, source) + git("remote", "remove", "origin", cwd=source) + edited(plan, (f" repository: {REPOSITORY}", f" repository: {name}")) + return plan + + +def unconfirmed(verb: str, plan: Path, tmp_path: Path, env: dict): + """`check`, or `execute` WITHOUT `--yes`, of `plan` in a child, with + `--local-remote-dir`: stdin is no terminal, so `execute` stops at its + confirmation, before anything is made, once every refusal before it has + passed.""" + return run_script(ADOPT, verb, "--plan", str(plan), "--work-dir", + str(tmp_path / f"{verb}-work"), "--local-remote-dir", + str(tmp_path / "remotes"), env=env) + + +@needs_filter_repo +@VERBS +@pytest.mark.parametrize("owner", [ + pytest.param(OWNER_OVER, id="owner-40"), + pytest.param(ISSUE_OWNER, id="owner-50"), +]) +def test_a_record_github_cannot_hold_offers_no_org(tmp_path, verb, owner): + """A source with no `origin` holds the plan to its record, and a record + of `<40 c's>/Thing` held it there: `plan-not-the-source`, told to run + `plan` again with `--org <40 c's>`, which `execute` refuses. It names no + repository on GitHub now, so it holds the plan to nothing, as a record + that names no `owner/repo` does: `check` says `plan ok`, and `execute` + goes on, here to the confirmation it is not given. The control: an + owner of 39 still holds it, and the remedy names it.""" + plan = held_to_the_record(tmp_path, f"{owner}/{PROJECT}") + env, log = recording_gh(tmp_path / "gh-bin") + + proc = unconfirmed(verb, plan, tmp_path, env) + + assert f"--org {owner}" not in said(proc), said(proc) + assert "plan-not-the-source" not in said(proc), said(proc) + if verb == "check": + assert proc.returncode == 0, said(proc) + assert "\nplan ok\n" in proc.stdout, said(proc) + else: + assert "REFUSED adopt-unconfirmed: " in proc.stderr, said(proc) + assert not (tmp_path / "remotes").exists() + assert log.read_bytes() == b"" + + edited(plan, (f" repository: {owner}/{PROJECT}", + f" repository: {OWNER_AT}/{PROJECT}")) + control = unconfirmed(verb, plan, tmp_path, env) + assert "plan-not-the-source" in control.stderr, said(control) + assert (f"Run `plan` again with `--project {PROJECT} --org {OWNER_AT}`, " + "the source's own name,") in control.stderr, said(control) + assert log.read_bytes() == b"" diff --git a/tests/test_adopt_unsearchable_source.py b/tests/test_adopt_unsearchable_source.py index c016e7b2..fd011773 100644 --- a/tests/test_adopt_unsearchable_source.py +++ b/tests/test_adopt_unsearchable_source.py @@ -399,10 +399,9 @@ def assert_both_readings_and_the_url(adopter, spec: str, name: str) -> None: 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"), + # A `github.com/` spelling past GitHub's limits names no `owner/repo`, + # and `_repository_in_url` refuses it before any `stat` (#1129): + # tests/test_adopt_source_github_limits.py holds it. ]) def test_a_spelling_too_long_for_the_disk_is_offered_no_url( adopter, monkeypatch, tmp_path, written, holder, names, limit): @@ -451,8 +450,7 @@ def test_a_spelling_too_long_for_the_disk_is_offered_no_url( 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"), + # A `github.com/` spelling: tests/test_adopt_source_github_limits.py. ]) def test_a_name_github_cannot_hold_is_offered_no_url_whatever_the_reason( adopter, monkeypatch, tmp_path, code, written, names, limit, length): From 6b03c51a92e4917200c2320d0b97c783be786a01 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 09:47:56 +0000 Subject: [PATCH 4/6] repo_shape.py and adopt-project.py: a local source's `origin` past GitHub's limits (an owner over 39 characters, a repository over 100) names no `org/repo`, by the rule `_github_name` reads a typed name by, so `plan --org testorg` is no longer told to pass `--org <40 c's>`, `check` holds the plan alike with `origin` there or gone, and the doctor's adoption line asks for `--org` (#1211) `url_repository`, the one reading of an `origin` that `adopt-project.py` and `shape-doctor.py` share, asked neither limit, though `_github_name` asks both of a typed or recorded `owner/repo` since #1129. Both now ask `repo_shape.github_can_hold(owner, repo)`, #1129's guard made one function, handed the repository as written: one trailing `.git`, in any case, is dropped before it is counted. - `GITHUB_NAME_MAX` and `GITHUB_OWNER_MAX` are defined in `scripts/repo_shape.py` now, which cannot import `scripts/shape_materialize.py`; `shape_materialize.py` imports them, so every other importer reads them where it did. - `plan` records `repository: null` for such an `origin`, compares nothing with it, and with no `--org` refuses `adopt-no-org`; the doctor's `the way in` line ends `--org ` for it. - At the limits nothing changes. NIT-3 (`--source github.com/<40 c's>/x` with that directory here) keeps its refusal. Rows: 28 in tests/test_adopt_source_github_limits.py and 4 in tests/test_shape_doctor.py's ORIGIN_ORGS; 20 fail at #1194's head 906172b4, and the 12 controls pass on both. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 15 +- scripts/repo_shape.py | 47 +++++- scripts/shape_materialize.py | 10 +- tests/test_adopt_source_github_limits.py | 187 +++++++++++++++++++++++ tests/test_shape_doctor.py | 11 ++ 5 files changed, 259 insertions(+), 11 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 9f988875..0aeea08f 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -383,8 +383,12 @@ # Whether a recorded `--pin-source` is a clone's top level, asked as `plan` # asks it (#800). from shape_materialize import pin_source_not_top_level # noqa: E402 -# One reading of an `origin`, shared with `shape-doctor.py` (#521). -from repo_shape import origin_is_a_path, url_repository # noqa: E402 +# One reading of an `origin`, shared with `shape-doctor.py` (#521), and the +# one rule for a name GitHub can hold, which it and `_github_name` ask +# (#1129, #1211). +from repo_shape import ( # noqa: E402 + github_can_hold, origin_is_a_path, url_repository, +) # The one spelling of a `neutral_product_pins:` entry, which # `scaffold-project.py` calls too, and the plain scalar it and `y` write a # text bare by (#924). @@ -2143,7 +2147,9 @@ def _github_name(written: str) -> str | None: names no repository (`_recorded_repository`, `_held_repository`); and beside a directory this user cannot search it is offered no url (`_source_there`, #1003). Asked of the name and not of the disk, it - holds on Windows as well, where no error is `File name too long`.""" + holds on Windows as well, where no error is `File name too long`. The + rule is `github_can_hold`'s, which a source's `origin` is read by too + (`url_repository`, #1211).""" segments = [part for part in written.split("/") if part] if len(segments) != 2: return None @@ -2154,7 +2160,8 @@ def _github_name(written: str) -> str | None: GITHUB_SEGMENT_RE.fullmatch(part) and part not in (".", "..") for part in (owner, repo)): return None - if len(owner) > GITHUB_OWNER_MAX or len(repo) > GITHUB_NAME_MAX: + # The repository as written: `github_can_hold` drops its `.git` itself. + if not github_can_hold(owner, segments[1]): return None return f"{owner}/{repo}" diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 00759f1e..a01e1e52 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -75,6 +75,13 @@ PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") TOPIC_PREFIX = "xf-project-" +#: The longest name GitHub gives a repository, in characters. Here, with the +#: owner's below, so that `url_repository` reads an `origin` by them +#: (`github_can_hold`, #1211); `shape_materialize.py` imports both. +GITHUB_NAME_MAX = 100 +#: The longest name GitHub gives a user or an organisation, in characters. +GITHUB_OWNER_MAX = 39 + #: The digest definition recorded in every pin this standard writes. Bumping #: the algorithm bumps this string, so a pin never silently means something #: else than it did when it was written. @@ -2997,10 +3004,27 @@ def origin_is_a_path(url: str) -> bool: return remote_is_a_path(url) or FILE_URL_RE.match(url) is not None +def github_can_hold(owner: str, repo: str) -> bool: + """Whether GitHub can hold a repository `repo` under the owner `owner`, + by their lengths: an owner of at most `GITHUB_OWNER_MAX` characters and + a repository of at most `GITHUB_NAME_MAX`, counted once one trailing + `.git`, in any case, is dropped (#1129, #1211). + + THE ONE RULE FOR EVERY READING OF A NAME. `adopt-project.py`'s + `_github_name` asks it of an `owner/repo` that is typed, recorded or in + a `--source` url, and `url_repository` of a source's `origin`. A name + past either limit names no repository on GitHub, so neither reading + gives one, and no remedy offers it.""" + if repo.lower().endswith(".git") and len(repo) > len(".git"): + repo = repo[:-len(".git")] + return len(owner) <= GITHUB_OWNER_MAX and len(repo) <= GITHUB_NAME_MAX + + def url_repository(url: str) -> str | None: """`/`: the last two segments of the path a url names, case - kept, however the url ends; None for a url whose path has fewer, and for - a filesystem PATH, however it is written (#521). + kept, however the url ends; None for a url whose path has fewer, for a + pair GitHub cannot hold (#1211), and for a filesystem PATH, however it is + written (#521). THE ONE READING OF AN `origin`, for both tools that need one: `adopt-project.py plan` takes `--org` from it and compares `--org` and @@ -3031,6 +3055,18 @@ def url_repository(url: str) -> str | None: separator, and `plan` says so in a NOTE; nor does one give `plan` an organisation, so it asks for `--org` (`adopt-no-org`), and so does the doctor's adoption line. + + NOR DOES A PAIR GITHUB CANNOT HOLD (#1211): an organisation over + `GITHUB_OWNER_MAX` characters, or a repository over `GITHUB_NAME_MAX`, + by the rule `adopt-project.py` reads a typed or recorded `owner/repo` + by (`github_can_hold`, #1129). A clone whose `origin` is + `https://gitlab.com/<40 c's>/Thing.git`, planned with `--org testorg`, + was refused `adopt-not-the-source` and told to pass `--org <40 c's>`, + which `execute` refuses; and `check` held a plan to that name while + `origin` was there, and to nothing once it was gone. Such an `origin` + names no repository now, as a path does: `plan` records none and + compares nothing with it, and with no `--org` it asks for one, as the + doctor's adoption line does. """ if origin_is_a_path(url): return None @@ -3043,11 +3079,16 @@ def url_repository(url: str) -> str | None: else: path = url.partition(":")[2] path = path.rstrip("/") + # The repository as written, `.git` and all, as `_github_name` hands it + # to `github_can_hold`, which drops one `.git` itself. + written = path.rpartition("/")[2] if path.endswith(".git"): path = path[:-4] above, _, name = path.rpartition("/") org = above.rpartition("/")[2] - return f"{org}/{name}" if org and name else None + if org and name and github_can_hold(org, written): + return f"{org}/{name}" + return None def remote_local_path(url: str) -> str | None: diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index cbbc5e0d..60c52ec0 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -86,6 +86,9 @@ # The topic's fixed prefix, which GitHub's limit on a topic bounds an id by # (#838). from repo_shape import TOPIC_PREFIX # noqa: E402 +# GitHub's limits on a repository's name and an owner's (#838), which +# `repo_shape.url_repository` reads an `origin` by too (#1211). +from repo_shape import GITHUB_NAME_MAX, GITHUB_OWNER_MAX # noqa: E402 SHAPE_REPOSITORY = "opensoft/openRepoShape" @@ -6328,12 +6331,11 @@ def refuse_manifest_problems(prefix: str, problems: list[tuple[str, str]], #: The longest name a file or a directory may have, in bytes. NAME_MAX = 255 -#: The longest name GitHub gives a repository, in characters. -GITHUB_NAME_MAX = 100 +# The longest name GitHub gives a repository, `GITHUB_NAME_MAX`, and a user +# or an organisation, `GITHUB_OWNER_MAX`, are `repo_shape`'s, imported above, +# so that its `url_repository` reads an `origin` by them too (#1211). #: The longest topic GitHub sets on a repository, in characters. GITHUB_TOPIC_MAX = 50 -#: The longest name GitHub gives a user or an organisation, in characters. -GITHUB_OWNER_MAX = 39 #: The longest `` GitHub can hold a project's repositories under: #: `-spec` and `-code` are five characters longer. GITHUB_PROJECT_MAX = GITHUB_NAME_MAX - len("-spec") diff --git a/tests/test_adopt_source_github_limits.py b/tests/test_adopt_source_github_limits.py index efeaa57d..bb1e6b7b 100644 --- a/tests/test_adopt_source_github_limits.py +++ b/tests/test_adopt_source_github_limits.py @@ -51,6 +51,7 @@ from conftest import (ADOPT, git, load_without_bytecode, make_source_repo, run_script) +from repo_shape import load_yaml from test_adopt_project_is_the_source import edited from test_adopt_temp_work_roots import ( ORG, PROJECT, REPOSITORY, SMALL_TREE, needs_filter_repo, recorded, @@ -479,3 +480,189 @@ def test_a_record_github_cannot_hold_offers_no_org(tmp_path, verb, owner): assert (f"Run `plan` again with `--project {PROJECT} --org {OWNER_AT}`, " "the source's own name,") in control.stderr, said(control) assert log.read_bytes() == b"" + + +# --- a local source's `origin` (#1211) -------------------------------------- +# +# AT #1194'S HEAD 906172b4 (2026-11-10), a local source's `origin` was read by +# `url_repository`, which asked neither limit: a clone whose `origin` is +# `https://gitlab.com/<40 c's>/Thing.git`, planned with `--org testorg`, was +# refused `adopt-not-the-source` and told to run `plan` again with `--org <40 +# c's>`, which `execute` refuses; planned with no `--org`, it was written with +# `org: <40 c's>`; and `check` held a plan to that name while `origin` was +# there, and to nothing once it was gone (RV1194's NIT-1 and NIT-2). The +# reading now asks `github_can_hold`, the rule `_github_name` asks, of the +# repository as written, so the two read every spelling alike. + +#: (the source's `origin`, its owner, and the repository as `url_repository` +#: names it). Each is past a limit GitHub keeps, as `_github_name` counts it. +ORIGINS_PAST = [ + pytest.param(f"{URL}{OWNER_OVER}/{PROJECT}.git", OWNER_OVER, PROJECT, + id="owner-40"), + pytest.param(f"https://gitlab.com/{OWNER_OVER}/{PROJECT}.git", + OWNER_OVER, PROJECT, id="gitlab-owner-40"), + pytest.param(f"git@github.com:{ISSUE_OWNER}/{PROJECT}.git", ISSUE_OWNER, + PROJECT, id="scp-owner-50"), + pytest.param(f"{URL}{ORG}/{NAME_OVER}.git", ORG, NAME_OVER, + id="repository-101"), + pytest.param(f"{URL}{ORG}/{NAME_AT}.git.git", ORG, f"{NAME_AT}.git", + id="repository-100-dot-git-dot-git"), + pytest.param(f"{URL}{OWNER_OVER}/{NAME_OVER}/", OWNER_OVER, NAME_OVER, + id="both-over"), +] +#: The controls: at GitHub's limits each names its repository, as it did. +ORIGINS_AT = [ + pytest.param(f"{URL}{OWNER_AT}/{PROJECT}.git", OWNER_AT, PROJECT, + id="owner-39"), + pytest.param(f"https://gitlab.com/{OWNER_AT}/{PROJECT}.git", OWNER_AT, + PROJECT, id="gitlab-owner-39"), + pytest.param(f"{URL}{ORG}/{NAME_AT}.git", ORG, NAME_AT, + id="repository-100"), + pytest.param(f"{URL}{ORG}/{NAME_AT}.GIT", ORG, f"{NAME_AT}.GIT", + id="repository-100-dot-GIT"), +] + + +def with_origin(tmp_path: Path, origin: str) -> Path: + """A local source whose `origin` is `origin`, as it is written.""" + source = make_source_repo(tmp_path / "source" / PROJECT, tree=SMALL_TREE, + edits=()) + git("remote", "add", "origin", origin, cwd=source) + return source + + +@pytest.mark.parametrize( + ("origin", "owner", "repo", "past"), + [pytest.param(*row.values, True, id=row.id) for row in ORIGINS_PAST] + + [pytest.param(*row.values, False, id=row.id) for row in ORIGINS_AT]) +def test_an_origin_is_read_by_the_rule_a_name_is(adopter, origin, owner, + repo, past): + """`url_repository` reads an `origin` as `_github_name` reads the same + `owner/repo` typed: past either limit, counted once one `.git` is + dropped, it names no repository; at them it names the one it did.""" + written = origin.rstrip("/").rpartition("/")[2] + + assert adopter.url_repository(origin) == (None if past else + f"{owner}/{repo}") + assert (adopter._github_name(f"{owner}/{written}") is None) is past + + +@pytest.mark.parametrize(("origin", "owner", "repo"), ORIGINS_PAST) +def test_an_origin_github_cannot_hold_offers_no_org(tmp_path, origin, owner, + repo): + """RV1194's P25 and P27: a local source whose `origin` names an owner of + 40 characters, on github.com or gitlab.com, planned with `--org + testorg`, was refused `adopt-not-the-source` and told to run `plan` + again with `--org <40 c's>`, which `execute` refuses; a repository of + 101 was told `--project <101 r's>`. Such an `origin` names no + repository now, as a path does: the plan is written, recording none, + and nothing is offered. `gh` is never asked.""" + source = with_origin(tmp_path, origin) + env, log = recording_gh(tmp_path / "gh-bin") + + proc = planned(tmp_path, tmp_path, str(source), PROJECT, ORG, env) + + assert proc.returncode == 0, said(proc) + assert "adopt-not-the-source" not in said(proc), said(proc) + assert "Run `plan` again" not in said(proc), said(proc) + assert "(origin: " not in proc.stdout, said(proc) + assert recorded(tmp_path / "adoption-plan.yaml")["repository"] is None + assert log.read_bytes() == b"" + + +@pytest.mark.parametrize(("origin", "owner", "repo"), ORIGINS_AT) +def test_an_origin_github_can_hold_is_still_the_source(tmp_path, origin, + owner, repo): + """The control: at GitHub's limits the `origin` names the source's own + repository, and `--org testorg --project Thing` is refused + `adopt-not-the-source` naming it, as it was.""" + source = with_origin(tmp_path, origin) + env, log = recording_gh(tmp_path / "gh-bin") + + proc = planned(tmp_path, tmp_path, str(source), PROJECT, ORG, env) + + assert proc.returncode == 2, said(proc) + assert (f"REFUSED adopt-not-the-source: --org {ORG} and --project " + f"{PROJECT} name {REPOSITORY}, and the source is {owner}/{repo}:" + ) in proc.stderr, said(proc) + assert not (tmp_path / "adoption-plan.yaml").exists() + assert log.read_bytes() == b"" + + +@pytest.mark.parametrize(("origin", "org"), [ + pytest.param(f"{URL}{OWNER_OVER}/{PROJECT}.git", None, id="owner-40"), + pytest.param(f"https://gitlab.com/{ISSUE_OWNER}/{PROJECT}.git", None, + id="gitlab-owner-50"), + pytest.param(f"{URL}{ORG}/{NAME_OVER}.git", None, id="repository-101"), + pytest.param(f"{URL}{OWNER_AT}/{PROJECT}.git", OWNER_AT, id="owner-39"), +]) +def test_plan_reads_no_org_from_an_origin_github_cannot_hold(tmp_path, + origin, org): + """With no `--org`, `plan` took the organisation from an `origin` of + `<40 c's>/Thing` and wrote the plan with `org: <40 c's>`, which + `execute` refuses. It reads none there now, as from a path, and asks + for `--org` (`adopt-no-org`), as `shape-doctor.py`'s adoption line + does. The control: an owner of 39 is still read.""" + source = with_origin(tmp_path, origin) + env, log = recording_gh(tmp_path / "gh-bin") + plan = tmp_path / "adoption-plan.yaml" + + proc = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--elected-by", "Test Human", "--elected-on", + "2026-09-02", "--out", str(plan), "--work-dir", + str(tmp_path / "work"), cwd=tmp_path, env=env) + + if org is None: + assert proc.returncode == 2, said(proc) + assert "REFUSED adopt-no-org: " in proc.stderr, said(proc) + assert not plan.exists() + else: + assert proc.returncode == 0, said(proc) + assert load_yaml(plan)["org"] == org + assert log.read_bytes() == b"" + + +@needs_filter_repo +@VERBS +@pytest.mark.parametrize(("owner", "held"), [ + pytest.param(OWNER_OVER, False, id="owner-40"), + pytest.param(OWNER_AT, True, id="owner-39"), +]) +def test_a_plan_is_held_alike_with_origin_there_or_gone(tmp_path, verb, + owner, held): + """RV1194-cont's P29: a source whose `origin` is + `https://gitlab.com/<40 c's>/Thing.git`, planned with `--org <40 c's>`, + its `org:` then edited to `testorg`. `check` found `plan-not-the-source` + with `origin` there, offering `--org <40 c's>`, and `plan ok` with it + removed. The `origin` names no repository now, and the plan records + none, so both readings hold it to nothing: `check` says `plan ok` and + `execute` goes on to the confirmation it is not given, either way. The + control: an owner of 39 holds it, with `origin` there and gone.""" + source = with_origin(tmp_path, + f"https://gitlab.com/{owner}/{PROJECT}.git") + env, log = recording_gh(tmp_path / "gh-bin") + written = planned(tmp_path, tmp_path, str(source), PROJECT, owner, env) + assert written.returncode == 0, said(written) + plan = tmp_path / "adoption-plan.yaml" + edited(plan, (f"org: {owner}", f"org: {ORG}")) + + there = unconfirmed(verb, plan, tmp_path, env) + git("remote", "remove", "origin", cwd=source) + gone = unconfirmed(verb, plan, tmp_path, env) + + for proc in (there, gone): + if held: + assert "plan-not-the-source: " in proc.stderr, said(proc) + assert (f"Run `plan` again with `--project {PROJECT} --org " + f"{owner}`, the source's own name,") in proc.stderr + else: + assert "plan-not-the-source" not in said(proc), said(proc) + assert f"--org {owner}" not in said(proc), said(proc) + if verb == "check": + assert proc.returncode == 0, said(proc) + assert "\nplan ok\n" in proc.stdout, said(proc) + else: + assert "REFUSED adopt-unconfirmed: " in proc.stderr, \ + said(proc) + assert not (tmp_path / "remotes").exists() + assert log.read_bytes() == b"" diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 26d962fb..6f136da1 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -9765,6 +9765,17 @@ def an_origin_of(path: Path, url: str | bytes | None) -> Path: pytest.param("file:///srv/git/opensoft/Atlas.git", None, id="file-url"), pytest.param(r"D:\a\_work\1\s\Atlas.git", None, id="windows-path"), pytest.param(NOT_UTF8 + b"Atlas.git", None, id="not-utf8"), + # A pair GitHub cannot hold, an owner over 39 characters or a repository + # over 100, names no organisation either (#1211): `plan` read + # `<40 c's>` and the line carried no `--org`. The control: at 39. + pytest.param("https://github.com/" + "c" * 40 + "/Atlas.git", None, + id="owner-40"), + pytest.param("https://gitlab.com/" + "c" * 40 + "/Atlas.git", None, + id="gitlab-owner-40"), + pytest.param("git@github.com:opensoft/" + "r" * 101 + ".git", None, + id="repository-101"), + pytest.param("https://github.com/" + "c" * 39 + "/Atlas.git", "c" * 39, + id="owner-39"), ] From df942efeeab210e65bb85241a5d28b9274cffa40 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 10:49:40 +0000 Subject: [PATCH 5/6] tests/test_adopt_source_github_limits.py: `ORIGINS_PAST` holds `https://github.com/testorg/<101 r's>.git/`, a repository of 101 with a trailing slash and an owner at the limit, so a `written` read from the url before its `/` is stripped is no longer a mutant that survives (#1211) RV1211's mutant R4 changes `written = path.rpartition("/")[2]` to `written = url.rpartition("/")[2]` in `url_repository`. It survived all 32 rows: the only origin ending in `/` was `both-over`, whose owner is also past its limit, which hid the repository case. The new row `repository-101-slash` has an owner at the limit, so only the repository's own count can set it aside. The row runs through both tests that read `ORIGINS_PAST` (`test_an_origin_is_read_by_the_rule_a_name_is` and `test_an_origin_github_cannot_hold_offers_no_org`): - at this head: `2 passed, 76 deselected`; - with #1194's `adopt-project.py`, `scripts/repo_shape.py` and `scripts/shape_materialize.py` swapped in: `2 failed, 76 deselected`; - under R4: `2 failed, 76 deselected`. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_source_github_limits.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_adopt_source_github_limits.py b/tests/test_adopt_source_github_limits.py index bb1e6b7b..3c816fdc 100644 --- a/tests/test_adopt_source_github_limits.py +++ b/tests/test_adopt_source_github_limits.py @@ -509,6 +509,8 @@ def test_a_record_github_cannot_hold_offers_no_org(tmp_path, verb, owner): id="repository-100-dot-git-dot-git"), pytest.param(f"{URL}{OWNER_OVER}/{NAME_OVER}/", OWNER_OVER, NAME_OVER, id="both-over"), + pytest.param(f"{URL}{ORG}/{NAME_OVER}.git/", ORG, NAME_OVER, + id="repository-101-slash"), ] #: The controls: at GitHub's limits each names its repository, as it did. ORIGINS_AT = [ From ddc21fb53f3e10c5fb860db01a24d5b2e1b9e996 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 13:49:55 +0000 Subject: [PATCH 6/6] tests/test_adopt_source_github_limits.py: the rows expect the --origin origin that #1268 hands every gh repo clone (#1129) #1194's rows were written before main's #1268 (2202af13e, for #1261), which made `Source.clone` hand `gh repo clone` `--origin origin` after `--quiet`, so that the clone names its remote `origin` whatever `clone.defaultRemoteName` says. The logging `gh` the rows use keeps the argv it was given, and `cloned_by_gh` still spelled the old one, so at the predicted squash of #1194 over main 106f92ff9 14 rows failed (6 of `test_plan_asks_gh_for_a_name_github_can_hold`, 4 of `test_a_recorded_name_github_can_hold_is_cloned_again`, 4 of `test_check_and_execute_read_a_source_name_as_plan_reads_it`), each at `-- --quiet` against `-- --quiet --origin origin`. `cloned_by_gh` now spells main's call, as #1268 changed the exact pin in `tests/test_adopt_source_clone_record.py`. No row's intent changes: each still checks that `gh` is asked once, or not at all, for the name it was given. `adopt-project.py` and every other file are as the merge left them. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_source_github_limits.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_adopt_source_github_limits.py b/tests/test_adopt_source_github_limits.py index efeaa57d..afc547e9 100644 --- a/tests/test_adopt_source_github_limits.py +++ b/tests/test_adopt_source_github_limits.py @@ -123,9 +123,11 @@ def adopter(): def cloned_by_gh(owner: str, repo: str, work: Path) -> str: """The one line the logging `gh` keeps for `plan`'s clone of - `/` into `work`.""" + `/` into `work`. Since #1268 (#1261) it ends `-- --quiet + --origin origin`, so that the clone names its remote `origin` whatever + `clone.defaultRemoteName` says.""" return (f"repo clone {owner}/{repo} {work / 'source-readonly' / repo} " - "-- --quiet\n") + "-- --quiet --origin origin\n") def planned(here: Path, tmp_path: Path, spec: str, project: str, org: str,