From aa95b6b28db971906ec2231fe8c9b414e43182f4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 1/3] WIP (account-swap checkpoint; untested on these bytes): family.py init: the creator is asked whether a file can hold it whichever of `--created-by`, GIT_AUTHOR_NAME or `git config user.name` gave it, git is asked about the first commit's author and committer before the remote exists, and a `--family` that is a Windows device name is refused, `--dry-run` alike (#688) #426 (#641) asked `unwritable_reason` of the creator only when `git config user.name` supplied it, so `--created-by $'Ada\nLovelace' --dry-run` exited 0 with the plan's `created by` line broken in two, and a `--created-by` or a GIT_AUTHOR_NAME holding U+202E, an ESC, a tab or a byte that is not UTF-8 exited 0 too. `_creator_name` now asks it once, of whichever source answered, and `family-unwritable-value`'s remediation names that source (`CREATOR_UNWRITABLE_REMEDIATIONS`): retype the flag, correct the variable, or correct the setting, whose remediation is #426's, byte for byte. The detail still names the creator as `_manifest_flags` (#307) names it, so this refusal and `family-manifest-invalid` agree on who is meant. A GIT_AUTHOR_NAME of whitespace alone is passed over when the creator is read, but `env_commit` replaces a variable only when it is unset or empty, so `git commit` was handed the whitespace and refused it, exit 2, after `/.git` and the work directory existed. `cmd_init` now asks git, after the clone URL and manifest questions and before the landing and the plan, `git var GIT_AUTHOR_IDENT` and `git var GIT_COMMITTER_IDENT`, from an empty directory and no repository as `_ask_git_branch` asks, about each role the person set a variable of, with stand-ins where `env_commit` puts its fallback; what git refuses is `family-bad-commit-identity`, naming the role's variables and quoting git. Of the issue's two directions this is the one that also catches what git refuses that is not whitespace, a name of only `<>` and a date it cannot read, both of which failed at the same commit. `--family CON` (`Nul`, `com1`, any Windows device name) is one CamelCase token, which is all the naming policy asks, and no Windows disk can make the holder's folder. `_checked_family_identity` now asks the leg path rule (`shape_materialize._unmountable_reason`) of the name, after the policy, and refuses it as `family-bad-holder-name` in that rule's words. The rule's other Windows answers, a trailing dot or space, were already refused before anything existed (`family-bad-id` or `naming-unclassified`, and `unsafe-value`) and still are. Each question is asked before any remote or folder exists, and `--dry-run` refuses alike. tests/test_family_init_asks_every_value.py holds every row, on the harness of tests/test_non_utf8_git_reads.py. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 230 ++++++++++-- tests/test_family_init_asks_every_value.py | 387 +++++++++++++++++++++ 2 files changed, 591 insertions(+), 26 deletions(-) create mode 100644 tests/test_family_init_asks_every_value.py diff --git a/scripts/family.py b/scripts/family.py index e4f430cb..e5db7955 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -195,6 +195,10 @@ # holder's clone URL under `--local-remote-dir` (#255, #433). It is the # question the scaffold and `adopt-project.py` ask of theirs. from shape_materialize import unwritable_reason # noqa: E402 +# Whether `--family` names a folder a Windows disk can make (#688): the rule +# the scaffold and `adopt-project.py` hold a leg's mount path to, asked of a +# path of one segment. +from shape_materialize import _unmountable_reason # noqa: E402 NAMING_POLICY = SHAPE_ROOT / "contracts" / "repository-naming.yaml" MANIFEST = "family.yaml" @@ -612,6 +616,50 @@ def _classify_family_name(policy: NamingPolicy, name: str) -> None: "construction and cannot be declared into a holder.") +#: What `family-bad-holder-name` is told to do (#688). +HOLDER_NAME_REMEDIATION = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Choose another --family. It names the holder, its repository and the " + "folder it is cloned into and, unless --work-dir is given, the family " + "folder around it, which `make siblings` looks for by that very name; " + "and a holder is cloned on Windows too, where no file or folder can be " + "called a device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, " + "`LPT1`-`LPT9`, in any case, with or without an extension), and a name " + "ending in a dot or a space is made under another name. There is no " + "--force.") + + +def _refuse_an_unholdable_holder_name(family: str) -> None: + """`--family` names a folder a Windows disk can make, or this refuses + as `family-bad-holder-name` (#688), before anything exists and under + `--dry-run` alike. + + `init` makes the holder at `//` (`--work-dir`: + `/`), its bare repository under `--local-remote-dir` is + `.git`, and `make siblings` goes on only in a family folder named + exactly `--family` (#543). `CON`, `Nul`, `com1` and the other device + names are each one CamelCase token, which is all the naming policy asks, + so `init` made and pushed a holder that no Windows disk could clone: it + can make no file or folder of that name, with or without an extension. + + THE QUESTION IS THE LEG PATH RULE'S (`_unmountable_reason`), the one the + scaffold and `adopt-project.py` ask of a leg's mount path, asked of a + path of one segment, in its own words. It is asked after + `_classify_family_name` on purpose: the policy's token holds no `.`, no + `~` and no space, so a device name is the only answer the rule can give + here, and the names it also refuses (a trailing dot or space, `.git`, + `git~1`, `.gitmodules`) are refused before it, as `unsafe-value`, + `family-bad-id` or `naming-unclassified`, as they always were. + """ + reason = _unmountable_reason(family) + if reason: + raise Refusal( + "family-bad-holder-name", + f"--family is {family!r}, which names the holder and the folders " + f"`init` makes for it, and {reason}.", + HOLDER_NAME_REMEDIATION) + + def _read_member_project(path: Path, project: str, repository: str, shown: str | None = None) -> str: """The member's OWN `project.yaml` id, and that it is an assembly root. @@ -1529,23 +1577,136 @@ def _checked_family_identity(args) -> tuple[str, str, str]: "Remediation: pass an explicit lowercase --id.") policy = NamingPolicy.load(NAMING_POLICY) _classify_family_name(policy, family) + _refuse_an_unholdable_holder_name(family) topic = policy.topic_for(family_id) return family, family_id, topic +#: Why a creator `unwritable_reason` refuses cannot be recorded (#426): how +#: each remediation below ends. +CREATOR_UNWRITABLE_WHY = ( + "which the manifest could keep only as a lone surrogate, and that is no " + "character; a line break or a control character would break or garble " + "the line the plan prints it on; and a bidirectional embedding, override " + "or isolate control would show the rest of that line in another order " + "than it is written.") + #: What `family-unwritable-value` is told to do (#426), in the scaffold's -#: words for the same refusal (`scaffold-unwritable-value`, #282). -CREATOR_UNWRITABLE_REMEDIATION = ( +#: words for the same refusal (`scaffold-unwritable-value`, #282), BY WHERE +#: `_creator_name` READ THE CREATOR (#688). Each names the flag, the variable +#: or the setting that supplied the name, so the person corrects the one that +#: did; the setting's is the words #426 gave it. +CREATOR_UNWRITABLE_REMEDIATIONS = { + "--created-by": ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Pass --created-by again, retyped. A byte that is not UTF-8 reaches " + "a command line from a terminal or a script whose encoding is not " + "the locale's, " + CREATOR_UNWRITABLE_WHY), + "GIT_AUTHOR_NAME": ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "`init` read the creator from GIT_AUTHOR_NAME, because no " + "--created-by was given: pass --created-by 'Your Name', or correct " + "that variable. The environment keeps it as the bytes it was given, " + "so a name set in a legacy code page holds a byte that is not UTF-8, " + + CREATOR_UNWRITABLE_WHY), + "`git config user.name`": ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "`init` read the creator from `git config user.name`, because no " + "--created-by was given and GIT_AUTHOR_NAME names nobody: pass " + "--created-by 'Your Name', or correct that setting. git keeps it as " + "the bytes it was given, so a name saved in a legacy code page holds " + "a byte that is not UTF-8, " + CREATOR_UNWRITABLE_WHY), +} + +#: The variables git makes the holder's first commit with, by role, and the +#: identity `git var` names for that role (#688). `env_commit` gives a name +#: or an address that is unset or empty a fallback of its own, and passes a +#: date on as it stands. +COMMIT_IDENTITY = ( + ("author", "GIT_AUTHOR_IDENT", + ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE")), + ("committer", "GIT_COMMITTER_IDENT", + ("GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_COMMITTER_DATE")), +) + +#: A name and an address git takes, standing in for one the person left +#: unset or empty, where `env_commit` puts its fallback: git is then asked +#: about the person's own variables, beside values it accepts. +COMMIT_IDENTITY_STAND_IN = { + "GIT_AUTHOR_NAME": "openRepoShape family", + "GIT_AUTHOR_EMAIL": "family@openreposhape.invalid", + "GIT_COMMITTER_NAME": "openRepoShape family", + "GIT_COMMITTER_EMAIL": "family@openreposhape.invalid", +} + +#: What `family-bad-commit-identity` is told to do (#688). +COMMIT_IDENTITY_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " - "`init` read the creator from `git config user.name`, because no " - "--created-by was given and GIT_AUTHOR_NAME names nobody: pass " - "--created-by 'Your Name', or correct that setting. git keeps it as the " - "bytes it was given, so a name saved in a legacy code page holds a byte " - "that is not UTF-8, which the manifest could keep only as a lone " - "surrogate, and that is no character; a line break or a control " - "character would break or garble the line the plan prints it on; and a " - "bidirectional embedding, override or isolate control would show the " - "rest of that line in another order than it is written.") + "Correct each variable named, or unset it, and run again. `init` makes " + "the holder's first commit with them only after the holder's remote and " + "work directory exist, and git would refuse them there. Unset or empty, " + "a name or an address is `init`'s own and a date is the moment of the " + "commit; --created-by sets who `family.yaml` records, not who git makes " + "the commit as.") + + +def _refuse_an_unusable_commit_identity() -> None: + """The author and the committer git will make the holder's first commit + as are ones git accepts, or this refuses as `family-bad-commit-identity` + (#688), before anything exists and under `--dry-run` alike. + + `_creator_name` passes over a GIT_AUTHOR_NAME of whitespace alone and + reads `git config user.name`, so `family.yaml` was written from a good + name. But `env_commit` replaces a variable only when it is unset or + empty, so `git commit` was handed the whitespace, and refused it (`fatal: + name consists only of disallowed characters`), exit 2, after the holder's + remote and work directory existed. A GIT_COMMITTER_NAME of whitespace, a + name of nothing but `<>`, and a GIT_AUTHOR_DATE or GIT_COMMITTER_DATE git + cannot read failed at the same commit in the same way. + + GIT'S OWN ANSWER, as `refuse_bad_branch` asks git of a branch (#283): + `git var GIT_AUTHOR_IDENT` and `git var GIT_COMMITTER_IDENT` build the + identity by the rules `git commit` applies, so the question is no copy + of git's rules that could fall behind them. Each role is asked with the + person's own variables and `COMMIT_IDENTITY_STAND_IN` where `env_commit` + would put its fallback, and only a role the person set a variable of: + the stand-ins alone are an identity git takes. Asked of NO repository, + from an empty directory, as `_ask_git_branch` asks: no repository's + config plays a part, as none but the new holder's does at the commit, + and `refuse_bad_branch` has just had an answer from git there, so a + refusal here is about the variables. The detail names every variable of + the role the person set, with git's own words, which say which one. + + `cmd_init` ASKS IT AFTER THE QUESTIONS ABOUT WHAT IT WRITES, before the + landing, the plan and anything made: the clone URL's refusal (#433) + still comes before any process but `git check-ref-format`, and the + manifest's (#307) before `run` is reached. + """ + problems = [] + for role, ident, variables in COMMIT_IDENTITY: + given = [f"{key} {os.environ[key]!r}" for key in variables + if os.environ.get(key)] + if not given: + continue + identity = {key: os.environ.get(key) or stand_in + for key, stand_in in COMMIT_IDENTITY_STAND_IN.items() + if key in variables} + nowhere = tempfile.mkdtemp(prefix="openreposhape-ident-") + try: + run(["git", f"--git-dir={nowhere}", "var", ident], + cwd=Path(nowhere), env=identity) + except CommandFailed as exc: + listed = (", ".join(given[:-1]) + " and " + given[-1] + if len(given) > 1 else given[0]) + problems.append( + f"git refuses the {role} the holder's first commit would be " + f"made as, from {listed} (`git var {ident}`: {exc.output})") + finally: + # Empty: git writes nothing there (`_ask_git_branch`'s reason). + shutil.rmtree(nowhere, ignore_errors=True) + if problems: + raise Refusal("family-bad-commit-identity", "; ".join(problems) + ".", + COMMIT_IDENTITY_REMEDIATION) def _creator_name(args) -> str: @@ -1568,29 +1729,36 @@ def _creator_name(args) -> str: holds a line break, a control character or a bidi control is refused in the same words as one that holds a byte that is not UTF-8. It is asked here, before the holder's remote or work directory, and `--dry-run` - refuses alike. ONLY WHAT `git config user.name` ANSWERED IS ASKED: a - `--created-by` or a GIT_AUTHOR_NAME holding such a byte is another - question (#426 leaves it), and neither is read as git's answer. + refuses alike. + + WHATEVER ANSWERED IS ASKED (#688). #426 asked only what `git config + user.name` answered, so the same name was refused from the setting and + accepted from `--created-by` or GIT_AUTHOR_NAME: `--created-by + $'Ada\\nLovelace' --dry-run` exited 0 with the plan's `created by` line + broken in two, and one holding U+202E exited 0. The scaffold asks its + elector whatever the source (`_identity`), and so does this now, once, + of the name that answered. The detail names the creator as + `_manifest_flags` (#307) names it, so this refusal and + `family-manifest-invalid` agree on who is meant; the remediation names + the flag, the variable or the setting that answered + (`CREATOR_UNWRITABLE_REMEDIATIONS`), for that is the one to correct. + + GIT_AUTHOR_NAME is also who git makes the holder's first commit as, and + a blank one passed over here is not passed over there: `cmd_init` asks + git about that later (`_refuse_an_unusable_commit_identity`, #688). """ - created_by = args.created_by or (os.environ.get("GIT_AUTHOR_NAME") or "").strip() + created_by, source = args.created_by, "--created-by" + if not created_by: + created_by = (os.environ.get("GIT_AUTHOR_NAME") or "").strip() + source = "GIT_AUTHOR_NAME" if not created_by: + source = "`git config user.name`" try: created_by = git_out(["config", "user.name"], cwd=SHAPE_ROOT, binary=True).decode( "utf-8", "surrogateescape").strip() except Refusal: created_by = "" - else: - reason = unwritable_reason(created_by) - if reason: - # Named as `_manifest_flags` (#307) names a creator no flag - # supplied, so the two refusals agree on who is meant. - raise Refusal( - "family-unwritable-value", - f"{_manifest_flags(args)['CREATED_BY']} is " - f"{created_by!r}, which `init` records in the holder's " - f"`family.yaml`, and {reason}.", - CREATOR_UNWRITABLE_REMEDIATION) if not created_by: raise Refusal( "family-no-creator", @@ -1598,6 +1766,13 @@ def _creator_name(args) -> str: "user.name`. Creating a family is a human's act and the manifest " "records whose.", "Remediation: re-run with --created-by 'Your Name'.") + reason = unwritable_reason(created_by) + if reason: + raise Refusal( + "family-unwritable-value", + f"{_manifest_flags(args)['CREATED_BY']} is {created_by!r}, which " + f"`init` records in the holder's `family.yaml`, and {reason}.", + CREATOR_UNWRITABLE_REMEDIATIONS[source]) return created_by @@ -2382,6 +2557,9 @@ def cmd_init(args) -> int: # own reader and `validate-family.py`, before the plan is printed, so a # `--dry-run` refuses what the real run would, and before `gh repo create`. refuse_unholdable_manifest(args, values) + # AND THE IDENTITY GIT WILL MAKE THE FIRST COMMIT AS (#688), asked of git + # here, before the landing and the plan, so `--dry-run` refuses it too. + _refuse_an_unusable_commit_identity() folder, holder = _checked_landing(args, family) _print_init_plan(values, repository, url, shape_commit, shape_tree, diff --git a/tests/test_family_init_asks_every_value.py b/tests/test_family_init_asks_every_value.py new file mode 100644 index 00000000..94878c2d --- /dev/null +++ b/tests/test_family_init_asks_every_value.py @@ -0,0 +1,387 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py init` asks every name it writes down or lays out, whichever +flag or variable supplied it, before anything exists and under `--dry-run` +alike (#688). + +Three lines of #459, each measured on main before this change: + + * THE CREATOR, FROM EVERY SOURCE. #426 asked `unwritable_reason` of the + creator only when `git config user.name` supplied it, so `--created-by + $'Ada\\nLovelace' --dry-run` exited 0 with the plan's `created by` line + broken in two, and a `--created-by` or a GIT_AUTHOR_NAME holding U+202E, + an ESC or a byte that is not UTF-8 exited 0 too. Now each is + `family-unwritable-value`, and the remediation names the flag, the + variable or the setting that supplied the name; + * THE COMMIT'S IDENTITY. `_creator_name` passes over a GIT_AUTHOR_NAME of + whitespace alone and reads `git config user.name`, but `git commit` reads + the variable: a real run exited 2 with `fatal: name consists only of + disallowed characters` after `/Contoso.git` and the work + directory existed. Now git is asked first (`git var`), and the run is + `family-bad-commit-identity`. A name of only `<>` and a date git cannot + read failed at the same commit, and are asked the same way; + * THE HOLDER'S NAME. `--family CON` (or `Nul`, `com1`, any Windows device + name) is one CamelCase token, which is all the naming policy asks, and a + Windows disk can make no folder of that name. Now it is + `family-bad-holder-name`, in the words of the leg path rule + (`_unmountable_reason`). The rule's other two Windows answers, a name + ending in a dot or a space, were already refused before anything existed + (`family-bad-id` or `naming-unclassified`, and `unsafe-value`); their + rows pin that. + +The harness is `tests/test_non_utf8_git_reads.py`'s (#426): `family.py init` +of this checkout into local bare remotes under `tmp_path`, which never calls +`gh`. NO NETWORK AND NO GITHUB. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +from conftest import REPO +from test_non_utf8_git_reads import ( + BIDI, CONTROL, CREATOR_NOT_GIVEN, HOLDER, LEGACY_NAME, LINE_BREAK, + NOT_UTF8_REASON, POSIX_ENVIRONMENT_ONLY, REFUSED, REMEDIATION, + WRITTEN_INTO, init, nothing_was_created, user_name, +) + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import load_yaml # noqa: E402 + +#: A byte that is not UTF-8 in an ARGUMENT: POSIX passes the byte; Windows +#: passes UTF-16, where it cannot be one. +POSIX_ARGV_ONLY = pytest.mark.skipif( + os.name == "nt", + reason="a POSIX command line's bytes; Windows' is UTF-16") + +DRY_RUN = pytest.mark.parametrize("dry_run", [True, False], + ids=["dry-run", "real"]) +LANDING = pytest.mark.parametrize("landing", ["--work-dir", "--into"]) + +#: How each remediation for an unwritable creator ends (#426). +WHY = ("which the manifest could keep only as a lone surrogate, and that is " + "no character; a line break or a control character would break or " + "garble the line the plan prints it on; and a bidirectional " + "embedding, override or isolate control would show the rest of that " + "line in another order than it is written.") +NOTHING = ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. ") +#: The creator came from `--created-by`. +FLAG_REMEDIATION = ( + NOTHING + "Pass --created-by again, retyped. A byte that is not UTF-8 " + "reaches a command line from a terminal or a script whose encoding is " + "not the locale's, " + WHY) +#: The creator came from GIT_AUTHOR_NAME. +ENV_REMEDIATION = ( + NOTHING + "`init` read the creator from GIT_AUTHOR_NAME, because no " + "--created-by was given: pass --created-by 'Your Name', or correct that " + "variable. The environment keeps it as the bytes it was given, so a name " + "set in a legacy code page holds a byte that is not UTF-8, " + WHY) +#: The creator came from `git config user.name`: #426's words, whole. +CONFIG_REMEDIATION = ( + REMEDIATION + "git keeps it as the bytes it was given, so a name saved " + "in a legacy code page holds a byte that is not UTF-8, " + WHY) + + +#: The one line `_shape_snapshot` prints first from a checkout with +#: uncommitted changes. The identity question comes after it. +DIRTY = "WARNING the openRepoShape checkout is DIRTY." + + +def refused(result, expected: str, tmp_path) -> None: + """Exit 2, the plan never printed, stderr opening with `expected` (after + the DIRTY warning, where there is one), and no remote, work directory or + family folder made.""" + assert result.returncode == 2, result.stderr + result.stdout + assert result.stdout == "", "the plan is never printed" + err = result.stderr + if err.startswith(DIRTY): + err = err.split("\n", 1)[1] + assert err.startswith(expected), result.stderr + assert "Traceback" not in result.stderr, result.stderr + nothing_was_created(tmp_path) + + +def initialised(result, tmp_path, dry_run: bool) -> None: + assert result.returncode == 0, result.stderr + result.stdout + assert "REFUSED" not in result.stderr, result.stderr + assert (tmp_path / "remotes").exists() is (not dry_run), ( + "a dry run creates no remote and a real run does") + + +# --------------------------------------------------------------------------- +# The creator, from every source +# --------------------------------------------------------------------------- + +UNWRITABLE = [ + pytest.param("Ada\nLovelace", LINE_BREAK, id="lf"), + pytest.param("Ada‮Lovelace", BIDI, id="u202e"), + pytest.param("Ada\x1bLovelace", CONTROL, id="esc"), + pytest.param("Ada\tLovelace", CONTROL, id="tab"), +] + + +@DRY_RUN +@pytest.mark.parametrize("value, reason", UNWRITABLE) +def test_a_created_by_no_file_can_hold_is_refused_before_anything_exists( + tmp_path, value, reason, dry_run): + """THE ISSUE'S ROWS: `--created-by $'Ada\\nLovelace'` and one holding + U+202E exited 0, the first with the plan's line broken after `created by + Ada`. Now `family-unwritable-value`, naming the flag, whose remediation + is to retype it.""" + result = init(tmp_path, {}, "--created-by", value, dry_run=dry_run) + refused(result, f"{REFUSED}--created-by is {value!r}, {WRITTEN_INTO}" + f"{reason}.\n{FLAG_REMEDIATION}\n", tmp_path) + + +@POSIX_ARGV_ONLY +def test_a_created_by_that_is_not_utf8_is_refused(tmp_path): + """`--created-by Jos` from a terminal whose encoding is not the + locale's printed `created by Jos\\xe9` and exited 0.""" + result = init(tmp_path, {}, "--created-by", LEGACY_NAME, dry_run=True) + refused(result, f"{REFUSED}--created-by is {LEGACY_NAME!r}, " + f"{WRITTEN_INTO}{NOT_UTF8_REASON}.\n{FLAG_REMEDIATION}\n", + tmp_path) + + +@DRY_RUN +@pytest.mark.parametrize("value, reason", UNWRITABLE) +def test_a_git_author_name_no_file_can_hold_is_refused_before_anything_exists( + tmp_path, value, reason, dry_run): + """The same names through GIT_AUTHOR_NAME, which answers when no + `--created-by` is given: refused as the setting is, and the remediation + names the variable.""" + result = init(tmp_path, {"GIT_AUTHOR_NAME": value}, dry_run=dry_run) + refused(result, f"{REFUSED}{CREATOR_NOT_GIVEN} is {value!r}, " + f"{WRITTEN_INTO}{reason}.\n{ENV_REMEDIATION}\n", tmp_path) + + +@POSIX_ENVIRONMENT_ONLY +def test_a_git_author_name_that_is_not_utf8_is_refused(tmp_path): + """GIT_AUTHOR_NAME `Jos` printed `created by Jos\\xe9` and exited + 0; `os.environ` keeps the byte as `sys.argv` does.""" + result = init(tmp_path, {"GIT_AUTHOR_NAME": LEGACY_NAME}, dry_run=True) + refused(result, f"{REFUSED}{CREATOR_NOT_GIVEN} is {LEGACY_NAME!r}, " + f"{WRITTEN_INTO}{NOT_UTF8_REASON}.\n{ENV_REMEDIATION}\n", + tmp_path) + + +def test_the_setting_keeps_the_remediation_426_gave_it(tmp_path): + """`git config user.name` answering is told what #426 told it, whole: + each source's remediation names that source, and only it.""" + value = "Ada\nLovelace" + result = init(tmp_path, user_name(value), dry_run=True) + refused(result, f"{REFUSED}{CREATOR_NOT_GIVEN} is {value!r}, " + f"{WRITTEN_INTO}{LINE_BREAK}.\n{CONFIG_REMEDIATION}\n", + tmp_path) + + +@DRY_RUN +@pytest.mark.parametrize("flags, env, recorded", [ + pytest.param(("--created-by", "José"), {}, "José", id="flag"), + pytest.param((), {"GIT_AUTHOR_NAME": "Mehr‌dad"}, "Mehr‌dad", + id="env"), +]) +def test_a_creator_that_is_text_still_initialises(tmp_path, flags, env, + recorded, dry_run): + """THE CONTROLS: an accent and a U+200C ZERO WIDTH NON-JOINER are text a + file holds, from the flag or the variable, as they are from the + setting.""" + result = init(tmp_path, env, *flags, dry_run=dry_run) + initialised(result, tmp_path, dry_run) + assert f"created by {recorded} on 2026-09-02" in result.stdout + if not dry_run: + manifest = load_yaml(tmp_path / "work" / HOLDER / "family.yaml") + assert manifest["created_by"] == recorded + + +# --------------------------------------------------------------------------- +# The identity git makes the holder's first commit as +# --------------------------------------------------------------------------- + +#: Git's own words, in English, so they are fixed text: no translation runs +#: under the C locale, and UTF-8 mode keeps the tool's own output UTF-8. +GIT_IN_ENGLISH = {"LC_ALL": "C", "PYTHONUTF8": "1"} +#: An identity git takes, every variable of it set, so a row names exactly +#: what it changes; no date, so git uses the moment of the commit. +IDENTITY = {"GIT_AUTHOR_NAME": "Ada", "GIT_AUTHOR_EMAIL": "ada@example.invalid", + "GIT_COMMITTER_NAME": "Ada", + "GIT_COMMITTER_EMAIL": "ada@example.invalid", + "GIT_AUTHOR_DATE": "", "GIT_COMMITTER_DATE": ""} + +BAD_IDENTITY = "REFUSED family-bad-commit-identity: " +DISALLOWED = "fatal: name consists only of disallowed characters:" +COMMIT_IDENTITY_REMEDIATION = ( + NOTHING + "Correct each variable named, or unset it, and run again. " + "`init` makes the holder's first commit with them only after the " + "holder's remote and work directory exist, and git would refuse them " + "there. Unset or empty, a name or an address is `init`'s own and a date " + "is the moment of the commit; --created-by sets who `family.yaml` " + "records, not who git makes the commit as.") + + +def refuses_the(role: str, ident: str, listed: str, said: str) -> str: + return (f"git refuses the {role} the holder's first commit would be made " + f"as, from {listed} (`git var {ident}`: {said})") + + +AUTHOR_BLANK = refuses_the( + "author", "GIT_AUTHOR_IDENT", + "GIT_AUTHOR_NAME ' ' and GIT_AUTHOR_EMAIL 'ada@example.invalid'", + DISALLOWED) +COMMITTER_BLANK = refuses_the( + "committer", "GIT_COMMITTER_IDENT", + "GIT_COMMITTER_NAME ' ' and GIT_COMMITTER_EMAIL 'ada@example.invalid'", + DISALLOWED) + + +def identity(**changed: str) -> dict: + """`IDENTITY` with `changed`, git in English, and a good + `git config user.name`, which is the creator where `changed` blanks + GIT_AUTHOR_NAME.""" + return user_name("Ada Lovelace", **{**IDENTITY, **GIT_IN_ENGLISH, + **changed}) + + +@LANDING +@DRY_RUN +def test_a_blank_git_author_name_is_refused_before_the_remote_exists( + tmp_path, landing, dry_run): + """THE ISSUE'S ROW: GIT_AUTHOR_NAME and GIT_COMMITTER_NAME `' '` and a + good `user.name`. The manifest was written from `Ada Lovelace`, and + `git commit` then refused the whitespace, exit 2, after + `/Contoso.git` and the work directory existed. Now git is asked + first, about both roles, and nothing exists.""" + result = init(tmp_path, identity(GIT_AUTHOR_NAME=" ", + GIT_COMMITTER_NAME=" "), + landing=landing, dry_run=dry_run) + refused(result, f"{BAD_IDENTITY}{AUTHOR_BLANK}; {COMMITTER_BLANK}.\n" + f"{COMMIT_IDENTITY_REMEDIATION}\n", tmp_path) + + +@DRY_RUN +@pytest.mark.parametrize("changed, expected", [ + pytest.param({"GIT_AUTHOR_NAME": " "}, AUTHOR_BLANK, id="author-blank"), + pytest.param({"GIT_COMMITTER_NAME": " "}, COMMITTER_BLANK, + id="committer-blank"), + pytest.param({"GIT_AUTHOR_NAME": "<>"}, refuses_the( + "author", "GIT_AUTHOR_IDENT", + "GIT_AUTHOR_NAME '<>' and GIT_AUTHOR_EMAIL 'ada@example.invalid'", + f"{DISALLOWED} <>"), id="author-only-delimiters"), + pytest.param({"GIT_COMMITTER_DATE": "garbage"}, refuses_the( + "committer", "GIT_COMMITTER_IDENT", + "GIT_COMMITTER_NAME 'Ada', GIT_COMMITTER_EMAIL 'ada@example.invalid' " + "and GIT_COMMITTER_DATE 'garbage'", + "fatal: invalid date format: garbage"), id="committer-date"), +]) +def test_each_identity_git_refuses_is_refused_in_its_words( + tmp_path, changed, expected, dry_run): + """GIT'S OWN ANSWER, not a copy of its rules: one role at a time, and + what git refuses that is not whitespace (a name of only `<>`, a date it + cannot read), each of which failed at the same commit. The detail names + every variable of the role that was set, and git's words say which.""" + result = init(tmp_path, identity(**changed), dry_run=dry_run) + refused(result, f"{BAD_IDENTITY}{expected}.\n" + f"{COMMIT_IDENTITY_REMEDIATION}\n", tmp_path) + + +def test_created_by_does_not_get_past_a_blank_git_author_name(tmp_path): + """`--created-by` sets who `family.yaml` records; git still makes the + commit as GIT_AUTHOR_NAME, so the run is still refused.""" + result = init(tmp_path, identity(GIT_AUTHOR_NAME=" "), + "--created-by", "T H") + refused(result, f"{BAD_IDENTITY}{AUTHOR_BLANK}.\n" + f"{COMMIT_IDENTITY_REMEDIATION}\n", tmp_path) + + +def test_an_identity_left_empty_still_initialises(tmp_path): + """THE CONTROL: every name and address EMPTY is `env_commit`'s fallback, + which git takes, so nothing is refused and the holder is made.""" + empty = {key: "" for key in IDENTITY} + result = init(tmp_path, user_name("Ada Lovelace", **empty, + **GIT_IN_ENGLISH)) + initialised(result, tmp_path, dry_run=False) + + +# --------------------------------------------------------------------------- +# The holder's name, on every disk it is cloned to +# --------------------------------------------------------------------------- + +HOLDER_NAME_REMEDIATION = ( + NOTHING + "Choose another --family. It names the holder, its repository " + "and the folder it is cloned into and, unless --work-dir is given, the " + "family folder around it, which `make siblings` looks for by that very " + "name; and a holder is cloned on Windows too, where no file or folder " + "can be called a device name (`CON`, `PRN`, `AUX`, `NUL`, " + "`COM1`-`COM9`, `LPT1`-`LPT9`, in any case, with or without an " + "extension), and a name ending in a dot or a space is made under another " + "name. There is no --force.") + + +def init_family(tmp_path, family: str, *flags: str, landing="--work-dir", + dry_run: bool): + """`init` with `--family family`: given after the harness's own + `--family`, and argparse keeps the last.""" + return init(tmp_path, {}, "--family", family, "--created-by", "T H", + *flags, landing=landing, dry_run=dry_run) + + +def bad_holder_name(family: str) -> str: + return (f"REFUSED family-bad-holder-name: --family is {family!r}, which " + "names the holder and the folders `init` makes for it, and " + f"`{family}` is a Windows device name (with or without an " + "extension), which cannot be created there.\n" + f"{HOLDER_NAME_REMEDIATION}\n") + + +@pytest.mark.parametrize("family", ["CON", "Con", "nul", "PRN", "Aux", + "COM1", "Lpt9"]) +def test_a_windows_device_name_is_refused_as_the_holders_name(tmp_path, + family): + """THE ISSUE'S ROWS: each exited 0 with a plan for a holder no Windows + disk can clone. Now `family-bad-holder-name`, in any case, in the leg + path rule's words.""" + result = init_family(tmp_path, family, dry_run=True) + refused(result, bad_holder_name(family), tmp_path) + + +@LANDING +def test_a_real_run_with_a_device_name_makes_nothing(tmp_path, landing): + """The real run refuses alike, under `--into` (the family folder would be + `CON/`) and `--work-dir`, before any remote or folder exists.""" + result = init_family(tmp_path, "CON", landing=landing, dry_run=False) + refused(result, bad_holder_name("CON"), tmp_path) + + +@DRY_RUN +@pytest.mark.parametrize("family, flags, expected", [ + pytest.param("Orbit.", (), "REFUSED family-bad-id: ", id="dot"), + pytest.param("Orbit.", ("--id", "orbit"), + "REFUSED naming-unclassified: 'Orbit.' matches no family in " + "the naming policy\n", id="dot-explicit-id"), + pytest.param("Orbit ", (), "REFUSED unsafe-value: --family is 'Orbit ', ", + id="space"), +]) +def test_a_name_windows_strips_was_already_refused_before_anything_exists( + tmp_path, family, flags, expected, dry_run): + """THE RULE'S OTHER TWO ROWS: a trailing dot or space, which Windows + strips, so `make siblings` there would never find its folder. The + policy's one CamelCase token holds no dot and `checked_value` no space, + so each is refused before the holder's name is asked, as on main, and + nothing exists.""" + result = init_family(tmp_path, family, *flags, dry_run=dry_run) + refused(result, expected, tmp_path) + + +@pytest.mark.parametrize("family", ["COM0", "LPT10", "CONX", "Conan", + "Nullify"]) +def test_a_name_that_only_starts_like_a_device_is_not_refused(tmp_path, + family): + """THE CONTROLS: the rule's device names are `CON`, `PRN`, `AUX`, `NUL`, + `COM1`-`COM9` and `LPT1`-`LPT9`, exactly; a name that only begins like + one is a folder Windows makes.""" + result = init_family(tmp_path, family, dry_run=True) + initialised(result, tmp_path, dry_run=True) + assert f"family {family} ({family.lower()})" in result.stdout From 0748fc466863dddbdc0947d0d5adc8d53d96dde2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:01:53 +0000 Subject: [PATCH 2/3] tests/test_family_init_asks_every_value.py and family.py: the test source spells U+202E and U+200C as escapes, and the identity git is asked with stands in once, so SonarCloud's S6389 and S1192 find nothing new in #688's lines (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud failed the quality gate on 22b7903a: new_security_rating 3, text:S6389 at tests/test_family_init_asks_every_value.py:120, "This line contains a bidirectional character". The test row `AdaLovelace` and the control `Mehrdad` (two places) held the character itself. Each is now the escape in the string literal, `‮` and `‌`, so the test builds the same runtime string (`ast.dump` of the file is the same before and after) and a reader of the source sees what the row feeds the tool. The file now holds no raw character of U+200B-U+200F, U+202A-U+202E, U+2060-U+2069, U+FEFF or U+061C. `COMMIT_IDENTITY_STAND_IN` spelled `openRepoShape family` and `family@openreposhape.invalid` twice each, beside the two copies `commit_on_branch` already holds, so S1192 flagged both at four. The stand-ins are now two constants spelled once, and are not that fallback: `git var` is asked whether git takes the person's variable beside an acceptable name and address, which does not depend on which one stands in. The one `" and "` this PR added, in `_refuse_an_unusable_commit_identity`'s list of variables, is an f-string, as the other eleven are not, so the literal's count in the file is main's again. Behaviour is unchanged. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scripts/family.py | 17 +++++++++++------ tests/test_family_init_asks_every_value.py | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 329cb51a..69add32d 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -1687,12 +1687,17 @@ def _checked_family_identity(args) -> tuple[str, str, str]: #: A name and an address git takes, standing in for one the person left #: unset or empty, where `env_commit` puts its fallback: git is then asked -#: about the person's own variables, beside values it accepts. +#: about the person's own variables, beside values it accepts. Each is +#: spelled once, and neither is the fallback `env_commit` or `commit_on_branch` +#: spells: the answer does not depend on which acceptable value stands in, and +#: a third copy of a fallback would be one more to keep in step with them. +COMMIT_STAND_IN_NAME = "identity check" +COMMIT_STAND_IN_EMAIL = "identity-check@openreposhape.invalid" COMMIT_IDENTITY_STAND_IN = { - "GIT_AUTHOR_NAME": "openRepoShape family", - "GIT_AUTHOR_EMAIL": "family@openreposhape.invalid", - "GIT_COMMITTER_NAME": "openRepoShape family", - "GIT_COMMITTER_EMAIL": "family@openreposhape.invalid", + "GIT_AUTHOR_NAME": COMMIT_STAND_IN_NAME, + "GIT_AUTHOR_EMAIL": COMMIT_STAND_IN_EMAIL, + "GIT_COMMITTER_NAME": COMMIT_STAND_IN_NAME, + "GIT_COMMITTER_EMAIL": COMMIT_STAND_IN_EMAIL, } #: What `family-bad-commit-identity` is told to do (#688). @@ -1752,7 +1757,7 @@ def _refuse_an_unusable_commit_identity() -> None: run(["git", f"--git-dir={nowhere}", "var", ident], cwd=Path(nowhere), env=identity) except CommandFailed as exc: - listed = (", ".join(given[:-1]) + " and " + given[-1] + listed = (f"{', '.join(given[:-1])} and {given[-1]}" if len(given) > 1 else given[0]) problems.append( f"git refuses the {role} the holder's first commit would be " diff --git a/tests/test_family_init_asks_every_value.py b/tests/test_family_init_asks_every_value.py index 94878c2d..6bb6e200 100644 --- a/tests/test_family_init_asks_every_value.py +++ b/tests/test_family_init_asks_every_value.py @@ -117,7 +117,7 @@ def initialised(result, tmp_path, dry_run: bool) -> None: UNWRITABLE = [ pytest.param("Ada\nLovelace", LINE_BREAK, id="lf"), - pytest.param("Ada‮Lovelace", BIDI, id="u202e"), + pytest.param("Ada\u202eLovelace", BIDI, id="u202e"), pytest.param("Ada\x1bLovelace", CONTROL, id="esc"), pytest.param("Ada\tLovelace", CONTROL, id="tab"), ] @@ -181,7 +181,7 @@ def test_the_setting_keeps_the_remediation_426_gave_it(tmp_path): @DRY_RUN @pytest.mark.parametrize("flags, env, recorded", [ pytest.param(("--created-by", "José"), {}, "José", id="flag"), - pytest.param((), {"GIT_AUTHOR_NAME": "Mehr‌dad"}, "Mehr‌dad", + pytest.param((), {"GIT_AUTHOR_NAME": "Mehr\u200cdad"}, "Mehr\u200cdad", id="env"), ]) def test_a_creator_that_is_text_still_initialises(tmp_path, flags, env, From 6958dfbcd3031c7f4497ca69478fb9fceb4023db Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:22:43 +0000 Subject: [PATCH 3/3] family.py init: git's words in the commit-identity refusal are spelled as a path is, a GIT_AUTHOR_NAME's remedy says to correct or unset it, and a blank one that no other creator backs is named as the cause (#688) Lane 1's review of 0748fc46 found four small defects in the words of #688's refusals and one gap in its tests. 1. `family-bad-commit-identity` pasted git's answer raw after `git var`. Git echoes a value it refuses as it was given (`fatal: invalid date format: `), so a GIT_AUTHOR_DATE holding a line break began a second line of the refusal of its own choosing, and one holding U+202E reached the terminal. The words are now quoted by `shown_path` (#610): as they are when printable, as Python's `repr` spells them when not. A printable answer reads as it did. 2. The remedy for an unwritable GIT_AUTHOR_NAME began "pass --created-by", and `--created-by` changes only who `family.yaml` records, so the next run met `family-bad-commit-identity` for the same variable. It now says to correct that variable or unset it, and why. And a GIT_AUTHOR_NAME of whitespace alone with no `--created-by` and no `git config user.name` said `family-no-creator`, "no GIT_AUTHOR_NAME", of a variable that was set, and told the person to pass `--created-by`, which led to the same next refusal. That case now says the variable is the cause, and tells the person to unset it AND pass `--created-by`. Each remedy's first exit is run in the tests and goes on to a holder, dry and real. 3. Mutant "a stand-in only where the variable is unset, not where it is empty" passed every test, though it refuses a holder `init` makes today: an empty name or address beside a date in the same role. One control row per role and per variable pins that an empty one is still `env_commit`'s fallback inside a role git is asked about. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scripts/family.py | 45 +++++-- tests/test_family_init_asks_every_value.py | 138 ++++++++++++++++++++- 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 69add32d..78bea670 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -198,6 +198,10 @@ # the scaffold and `adopt-project.py` hold a leg's mount path to, asked of a # path of one segment. from shape_materialize import _unmountable_reason # noqa: E402 +# How a refusal quotes the words git echoes of a value it refused (#688): as +# a path is quoted (#610), as it is when printable and as Python's `repr` +# spells it when it is not. +from repo_shape import shown_path # noqa: E402 # A reused holder's own visibility and the Free-plan secrets note, asked as # the scaffold asks them of its root (#660). from shape_materialize import ( # noqa: E402 @@ -1661,10 +1665,11 @@ def _checked_family_identity(args) -> tuple[str, str, str]: "GIT_AUTHOR_NAME": ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "`init` read the creator from GIT_AUTHOR_NAME, because no " - "--created-by was given: pass --created-by 'Your Name', or correct " - "that variable. The environment keeps it as the bytes it was given, " - "so a name set in a legacy code page holds a byte that is not UTF-8, " - + CREATOR_UNWRITABLE_WHY), + "--created-by was given: correct that variable, or unset it, for git " + "makes the holder's first commit as it too; --created-by 'Your Name' " + "changes only who `family.yaml` records. The environment keeps it as " + "the bytes it was given, so a name set in a legacy code page holds a " + "byte that is not UTF-8, " + CREATOR_UNWRITABLE_WHY), "`git config user.name`": ( "Remediation: nothing was created, and `--dry-run` refuses the same. " "`init` read the creator from `git config user.name`, because no " @@ -1737,6 +1742,11 @@ def _refuse_an_unusable_commit_identity() -> None: and `refuse_bad_branch` has just had an answer from git there, so a refusal here is about the variables. The detail names every variable of the role the person set, with git's own words, which say which one. + Git echoes a value it refuses (`fatal: invalid date format: `) + as it was given, a line break and a bidi control included, so its words + are quoted by `shown_path` (#610): as they are when printable, as + Python's `repr` spells them when not. Raw, a `GIT_AUTHOR_DATE` holding a + line break began a line of its own choosing in the refusal. `cmd_init` ASKS IT AFTER THE QUESTIONS ABOUT WHAT IT WRITES, before the landing, the plan and anything made: the clone URL's refusal (#433) @@ -1759,9 +1769,12 @@ def _refuse_an_unusable_commit_identity() -> None: except CommandFailed as exc: listed = (f"{', '.join(given[:-1])} and {given[-1]}" if len(given) > 1 else given[0]) + # Git echoes a value it refuses (a date) as it was given, so its + # words are spelled as a path is (#610), never printed raw. problems.append( f"git refuses the {role} the holder's first commit would be " - f"made as, from {listed} (`git var {ident}`: {exc.output})") + f"made as, from {listed} (`git var {ident}`: " + f"{shown_path(exc.output)})") finally: # Empty: git writes nothing there (`_ask_git_branch`'s reason). shutil.rmtree(nowhere, ignore_errors=True) @@ -1807,11 +1820,20 @@ def _creator_name(args) -> str: GIT_AUTHOR_NAME is also who git makes the holder's first commit as, and a blank one passed over here is not passed over there: `cmd_init` asks git about that later (`_refuse_an_unusable_commit_identity`, #688). + THE REMEDIES ARE EXITS THAT WORK (Brett Heap's ruling, "Offer only exits + that work"): the variable's own says to correct or unset it, not to pass + `--created-by`, which changes only who `family.yaml` records and leaves + git to refuse the same variable at the commit; and where the variable is + set but blank and nothing else names a creator, the refusal says that + variable is there and is the cause, instead of "no GIT_AUTHOR_NAME", and + tells the person to unset it AND pass `--created-by`. """ created_by, source = args.created_by, "--created-by" + blank_variable = False if not created_by: - created_by = (os.environ.get("GIT_AUTHOR_NAME") or "").strip() - source = "GIT_AUTHOR_NAME" + named = os.environ.get("GIT_AUTHOR_NAME") or "" + created_by, source = named.strip(), "GIT_AUTHOR_NAME" + blank_variable = bool(named) and not created_by if not created_by: source = "`git config user.name`" try: @@ -1820,6 +1842,15 @@ def _creator_name(args) -> str: "utf-8", "surrogateescape").strip() except Refusal: created_by = "" + if not created_by and blank_variable: + raise Refusal( + "family-no-creator", + "no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and no " + "`git config user.name`. Creating a family is a human's act and " + "the manifest records whose.", + "Remediation: unset GIT_AUTHOR_NAME, which git would make the " + "holder's first commit as and refuses, and re-run with " + "--created-by 'Your Name'.") if not created_by: raise Refusal( "family-no-creator", diff --git a/tests/test_family_init_asks_every_value.py b/tests/test_family_init_asks_every_value.py index 6bb6e200..6f63f5a2 100644 --- a/tests/test_family_init_asks_every_value.py +++ b/tests/test_family_init_asks_every_value.py @@ -11,14 +11,18 @@ broken in two, and a `--created-by` or a GIT_AUTHOR_NAME holding U+202E, an ESC or a byte that is not UTF-8 exited 0 too. Now each is `family-unwritable-value`, and the remediation names the flag, the - variable or the setting that supplied the name; + variable or the setting that supplied the name, and each is an exit that + works: the variable's says to correct or unset it, for `--created-by` + would only meet the commit's refusal of the same variable, and a blank + one with no other creator is named as the cause; * THE COMMIT'S IDENTITY. `_creator_name` passes over a GIT_AUTHOR_NAME of whitespace alone and reads `git config user.name`, but `git commit` reads the variable: a real run exited 2 with `fatal: name consists only of disallowed characters` after `/Contoso.git` and the work directory existed. Now git is asked first (`git var`), and the run is `family-bad-commit-identity`. A name of only `<>` and a date git cannot - read failed at the same commit, and are asked the same way; + read failed at the same commit, and are asked the same way; git's words + are quoted as a path is (#610), for git echoes the date it refuses; * THE HOLDER'S NAME. `--family CON` (or `Nul`, `com1`, any Windows device name) is one CamelCase token, which is all the naming policy asks, and a Windows disk can make no folder of that name. Now it is @@ -50,6 +54,10 @@ sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 +#: A backslash, so that a row spells what `repr` writes for a character (a +#: line break, U+202E) without the source holding the character or its escape. +BACKSLASH = chr(92) + #: A byte that is not UTF-8 in an ARGUMENT: POSIX passes the byte; Windows #: passes UTF-16, where it cannot be one. POSIX_ARGV_ONLY = pytest.mark.skipif( @@ -76,9 +84,11 @@ #: The creator came from GIT_AUTHOR_NAME. ENV_REMEDIATION = ( NOTHING + "`init` read the creator from GIT_AUTHOR_NAME, because no " - "--created-by was given: pass --created-by 'Your Name', or correct that " - "variable. The environment keeps it as the bytes it was given, so a name " - "set in a legacy code page holds a byte that is not UTF-8, " + WHY) + "--created-by was given: correct that variable, or unset it, for git " + "makes the holder's first commit as it too; --created-by 'Your Name' " + "changes only who `family.yaml` records. The environment keeps it as the " + "bytes it was given, so a name set in a legacy code page holds a byte " + "that is not UTF-8, " + WHY) #: The creator came from `git config user.name`: #426's words, whole. CONFIG_REMEDIATION = ( REMEDIATION + "git keeps it as the bytes it was given, so a name saved " @@ -197,6 +207,70 @@ def test_a_creator_that_is_text_still_initialises(tmp_path, flags, env, assert manifest["created_by"] == recorded +#: A GIT_AUTHOR_NAME of only control characters: no creator a file can hold, +#: and a name git refuses at the commit too. +ONLY_CONTROLS = "\x1b\x07" + +#: What a GIT_AUTHOR_NAME of whitespace alone is told when nothing else names +#: a creator: the variable is there, and is the cause. +BLANK_NO_CREATOR = ( + "REFUSED family-no-creator: no --created-by, a GIT_AUTHOR_NAME of " + "whitespace alone, and no `git config user.name`. Creating a family is a " + "human's act and the manifest records whose.\n" + "Remediation: unset GIT_AUTHOR_NAME, which git would make the holder's " + "first commit as and refuses, and re-run with --created-by 'Your Name'.\n") + + +@DRY_RUN +def test_a_blank_git_author_name_and_no_other_creator_is_named_as_the_cause( + tmp_path, dry_run): + """`family-no-creator` said "no GIT_AUTHOR_NAME" of a variable that was + set, and the remedy it gave, `--created-by`, led to + `family-bad-commit-identity` for that very variable. Now it names the + blank variable as the cause, and says to unset it AND pass the flag.""" + result = init(tmp_path, user_name("", GIT_AUTHOR_NAME=" "), + dry_run=dry_run) + refused(result, BLANK_NO_CREATOR, tmp_path) + + +@DRY_RUN +def test_the_blank_creator_remedy_goes_on_to_a_holder(tmp_path, dry_run): + """THE REMEDY'S EXIT IS MEASURED: GIT_AUTHOR_NAME unset (empty is the same + to `init`: `_creator_name` and `env_commit` both treat it as unset) and + `--created-by 'T H'` is a run that initialises, where the variable left + blank and the flag alone is the next refusal.""" + result = init(tmp_path, user_name(""), "--created-by", "T H", + dry_run=dry_run) + initialised(result, tmp_path, dry_run) + + +@pytest.mark.parametrize("env", [ + pytest.param({"GIT_AUTHOR_NAME": "Ada Lovelace"}, id="correct-it"), + pytest.param(user_name("Ada Lovelace"), id="unset-it"), +]) +@DRY_RUN +def test_each_exit_of_the_variables_remedy_goes_on_to_a_holder( + tmp_path, env, dry_run): + """THE VARIABLE'S REMEDY, MEASURED: a GIT_AUTHOR_NAME no file can hold is + told to correct it, or unset it; a corrected name, and no name with a + good `user.name` behind it, are runs that initialise. It no longer says + `--created-by`, which the next test shows leads nowhere.""" + result = init(tmp_path, env, dry_run=dry_run) + initialised(result, tmp_path, dry_run) + + +def test_created_by_does_not_get_a_bad_git_author_name_past_the_commit( + tmp_path): + """WHY THE REMEDY NO LONGER OFFERS IT: with `--created-by` the creator is + fine, and git is then asked about the same GIT_AUTHOR_NAME, which it + refuses, as `family-bad-commit-identity`.""" + result = init(tmp_path, identity(GIT_AUTHOR_NAME=ONLY_CONTROLS), + "--created-by", "T H") + refused(result, f"{BAD_IDENTITY}git refuses the author the holder's first " + f"commit would be made as, from GIT_AUTHOR_NAME " + f"{ONLY_CONTROLS!r} and GIT_AUTHOR_EMAIL ", tmp_path) + + # --------------------------------------------------------------------------- # The identity git makes the holder's first commit as # --------------------------------------------------------------------------- @@ -305,6 +379,60 @@ def test_an_identity_left_empty_still_initialises(tmp_path): initialised(result, tmp_path, dry_run=False) +#: A date git reads. +GOOD_DATE = "2026-01-01T00:00:00 +0000" + + +@DRY_RUN +@pytest.mark.parametrize("changed", [ + pytest.param({"GIT_AUTHOR_NAME": "", "GIT_AUTHOR_DATE": GOOD_DATE}, + id="author-name"), + pytest.param({"GIT_AUTHOR_EMAIL": "", "GIT_AUTHOR_DATE": GOOD_DATE}, + id="author-email"), + pytest.param({"GIT_COMMITTER_NAME": "", "GIT_COMMITTER_DATE": GOOD_DATE}, + id="committer-name"), + pytest.param({"GIT_COMMITTER_EMAIL": "", "GIT_COMMITTER_DATE": GOOD_DATE}, + id="committer-email"), +]) +def test_an_empty_name_or_address_beside_a_date_is_still_the_fallback( + tmp_path, changed, dry_run): + """THE CONTROL INSIDE A ROLE THAT IS ASKED: the row above empties every + variable, so no role is asked at all. Here one role has a date, so git IS + asked, and its empty name or address must still be the stand-in `env_commit` + puts there (an empty one is unset to it): asked as the person gave it, + git refuses an empty ident name, and `init` would refuse a holder it + makes.""" + result = init(tmp_path, identity(**changed), dry_run=dry_run) + initialised(result, tmp_path, dry_run) + + +@DRY_RUN +@pytest.mark.parametrize("date, spelled", [ + pytest.param("x\nREFUSED family-fake: not real", + "x" + BACKSLASH + "nREFUSED family-fake: not real", + id="line-break"), + pytest.param("2026" + chr(0x202E) + "-01-01", + "2026" + BACKSLASH + "u202e-01-01", id="bidi"), +]) +def test_what_git_echoes_of_a_refused_date_is_spelled_never_printed_raw( + tmp_path, date, spelled, dry_run): + """git echoes a date it cannot read as it was given + (`fatal: invalid date format: `), so the refusal quotes git's words + as a path is quoted (#610): printable as they are, otherwise as `repr` + spells them. Raw, a line break began a refusal's second line of the + date's own choosing, and U+202E reached the terminal. The variable is + already quoted with `!r`; this pins git's words beside it.""" + result = init(tmp_path, identity(GIT_AUTHOR_DATE=date), dry_run=dry_run) + listed = ("GIT_AUTHOR_NAME 'Ada', GIT_AUTHOR_EMAIL 'ada@example.invalid' " + f"and GIT_AUTHOR_DATE {date!r}") + refused(result, f"{BAD_IDENTITY}" + + refuses_the("author", "GIT_AUTHOR_IDENT", listed, + f"'fatal: invalid date format: {spelled}'") + + f".\n{COMMIT_IDENTITY_REMEDIATION}\n", tmp_path) + assert "\nREFUSED family-fake" not in result.stderr, result.stderr + assert chr(0x202E) not in result.stderr, result.stderr + + # --------------------------------------------------------------------------- # The holder's name, on every disk it is cloned to # ---------------------------------------------------------------------------