diff --git a/scaffold-project.py b/scaffold-project.py index 2c23d9e8..1fe86753 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -476,7 +476,8 @@ def seed_tree(role: str, values: dict, tracking: str, scratch: Path) -> str: #: THE ONE PROMPT RULE (#542, #915): what every git command this scaffold #: sends to a remote runs with, over the caller's environment. That is #: `only_commit`'s two (`PROBE_ENV`), `_take_held_seed`'s fetch and each -#: push, of a leg and of the root. git's own terminal prompt is off. `gh` can +#: push, of a leg and of the root (`PUSH_ENV`). git's own terminal prompt is +#: off. `gh` can #: see a repository that git cannot read -- `gh` is logged in and git has no #: credential for the host -- and git then asks the terminal for a username, #: so a re-run typed in one stopped there, waiting, before a word about the @@ -503,6 +504,16 @@ def seed_tree(role: str, values: dict, tracking: str, scratch: Path) -> str: #: `only_commit` reads of git's output depends on the locale. PROBE_ENV = {**REMOTE_ENV, "LC_ALL": "C"} +#: What each push, a leg's and the root's, runs with: the prompt rule, and +#: git's words untranslated, as `only_commit`'s two (#993). `_refused_push` +#: reads a refused push's output to name its fix, and git translates the +#: line it prints where the host refused the login it sent +#: (`LOGIN_REFUSED`), so in a locale whose catalogue translates git that +#: login was named by the ruleset hint alone. The cost is git's own lines of +#: a refused push in English, inside a refusal this tool prints in English; +#: a push that goes through shows nothing of git's. +PUSH_ENV = {**REMOTE_ENV, "LC_ALL": "C"} + def only_commit(url: str, scratch: Path) -> tuple[str, str] | None: """`(commit, tree)` when the repository at `url` holds exactly ONE commit @@ -683,12 +694,14 @@ def _reusable_remotes(names: dict, urls: dict, repositories: dict, #: `core.sshCommand` gives by `-i` (RV971). GitHub takes ssh only as the user #: `git`, so `ssh -T -p 443 ssh.github.com` without `git@` is refused #: whatever the key, which is why the user is named too (RV971, round 2). +#: That check alone is `SSH_KEY_CHECK`, which `_login_remedy` also names for +#: a push ssh was refused (#993). +SSH_KEY_CHECK = ("`ssh -T` against the user, host and port your rule names, " + "with anything your `GIT_SSH_COMMAND` or `core.sshCommand` " + "adds, says whether GitHub takes yours") SSH_KEY_CLAUSE = ("where a `url..insteadOf` rule in your git config " "sends `https://github.com/` over ssh, git logs in with an " - "ssh key instead, and `ssh -T` against the user, host and " - "port your rule names, with anything your " - "`GIT_SSH_COMMAND` or `core.sshCommand` adds, says whether " - "GitHub takes yours") + "ssh key instead, and " + SSH_KEY_CHECK) def _exists_remedy(role: str, empty: bool, reuse_flag: bool, @@ -1896,48 +1909,96 @@ def _take_held_seed(work: Path, url: str, seed: str, tracking: str) -> str: #: first and answers nothing. These are git's own words, untranslated in #: any locale: gettext translates only the `fatal: ` before them (git 2.43, #: asked under a catalogue that translates each of these words). So they are -#: found without `LC_ALL=C`, which a push does not take: its output is -#: printed whole for a person to read. +#: found in any locale; each push runs under `LC_ALL=C` all the same +#: (`PUSH_ENV`), for the words of `LOGIN_REFUSED`, which git translates. NO_LOGIN = ("terminal prompts disabled", "could not read Username", "could not read Password") +#: What git says when it sent a login and the host refused it (#993): `fatal: +#: Authentication failed for ''`, where a token a credential helper +#: holds for the host is wrong, expired or revoked. Its own case, and not one +#: of `NO_LOGIN`: git had a login, so "could not get one" is untrue of it. +#: git translates these words (git 2.43, asked under a catalogue that +#: translates them), which is why each push runs under `PUSH_ENV`. git also +#: asks its helpers to forget the refused login (a `store` file's line for +#: it is gone after it, measured), so a re-run before it is replaced is +#: refused as `NO_LOGIN`, with the same exit. +LOGIN_REFUSED = ("Authentication failed for ",) + +#: What ssh says when the host took no key of the person's (#993): +#: `@: Permission denied (publickey).`, the line before git's +#: generic one (`GENERIC_FATAL`), where a `url..insteadOf` or +#: `url..pushInsteadOf` rule sends a push over ssh. OpenSSH's own +#: words, which it does not translate; a host offering more ways to log in +#: than a key lists them after `publickey`, so the list is read up to it. +KEY_REFUSED = ("Permission denied (publickey",) + + +def _login_remedy(output: str, repo: str, reused: bool) -> str: + """How to give git the login a refused push lacked, then the re-run + (#915, #993), or "" where `output`, git's for that push, shows no login + trouble. Printed before `RULESET_HINT`, whose one exit is the same + re-run. A login git could not get (`NO_LOGIN`), one it sent and the host + refused (`LOGIN_REFUSED`) and a key ssh was refused (`KEY_REFUSED`) each + fail a first scaffold's push and leave the three repositories empty, and + the re-run is refused the same way until git has a login the host takes, + so the hint alone named an exit that could not finish (RV971, #993). -def _login_remedy(repo: str, reused: bool) -> str: - """How to give git the login a push could not get, then the re-run - (#915). Printed before `RULESET_HINT`, whose one exit is the same re-run. - With the prompt off, a first scaffold's push that git has no login for - is refused where git used to ask for one, and leaves the three - repositories empty; the re-run is refused the same way until git has a - login, so the hint alone named an exit that could not finish (RV971). On GitHub git logs in over https with the login `gh auth setup-git` gives it or a token a credential helper holds, and over ssh with a key - (`SSH_KEY_CLAUSE`). A run that was itself the re-run (`reused`) is - re-run as it was.""" + (`SSH_KEY_CLAUSE`). `gh auth setup-git` writes an empty helper before its + own for `https://github.com`, which empties the list git has read so + far, so a stale helper named before it is no longer asked, and one named + after it is asked only when `gh`'s answers nothing + (`setup-project.py`'s `_credential_helper_configured` reads it so). So + it replaces a refused login too. Over ssh it changes nothing, so a key + ssh was refused is told the ssh check alone (`SSH_KEY_CHECK`). A run + that was itself the re-run (`reused`) is re-run as it was.""" + if any(words in output for words in NO_LOGIN): + fix = (f"git could not get a login for {repo}: every push this " + "scaffold makes runs with git's terminal prompt off, so where " + "git would have asked you for one the push is refused, and a " + "credential helper with nothing stored for the host answers " + "nothing. Give git a login first (on GitHub, `gh auth " + "setup-git` gives git the login `gh` has, or store a token for " + "the host in your credential helper; " + SSH_KEY_CLAUSE + ")") + elif any(words in output for words in LOGIN_REFUSED): + fix = (f"the host refused the login git sent for {repo}: git had " + "one, from your credential helper for one, and it is wrong, " + "expired or revoked, so a re-run is refused too until git has " + "one the host takes. Replace it first (on GitHub, `gh auth " + "setup-git` makes git use the login `gh` has instead, or " + "store a new token for the host in your credential helper; " + + SSH_KEY_CLAUSE + ")") + elif any(words in output for words in KEY_REFUSED): + fix = (f"GitHub took no ssh key of yours for the push to {repo}: a " + "`url..insteadOf` or `url..pushInsteadOf` rule in " + "your git config sends it over ssh, where git logs in with an " + "ssh key and not with the login `gh` has, so `gh auth " + "setup-git` changes nothing for it. Give GitHub a key it takes " + "first (" + SSH_KEY_CHECK + ")") + else: + return "" again = ("the same command with a new --work-dir or none" if reused else "the same command with --reuse-empty-repo added (after `--` " "through setup.sh) and a new --work-dir or none") - return (f"\nRemediation: git could not get a login for {repo}: every " - "push this scaffold makes runs with git's terminal prompt off, " - "so where git would have asked you for one the push is refused, " - "and a credential helper with nothing stored for the host " - "answers nothing. Give git a login first (on GitHub, `gh auth " - "setup-git` gives git the login `gh` has, or store a token for " - "the host in your credential helper; " + SSH_KEY_CLAUSE + "), " - f"then re-run {again}: it reuses the repositories this run left " - "empty, and a leg whose one commit is the seed this scaffold " - "pushes.") + return (f"\nRemediation: {fix}, then re-run {again}: it reuses the " + "repositories this run left empty, and a leg whose one commit is " + "the seed this scaffold pushes.") def _refused_push(failed: CommandFailed, what: str, repo: str, tracking: str, reused: bool) -> None: - """What a refused push prints, a leg's and the root's (#462, #915): the - command and git's output, then, where git could not get a login - (`NO_LOGIN`), how to give it one and the re-run (`_login_remedy`), then + """What a refused push prints, a leg's and the root's (#462, #915, + #993): the command and git's output, then, where it shows git could not + get a login, the host refused the one git sent, or ssh was refused the + key, how to give git one and the re-run (`_login_remedy`), then `RULESET_HINT` for `repo` and `tracking`. Any other refusal, a ruleset's - above all, prints what it always printed.""" + above all, and an HTTP 403 too, prints what it always printed.""" print(failed.loudly(what), file=sys.stderr) - if any(words in failed.output for words in NO_LOGIN): - print(_login_remedy(repo, reused), file=sys.stderr) + remedy = _login_remedy(failed.output, repo, reused) + if remedy: + print(remedy, file=sys.stderr) print(RULESET_HINT.format(repo=repo, tracking=tracking), file=sys.stderr) @@ -1980,14 +2041,15 @@ def _seed_legs(args, values: dict, names: dict, repositories: dict, # every name git accepts is pushed as the branch it is, and # `-u` still records `origin/` as its upstream. # `adopt-project.py` pushes `HEAD:refs/heads/` too. - # Under `REMOTE_ENV` (#915), a first run's and a re-run's: a - # reuse re-run whose legs are empty asks `only_commit` - # nothing, and this push waited at git's `Username for - # '...':` prompt. A login git cannot get is named by - # `_refused_push`. + # Under `PUSH_ENV`, the prompt rule (#915), a first run's + # and a re-run's: a reuse re-run whose legs are empty asks + # `only_commit` nothing, and this push waited at git's + # `Username for '...':` prompt. A login git cannot get, or + # one the host refused, is named by `_refused_push`, which + # reads git's words untranslated (#993). run(["git", "push", "-q", "-u", "origin", f"HEAD:refs/heads/{args.tracking_branch}"], cwd=work, - env=REMOTE_ENV) + env=PUSH_ENV) except CommandFailed as exc: _refused_push(exc, f"pushing the {role} leg", repositories[role], args.tracking_branch, @@ -2103,10 +2165,10 @@ def _build_assembly_root(args, values: dict, names: dict, repositories: dict, try: # By full refname, as the legs are pushed (`_seed_legs`, #421): a # bare `+x` is a forced refspec, not the branch named `+x`. And - # under `REMOTE_ENV`, as they are (#915). + # under `PUSH_ENV`, as they are (#915, #993). run(["git", "push", "-q", "-u", "origin", f"HEAD:refs/heads/{args.tracking_branch}"], cwd=assembly, - env=REMOTE_ENV) + env=PUSH_ENV) except CommandFailed as exc: _refused_push(exc, "pushing the assembly root", repositories["assembly"], args.tracking_branch, diff --git a/tests/test_scaffold_reuse_seeded_legs.py b/tests/test_scaffold_reuse_seeded_legs.py index 873c1a60..5597f7ba 100644 --- a/tests/test_scaffold_reuse_seeded_legs.py +++ b/tests/test_scaffold_reuse_seeded_legs.py @@ -33,6 +33,12 @@ operator's `insteadOf` rewrite, is refused with ssh's reason and a remedy for an ssh key, in the locale git runs in and in one that translates git; the ssh is a stand-in that refuses every key, so nothing leaves this machine. + +A PUSH THE HOST REFUSED A LOGIN FOR names the fix as well (#993): a login a +credential helper holds that the local server refuses, a first run's and a +re-run's, in a terminal, and a key the stand-in ssh refuses where an +`insteadOf` or a `pushInsteadOf` rule sends the push over ssh, each in git's +own locale and in one that translates git. """ from __future__ import annotations @@ -636,10 +642,13 @@ def test_every_git_command_that_reaches_a_remote_runs_under_the_rule( legs that hold the seed, in-process, records each git command that reaches a remote, in order, with the environment it was given: the probe's two for each leg under `PROBE_ENV`, then each leg's fetch and - push, and the root's push, under `REMOTE_ENV`. Asked of bare - repositories on disk, so it runs on every platform.""" + push, and the root's push, the fetches under `REMOTE_ENV` and the pushes + under `PUSH_ENV`, which also keeps git's words untranslated for + `_refused_push` to read (#993). Asked of bare repositories on disk, so it + runs on every platform.""" seeds = refused_at_the_root(tmp_path, template) module = load_without_bytecode(SCAFFOLD, "scaffold_for_remote_env") + assert module.PUSH_ENV == {"GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"} asked = [] real = module.run @@ -661,12 +670,12 @@ def recording(args, cwd=None, capture=True, env=None): assert code == 0, err + out assert {leg: tip(tmp_path, leg) for leg in LEGS} == seeds assert commits(tmp_path, "assembly") == 1 - probe, rule = module.PROBE_ENV, module.REMOTE_ENV + probe, rule, push = module.PROBE_ENV, module.REMOTE_ENV, module.PUSH_ENV assert asked == [("ls-remote", probe), ("fetch --depth=1", probe), ("ls-remote", probe), ("fetch --depth=1", probe), - ("fetch", rule), ("push", rule), - ("fetch", rule), ("push", rule), - ("push", rule)], asked + ("fetch", rule), ("push", push), + ("fetch", rule), ("push", push), + ("push", push)], asked def git_reads_github_from(base: str, helper: str = "") -> dict: @@ -699,11 +708,12 @@ def git_reads_github_from(base: str, helper: str = "") -> dict: #: was not for a `Host` alias, `ssh.github.com` on port 443 or a key given by #: `-i` (RV971), and which `ssh -T -p 443 ssh.github.com` without the user #: `git` was not either: GitHub refuses it whatever the key (RV971, round 2). +SSH_KEY_CHECK = ("`ssh -T` against the user, host and port your rule names, " + "with anything your `GIT_SSH_COMMAND` or `core.sshCommand` " + "adds, says whether GitHub takes yours") SSH_KEY = ("where a `url..insteadOf` rule in your git config sends " "`https://github.com/` over ssh, git logs in with an ssh key " - "instead, and `ssh -T` against the user, host and port your rule " - "names, with anything your `GIT_SSH_COMMAND` or `core.sshCommand` " - "adds, says whether GitHub takes yours") + f"instead, and {SSH_KEY_CHECK}") UNREAD_EXIT = ("so whether its one commit is the seed this scaffold pushes " "cannot be asked. Let git read it (on GitHub, `gh auth " f"setup-git` gives git the login `gh` has; {SSH_KEY}), then " @@ -752,19 +762,27 @@ def log_message(self, *args): # the test reads the screen, not this def in_a_terminal_asked_for_a_login(tmp_path, env: dict, *flags: str, - helper: str = "", + helper: str = "", stored: str = "", + said: str = "terminal prompts disabled", timeout: float = 90) -> tuple: """The scaffold in its GitHub mode with `flags`, a first run or, with `--reuse-empty-repo` among them, a re-run, typed in a terminal, with `env` (a `fake_gh`'s) and git reading `https://github.com//` from a local server that answers every request 401, with `helper` as its one - credential helper or none: `(exit status, the screen)`. A run that waits - at git's prompt is killed after `timeout` seconds, which fails the - test.""" + credential helper or none, or, given `stored`, a `user:token` login, a + `store` helper holding that login for the server, which refuses it as a + host refuses a stale token (#993): `(exit status, the screen)`, the + screen holding `said`, git's words for the refusal. A run that waits at + git's prompt is killed after `timeout` seconds, which fails the test.""" server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _AsksForALogin) threading.Thread(target=server.serve_forever, daemon=True).start() try: port = server.server_address[1] + if stored: + store = tmp_path / "stored-login" + store.write_text(f"http://{stored}@127.0.0.1:{port}\n", + encoding="utf-8") + helper = f'store --file="{store}"' status, screen, _typed = run_in_a_terminal( [sys.executable, str(SCAFFOLD), "--org", ORG, "--project", PROJECT, "--elected-by", "Test Human", *flags, @@ -779,7 +797,7 @@ def in_a_terminal_asked_for_a_login(tmp_path, env: dict, *flags: str, # git's prompt starts a line; its refusal says `could not read Username # for '...': terminal prompts disabled` inside a line of its own. assert not re.search(r"(?m)^Username for ", screen), screen - assert "terminal prompts disabled" in screen, screen + assert said in screen, screen return status, screen @@ -850,12 +868,32 @@ def test_a_reuse_re_run_over_empty_legs_does_not_wait_at_its_push(tmp_path): "seed this scaffold pushes.") -def assert_the_login_is_named(printed: str, repo: str, re_run: str) -> None: - """`printed` says a push was refused, then how to give git a login for - `repo`, then `re_run`, then the ruleset hint: the remedy once, read with - its lines joined, as a person reads a line the terminal wrapped.""" +#: How it names the fix for a push whose login the host refused (#993): git +#: had one, so "could not get" is not said of it. +LOGIN_REFUSED_REMEDY = ( + "Remediation: the host refused the login git sent for @REPO@: git had " + "one, from your credential helper for one, and it is wrong, expired or " + "revoked, so a re-run is refused too until git has one the host takes. " + "Replace it first (on GitHub, `gh auth setup-git` makes git use the login " + "`gh` has instead, or store a new token for the host in your credential " + f"helper; {SSH_KEY}), then re-run ") +#: And for a push ssh was refused the key for (#993): `setup-git` is not +#: named as a fix, since it changes nothing over ssh. +KEY_REFUSED_REMEDY = ( + "Remediation: GitHub took no ssh key of yours for the push to @REPO@: a " + "`url..insteadOf` or `url..pushInsteadOf` rule in your git " + "config sends it over ssh, where git logs in with an ssh key and not with " + "the login `gh` has, so `gh auth setup-git` changes nothing for it. Give " + f"GitHub a key it takes first ({SSH_KEY_CHECK}), then re-run ") + + +def assert_the_login_is_named(printed: str, repo: str, re_run: str, + fix: str = NO_LOGIN_REMEDY) -> None: + """`printed` says a push was refused, then `fix`, how to give git a login + for `repo`, then `re_run`, then the ruleset hint: the remedy once, read + with its lines joined, as a person reads a line the terminal wrapped.""" said = " ".join(printed.split()) - remedy = NO_LOGIN_REMEDY.replace("@REPO@", repo) + re_run + remedy = fix.replace("@REPO@", repo) + re_run assert said.count(remedy) == 1, printed assert (said.index("REFUSED pushing ") < said.index(remedy) < said.index("If the organisation applies a ruleset")), printed @@ -894,6 +932,47 @@ def test_a_first_scaffold_s_push_git_has_no_login_for_names_the_fix( assert store.read_text(encoding="utf-8") == "", "nothing was stored" +#: What the GitHub side holds for a re-run: the three repositories, empty. +ALL_THREE = {PROJECT: "PRIVATE", f"{PROJECT}-spec": "PRIVATE", + f"{PROJECT}-code": "PRIVATE"} + + +@WINDOWS_SKIP +@PTY_SKIP +@pytest.mark.parametrize("flags,repos,re_run", [ + ((), {}, RE_RUN_WITH_THE_FLAG), + (("--reuse-empty-repo",), ALL_THREE, RE_RUN_AS_IT_WAS), +], ids=["a-first-run", "a-re-run"]) +def test_a_push_whose_stored_login_the_host_refuses_names_the_fix( + tmp_path, git_language, flags, repos, re_run): + """#993. A stored login the host refuses is not one git could not get: + git sends the token its credential helper holds, the host answers 401, + and git dies `Authentication failed for ''`, which holds none of + `NO_LOGIN`'s words, so on main through 04923f1 the push printed the + ruleset hint alone, whose re-run pushes with the same login. Typed in a + terminal, with `store` holding a token the local server refuses, a first + run and a re-run each exit 2 without waiting, and name the login the + host refused, how to replace it and the re-run, before the hint. + + `translated` runs git where it speaks another language, whose catalogue + translates `Authentication failed for` (`XX_GIT`): each push runs under + `PUSH_ENV`, so git's words are found there too; under `REMOTE_ENV` they + were not, and the run printed the hint alone.""" + env, log = fake_gh(tmp_path, repos, plan="team") + env.update(git_language) + status, screen = in_a_terminal_asked_for_a_login( + tmp_path, env, *flags, stored="someone:badtoken", + said="fatal: Authentication failed for ", timeout=120) + assert status == 2, screen + assert "REFUSED pushing the spec leg. THE EXACT COMMAND WAS:" in screen, ( + screen) + assert_the_login_is_named(screen, SPEC_REPO, re_run, + fix=LOGIN_REFUSED_REMEDY) + assert "could not get a login" not in screen, screen + assert log.read_text(encoding="utf-8").count("repo create") == ( + 0 if flags else 3), "a first run creates all three, a re-run none" + + # --- a leg git reaches over ssh (#915) -------------------------------------- #: What ssh prints when GitHub takes none of its keys, on stderr, before it @@ -909,16 +988,17 @@ def test_a_first_scaffold_s_push_git_has_no_login_for_names_the_fix( """) -def git_reaches_github_over_ssh(tmp_path) -> dict: +def git_reaches_github_over_ssh(tmp_path, rule: str = "insteadOf") -> dict: """What an operator's git config that rewrites `https://github.com/` to ssh does (#915), for `https://github.com//`, appended to the command scope the environment holds, with `FAKE_SSH` as the ssh git - runs. `GIT_SSH_VARIANT` says it takes OpenSSH's options, which git would - otherwise find out by running it once more, with `-G`.""" + runs. `rule` is `insteadOf`, for every command, or `pushInsteadOf`, for + pushes alone (#993). `GIT_SSH_VARIANT` says it takes OpenSSH's options, + which git would otherwise find out by running it once more, with `-G`.""" ssh = tmp_path / "fake_ssh.py" ssh.write_text(FAKE_SSH, encoding="utf-8") return {**appended_to_command_scope( - os.environ, [(f"url.ssh://git@github.com/{ORG}/.insteadOf", + os.environ, [(f"url.ssh://git@github.com/{ORG}/.{rule}", f"https://github.com/{ORG}/")]), "GIT_SSH_COMMAND": f'"{sys.executable}" "{ssh}"', "GIT_SSH_VARIANT": "ssh"} @@ -930,12 +1010,14 @@ def git_reaches_github_over_ssh(tmp_path) -> dict: C_UTF8 = Path("/usr/lib/locale/C.utf8") #: What git prints for `fatal: ` in `xx`, the test's own language. XX_FATAL = "XX-FATAL: " -#: A catalogue for git in `xx`: the `fatal: ` before every fatal line, and -#: git's generic message with its advice, translated as a person's locale -#: translates them, so where git speaks `xx` neither is in git's words. +#: A catalogue for git in `xx`: the `fatal: ` before every fatal line, git's +#: generic message with its advice, and its line for a login the host +#: refused (#993), translated as a person's locale translates them, so where +#: git speaks `xx` none is in git's words. XX_GIT = { "": "Content-Type: text/plain; charset=UTF-8\nLanguage: xx\n", "fatal: ": XX_FATAL, + "Authentication failed for '%s'": "XX the host said no to '%s'", "Could not read from remote repository.\n\nPlease make sure you have " "the correct access rights\nand the repository exists.": "XX could not read the remote.\n\nXX make sure of your rights\n" @@ -1031,6 +1113,36 @@ def test_a_leg_git_reaches_over_ssh_is_refused_with_ssh_s_reason( "nothing was created") +@WINDOWS_SKIP +@pytest.mark.parametrize("rule", ["insteadOf", "pushInsteadOf"]) +def test_a_push_ssh_was_refused_the_key_for_names_the_fix( + tmp_path, git_language, rule): + """#993. Where git config sends a first scaffold's push over ssh, by an + `insteadOf` rule or a `pushInsteadOf` one, and the host takes no key, + ssh says `Permission denied (publickey).` and git its generic line, + neither of them `NO_LOGIN`'s words, so on main through 04923f1 the push + printed the ruleset hint alone. Now the run exits 2 and names the ssh + check, then the re-run with the flag, then the hint, and not `gh auth + setup-git`, which changes nothing over ssh. ssh's words are its own and + untranslated, so `translated` finds them too.""" + env, log = fake_gh(tmp_path, {}, plan="team") + env.update(git_reaches_github_over_ssh(tmp_path, rule)) + env.update(git_language) + result = run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", + "--work-dir", str(tmp_path / "work"), + cwd=tmp_path, env=env) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED pushing the spec leg. THE EXACT COMMAND WAS:" in ( + result.stderr), result.stderr + assert REFUSED_KEY in result.stderr, result.stderr + assert_the_login_is_named(result.stderr, SPEC_REPO, RE_RUN_WITH_THE_FLAG, + fix=KEY_REFUSED_REMEDY) + assert "setup-git` gives git" not in result.stderr, result.stderr + assert log.read_text(encoding="utf-8").count("repo create") == 3, ( + "a first run creates all three") + + @pytest.fixture(scope="module") def scaffold_module(): return load_without_bytecode(SCAFFOLD, "scaffold_for_unread_because") @@ -1098,33 +1210,56 @@ def test_the_reason_git_could_not_read_a_leg(scaffold_module, output, #: login git could not get as much as the two above. NO_TERMINAL = ("fatal: could not read Username for 'https://github.com': " "No such device or address") - - -@pytest.mark.parametrize("output,reused,re_run", [ - (NO_PROMPT, False, RE_RUN_WITH_THE_FLAG), - (NO_PROMPT, True, RE_RUN_AS_IT_WAS), - (NO_PASSWORD, False, RE_RUN_WITH_THE_FLAG), - (NO_TERMINAL, False, RE_RUN_WITH_THE_FLAG), - (RULESET_SAID, False, None), - (f"{REFUSED_KEY}\n{GENERIC}{ADVICE}", True, None), +#: git's line where it sent a login and the host refused it (#993). +LOGIN_REFUSED = ("fatal: Authentication failed for " + "'https://github.com/o/r.git/'") +#: What GitHub and git say for a login the host took and a right it lacks: +#: not a login to give or replace, so it keeps the hint alone (#993). +FORBIDDEN = ("remote: Permission to o/r.git denied to someone.\nfatal: " + "unable to access 'https://github.com/o/r.git/': The requested " + "URL returned error: 403") +#: ssh's line where the host offers more ways to log in than a key. +REFUSED_KEY_AND_MORE = ("git@example.invalid: Permission denied " + "(publickey,password).") + + +@pytest.mark.parametrize("output,reused,fix,re_run", [ + (NO_PROMPT, False, NO_LOGIN_REMEDY, RE_RUN_WITH_THE_FLAG), + (NO_PROMPT, True, NO_LOGIN_REMEDY, RE_RUN_AS_IT_WAS), + (NO_PASSWORD, False, NO_LOGIN_REMEDY, RE_RUN_WITH_THE_FLAG), + (NO_TERMINAL, False, NO_LOGIN_REMEDY, RE_RUN_WITH_THE_FLAG), + (LOGIN_REFUSED, False, LOGIN_REFUSED_REMEDY, RE_RUN_WITH_THE_FLAG), + (LOGIN_REFUSED, True, LOGIN_REFUSED_REMEDY, RE_RUN_AS_IT_WAS), + (f"{REFUSED_KEY}\n{GENERIC}{ADVICE}", False, KEY_REFUSED_REMEDY, + RE_RUN_WITH_THE_FLAG), + (f"{REFUSED_KEY}\n{GENERIC}{ADVICE}", True, KEY_REFUSED_REMEDY, + RE_RUN_AS_IT_WAS), + (f"{REFUSED_KEY_AND_MORE}\n{GENERIC}{ADVICE}", False, KEY_REFUSED_REMEDY, + RE_RUN_WITH_THE_FLAG), + (RULESET_SAID, False, None, None), + (FORBIDDEN, False, None, None), ], ids=["no-username", "no-username-on-the-re-run", "no-password", - "no-terminal", "a-ruleset", "ssh-refused-the-key"]) + "no-terminal", "login-refused", "login-refused-on-the-re-run", + "ssh-refused-the-key", "ssh-refused-the-key-on-the-re-run", + "ssh-refused-the-key-of-more-ways", "a-ruleset", "a-403"]) def test_what_a_refused_push_prints(scaffold_module, capsys, output, reused, - re_run): - """`_refused_push` (#915): the command and git's output, then, where git - could not get a login (`NO_LOGIN`), how to give it one and the re-run, - then the ruleset hint. Any other refusal prints, byte for byte, what a - refused push always printed: the command, git's output, the hint. A - re-run's remedy names its own command again, not the flag it has. - In-process, so it runs on every platform.""" + fix, re_run): + """`_refused_push` (#915, #993): the command and git's output, then, + where git could not get a login (`NO_LOGIN`), the host refused the one + git sent (`LOGIN_REFUSED`) or ssh was refused the key (`KEY_REFUSED`), + that case's fix and the re-run, then the ruleset hint. Any other + refusal, an HTTP 403 among them, prints, byte for byte, what a refused + push always printed: the command, git's output, the hint. A re-run's + remedy names its own command again, not the flag it has. In-process, so + it runs on every platform.""" failed = scaffold_module.CommandFailed( ["git", "push", "-q", "-u", "origin", "HEAD:refs/heads/main"], Path("work"), 128, output) scaffold_module._refused_push(failed, "pushing the spec leg", "o/r", "main", reused) out, err = capsys.readouterr() - remedy = ("" if re_run is None else - "\n" + NO_LOGIN_REMEDY.replace("@REPO@", "o/r") + re_run + "\n") + remedy = ("" if fix is None else + "\n" + fix.replace("@REPO@", "o/r") + re_run + "\n") assert out == "" assert err == (failed.loudly("pushing the spec leg") + "\n" + remedy + scaffold_module.RULESET_HINT.format(repo="o/r",