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",