From 96ebe450608d65b6abcd01df8f0b97bdc42fb3dd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 13:46:39 +0000 Subject: [PATCH 1/2] WIP (account-swap checkpoint; untested on these bytes): family.py and scaffold-project.py: `add --local-remote-dir` refuses a member clone URL `.gitmodules` cannot hold as text, `--dry-run` alike, each clone-URL remedy names a link's target and a lone surrogate, and the READMEs quote the clone URL, so the line under `a b/` pastes as one command (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #640 (#433) asked `unwritable_reason` of the clone URL the scaffold and `family.py init` write into their READMEs. Four things were left, each measured on main 8062810d: - `family.py add --local-remote-dir /caf` exited 0, dry run and real, and committed the raw byte into the holder's `.gitmodules`, where the holder's `make siblings` (`siblings.submodule_urls`, `git config --list` read with `errors="replace"`) read the URL back as `caf\ufffd`; a line break was escaped as `\n` by git and read back cut short at it, and an escape and a bidi override were committed raw. `cmd_add` now asks `_refuse_an_unwritable_member_url` of `_member_url`'s result, after the branch question and before `_probe_member`'s throwaway clone, so the real run and `--dry-run` refuse alike, `family-unwritable-value`, with nothing read, mounted or written. - `--local-remote-dir link`, a UTF-8 name linked to a `caf` directory, was refused naming the RESOLVED URL by a remedy that said to pass a path that is UTF-8 text, which it was. Every clone-URL remedy now says the URL is the path the flag resolves to, and that where the path given is text and the URL is not, a link's target (or, for a relative path, the directory it was given from) is the one to rename. - `unwritable_reason` names a lone surrogate, which a Windows name can hold, and no clone-URL remedy did; both now say "or a lone surrogate", as #282's `UNWRITABLE_REMEDIATION` does. - `git clone --recurse-submodules {{CLONE_URL}}` (holder) and `{{CLONE_URL}}`/`{{ASSEMBLY_CLONE_URL}}` (assembly root, both legs) took a local path raw, so under `a b/` or `it's/` the line split when pasted. Both tools now render the value through `shell_quoted(url, "posix")`, POSIX whatever the host as #532 rules for pasted commands: a GitHub URL and an ordinary ASCII path stay bare, so the templates and their pins are unchanged, but a path holding `é` is quoted too, as `shell_quoted`'s conservative set has it, and `test_clone_url_bytes.py`'s two `café` README asserts now expect the quotes. The link sentence and the surrogate are static text in the remedies, not a branch on what was typed: it is true of every refusal (including #640's relative path under such a directory), and it leaves `cmd_init`'s and `_scaffold`'s call sites untouched. `add`'s remedy shares `init`'s words (`LOCAL_REMOTE_DIR_REMEDY`) with its own verb, GitHub URL and reasons: `.gitmodules` holds what `README.md` cannot, so its reasons say how the holder reads it back, not that it cannot be written. Closes #693 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scaffold-project.py | 43 +++- scripts/family.py | 121 +++++++++-- tests/test_clone_url_bytes.py | 45 ++-- tests/test_local_remote_dir_urls.py | 316 ++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 37 deletions(-) create mode 100644 tests/test_local_remote_dir_urls.py diff --git a/scaffold-project.py b/scaffold-project.py index 5a73dd13..5332c67b 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -124,6 +124,9 @@ make_folder, refuse_unless_not_found, refuse_unmakeable_folder, target_in_the_way, target_remediation, ) +# How the READMEs' `git clone` line spells the clone URL, one word (#693), +# in the quoting `shell_line` gives the NEXT STEPS clone (#486). +from repo_shape import shell_quoted # noqa: E402 #: How a refusal names each value the manifest records (#258, #261). The leg #: paths are not here: `refuse_bad_leg_paths` asks for them (#179, #225). @@ -849,18 +852,34 @@ def _refuse_unwritable_values(fields) -> None: #: What a `scaffold-unwritable-value` for the clone URL is told to do (#433). #: It is not `UNWRITABLE_REMEDIATION`, which says to retype a value: the path #: is a directory, so the way out is another directory, or GitHub. +#: +#: THE URL IS THE PATH `--local-remote-dir` RESOLVES TO (#693), so one typed +#: as text can lead through a symbolic link to a directory whose name is not, +#: or be relative to one; told only to pass a path that is "one line of UTF-8 +#: text", which it was, a person renames the link and is refused again, so +#: the remedy says where the fault is. And as `UNWRITABLE_REMEDIATION` says +#: (#282), a lone surrogate, which a Windows name can hold, cannot be +#: written either: the READMEs quote the URL for a shell (#693) and escape +#: no character of it. CLONE_URL_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " "control character in it, a directory renamed or made anew, and run " "again; or scaffold against GitHub, without --local-remote-dir, where " - "the clone URL is `https://github.com//.git`. The READMEs " - "print the clone URL as it stands: a byte that is not UTF-8 in the path " - "(a directory named in a legacy code page) cannot be written at all, a " - "line break or a control character would break or garble the line it is " - "written into, and a bidirectional embedding, override or isolate " - "control would show the rest of that line in another order than it is " - "written.") + "the clone URL is `https://github.com//.git`. The clone URL " + "is the path --local-remote-dir resolves to, so where the path given is " + "text and the URL named above is not, the fault is in what it leads " + "through: a symbolic link on it whose target is not text, or, for a " + "relative path, the directory it was given from. Rename or replace the " + "directory whose name is not text, or pass a path that does not lead " + "through it; renaming a link changes nothing. The READMEs print the " + "clone URL in a `git clone` line, in quotes where a shell needs them " + "but with no character escaped: a byte that is not UTF-8 in the path (a " + "directory named in a legacy code page), or a lone surrogate, cannot be " + "written at all, a line break or a control character would break or " + "garble the line it is written into, and a bidirectional embedding, " + "override or isolate control would show the rest of that line in " + "another order than it is written.") def _refuse_an_unwritable_clone_url(local: bool, url: str) -> None: @@ -1794,8 +1813,14 @@ def _scaffold(args) -> int: "SHAPE_COMMIT": shape_commit, "SHAPE_TREE_SHA256": shape_tree, "DIGEST_DEFINITION": TREE_DIGEST_DEFINITION, - "CLONE_URL": urls["assembly"], - "ASSEMBLY_CLONE_URL": urls["assembly"], + # ONE WORD OF A COMMAND A PERSON PASTES (#693): every README prints + # it in `git clone --recurse-submodules `, and under + # `--local-remote-dir` it is a path, which was rendered raw, so one + # under `a b/` split the line when pasted. POSIX quoting whatever the + # host, as `_next_steps_clone` prints it (#532): a README is a + # committed file read on every OS. A GitHub URL is left bare. + "CLONE_URL": shell_quoted(urls["assembly"], "posix"), + "ASSEMBLY_CLONE_URL": shell_quoted(urls["assembly"], "posix"), "NEUTRAL_PRODUCT_PINS": _neutral_product_pins(pins), "ASSEMBLY_NAMING": naming_block(policy, names["assembly"], "assembly", declared_pins, chain=chain), diff --git a/scripts/family.py b/scripts/family.py index e4f430cb..b84ac384 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -1613,21 +1613,65 @@ def _holder_remote(args, family: str) -> tuple[bool, str, str]: return local, repository, url +#: What every `family-unwritable-value` for a clone URL made under +#: `--local-remote-dir` tells a person to do, `init`'s for the holder (#433) +#: and `add`'s for a member (#693) alike: another directory, or GitHub, as +#: the scaffold's twin says (`CLONE_URL_REMEDIATION`), with the verb and the +#: last name of the GitHub URL filled in. +#: +#: THE URL IS THE PATH THE FLAG RESOLVES TO (#693). A `--local-remote-dir` +#: typed as text can lead through a symbolic link to a directory whose name +#: is not, or be relative to one, and the URL a refusal names is then not +#: the path that was typed. Told only to pass a path that is "one line of +#: UTF-8 text", which it already was, a person renames the link and is +#: refused again, so this says where the fault is. +LOCAL_REMOTE_DIR_REMEDY = ( + "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " + "control character in it, a directory renamed or made anew, and run " + "again; or run `{verb}` against GitHub, without --local-remote-dir, " + "where the clone URL is `https://github.com//<{name}>.git`. The " + "clone URL is the path --local-remote-dir resolves to, so where the path " + "given is text and the URL named above is not, the fault is in what it " + "leads through: a symbolic link on it whose target is not text, or, for " + "a relative path, the directory it was given from. Rename or replace " + "the directory whose name is not text, or pass a path that does not " + "lead through it; renaming a link changes nothing.") + #: What a `family-unwritable-value` for the holder's clone URL is told to do -#: (#433): another directory, or GitHub, as the scaffold's twin says -#: (`CLONE_URL_REMEDIATION`). +#: (#433). The holder's `README.md` quotes the URL for a shell (#693) and +#: escapes no character of it, so a lone surrogate, which a Windows name can +#: hold, cannot be written either, as #282's `UNWRITABLE_REMEDIATION` in +#: `scaffold-project.py` says (#693). CLONE_URL_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " - "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " - "control character in it, a directory renamed or made anew, and run " - "again; or run `init` against GitHub, without --local-remote-dir, where " - "the clone URL is `https://github.com//.git`. The holder's " - "`README.md` prints the clone URL as it stands: a byte that is not UTF-8 " - "in the path (a directory named in a legacy code page) cannot be " - "written at all, a line break or a control character would break or " - "garble the line it is written into, and a bidirectional embedding, " - "override or isolate control would show the rest of that line in " - "another order than it is written.") + + LOCAL_REMOTE_DIR_REMEDY.format(verb="init", name="Family") + + " The holder's `README.md` prints the clone URL in a `git clone` " + "line, in quotes where a shell needs them but with no character " + "escaped: a byte that is not UTF-8 in the path (a directory named in a " + "legacy code page), or a lone surrogate, cannot be written at all, a " + "line break or a control character would break or garble the line it " + "is written into, and a bidirectional embedding, override or isolate " + "control would show the rest of that line in another order than it is " + "written.") + +#: What a `family-unwritable-value` for a member's clone URL is told to do +#: (#693). `git submodule add` records the URL in the holder's `.gitmodules` +#: as it is given (a line break escaped as `\n`, every other character +#: raw), and the holder's `make siblings` reads it back through `git config +#: --list` as UTF-8 text, a line a value. Measured on main: `caf` came +#: back as `caf\ufffd`, a line break cut the URL short at it, and an escape +#: and a bidi override came back as themselves. +MEMBER_URL_REMEDIATION = ( + "Remediation: `--dry-run` refuses the same. " + + LOCAL_REMOTE_DIR_REMEDY.format(verb="add", name="Project") + + " The holder's `.gitmodules` records the clone URL as git is given " + "it, and the holder's `make siblings` reads it back as UTF-8 text, a " + "line a value: a byte that is not UTF-8 in the path (a directory named " + "in a legacy code page), or a lone surrogate, reads back as another " + "path, a line break ends the URL where it falls, a control character " + "garbles the line it is shown in, and a bidirectional embedding, " + "override or isolate control shows the rest of that line in another " + "order than it is written.") def _refuse_an_unwritable_clone_url(local: bool, url: str) -> None: @@ -1662,6 +1706,38 @@ def _refuse_an_unwritable_clone_url(local: bool, url: str) -> None: CLONE_URL_REMEDIATION) +def _refuse_an_unwritable_member_url(local: bool, url: str) -> None: + r"""Refuse a member's clone URL that the holder's `.gitmodules` cannot + hold as text, before the member is read or anything is mounted, and + under `--dry-run` alike, as `init` refuses the holder's (#433, #693). + + Only a local one can be refused: under `--local-remote-dir` the URL is + the bare repository's own path (`_member_url`), which `git submodule add` + writes into `.gitmodules`; against GitHub it is + `https://github.com//.git`, ASCII by construction + (`MEMBER_RE`). On main `add --local-remote-dir /caf` exited 0, + dry run and real, and committed the byte into `.gitmodules`, where the + holder's `make siblings` read the URL as `caf\ufffd`, another path than + the one the holder is pinned to; a line break, an escape and a bidi + override were committed too (#693). + + `init`'s question (`unwritable_reason`) and `init`'s code + (`family-unwritable-value`), with `add`'s own words for where the URL + goes (`MEMBER_URL_REMEDIATION`). + """ + if not local: + return + reason = unwritable_reason(url) + if reason: + raise Refusal( + "family-unwritable-value", + "under --local-remote-dir the member's clone URL is the bare " + f"repository's own path, {url!r}, which `add` writes into the " + f"holder's `.gitmodules`, and {reason}. No git command that " + "writes was run and nothing was written.", + MEMBER_URL_REMEDIATION) + + def _shape_snapshot() -> tuple[str, str]: """`(shape_commit, shape_tree)` of this checkout, warning to stderr when it is dirty. @@ -1694,6 +1770,17 @@ def _template_values(args, family: str, family_id: str, repository: str, template. Split from `cmd_init` for #136. + + THE CLONE URL IS ONE WORD OF A COMMAND A PERSON PASTES (#693). The + holder's `README.md` prints it in `git clone --recurse-submodules + {{CLONE_URL}}`, and under `--local-remote-dir` it is a path, which was + rendered raw: under `a b/` the line split in two when pasted, and a path + holding `$`, `'`, `;` or `&` broke it too. It is spelled by + `shell_quoted` in the POSIX dialect whatever the host, since the README + is a committed file read on every OS and pasted commands are for a POSIX + shell (#532); a GitHub URL and an ordinary path are left bare. Only this + value is quoted: the block's other placeholder, `{{FAMILY}}` in `cd + {{FAMILY}}`, is a name the naming policy spells in letters and digits. """ return { "FAMILY": family, @@ -1705,7 +1792,7 @@ def _template_values(args, family: str, family_id: str, repository: str, "TRACKING_BRANCH": args.tracking_branch, "CREATED_BY": created_by, "CREATED_ON": args.created_on or _dt.date.today().isoformat(), - "CLONE_URL": url, + "CLONE_URL": shell_quoted(url, "posix"), "SHAPE_REPOSITORY": SHAPE_REPOSITORY, "SHAPE_COMMIT": shape_commit, "SHAPE_TREE_SHA256": shape_tree, @@ -4199,7 +4286,9 @@ def cmd_add(args) -> int: file, `_checked_mount_path`, #612), the paths the sparse rules leave out (`_checked_inside_sparse_rules`, #612), then the branch the commit lands on, which the real run refuses on the tracking branch or none - (`_checked_branch`, #487) and a dry run only reports, then the member's + (`_checked_branch`, #487) and a dry run only reports, then the clone URL + `--local-remote-dir` makes, which `.gitmodules` will hold + (`_refuse_an_unwritable_member_url`, #693), then the member's (`_probe_member`, of a throwaway clone), then the member's id against the rows (`_checked_member_id`, #330), and only then the mount, the pin and the commit (`_mount_and_pin`), inside a `MountRollback` that puts the @@ -4232,6 +4321,10 @@ def cmd_add(args) -> int: suggested) url = _member_url(repository, args.local_remote_dir) + # THE CLONE URL IS A PATH `.gitmodules` WILL HOLD (#693): asked as + # `init` asks the holder's (#433), before the member is read or anything + # is mounted, and `--dry-run` refuses alike. + _refuse_an_unwritable_member_url(args.local_remote_dir is not None, url) protocol = FILE_PROTOCOL if args.local_remote_dir is not None else [] # FLUSHED, so that in a log holding both streams the line saying what # was being done comes before a refusal of it, not after. diff --git a/tests/test_clone_url_bytes.py b/tests/test_clone_url_bytes.py index 373b8de7..f3044151 100644 --- a/tests/test_clone_url_bytes.py +++ b/tests/test_clone_url_bytes.py @@ -73,27 +73,42 @@ BAD_IDS = [row[0] for row in BAD_DIRECTORIES] #: What the refusal tells a person to do, which is fixed text and is the same -#: in every case: the way out is another directory, or GitHub. +#: in every case: the way out is another directory, or GitHub. Since #693 it +#: says that the URL is the RESOLVED path, so that a path typed as text that +#: leads to one that is not is told where the fault is, and it names a lone +#: surrogate among the reasons, as #282's remedy does. WHY_IT_IS_REFUSED = ( "a byte that is not UTF-8 in the path (a directory named in a legacy " - "code page) cannot be written at all, a line break or a control " - "character would break or garble the line it is written into, and a " - "bidirectional embedding, override or isolate control would show the " - "rest of that line in another order than it is written.") + "code page), or a lone surrogate, cannot be written at all, a line break " + "or a control character would break or garble the line it is written " + "into, and a bidirectional embedding, override or isolate control would " + "show the rest of that line in another order than it is written.") +THE_RESOLVED_PATH = ( + "The clone URL is the path --local-remote-dir resolves to, so where the " + "path given is text and the URL named above is not, the fault is in what " + "it leads through: a symbolic link on it whose target is not text, or, " + "for a relative path, the directory it was given from. Rename or replace " + "the directory whose name is not text, or pass a path that does not lead " + "through it; renaming a link changes nothing. ") +#: How a README prints the clone URL since #693: one word of a POSIX shell. +QUOTED_NOT_ESCAPED = ("the clone URL in a `git clone` line, in quotes where a " + "shell needs them but with no character escaped: ") SCAFFOLD_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " "control character in it, a directory renamed or made anew, and run " "again; or scaffold against GitHub, without --local-remote-dir, where " - "the clone URL is `https://github.com//.git`. The READMEs " - "print the clone URL as it stands: " + WHY_IT_IS_REFUSED) + "the clone URL is `https://github.com//.git`. " + + THE_RESOLVED_PATH + "The READMEs print " + QUOTED_NOT_ESCAPED + + WHY_IT_IS_REFUSED) FAMILY_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " "control character in it, a directory renamed or made anew, and run " "again; or run `init` against GitHub, without --local-remote-dir, where " - "the clone URL is `https://github.com//.git`. The holder's " - "`README.md` prints the clone URL as it stands: " + WHY_IT_IS_REFUSED) + "the clone URL is `https://github.com//.git`. " + + THE_RESOLVED_PATH + "The holder's `README.md` prints " + + QUOTED_NOT_ESCAPED + WHY_IT_IS_REFUSED) @pytest.fixture @@ -332,7 +347,9 @@ def test_a_utf8_directory_still_plans_a_scaffold(scaffold, capsys, tmp_path): def test_a_utf8_directory_still_scaffolds(tmp_path): """End to end into local bare remotes under `/café`: exit 0, all three remotes made, and the assembly root's README prints the clone URL - exactly as it stands (read as the bytes the scaffold wrote).""" + with every character as it stands (read as the bytes the scaffold + wrote), in the single quotes `shell_quoted` gives a word holding `é` + (#693).""" cafe = tmp_path / "café" cafe.mkdir() work = tmp_path / "work" @@ -343,7 +360,7 @@ def test_a_utf8_directory_still_scaffolds(tmp_path): f"{PROJECT}.git"] readme = (work / PROJECT / "README.md").read_bytes().decode("utf-8") url = str(cafe.resolve() / f"{PROJECT}.git") - assert f"git clone --recurse-submodules {url}\n" in readme + assert f"git clone --recurse-submodules '{url}'\n" in readme def test_a_utf8_directory_still_plans_a_family(capsys, tmp_path): @@ -361,8 +378,8 @@ def test_a_utf8_directory_still_plans_a_family(capsys, tmp_path): def test_a_utf8_directory_still_creates_a_holder(tmp_path): """End to end into a local bare remote under `/café`: exit 0, - `Contoso.git` made, and the holder's README prints the clone URL as it - stands.""" + `Contoso.git` made, and the holder's README prints the clone URL with + every character as it stands, in single quotes (#693).""" cafe = tmp_path / "café" cafe.mkdir() into = tmp_path / "into" @@ -372,4 +389,4 @@ def test_a_utf8_directory_still_creates_a_holder(tmp_path): readme = (into / HOLDER / HOLDER / "README.md").read_bytes().decode( "utf-8") url = str(cafe.resolve() / f"{HOLDER}.git") - assert f"git clone --recurse-submodules {url}\n" in readme + assert f"git clone --recurse-submodules '{url}'\n" in readme diff --git a/tests/test_local_remote_dir_urls.py b/tests/test_local_remote_dir_urls.py new file mode 100644 index 00000000..5586256e --- /dev/null +++ b/tests/test_local_remote_dir_urls.py @@ -0,0 +1,316 @@ +# SPDX-License-Identifier: Apache-2.0 +r"""A `--local-remote-dir` the tools cannot write down is refused by every tool +that writes it, with a remedy that says why, and the clone line the READMEs +print pastes as one command (#693). + +#640 (#433) asked `unwritable_reason` of the clone URL `scaffold-project.py` +and `family.py init` write into their READMEs. Four things were left, each +measured on main: + + * `family.py add --local-remote-dir /caf` exited 0, dry run and + real, and committed the byte into the holder's `.gitmodules`, where the + holder's `make siblings` read the URL back as `caf\ufffd`, another path; + a line break, an escape and a bidi override were committed too. `add` + now asks `init`'s question of the member's URL, before the member is + read or anything is mounted, `--dry-run` alike, and refuses + `family-unwritable-value`. + * `--local-remote-dir link`, a UTF-8 name linked to a `caf` directory, + was refused naming the RESOLVED URL, by a remedy that said to pass a path + that is UTF-8 text, which it was. Each remedy now says that the URL is + the resolved path, and that a link's target is then the fault. + * a lone surrogate, the unpaired UTF-16 half a Windows name can hold, is a + reason `unwritable_reason` gives, and no clone-URL remedy named it. Linux + has no file name that decodes to one, so it is asked of each refusal + function directly. + * the READMEs' `git clone --recurse-submodules ` line took a local + path raw, so under `a b/` or `it's/` it split when pasted. The URL is now + one word in the POSIX dialect, and an ordinary path is left bare. + +NO NETWORK AND NO GITHUB, on the harnesses of `test_clone_url_bytes.py` and +`test_family_add_refusals.py`: local bare repositories, a `gh` that logs and +exits 97 (or no process at all, where a refusal comes first), and directory +names made from BYTES, skipped where the disk cannot hold one. Every +invisible character in this file is spelled as an escape. +""" + +from __future__ import annotations + +import os +import shlex +import shutil +from pathlib import Path + +import pytest + +from conftest import SCAFFOLD, git, pasted +from test_clone_url_bytes import ( # noqa: F401 - only_the_branch_question + BAD_DIRECTORIES, BAD_IDS, HOLDER, NOT_UTF8, PROJECT, THE_RESOLVED_PATH, + assert_nothing_was_made, child, directory_named, family_argv, + family_refusal, only_the_branch_question, scaffold_argv, scaffold_refusal) +from test_family import ( # noqa: F401 - fixtures + ALLOW_FILE_PROTOCOL, FAMILY, SPARE, family, family_module) +from test_family_add_refusals import ( # noqa: F401 - fixtures + assert_unchanged, holder, offline, snapshot) +from test_scaffold_free_text_values import ( # noqa: F401 - fixtures + no_process, scaffold) + +#: What `unwritable_reason` says of a lone surrogate. +LONE_SURROGATE = "it holds a lone surrogate, which is no character" + +#: What `add`'s refusal tells a person to do: `init`'s way out, with `add`'s +#: verb and GitHub URL, and why `.gitmodules` cannot hold the URL as text. +MEMBER_REMEDIATION = ( + "Remediation: `--dry-run` refuses the same. Pass a --local-remote-dir " + "whose path is one line of UTF-8 text with no control character in it, " + "a directory renamed or made anew, and run again; or run `add` against " + "GitHub, without --local-remote-dir, where the clone URL is " + "`https://github.com//.git`. " + THE_RESOLVED_PATH + + "The holder's `.gitmodules` records the clone URL as git is given it, " + "and the holder's `make siblings` reads it back as UTF-8 text, a line a " + "value: a byte that is not UTF-8 in the path (a directory named in a " + "legacy code page), or a lone surrogate, reads back as another path, a " + "line break ends the URL where it falls, a control character garbles " + "the line it is shown in, and a bidirectional embedding, override or " + "isolate control shows the rest of that line in another order than it " + "is written.") + +DRY_RUN = pytest.mark.parametrize("dry_run", [True, False], + ids=["dry-run", "real"]) + + +def member_refusal(url: str, reason: str) -> str: + """The whole of what `family.py add` prints to stderr.""" + return ( + "REFUSED family-unwritable-value: under --local-remote-dir the " + "member's clone URL is the bare repository's own path, " + f"{url!r}, which `add` writes into the holder's `.gitmodules`, and " + f"{reason}. No git command that writes was run and nothing was " + f"written.\n{MEMBER_REMEDIATION}\n") + + +def holding_the_spare(family, directory: Path) -> Path: + """`directory` with a copy of the spare member's bare repository in it, + where `add --local-remote-dir` looks for `.git`.""" + shutil.copytree(family["remotes"] / f"{SPARE}.git", + directory / f"{SPARE}.git", symlinks=True) + return directory + + +def linked_to(tmp_path: Path, target: Path) -> Path: + """`tmp_path/link`, a name that is text, linked to `target`; skipped + where this host makes no symbolic link.""" + link = tmp_path / "link" + try: + os.symlink(target, link, target_is_directory=True) + except (OSError, NotImplementedError) as error: + pytest.skip(f"this host makes no symbolic link: {error}") + return link + + +def flags(dry_run: bool) -> list[str]: + return ["--dry-run"] if dry_run else [] + + +# --- `add`: the member's clone URL, asked before anything is read ---------- + +@DRY_RUN +@pytest.mark.parametrize("label, raw, reason", BAD_DIRECTORIES, ids=BAD_IDS) +def test_add_from_a_directory_no_file_can_name_is_refused( + family, holder, offline, tmp_path, label, raw, reason, dry_run): + """The issue's row, `add --local-remote-dir /caf`, and the + other paths `unwritable_reason` refuses. On main each exited 0 and wrote + the URL into `.gitmodules`. Now: exit 2, nothing on stdout (the member + is not even read), the refusal in full, and the holder exactly as it + was, with no throwaway clone left and no `gh` called.""" + remotes = holding_the_spare(family, directory_named(tmp_path, raw)) + before = snapshot(holder) + result = offline.add(holder, SPARE, *flags(dry_run), remotes=remotes) + assert (result.returncode, result.stdout) == (2, ""), result.stderr + assert result.stderr == member_refusal( + str(remotes.resolve() / f"{SPARE}.git"), reason) + assert_unchanged(holder, before, SPARE) + offline.assert_left_nothing() + + +@DRY_RUN +def test_add_through_a_link_to_such_a_directory_says_the_target_is_the_fault( + family, holder, offline, tmp_path, dry_run): + """`add --local-remote-dir link`, `link` a UTF-8 name pointing at the + `caf` directory: refused, naming the URL the link RESOLVES to, with + the remedy saying that a link's target is then what to rename.""" + target = holding_the_spare(family, directory_named(tmp_path, b"caf\xe9")) + link = linked_to(tmp_path, target) + before = snapshot(holder) + result = offline.add(holder, SPARE, *flags(dry_run), remotes=link) + assert (result.returncode, result.stdout) == (2, ""), result.stderr + assert result.stderr == member_refusal( + str(target.resolve() / f"{SPARE}.git"), NOT_UTF8) + assert THE_RESOLVED_PATH in result.stderr + assert_unchanged(holder, before, SPARE) + offline.assert_left_nothing() + + +def test_add_from_a_utf8_directory_still_mounts(family, holder, offline, + tmp_path): + """The control, `/café`: text, so `add` mounts and pins it, exit 0, + and `.gitmodules` records the path as it stands.""" + remotes = holding_the_spare(family, tmp_path / "café") + result = offline.add(holder, SPARE, remotes=remotes) + assert result.returncode == 0, result.stderr + result.stdout + url = str(remotes.resolve() / f"{SPARE}.git") + assert git("config", "-f", ".gitmodules", "--get", + f"submodule.members/{SPARE}.url", + cwd=holder).stdout == f"{url}\n" + offline.assert_left_nothing() + + +# --- the scaffold and `init`: a link's target is named as the fault -------- + +@DRY_RUN +def test_a_scaffold_through_a_link_says_the_target_is_the_fault( + scaffold, no_process, capsys, tmp_path, dry_run): + """`--local-remote-dir link` to a `caf` directory: refused as #640 + refused it, before any process, and the remedy now says the link's + target is the fault.""" + target = directory_named(tmp_path, b"caf\xe9") + link = linked_to(tmp_path, target) + work = tmp_path / "work" + code = scaffold.main(scaffold_argv(link, work, dry_run=dry_run)) + out, err = capsys.readouterr() + assert (code, out) == (2, ""), err + assert err == scaffold_refusal(str(target.resolve() / f"{PROJECT}.git"), + NOT_UTF8) + assert THE_RESOLVED_PATH in err + assert_nothing_was_made(target, work) + + +@DRY_RUN +def test_a_family_through_a_link_says_the_target_is_the_fault( + only_the_branch_question, capsys, tmp_path, dry_run): + """The same for `family.py init`.""" + target = directory_named(tmp_path, b"caf\xe9") + link = linked_to(tmp_path, target) + into = tmp_path / "into" + code = family_module().main(family_argv(link, into, dry_run=dry_run)) + out, err = capsys.readouterr() + assert (code, out) == (2, ""), err + assert err == family_refusal(str(target.resolve() / f"{HOLDER}.git"), + NOT_UTF8) + assert THE_RESOLVED_PATH in err + assert_nothing_was_made(target, into) + + +# --- a lone surrogate, which only a Windows name holds --------------------- + +#: A clone URL holding U+D800, outside the U+DC80-U+DCFF range a byte that is +#: not UTF-8 decodes to, so the reason given is the surrogate's own. +SURROGATE_URL = "/remotes/\ud800/Atlas.git" + + +def refused_text(module, function: str) -> str: + """What `module`'s refusal `function` raises for `SURROGATE_URL` under + `--local-remote-dir`, as the tool prints it to stderr.""" + with pytest.raises(module.Refusal) as caught: + getattr(module, function)(True, SURROGATE_URL) + return f"{caught.value}\n" + + +def test_the_scaffold_explains_a_lone_surrogate(scaffold): + text = refused_text(scaffold, "_refuse_an_unwritable_clone_url") + assert text == scaffold_refusal(SURROGATE_URL, LONE_SURROGATE) + assert "or a lone surrogate, cannot be written at all" in text + + +def test_init_explains_a_lone_surrogate(): + text = refused_text(family_module(), "_refuse_an_unwritable_clone_url") + assert text == family_refusal(SURROGATE_URL, LONE_SURROGATE) + assert "or a lone surrogate, cannot be written at all" in text + + +def test_add_explains_a_lone_surrogate(): + text = refused_text(family_module(), "_refuse_an_unwritable_member_url") + assert text == member_refusal(SURROGATE_URL, LONE_SURROGATE) + assert "or a lone surrogate, reads back as another path" in text + + +def test_add_asks_only_a_local_url(): + """Against GitHub the member's URL is ASCII by construction.""" + assert family_module()._refuse_an_unwritable_member_url( + False, f"https://github.com/InkRouter/{SPARE}.git") is None + + +# --- the READMEs' clone line pastes as one command -------------------------- + +#: `(id, the folder the remotes go under, whether a POSIX shell needs the URL +#: quoted)`: the issue's two rows, and an ordinary name left bare. On Windows +#: the path is spelled with `\` and `:`, which `sh` reads as syntax, so the +#: ordinary row is quoted there too. +FOLDERS = [("space", "a b", True), ("apostrophe", "it's", True), + ("ordinary", "plain", os.name == "nt")] +FOLDER_IDS = [row[0] for row in FOLDERS] + + +def clone_line(url: str, quoted: bool) -> str: + """The clone line a POSIX shell reads as three words and the URL: the URL + in single quotes, each `'` in it closed, escaped and reopened, or bare.""" + word = "'" + url.replace("'", "'\\''") + "'" if quoted else url + return f"git clone --recurse-submodules {word}" + + +def clone_lines(readme: Path) -> list[str]: + """Every `git clone --recurse-submodules` line `readme` holds, read as + the bytes the tool wrote.""" + text = readme.read_bytes().decode("utf-8") + return [line for line in text.splitlines() + if line.startswith("git clone --recurse-submodules ")] + + +def assert_one_command(lines: list[str], url: str, quoted: bool) -> None: + """Each line is the clone line, and `shlex` reads it as `sh` does: the + three words and the URL whole.""" + assert lines and set(lines) == {clone_line(url, quoted)}, lines + assert shlex.split(lines[0]) == ["git", "clone", "--recurse-submodules", + url] + + +@pytest.mark.parametrize("label, folder, quoted", FOLDERS, ids=FOLDER_IDS) +def test_the_scaffolds_readmes_print_a_clone_line_that_pastes_whole( + tmp_path, label, folder, quoted): + """End to end into local bare remotes under `//`: the + assembly root's README (twice, getting started and parking) and each + leg's print the clone URL as one word. On main the `a b` row printed + `.../a b/remotes/Atlas.git` bare, which `sh` reads as two arguments.""" + remotes = tmp_path / folder / "remotes" + work = tmp_path / "work" + code, out, err = child(SCAFFOLD, *scaffold_argv(remotes, work, + dry_run=False)) + assert code == 0, err + out + url = str(remotes.resolve() / f"{PROJECT}.git") + root_lines = clone_lines(work / PROJECT / "README.md") + assert len(root_lines) == 2, root_lines + assert_one_command(root_lines, url, quoted) + for leg in ("spec", "code"): + assert_one_command(clone_lines(work / f"{PROJECT}-{leg}" / "README.md"), + url, quoted) + + +@pytest.mark.skipif(shutil.which("sh") is None, reason="no `sh` to paste into") +@pytest.mark.parametrize("label, folder, quoted", FOLDERS, ids=FOLDER_IDS) +def test_the_holders_readme_prints_a_clone_line_that_pastes_whole( + tmp_path, label, folder, quoted): + """End to end for `family.py init`, and the line then PASTED into `sh` + as a person pastes it: it clones the holder. On main the `a b` row + asked git to clone `.../a`, which is no repository.""" + remotes = tmp_path / folder / "remotes" + into = tmp_path / "into" + code, out, err = child(FAMILY, *family_argv(remotes, into, dry_run=False)) + assert code == 0, err + out + url = str(remotes.resolve() / f"{HOLDER}.git") + lines = clone_lines(into / HOLDER / HOLDER / "README.md") + assert len(lines) == 1, lines + assert_one_command(lines, url, quoted) + paste = tmp_path / "paste" + paste.mkdir() + done = pasted(lines, paste, ALLOW_FILE_PROTOCOL) + assert done.returncode == 0, done.stderr + assert (paste / HOLDER / "family.yaml").is_file() From 372196a7dc58771073367de00d7645660304247f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:37:33 +0000 Subject: [PATCH 2/2] family.py and scaffold-project.py: `add`'s `--local-remote-dir` remedy says the directory must hold the member's bare repository and names no new directory as a way out, and the clone-URL remedies and `_template_values` say the apostrophe is escaped and a Windows path quoted, so none sends a person to a second refusal or says a README escapes no character (#693) Lane 1's review of #812 (comment 6233341619) measured three texts of this branch that were false for a person following them. - `LOCAL_REMOTE_DIR_REMEDY` is shared by `init` and `add`, and said the directory could be "renamed or made anew". For `init` and the scaffold that holds: they make the bare repository there. For `add` the directory must already hold the member's `.git` (`_probe_member` clones it), so a new, empty one is refused again with `repository ... does not exist`. The remedy now takes the caller's words (`{holding}`): `init` keeps its text byte for byte, `add` says "and that holds the member's bare repository `.git`, its directory renamed or the repository moved". `MEMBER_REMEDIATION` in the new test file follows, and a new test makes both ways out of `add`'s refusal exit 0 with the member mounted. - "with no character escaped" was false for the PR's own `it's` row, which prints `'

