diff --git a/docs/cli.md b/docs/cli.md index 56734365..64176127 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -119,7 +119,9 @@ Set $OPENREPOSHAPE_REPO to clone a fork or mirror in self-bootstrap mode instead of opensoft/openRepoShape. Anything after `--` is passed straight through to scaffold-project.py. A -`--dry-run` there ends this run at the plan, creating nothing. +`--dry-run` there ends this run at the plan, creating nothing, and makes +none of the preflight's offers: a run without it, or --preflight, makes +them. ``` ### `openRepoShape` @@ -260,7 +262,9 @@ Set $OPENREPOSHAPE_REPO to clone a fork or mirror in self-bootstrap mode instead of opensoft/openRepoShape. Anything after `--` is passed straight through to scaffold-project.py. A -`--dry-run` there ends this run at the plan, creating nothing. +`--dry-run` there ends this run at the plan, creating nothing, and makes +none of the preflight's offers: a run without it, or --preflight, makes +them. ``` ### `scaffold-project.py` diff --git a/setup-project.py b/setup-project.py index 1e63df72..2c50210b 100755 --- a/setup-project.py +++ b/setup-project.py @@ -186,7 +186,9 @@ instead of opensoft/openRepoShape. Anything after `--` is passed straight through to scaffold-project.py. A -`--dry-run` there ends this run at the plan, creating nothing. +`--dry-run` there ends this run at the plan, creating nothing, and makes +none of the preflight's offers: a run without it, or --preflight, makes +them. """ @@ -423,7 +425,8 @@ def program_version(argv, field: int) -> str: #: What a git REF may contain, conservatively: `--shape-ref` becomes #: `git checkout ` in the temporary checkout. `git check-ref-format` is -#: the authority; this is the subset of it that a person types. +#: the authority; this is the subset of its characters that a person types, +#: and `why_not_a_ref` asks the rest of its rule of a value that passes here. REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$") #: What a PATH may contain: whatever the filesystem it names accepts - a drive @@ -467,6 +470,16 @@ def program_version(argv, field: int) -> str: "a ref.") WHY_LOCK = ("It ends in `.lock`, the name git gives the lock file it writes " "beside a ref.") +#: The rest of git's rule for a ref, which is asked of each part between +#: slashes and not of the whole alone (#857). +WHY_LOCK_PART = ("A part of it before a `/` ends in `.lock`, the name git " + "gives the lock file it writes beside a ref, and git takes " + "no ref with such a part.") +WHY_DOT_PART = ("A part of it after a `/` starts with `.`, and git takes no " + "ref with such a part.") +WHY_EMPTY_PART = ("It holds `//` or ends in `/`, and git takes no ref with an " + "empty part between slashes.") +WHY_DOT_END = "It ends in `.`, and git takes no ref that does." #: What each pattern asks, in words and then as the pattern itself, as the #: sibling `repo_shape.checked_value` prints its own. `PATH_RE` has no row: it @@ -564,19 +577,49 @@ def checked_project_length(what: str, name: str, local_mode: bool) -> str: PROJECT_NAME_MAX)) +def why_not_a_ref(text: str) -> str: + """The reason `git check-ref-format` refuses `text`, a value `REF_RE` has + passed, or "" when it takes it. + + GIT'S RULE IS PER PART (#857). Each part between slashes is there (no + `//`, no `/` at the end), does not start with `.` and does not end in + `.lock`, and the whole holds no `..` and does not end in `.`. Only `..` + and a `.lock` at the very end were asked before, so `a.lock/b`, `a/.b`, + `a//b`, `a/b/` and `a.` passed, and in self-bootstrap mode the person met + git's own `pathspec ... did not match` after the standard was cloned. + A `/` or a `.` as the first character is `REF_RE`'s to refuse. The checks + run in this order, so the sentence names the first one the value failed. + """ + parts = text.split("/") + if ".." in text: + return WHY_RANGE + if "" in parts: + return WHY_EMPTY_PART + if any(part.startswith(".") for part in parts): + return WHY_DOT_PART + if text.endswith(".lock"): + return WHY_LOCK + if any(part.endswith(".lock") for part in parts): + return WHY_LOCK_PART + if text.endswith("."): + return WHY_DOT_END + return "" + + def checked_ref(flag: str, value: str) -> str: """`--shape-ref`: narrower than a name, because git reads more into one. - `..` is a revision RANGE rather than a ref, and a ref ending in `.lock` is - the name git gives the lock file it writes beside one - neither is a thing - to check out, and both are refused here rather than discovered as a - confusing failure inside the temporary checkout. + `..` is a revision RANGE rather than a ref, a part ending in `.lock` is + the name git gives the lock file it writes beside one, and git takes no + ref with an empty part, a part that starts with `.`, or a `.` at its end + (`why_not_a_ref`) - none is a thing to check out, and each is refused + here rather than discovered as a confusing failure inside the temporary + checkout. """ text = checked_value(flag, value, REF_RE) - if ".." in text: - refuse_value(flag, text, WHY_RANGE) - if text.endswith(".lock"): - refuse_value(flag, text, WHY_LOCK) + why = why_not_a_ref(text) + if why: + refuse_value(flag, text, why) return text @@ -1027,7 +1070,12 @@ def run_command_text(text: str) -> int: return run_at_the_terminal(argv).returncode -def offer(command_text: str) -> bool: +#: What the terminal is told where a dry run makes no offer (#857). +NOT_OFFERED = (" not offered: --dry-run was passed through. A run without " + "it offers this here, and so does --preflight.") + + +def offer(command_text: str, offering: bool = True) -> bool: """Show one install command and take exactly one typed `yes` for it. THE SIGNATURE IS THE GUARANTEE, not the care of whoever edits this next: @@ -1038,17 +1086,27 @@ def offer(command_text: str) -> bool: AGENTS.md says the same thing to an assistant driving this: the human types this one. - NO TERMINAL, NO OFFER. `can_ask()` is the only gate, exactly as in + NO TERMINAL, NO OFFER. `can_ask()` is the gate, exactly as in `plan_and_confirm`: the person's terminal, which is stdin or, when a pipe is, the controlling terminal (#492). A run with neither, scheduled or in an assistant's shell, prints what it printed before any of this existed and stops where it stopped. + A DRY RUN, NO OFFER EITHER (#857). `offering` is False in a run passed + `--dry-run` after `--` (`preflight`), which ends at the plan saying + nothing was created: a `yes` typed here would still have installed + software, signed gh in, or written the person's global git config + (`gh auth setup-git`). The terminal is told the offer was not made, and + asked nothing. A bool, never `Options`, for the reason above. + The `yes` is EXACT and the prompt is the one this file already asks: `y`, `Yes`, an empty line and EOF are all refusals. """ if not can_ask(): return False + if not offering: + say(NOT_OFFERED) + return False say("") if "\n" in command_text: # A BLOCK IS SHOWN BEFORE IT IS OFFERED. GitHub's apt instructions @@ -1063,8 +1121,9 @@ def offer(command_text: str) -> bool: def offer_install(tool: str, command_text: str, probe, - platform: str = "") -> bool: + platform: str = "", offering: bool = True) -> bool: """One offer, one run, ONE re-check. True when the check now passes. + `offering` is `offer`'s (#857). Never a loop: a person told twice that the thing is still missing is watching this program guess, and the second guess is no better than the @@ -1083,7 +1142,7 @@ def offer_install(tool: str, command_text: str, probe, the next one. """ platform = platform or sys.platform - if not offer(command_text): + if not offer(command_text, offering): return False if run_command_text(command_text) != 0: return False @@ -1105,19 +1164,20 @@ def offer_install(tool: str, command_text: str, probe, return False -def offer_missing(tool: str, probe) -> bool: +def offer_missing(tool: str, probe, offering: bool = True) -> bool: """The offer a failed check makes: look this platform's command up, and - make no offer at all when there is none.""" + make no offer at all when there is none. `offering` is `offer`'s.""" command = offer_for(tool, sys.platform) if command is None: return False - return offer_install(tool, command, probe, sys.platform) + return offer_install(tool, command, probe, sys.platform, offering) -def _check_git() -> bool: +def _check_git(offering: bool = True) -> bool: if not shutil.which("git"): bad(GIT_MISSING) - if not offer_missing("git", lambda: bool(shutil.which("git"))): + if not offer_missing("git", lambda: bool(shutil.which("git")), + offering): return False ok("git %s" % program_version(["git", "--version"], 2)) return True @@ -1170,7 +1230,7 @@ def _gh_login_suffix() -> str: return "" -def _check_gh(opts: Options) -> bool: +def _check_gh(opts: Options, offering: bool = True) -> bool: if opts.local_mode: ok("gh not required (--local-remote-dir: bare repositories on disk, " "no network)") @@ -1187,18 +1247,19 @@ def _check_gh(opts: Options) -> bool: # command that follows it, and that is where we stop. say(" Homebrew installs it with `brew install gh`, and is " "its own install: https://brew.sh") - if not offer_missing("gh", lambda: bool(shutil.which("gh"))): + if not offer_missing("gh", lambda: bool(shutil.which("gh")), + offering): return False ok("gh %s" % program_version(["gh", "--version"], 2)) if run(["gh", "auth", "status"], capture=True).returncode != 0: bad("gh is not authenticated. Run: gh auth login") - if not _offer_login(): + if not _offer_login(offering): return False ok("gh is authenticated%s" % _gh_login_suffix()) return True -def _offer_login() -> bool: +def _offer_login(offering: bool = True) -> bool: """`gh auth login`, offered and then run as a PLAIN ARGV. Never through a shell: it is a terminal UI that ends in a browser and @@ -1207,8 +1268,9 @@ def _offer_login() -> bool: what its prompts and its one-time code need, and what the gate on the offer has already guaranteed is there. The browser half is the human's; this starts the login and asks `gh auth status` once when gh comes back. + `offering` is `offer`'s (#857). """ - if not offer("gh auth login"): + if not offer("gh auth login", offering): return False if run_at_the_terminal(["gh", "auth", "login"]).returncode != 0: return False @@ -1234,7 +1296,7 @@ def _credential_helper_configured() -> bool: return False -def _check_credential_helper(opts: Options) -> None: +def _check_credential_helper(opts: Options, offering: bool = True) -> None: """WARN, DO NOT REFUSE - the class `_warn_autocrlf` belongs to. The scaffold clones and pushes over HTTPS (`clone_and_bootstrap`), so a @@ -1254,6 +1316,10 @@ def _check_credential_helper(opts: Options) -> None: `--preflight`: a warning that refuses is a refusal, and this one would refuse a machine whose remotes are SSH and whose HTTPS push this tool never makes. + + A DRY RUN STILL WARNS AND OFFERS NOTHING (#857): `offering` is `offer`'s. + A `yes` here makes gh write its helper into the person's global git + config, which is no part of a run that ends at the plan. """ if opts.local_mode: return @@ -1261,7 +1327,7 @@ def _check_credential_helper(opts: Options) -> None: return warn("git has no credential helper for github.com, so the first push " "over HTTPS will fail. Fix it once: gh auth setup-git") - if not offer("gh auth setup-git"): + if not offer("gh auth setup-git", offering): return if run_at_the_terminal(["gh", "auth", "setup-git"]).returncode != 0: return @@ -1298,13 +1364,14 @@ def _warn_autocrlf() -> None: "git config --global core.autocrlf false") -def preflight_checks(opts: Options) -> bool: +def preflight_checks(opts: Options, offering: bool = True) -> bool: """Every check, in order, and whether they all passed. SPLIT OUT OF `preflight` FOR `--preflight` (#59, renamed 2026-09-10), which runs exactly this and then reports instead of refusing. The scaffold path is unchanged: the refusal is still one refusal, still at the end, still naming everything - that was missing. + that was missing. `offering` False (`preflight`, #857) runs every check + and makes none of the offers. """ say("openRepoShape setup") say("") @@ -1313,20 +1380,29 @@ def preflight_checks(opts: Options) -> bool: # is told about git AND gh in one go rather than one missing thing per # re-run, which is what the original `failed = True` accumulator bought. # The list is complete before `all` reads it, so nothing short-circuits. - checked = [_check_git(), _check_python(), _check_bootstrap()] + checked = [_check_git(offering), _check_python(), _check_bootstrap()] # THE CREDENTIAL HELPER IS ASKED ABOUT ONLY WHEN gh IS READY. With no gh, # or no login, there is nothing to set up as a helper yet and the person # has already been told the thing that has to happen first. - gh_ready = _check_gh(opts) + gh_ready = _check_gh(opts, offering) checked.append(gh_ready) if gh_ready: - _check_credential_helper(opts) + _check_credential_helper(opts, offering) _warn_autocrlf() return all(checked) def preflight(opts: Options) -> None: - if not preflight_checks(opts): + """The scaffold run's preflight, refusing once if anything is missing. + + A `--dry-run` PASSED THROUGH AFTER `--` MAKES NONE OF ITS OFFERS (#857). + That run ends at the plan, and its help and its last line say nothing was + created, so it reports what is missing and asks nothing that would change + this machine. `--preflight` (`preflight_only`) makes every offer whatever + follows `--`: it is the run that exists for them (#59), and it returns + before anything that reads the passthrough. + """ + if not preflight_checks(opts, not passes_a_dry_run(opts.passthrough)): die(PREREQ_MISSING) @@ -2311,7 +2387,8 @@ def passes_a_dry_run(passthrough: list) -> bool: The scaffold then creates nothing in step (6), and step (7) cloned three repositories that did not exist and refused saying they WERE created. - `plan_and_confirm` ends the run at the plan instead. + `plan_and_confirm` ends the run at the plan instead, and `preflight` + makes none of its offers in it (#857). """ return any(names_flag(word, "--dry-run") for word in scaffold_words(passthrough)) @@ -2569,9 +2646,13 @@ def plan_and_confirm(opts: Options, shape_root: Path, org: str, say("clone into %s" % clone) if passes_a_dry_run(opts.passthrough): + # AND THE PREFLIGHT CHANGED NOTHING EITHER (#857): `preflight` made + # none of its offers, and named each one where it would have asked. say("") say("--dry-run was passed through to the scaffold, so this run ends " - "at the plan above: nothing was created and nothing was cloned.") + "at the plan above: nothing was created and nothing was cloned. " + "The preflight made none of its offers; a run without --dry-run " + "makes each one marked `not offered` above.") return False if opts.assume_yes: return True diff --git a/tests/test_setup_project_dry_run_offers.py b/tests/test_setup_project_dry_run_offers.py new file mode 100644 index 00000000..df38e7eb --- /dev/null +++ b/tests/test_setup_project_dry_run_offers.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A `--dry-run` passed through `setup-project.py` makes none of the +preflight's offers (#857). + +The help says a `--dry-run` after `--` "ends this run at the plan, creating +nothing", and the plan says "nothing was created". The preflight before it +still offered, on a terminal, each command that fixes what it found missing: +an install of git or gh, `gh auth login`, and `gh auth setup-git`. Every one +of them changes the machine on a typed `yes` - software installed, gh signed +in, a credential helper written into the person's global git config - in a +run that then said nothing was created. And a rehearsal that typed only the +project name waited at the question until it was killed, as five of #777's +tests did on CI. + +So a dry run reports what is missing and asks nothing that would change the +machine: where a terminal would have been offered a command, it is told the +offer was not made, and the plan's last line says so. `--preflight` still +makes every offer, whatever follows `--`: it is the run that exists for +them (#59). + +The terminal runs are POSIX only (`PTY_SKIP`). Their `gh` is a stub first on +`PATH` that answers what the run asks and writes what the real one would +write: a login state of its own, and the helper `gh auth setup-git` adds to +the file `GIT_CONFIG_GLOBAL` names. The real `gh` is never reached. The +in-process runs fake the terminal and record every command, running none. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +from conftest import DRY_RUN_ENDS_IT, PTY_SKIP, run_in_a_terminal +from test_setup_project_py import (EVERY_MANAGER, SETUP_PROJECT, _answers, + _no_terminal, _Runs, _which, + entry_point_module, piped, terminal_env) +from test_setup_project_typed_name import the_refusal + +#: What the terminal is told where an offer was not made. +NOT_OFFERED = (" not offered: --dry-run was passed through. A run " + "without it offers this here, and so does --preflight.") + +#: The plan's last line in a dry run: what it always said, and then that the +#: preflight made no offer. +DRY_RUN_LAST_LINE = (DRY_RUN_ENDS_IT + " The preflight made none of its " + "offers; a run without --dry-run makes each one marked " + "`not offered` above.") + +PREREQ_MISSING = "one or more prerequisites are missing (see above)." + +HELPER_WARNING = ("[??] git has no credential helper for github.com, so the " + "first push over HTTPS will fail. Fix it once: gh auth " + "setup-git") + +NOT_SIGNED_IN = "[!!] gh is not authenticated. Run: gh auth login" + + +# --- in process: every offer, on every platform ------------------------------ + +#: A machine missing what each offer is for: the programs `shutil.which` +#: finds, what `run` answers, the offers a run without `--dry-run` makes, +#: and the refusal the preflight ends in ("" when it passes). +MACHINES = { + "install": (EVERY_MANAGER, {}, 2, PREREQ_MISSING), + "login": (("git", "gh"), {"auth status": (1, "")}, 1, PREREQ_MISSING), + "helper": (("git", "gh"), {"config --get-all": (1, "")}, 1, ""), +} + +#: The commands an offer runs on a `yes`: an install goes through a shell, +#: and the two gh commands are argvs of their own. +OFFERED = (["gh", "auth", "login"], ["gh", "auth", "setup-git"]) + + +#: The ways a run passes `--dry-run` through: the flag itself, and the short +#: spelling the scaffold's argparse takes for it (`passes_a_dry_run`). +DRY_RUN_SPELLINGS = ["--dry-run", "--dry"] + + +def a_machine(monkeypatch, machine): + """`setup-project.py` as a module on `machine`, with `run` a recorder: + the module, the recorder, the offers a run without `--dry-run` makes + there, and the refusal its preflight ends in.""" + present, answers, offers, refused = MACHINES[machine] + module = entry_point_module() + monkeypatch.setattr(module.shutil, "which", _which(*present)) + runs = _Runs(answers) + monkeypatch.setattr(module, "run", runs) + return module, runs, offers, refused + + +def refusal_of(module, opts): + """What `preflight` refused, or "" when it passed.""" + try: + module.preflight(opts) + except module.Refusal as refusal: + return str(refusal) + return "" + + +def ran_an_offer(call): + return call[:2] in (["bash", "-c"], ["cmd", "/c"]) or call in OFFERED + + +@pytest.mark.parametrize("flag", DRY_RUN_SPELLINGS) +@pytest.mark.parametrize("machine", MACHINES) +def test_a_dry_run_makes_none_of_the_offers_preflight_makes( + monkeypatch, capsys, tmp_path, machine, flag): + """A terminal that types `yes` to anything is asked nothing by a dry + run, in either spelling, and nothing an offer runs is run: the offers + are named as not made, and every check still says what is missing. + `--preflight -- --dry-run` makes the same offers a run without + `--dry-run` makes, and the recorder runs none of them.""" + module, runs, offers, refused = a_machine(monkeypatch, machine) + prompts = _answers(monkeypatch, module, "yes") + rehearsal = module.parse_args(["Sample", "--org", "demoorg", "--", flag], + str(tmp_path)) + assert refusal_of(module, rehearsal) == refused + assert prompts == [], "a dry run made an offer" + assert [call for call in runs.calls if ran_an_offer(call)] == [] + assert capsys.readouterr().out.count(NOT_OFFERED) == offers + module.preflight_only(module.parse_args(["--preflight", "--", flag], + str(tmp_path))) + assert len(prompts) == offers, "--preflight made no offer" + assert NOT_OFFERED not in capsys.readouterr().out + + +@pytest.mark.parametrize("machine", MACHINES) +def test_a_dry_run_with_no_terminal_says_what_a_run_without_it_says( + monkeypatch, capsys, tmp_path, machine): + """`offer` asks `can_ask()` before it asks whether it is offering: a run + with no terminal makes no offer and has nothing to name as not made, so + it prints no `not offered` line, and its preflight reads as it does + without `--dry-run`.""" + module, _, _, refused = a_machine(monkeypatch, machine) + _no_terminal(monkeypatch, module) + shown = [] + for passthrough in ([], ["--", "--dry-run"]): + opts = module.parse_args(["Sample", "--org", "demoorg", *passthrough], + str(tmp_path)) + assert refusal_of(module, opts) == refused + shown.append(capsys.readouterr().out) + assert "not offered" not in shown[1] + assert shown[1] == shown[0] + + +# --- at a terminal ----------------------------------------------------------- + +#: A `gh` that logs each call, answers the preflight and the plan's three +#: questions, and writes what the real one writes. Signed in where its +#: folder holds `signed-in`. +STUB_GH = (b"#!/bin/sh\n" + b'here=$(dirname "$0")\n' + b'printf "%s\\n" "$*" >> "$here/calls.log"\n' + b'case "$*" in\n' + b' --version) echo "gh version 2.50.0 (stub)" ;;\n' + b' "auth status") [ -e "$here/signed-in" ] || exit 1 ;;\n' + b' "auth login") : > "$here/signed-in" ;;\n' + b' "auth setup-git") printf \'[credential "https://github.com"]' + b'\\n\\thelper =\\n\\thelper = !gh auth git-credential\\n\' ' + b'>> "${GIT_CONFIG_GLOBAL:?}" ;;\n' + b" api*) echo tester ;;\n" + b' "repo view"*) echo "GraphQL: Could not resolve to a ' + b'Repository with the name x." >&2; exit 1 ;;\n' + b' *) echo "stub gh: refused $*" >&2; exit 97 ;;\n' + b"esac\n") + +#: The person's global git config: a name, and no credential helper. +GLOBAL_CONFIG = b"[user]\n\tname = Tester\n" + +#: The same, with the helper `gh auth setup-git` writes. +GLOBAL_CONFIG_WITH_A_HELPER = (GLOBAL_CONFIG + + b'[credential "https://github.com"]\n' + b"\thelper =\n" + b"\thelper = !gh auth git-credential\n") + + +class Rehearsal: + """`setup-project.py Sample --org demoorg ... -- ` (`--dry-run`, or + its short spelling) at a terminal that types `yes` to the first `Type yes + to continue:` it is shown, under `STUB_GH`, signed in or not, and a global + git config of the run's own: what the run did, and the files it could + have changed. + + Its git reads no config but that file and an empty system one: the + environment's `GIT_CONFIG_COUNT` and `-c` pairs are dropped, and it runs + from a folder that is no repository, so no checkout's config gives it a + helper either.""" + + def __init__(self, tmp_path, signed_in, global_config, entry="py", + flag="--dry-run"): + stub = tmp_path / "ghbin" + stub.mkdir() + (stub / "gh").write_bytes(STUB_GH) + (stub / "gh").chmod(0o755) + self.signed_in = stub / "signed-in" + if signed_in: + self.signed_in.write_bytes(b"") + self.global_config = tmp_path / "global.gitconfig" + self.global_config.write_bytes(global_config) + system = tmp_path / "system.gitconfig" + system.write_bytes(b"") + (tmp_path / "gh-config").mkdir() + env = {key: value for key, value in terminal_env().items() + if key not in ("GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS")} + env.update({"PATH": str(stub) + os.pathsep + env["PATH"], + "GH_CONFIG_DIR": str(tmp_path / "gh-config"), + "GIT_CONFIG_GLOBAL": str(self.global_config), + "GIT_CONFIG_SYSTEM": str(system)}) + self.into = tmp_path / "into" + flags = ["Sample", "--org", "demoorg", "--elected-by", "Tester", + "--into", str(self.into), "--", flag] + argv = {"py": [sys.executable, str(SETUP_PROJECT), *flags], + "piped": piped("", *flags)}[entry] + self.status, self.screen, self.answered = run_in_a_terminal( + argv, cwd=tmp_path, env=env, answers=["yes\n"], timeout=60) + log = stub / "calls.log" + self.calls = (log.read_text(encoding="utf-8").splitlines() + if log.exists() else []) + + def assert_asked_nothing(self): + assert self.answered == 0, f"it made an offer:\n{self.screen}" + assert "Type yes to continue" not in self.screen, self.screen + assert not self.into.exists() + + +@PTY_SKIP +@pytest.mark.parametrize("entry, flag", [("py", "--dry-run"), + ("piped", "--dry-run"), + ("py", "--dry")]) +def test_a_dry_run_leaves_git_with_no_helper_as_it_found_it(tmp_path, entry, + flag): + """Signed in, and git has no credential helper: the preflight warns, says + `gh auth setup-git` was not offered, and goes on to the plan, which ends + the run. The global git config is the bytes it was, whichever way the + terminal's run spells `--dry-run`.""" + run = Rehearsal(tmp_path, True, GLOBAL_CONFIG, entry, flag) + run.assert_asked_nothing() + assert run.status == 0, run.screen + assert HELPER_WARNING + "\n" + NOT_OFFERED + "\n" in run.screen + assert DRY_RUN_LAST_LINE in run.screen + assert run.global_config.read_bytes() == GLOBAL_CONFIG + assert "auth setup-git" not in run.calls + + +@PTY_SKIP +def test_a_dry_run_does_not_sign_gh_in(tmp_path): + """Not signed in: the preflight says `gh auth login` was not offered and + refuses as it does with nothing to ask on. gh is not signed in. Git has a + helper, so the login is the one offer at stake.""" + run = Rehearsal(tmp_path, False, GLOBAL_CONFIG_WITH_A_HELPER) + run.assert_asked_nothing() + assert run.status == 2, run.screen + assert NOT_SIGNED_IN + "\n" + NOT_OFFERED + "\n" in run.screen + assert the_refusal(run.screen) == "REFUSED: " + PREREQ_MISSING + assert not run.signed_in.exists() + assert "auth login" not in run.calls diff --git a/tests/test_setup_project_ref_parts.py b/tests/test_setup_project_ref_parts.py new file mode 100644 index 00000000..9d5e915b --- /dev/null +++ b/tests/test_setup_project_ref_parts.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`--shape-ref` is held to git's rule for a ref part by part (#857). + +`checked_ref` refused `..` and a `.lock` at the very end, where git's rule +is asked of each part between slashes: none is empty, none starts with `.`, +none ends in `.lock`, and the whole does not end in `.`. So `a.lock/b`, +`x/y.lock/z`, `release.lock/v1`, `a/.b`, `a//b`, `a/b/`, `a.lock/` and `a.` +passed, and `git check-ref-format --branch` refuses each of them. In +self-bootstrap mode the person met git's own `pathspec ... did not match` +after the standard was cloned. Each is refused now, before anything, with +the part of the rule it failed; `topic.locked` and `a./b` still go on. A part +that starts with `.` is refused wherever it stands (`a/.b/c`, `a/b/.c/d`), and +a name that fails two rules is told the first one in the order +`why_not_a_ref` asks them (`a//..b` is a range, not an empty part). + +The table asks git itself, `git check-ref-format --branch`, beside the tool: +no network, and the two give one answer for every name in it. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from conftest import REPO +from test_setup_project_name_edges import FIRST, LOCK, RANGE +from test_setup_project_py import (entry_point_module, make_outside_checkout, + run_entry) +from test_setup_project_typed_name import the_refusal + +LOCK_PART = ("A part of it before a `/` ends in `.lock`, the name git gives " + "the lock file it writes beside a ref, and git takes no ref with " + "such a part.") +DOT_PART = ("A part of it after a `/` starts with `.`, and git takes no ref " + "with such a part.") +EMPTY_PART = ("It holds `//` or ends in `/`, and git takes no ref with an " + "empty part between slashes.") +DOT_END = "It ends in `.`, and git takes no ref that does." + +#: Each name, and the reason the tool refuses it ("" where it goes on). The +#: issue's ten come first, then the names around them. +NAMES = { + "a.lock": LOCK, + "a/b.lock": LOCK, + "a.lock/b": LOCK_PART, + "x/y.lock/z": LOCK_PART, + "release.lock/v1": LOCK_PART, + "a/.b": DOT_PART, + "a/.b/c": DOT_PART, + "a/b/.c/d": DOT_PART, + "a//b": EMPTY_PART, + "a/b/": EMPTY_PART, + "a.lock/": EMPTY_PART, + "topic.locked": "", + "a.": DOT_END, + "a/b.": DOT_END, + "a./b": "", + "a.lockb": "", + "a/b.lock/": EMPTY_PART, + "a/.lock": DOT_PART, + "a..b": RANGE, + "refs/tags/v1.0": "", + "v1.0.0": "", + # A name that breaks two rules is told the first one `why_not_a_ref` asks, + # in its order: `..`, an empty part, a part that starts with `.`, a name + # that ends in `.lock`, a part that ends in `.lock`, a `.` at the end. + # Each row below holds one pair of neighbours in that order apart; two + # rows above hold the others (`a/.lock`: a dot part, then a lock at the + # end; `a/b.lock`: a lock at the end, then a lock part). + "a//..b": RANGE, + "a//.b": EMPTY_PART, + "a.lock/b.": LOCK_PART, +} + + +def tool_answer(module, name): + """The reason `checked_ref` gives for refusing `name`, or "".""" + try: + module.checked_ref("--shape-ref", name) + except module.Refusal as refusal: + return str(refusal).split(" command line. ", 1)[1] + return "" + + +@pytest.mark.parametrize("name", NAMES) +def test_the_tool_and_git_give_one_answer(tmp_path, name): + git = subprocess.run(["git", "check-ref-format", "--branch", name], + cwd=str(tmp_path), capture_output=True, text=True, + check=False) + why = tool_answer(entry_point_module(), name) + assert (why == "") == (git.returncode == 0), (name, why, git.stderr) + assert why == NAMES[name] + + +@pytest.mark.parametrize("name", ["a.lock/b", "a/.b", "a//b"]) +def test_a_ref_git_refuses_is_refused_before_the_clone(tmp_path, name): + """A ref git refuses ended a self-bootstrap run in git's `pathspec ... + did not match`, after the standard was cloned. It is refused before + `(0) self-bootstrap` prints.""" + outside = make_outside_checkout(tmp_path) + result = run_entry("--org", "demoorg", "--project", "Sample", + "--shape-ref", name, + "--local-remote-dir", str(tmp_path / "remotes"), + "--into", str(tmp_path / "work"), + cwd=outside, script=outside / "setup-project.py", + extra_env={"OPENREPOSHAPE_REPO": str(REPO)}) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "", result.stdout + assert the_refusal(result.stderr) == ( + "REFUSED: " + FIRST.format(what="--shape-ref", shown=ascii(name)) + + NAMES[name]) + assert not (tmp_path / "remotes").exists()