/it'\''s/...'`. Both remedies, the two comments above them and `QUOTED_NOT_ESCAPED` (renamed `QUOTED_ONE_WORD`) now say "every character but an apostrophe written as itself". - `shell_quoted` has no `\` in its bare set, so a Windows path is quoted (`'C:\Users\me\remotes\Atlas.git'`, read by `sh` as `C:UsersmeremotesAtlas.git` on main). `_template_values` said an ordinary path is left bare; it now says an ordinary POSIX path is, and the test comment above `FOLDERS` says `\`, which `sh` reads as an escape, not `\` and `:`. Two test nits: the composite assert in `assert_one_command` is two asserts, and `refused_text` binds the refusal before the `pytest.raises` block so it holds one call. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scaffold-project.py | 18 +++++------ scripts/family.py | 48 ++++++++++++++++++---------- tests/test_clone_url_bytes.py | 13 +++++--- tests/test_local_remote_dir_urls.py | 49 ++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 38 deletions(-) diff --git a/scaffold-project.py b/scaffold-project.py index 53142c40..8f9c8f8e 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -912,8 +912,8 @@ def _refuse_unwritable_values(fields) -> None: #: text", which it was, a person renames the link and is refused again, so #: the remedy says where the fault is. And as `UNWRITABLE_REMEDIATION` says #: (#282), a lone surrogate, which a Windows name can hold, cannot be -#: written either: the READMEs quote the URL for a shell (#693) and escape -#: no character of it. +#: written either: the READMEs quote the URL for a shell (#693) and write +#: every character of it as itself but an apostrophe. CLONE_URL_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " @@ -926,13 +926,13 @@ def _refuse_unwritable_values(fields) -> None: "relative path, the directory it was given from. Rename or replace the " "directory whose name is not text, or pass a path that does not lead " "through it; renaming a link changes nothing. The READMEs print the " - "clone URL in a `git clone` line, in quotes where a shell needs them " - "but with no character escaped: a byte that is not UTF-8 in the path (a " - "directory named in a legacy code page), or a lone surrogate, cannot be " - "written at all, a line break or a control character would break or " - "garble the line it is written into, and a bidirectional embedding, " - "override or isolate control would show the rest of that line in " - "another order than it is written.") + "clone URL in a `git clone` line, in quotes where a shell needs them, " + "every character but an apostrophe written as itself: a byte that is " + "not UTF-8 in the path (a directory named in a legacy code page), or a " + "lone surrogate, cannot be written at all, a line break or a control " + "character would break or garble the line it is written into, and a " + "bidirectional embedding, override or isolate control would show the " + "rest of that line in another order than it is written.") def _refuse_an_unwritable_clone_url(local: bool, url: str) -> None: diff --git a/scripts/family.py b/scripts/family.py index 9cd07241..1e817a4d 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -1681,10 +1681,17 @@ def _holder_remote(args, family: str) -> tuple[bool, str, str]: #: the path that was typed. Told only to pass a path that is "one line of #: UTF-8 text", which it already was, a person renames the link and is #: refused again, so this says where the fault is. +#: +#: WHAT THE DIRECTORY MUST HOLD IS THE CALLER'S (`{holding}`, #693). `init` +#: makes the holder's bare repository there, so a directory made anew is a +#: way out of its refusal. `add` reads the member's bare `.git` +#: from it (`_probe_member`), so a new, empty directory is refused again +#: with `does not exist`: its remedy says the directory must hold the +#: repository, renamed or moved, as lane 1's review of #812 measured. LOCAL_REMOTE_DIR_REMEDY = ( "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " - "control character in it, a directory renamed or made anew, and run " - "again; or run `{verb}` against GitHub, without --local-remote-dir, " + "control character in it{holding}, and run again; or run `{verb}` " + "against GitHub, without --local-remote-dir, " "where the clone URL is `https://github.com//<{name}>.git`. The " "clone URL is the path --local-remote-dir resolves to, so where the path " "given is text and the URL named above is not, the fault is in what it " @@ -1695,20 +1702,22 @@ def _holder_remote(args, family: str) -> tuple[bool, str, str]: #: What a `family-unwritable-value` for the holder's clone URL is told to do #: (#433). The holder's `README.md` quotes the URL for a shell (#693) and -#: escapes no character of it, so a lone surrogate, which a Windows name can -#: hold, cannot be written either, as #282's `UNWRITABLE_REMEDIATION` in -#: `scaffold-project.py` says (#693). +#: writes every character of it as itself but an apostrophe, so a lone +#: surrogate, which a Windows name can hold, cannot be written either, as +#: #282's `UNWRITABLE_REMEDIATION` in `scaffold-project.py` says (#693). CLONE_URL_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " - + LOCAL_REMOTE_DIR_REMEDY.format(verb="init", name="Family") + + LOCAL_REMOTE_DIR_REMEDY.format( + verb="init", name="Family", + holding=", a directory renamed or made anew") + " The holder's `README.md` prints the clone URL in a `git clone` " - "line, in quotes where a shell needs them but with no character " - "escaped: a byte that is not UTF-8 in the path (a directory named in a " - "legacy code page), or a lone surrogate, cannot be written at all, a " - "line break or a control character would break or garble the line it " - "is written into, and a bidirectional embedding, override or isolate " - "control would show the rest of that line in another order than it is " - "written.") + "line, in quotes where a shell needs them, every character but an " + "apostrophe written as itself: a byte that is not UTF-8 in the path (a " + "directory named in a legacy code page), or a lone surrogate, cannot be " + "written at all, a line break or a control character would break or " + "garble the line it is written into, and a bidirectional embedding, " + "override or isolate control would show the rest of that line in " + "another order than it is written.") #: What a `family-unwritable-value` for a member's clone URL is told to do #: (#693). `git submodule add` records the URL in the holder's `.gitmodules` @@ -1719,7 +1728,11 @@ def _holder_remote(args, family: str) -> tuple[bool, str, str]: #: and a bidi override came back as themselves. MEMBER_URL_REMEDIATION = ( "Remediation: `--dry-run` refuses the same. " - + LOCAL_REMOTE_DIR_REMEDY.format(verb="add", name="Project") + + LOCAL_REMOTE_DIR_REMEDY.format( + verb="add", name="Project", + holding=" and that holds the member's bare repository " + "`.git`, its directory renamed or the repository " + "moved") + " The holder's `.gitmodules` records the clone URL as git is given " "it, and the holder's `make siblings` reads it back as UTF-8 text, a " "line a value: a byte that is not UTF-8 in the path (a directory named " @@ -1834,9 +1847,10 @@ def _template_values(args, family: str, family_id: str, repository: str, holding `$`, `'`, `;` or `&` broke it too. It is spelled by `shell_quoted` in the POSIX dialect whatever the host, since the README is a committed file read on every OS and pasted commands are for a POSIX - shell (#532); a GitHub URL and an ordinary path are left bare. Only this - value is quoted: the block's other placeholder, `{{FAMILY}}` in `cd - {{FAMILY}}`, is a name the naming policy spells in letters and digits. + shell (#532); a GitHub URL and an ordinary POSIX path are left bare (a + Windows path, spelled with `\\`, is quoted). Only this value is quoted: + the block's other placeholder, `{{FAMILY}}` in `cd {{FAMILY}}`, is a + name the naming policy spells in letters and digits. """ return { "FAMILY": family, diff --git a/tests/test_clone_url_bytes.py b/tests/test_clone_url_bytes.py index f3044151..e01b99e7 100644 --- a/tests/test_clone_url_bytes.py +++ b/tests/test_clone_url_bytes.py @@ -90,16 +90,19 @@ "for a relative path, the directory it was given from. Rename or replace " "the directory whose name is not text, or pass a path that does not lead " "through it; renaming a link changes nothing. ") -#: How a README prints the clone URL since #693: one word of a POSIX shell. -QUOTED_NOT_ESCAPED = ("the clone URL in a `git clone` line, in quotes where a " - "shell needs them but with no character escaped: ") +#: How a README prints the clone URL since #693: one word of a POSIX shell, +#: in quotes where one is needed, an apostrophe the only character written as +#: something else. +QUOTED_ONE_WORD = ("the clone URL in a `git clone` line, in quotes where a " + "shell needs them, every character but an apostrophe " + "written as itself: ") SCAFFOLD_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "Pass a --local-remote-dir whose path is one line of UTF-8 text with no " "control character in it, a directory renamed or made anew, and run " "again; or scaffold against GitHub, without --local-remote-dir, where " "the clone URL is `https://github.com//.git`. " - + THE_RESOLVED_PATH + "The READMEs print " + QUOTED_NOT_ESCAPED + + THE_RESOLVED_PATH + "The READMEs print " + QUOTED_ONE_WORD + WHY_IT_IS_REFUSED) FAMILY_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " @@ -108,7 +111,7 @@ "again; or run `init` against GitHub, without --local-remote-dir, where " "the clone URL is `https://github.com//.git`. " + THE_RESOLVED_PATH + "The holder's `README.md` prints " - + QUOTED_NOT_ESCAPED + WHY_IT_IS_REFUSED) + + QUOTED_ONE_WORD + WHY_IT_IS_REFUSED) @pytest.fixture diff --git a/tests/test_local_remote_dir_urls.py b/tests/test_local_remote_dir_urls.py index 5586256e..8809e3f6 100644 --- a/tests/test_local_remote_dir_urls.py +++ b/tests/test_local_remote_dir_urls.py @@ -58,12 +58,15 @@ LONE_SURROGATE = "it holds a lone surrogate, which is no character" #: What `add`'s refusal tells a person to do: `init`'s way out, with `add`'s -#: verb and GitHub URL, and why `.gitmodules` cannot hold the URL as text. +#: verb and GitHub URL, but a directory that already holds the member's bare +#: repository, not one made anew (a new, empty one is refused again), and why +#: `.gitmodules` cannot hold the URL as text. MEMBER_REMEDIATION = ( "Remediation: `--dry-run` refuses the same. Pass a --local-remote-dir " - "whose path is one line of UTF-8 text with no control character in it, " - "a directory renamed or made anew, and run again; or run `add` against " - "GitHub, without --local-remote-dir, where the clone URL is " + "whose path is one line of UTF-8 text with no control character in it " + "and that holds the member's bare repository `.git`, its " + "directory renamed or the repository moved, and run again; or run `add` " + "against GitHub, without --local-remote-dir, where the clone URL is " "`https://github.com//.git`. " + THE_RESOLVED_PATH + "The holder's `.gitmodules` records the clone URL as git is given it, " "and the holder's `make siblings` reads it back as UTF-8 text, a line a " @@ -150,6 +153,36 @@ def test_add_through_a_link_to_such_a_directory_says_the_target_is_the_fault( offline.assert_left_nothing() +@pytest.mark.parametrize("how", ["directory-renamed", "repository-moved"]) +def test_add_follows_its_own_remedy_to_a_mount(family, holder, offline, + tmp_path, how): + """`add`'s remedy says to pass a path of text that HOLDS the member's bare + repository, "its directory renamed or the repository moved". Both + are measured: from the refused `caf` directory, either one makes + `add` exit 0 with the member mounted, and the pin in `.gitmodules` is + the new path. The words "made anew" are not there because a new, empty + directory is refused again (`.git` does not exist in it), which + lane 1's review of #812 measured.""" + bad = holding_the_spare(family, directory_named(tmp_path, b"caf\xe9")) + refused = offline.add(holder, SPARE, remotes=bad) + assert refused.returncode == 2, refused.stderr + assert "family-unwritable-value" in refused.stderr + if how == "directory-renamed": + good = tmp_path / "renamed" + os.rename(bad, good) + else: + good = tmp_path / "moved" + good.mkdir() + shutil.move(bad / f"{SPARE}.git", good / f"{SPARE}.git") + result = offline.add(holder, SPARE, remotes=good) + assert result.returncode == 0, result.stderr + result.stdout + url = str(good.resolve() / f"{SPARE}.git") + assert git("config", "-f", ".gitmodules", "--get", + f"submodule.members/{SPARE}.url", + cwd=holder).stdout == f"{url}\n" + offline.assert_left_nothing() + + def test_add_from_a_utf8_directory_still_mounts(family, holder, offline, tmp_path): """The control, `

/café`: text, so `add` mounts and pins it, exit 0, @@ -210,8 +243,9 @@ def test_a_family_through_a_link_says_the_target_is_the_fault( def refused_text(module, function: str) -> str: """What `module`'s refusal `function` raises for `SURROGATE_URL` under `--local-remote-dir`, as the tool prints it to stderr.""" + refuse = getattr(module, function) with pytest.raises(module.Refusal) as caught: - getattr(module, function)(True, SURROGATE_URL) + refuse(True, SURROGATE_URL) return f"{caught.value}\n" @@ -243,7 +277,7 @@ def test_add_asks_only_a_local_url(): #: `(id, the folder the remotes go under, whether a POSIX shell needs the URL #: quoted)`: the issue's two rows, and an ordinary name left bare. On Windows -#: the path is spelled with `\` and `:`, which `sh` reads as syntax, so the +#: the path is spelled with `\`, which `sh` reads as an escape, so the #: ordinary row is quoted there too. FOLDERS = [("space", "a b", True), ("apostrophe", "it's", True), ("ordinary", "plain", os.name == "nt")] @@ -268,7 +302,8 @@ def clone_lines(readme: Path) -> list[str]: def assert_one_command(lines: list[str], url: str, quoted: bool) -> None: """Each line is the clone line, and `shlex` reads it as `sh` does: the three words and the URL whole.""" - assert lines and set(lines) == {clone_line(url, quoted)}, lines + assert lines, lines + assert set(lines) == {clone_line(url, quoted)}, lines assert shlex.split(lines[0]) == ["git", "clone", "--recurse-submodules", url]