Skip to content

scaffold-project.py: a push whose stored login the host refuses (Authentication failed), or whose ssh key it refuses (Permission denied (publickey)), names how to replace it and the --reuse-empty-repo re-run before the ruleset hint, in any locale (#993) - #1012

Merged
brettheap merged 1 commit into
mainfrom
l3-993-push-rejected-login-remedy
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #993.

What changed

The issue's case: a stored login the host refuses (fatal: Authentication failed for '<url>'). _refused_push passes git's output to _login_remedy, which now picks the sentence for the case the output shows:

  • NO_LOGIN: git could not get a login. Its words are unchanged, byte for byte.
  • LOGIN_REFUSED = ("Authentication failed for ",): a case of its own, as the Direction asked, not a phrase added to NO_LOGIN. The remedy says the host refused the login git sent, so it is wrong, expired or revoked, and a re-run is refused too until git has one the host takes. To replace it: gh auth setup-git makes git use the login gh has, or a new token goes in the credential helper, or the ssh clause (SSH_KEY_CLAUSE) applies.
  • Each case then names the re-run once, with the flag on a first run and as it was on a re-run, and the ruleset hint follows.

The comment's case: a push an insteadOf rule sends over ssh, refused for its key (Permission denied (publickey)).

  • KEY_REFUSED = ("Permission denied (publickey",) names the ssh -T check against the user, host and port the rule names (SSH_KEY_CHECK, split out of SSH_KEY_CLAUSE with no byte of that clause changed), then the re-run, then the hint.
  • It does not offer gh auth setup-git as the fix, since that changes nothing over ssh.

Any other refusal, a ruleset's above all, prints exactly what it printed before.

Decisions the Direction left open

  1. Translation was measured, so each push now runs under LC_ALL=C.

    • The Direction asked for Authentication failed for to be measured before the detection relied on it. git 2.43, given a catalogue that translates the phrase, prints XX-FATAL: XX-Anmeldung abgelehnt fuer 'http://127.0.0.1:34051/acme/Atlas-spec.git/'; without one it prints fatal: Authentication failed for '...'. So git translates it.
    • Each push, a leg's and the root's, now runs under the new PUSH_ENV = {**REMOTE_ENV, "LC_ALL": "C"}, the Direction's first option. Reading the HTTP status would need GIT_TRACE_CURL and would put headers on stderr.
    • The cost: git's own lines of a refused push print in English, inside a refusal this tool already prints in English. A push that goes through shows nothing of git's.
    • The NO_LOGIN comment that said pushes do not take LC_ALL=C is rewritten to match.
  2. The ssh match reads Permission denied (publickey without the closing parenthesis. A host that offers more ways to log in lists them after publickey, as in (publickey,password). These are OpenSSH's own words, and OpenSSH does not translate them.

  3. The ssh remedy names url.<base>.pushInsteadOf as well as insteadOf. The common setup that fetches over https and pushes over ssh sends only the push over ssh.

  4. I checked the gh auth setup-git claim before the words relied on it. gh writes an empty helper = before its own (setup-project.py's _credential_helper_configured says so). On git's side, I probed git credential fill with three helpers in this order:

    • a stale store helper;
    • the empty line, then a helper standing in for gh's;
    • another store helper.

    The helper standing in for gh's answered. The stale helper before the empty line was not asked, and the one after it was not asked once gh's stand-in had answered. I did not run the real gh, which this lane uses only for reads and its named writes.

  5. The re-run sentence holds after a refused login. On a 401, git asks its helpers to forget the refused login: the store file's line was gone after the probe. A re-run before the login is replaced is then refused with NO_LOGIN's words, and has the same exit. So the sentence says "a re-run is refused too until git has one the host takes", not "the same way".

  6. No docs change. No refusal code was added, and no doc quotes the push remedies (scaffold-project.py: a first scaffold's pushes and a --reuse-empty-repo re-run's pushes and seed fetch no longer ask at the terminal: a push git has no login for fails naming the fix, and a leg git reaches over ssh is refused with ssh's reason (#915) #971 changed none either). The README's login step ("without it the three repositories are created and the push fails") still holds.

Out of scope, and why

  • An HTTP 403, a login the host took for an account or token without the right, keeps today's words, as the launch brief ruled ("Any other push failure keeps today's words"). Its fix is a right, not a login to give or replace. A test row pins it: a-403.
  • family.py init's and adopt-project.py's pushes (FAMILY_RULESET_HINT, ADOPT_RULESET_HINT): the Direction says these are their own issues.
  • ssh clients other than OpenSSH, such as plink's No supported authentication methods available: not read, and not probed.
  • _take_held_seed's fetch stays under REMOTE_ENV. Its output names no remedy.
  • The issue has no "Needs a ruling" or "Documented limit" section.

Tests

Each test file was run alone under a stub gh (exit 97) first on PATH, with GH_TOKEN=bogus, an empty GH_CONFIG_DIR and EMAIL=tests@openreposhape.invalid, as timeout 2400 /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<name> -o tmp_path_retention_policy=failed <file>:

  • tests/test_scaffold_reuse_seeded_legs.py: 64 passed in 50.32s
  • tests/test_scaffold_e2e.py: 46 passed in 18.94s
  • tests/test_scaffold_pin_and_reuse.py: 75 passed in 27.24s
  • tests/test_dry_run_asks_remotes.py: 32 passed in 10.48s
  • tests/test_ruleset_hint_tracking_branch.py (pins RULESET_HINT): 23 passed in 32.50s
  • tests/test_adopt_late_failure.py (pins RULESET_HINT): 88 passed in 35.83s
  • tests/test_git_output_decoding.py: 67 passed in 2.50s
  • tests/test_agents_md_codes.py: 47 passed in 1.91s
  • tests/test_cli_reference.py: 23 passed in 29.88s
  • tests/test_update_add_codes_documented.py: 6 passed in 0.43s
  • tests/test_repo_hygiene.py: 100 passed in 2.41s
  • tests/test_no_auto_maintenance.py: 60 passed in 11.77s

New and changed tests, all in tests/test_scaffold_reuse_seeded_legs.py:

  • test_a_push_whose_stored_login_the_host_refuses_names_the_fix covers a-first-run and a-re-run, each as-it-is and translated.
  • test_a_push_ssh_was_refused_the_key_for_names_the_fix covers insteadOf and pushInsteadOf, each as-it-is and translated. It runs a first run pushing over the stand-in ssh, which prints Permission denied (publickey). and exits 255.
  • test_what_a_refused_push_prints gains these rows: login-refused, login-refused-on-the-re-run, ssh-refused-the-key-on-the-re-run, ssh-refused-the-key-of-more-ways and a-403.
    • The ssh-refused-the-key row now expects the ssh remedy. At main it pinned the hint alone, which was this issue's gap.
  • test_every_git_command_that_reaches_a_remote_runs_under_the_rule now expects the three pushes under PUSH_ENV and pins its value.

Mutants

Each mutant was run against the new and changed tests (-k over the four tests above and the two existing pty push tests), and the file was restored after each:

Mutant Killed by
M1: LOGIN_REFUSED = (), so the detection is narrowed to NO_LOGIN the 4 stored-login rows, login-refused, login-refused-on-the-re-run
M2: "Authentication failed for " put into NO_LOGIN the same 6
M3: KEY_REFUSED = () the 4 ssh push rows, plus ssh-refused-the-key, -on-the-re-run, -of-more-ways
M4: PUSH_ENV = REMOTE_ENV, so pushes are translated translated-a-first-run, translated-a-re-run, the env rule test
M5: the root's push under REMOTE_ENV the env rule test
M6: LOGIN_REFUSED = ("failed",) a-ruleset
M7: KEY_REFUSED = ("denied",) a-403
M8: KEY_REFUSED narrowed to "Permission denied (publickey)." ssh-refused-the-key-of-more-ways
M9: the remedy printed after the hint 21 tests, every remedy row and pty test
M10: an empty remedy printed for any refusal a-ruleset, a-403
M11: the ssh remedy given SSH_KEY_CLAUSE (which names setup-git) the 4 ssh push rows and 3 ssh table rows

Merges

git merge-tree --write-tree of the head d87263e4ca81066c908930863dfc909607dc9ebc:

  • with origin/main 3864b18cc99a35a0e11344f591fae7f76cbfbe72: clean. Main moved since the base 04923f10d, but not in either file this changes.
  • with W989's l3-989-999-scaffold-names-folder-help 37b0fc438d2516e921a4c5dbbfb6dc8bf629b252: clean. It changes scaffold-project.py in other functions.
  • with W989's wip/l3-989-usage-20261019 099eca4ae12b427db7eba5f9225ac1dd11a3f621: clean.
  • gh pr list --state open shows no other open PR touching scaffold-project.py or tests/test_scaffold_reuse_seeded_legs.py.

Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code

Summary by Sourcery

Improve scaffold push failure messages so users can replace rejected credentials or SSH keys and safely re-run the scaffold.

New Features:

  • Provide targeted remediation guidance when a push is rejected because the host refused stored credentials or an SSH key.

Bug Fixes:

  • Correctly identify translated credential-rejection messages by running pushes with a fixed English locale.
  • Preserve existing generic output for unrelated push failures, including HTTP 403 responses.

Enhancements:

  • Distinguish missing credentials, refused credentials, and refused SSH keys, including push-only SSH URL rewrites, and place the relevant re-run guidance before the ruleset hint.

Tests:

  • Add coverage for refused stored logins, translated Git output, SSH and pushInsteadOf rewrites, re-runs, multi-method SSH errors, and unchanged generic failures.

…hentication failed`), or whose ssh key it refuses (`Permission denied (publickey)`), names how to replace it and the `--reuse-empty-repo` re-run before the ruleset hint, in any locale (#993)

A first scaffold's push, or a re-run's, that git made with a login its
credential helper holds and the host refused printed only the ruleset
hint, whose one exit is a re-run refused the same way. So did a push an
`insteadOf` or `pushInsteadOf` rule sends over ssh where the host took no
key. Each now names its fix, then the re-run, then the hint, as a push git
has no login for has since #971. Each push runs under `LC_ALL=C`
(`PUSH_ENV`), because git translates `Authentication failed for`
(measured under a catalogue). Any other refusal, a 403 included, prints
what it printed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR enhances scaffold push failure diagnostics by detecting refused HTTPS credentials and SSH keys, providing case-specific replacement and rerun guidance before the ruleset hint, and forcing pushes to use English Git output so detection works across locales while leaving unrelated failures unchanged.

Sequence diagram for locale-safe scaffold push handling

sequenceDiagram
    participant Scaffold
    participant Git
    participant Host
    Scaffold->>Git: push(..., env=PUSH_ENV)
    Note over Git: LC_ALL=C and terminal prompts disabled
    Git->>Host: Authenticate and push
    Host-->>Git: Accept or refuse login/key
    Git-->>Scaffold: Output and exit status
    Scaffold->>Scaffold: _login_remedy(output, repo, reused)
    alt Login refused
        Scaffold-->>Scaffold: Print credential replacement and rerun guidance
    else SSH key refused
        Scaffold-->>Scaffold: Print SSH_KEY_CHECK and rerun guidance
    else Other refusal
        Scaffold-->>Scaffold: Print no remedy
    end
    Scaffold-->>Scaffold: Print RULESET_HINT
Loading

State diagram for scaffold rerun after refused credentials

stateDiagram-v2
    [*] --> InitialPush
    InitialPush --> LoginRefused: LOGIN_REFUSED
    InitialPush --> KeyRefused: KEY_REFUSED
    InitialPush --> NoLogin: NO_LOGIN
    InitialPush --> OtherFailure: Other output
    LoginRefused --> CredentialReplacement: replace rejected login
    KeyRefused --> SSHKeyCheck: provide accepted SSH key
    NoLogin --> LoginSetup: configure Git login
    CredentialReplacement --> Rerun
    SSHKeyCheck --> Rerun
    LoginSetup --> Rerun
    Rerun --> Completed: host accepts credentials
    Rerun --> LoginRefused: credential still rejected
    Rerun --> NoLogin: credential unavailable
    OtherFailure --> [*]
    Completed --> [*]
Loading

Flow diagram for scaffold push refusal diagnostics

flowchart TD
    A[Git push with PUSH_ENV] --> B{Refusal output matches}
    B -->|NO_LOGIN| C[Explain missing login and credential options]
    B -->|LOGIN_REFUSED| D[Explain rejected credential and replacement options]
    B -->|KEY_REFUSED| E[Explain rejected SSH key and run SSH_KEY_CHECK]
    B -->|Other refusal| F[Keep existing failure output]
    C --> G[Print rerun guidance]
    D --> G
    E --> G
    F --> H[Print RULESET_HINT]
    G --> H
Loading

File-Level Changes

Change Details Files
Classify refused pushes by the specific authentication failure and emit a targeted remediation before the existing ruleset hint.
  • Added distinct detection for missing credentials, refused HTTPS logins, and refused SSH keys.
  • Added guidance for replacing rejected credentials, checking SSH configuration, and rerunning first attempts versus reuse runs.
  • Preserved the prior output for unrelated failures, including ruleset failures and HTTP 403 responses.
scaffold-project.py
Make push diagnostics locale-independent while preserving the existing remote-command environment behavior for non-push operations.
  • Introduced PUSH_ENV with LC_ALL=C and applied it to leg and root pushes.
  • Separated the reusable SSH diagnostic clause from the broader SSH login guidance.
  • Covered both insteadOf and pushInsteadOf SSH rewrites.
scaffold-project.py
Expand coverage for localized HTTPS authentication failures, SSH key refusals, reruns, and environment propagation.
  • Added terminal-based tests for rejected stored logins on first runs and reuse reruns.
  • Added SSH refusal tests for both URL rewrite forms and translated locales.
  • Added regression cases for multi-method SSH errors, HTTP 403 preservation, and push environment assertions.
tests/test_scaffold_reuse_seeded_legs.py

Assessment against linked issues

Issue Objective Addressed Explanation
#993 Detect a push rejected with Authentication failed as a distinct stored-login failure, and explain that the host rejected the credential and how to replace it. ✅
#993 For that failure, give the --reuse-empty-repo re-run guidance exactly once before the ruleset hint, including on a re-run. ✅
#993 Make the rejection detection reliable across locales, and test the stored-login failure path. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-19T22:01:43Z) RV1012, lane 3's independent review of #1012 at head d87263e: READY. No MUST, no SHOULD, four NITs. GitHub's tests job was still pending at my last read; it must be green before landing.

Detection, measured on real git 2.43 against a local server that answers 401, with store holding someone:badtoken, in a pty:

  • First run: exit 2, no prompt; the LOGIN_REFUSED remedy once, then the re-run with the flag, then the ruleset hint. The store file is empty afterwards (git erased the refused login).
  • Re-run over that same store: exit 2, scaffold-project.py: a first scaffold's pushes and a --reuse-empty-repo re-run's pushes and seed fetch no longer ask at the terminal: a push git has no login for fails naming the fix, and a leg git reaches over ssh is refused with ssh's reason (#915) #971's no-login remedy, then the re-run as it was. This confirms decision 5.
  • Re-run with the bad login stored again: the LOGIN_REFUSED remedy, then the re-run as it was.
  • The same in a locale whose catalogue translates Authentication failed for '%s': git's line stays English and the remedies follow. Main there prints XX-FATAL: XX-Anmeldung abgelehnt fuer ... and the hint alone.
  • ssh: a stand-in prints Permission denied (publickey). and exits 255. Under insteadOf and pushInsteadOf, first run and re-run, translated, and (publickey,password): each exits 2 with the ssh remedy, then the re-run, then the hint, and never offers gh auth setup-git. Main with pushInsteadOf prints the hint alone.
  • Byte for byte: _refused_push on 12 git outputs, main and head in separate processes, first run and re-run. Only login-refused and key-refused differ. The three no-login cases, ruleset, 403, not-found, hung-up, empty, ssh connection refused and hook-declined are identical, and SSH_KEY_CLAUSE is the same string. A real-git 403 refusal and a real pre-receive refusal are byte-identical on head and main.

PUSH_ENV:

  • Both pushes (the legs' and the root's) run under it; the environment test pins this.
  • A local-remote scaffold whose --work-dir and --local-remote-dir hold non-ASCII (Latin and CJK) letters, under C.UTF-8 and translated: the finished run exits 0 with stderr empty; the refused run's stderr is identical to main's, with paths and the hook's remote: line printed right.
  • The ssh, credential-helper and hook exposure under LC_ALL=C is not new: PROBE_ENV already runs only_commit's commands under it.

Remedy text:

  • git's gitcredentials doc: an empty credential.helper "resets the helper list to empty", and no more helpers are tried once git has a username and password.
  • git credential fill with stub helpers (stale one, then an empty line, then a gh stand-in, then another one): only the stand-in was asked when it answered; the helper after it was asked only when the stand-in answered nothing; the stale one was never asked. The real gh was never run.

Tests (each file alone, stub gh exit 97, GH_TOKEN=bogus, empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null, EMAIL set):

  • test_scaffold_reuse_seeded_legs 64 passed in 53.60s (no skips)
  • test_scaffold_e2e 46 passed in 20.27s
  • test_scaffold_pin_and_reuse 75 passed in 24.93s
  • test_ruleset_hint_tracking_branch 23 passed in 34.44s
  • test_adopt_late_failure 88 passed in 36.64s
  • test_git_output_decoding 67 passed in 2.56s
  • test_readme_windows_quoting_sentence 44 passed in 17.06s (the diff adds no shell_quoted call)
  • test_agents_md_codes 47 passed in 2.18s
  • test_cli_reference 23 passed in 33.10s
  • test_update_add_codes_documented 6 passed in 0.44s
  • test_repo_hygiene 100 passed in 2.47s
  • test_no_auto_maintenance 58 passed, 2 skipped (skips caused by my GIT_CONFIG_GLOBAL), then 60 passed in 8.65s without it
  • test_scaffold_reuse_seeded_legs on head merged with main 7fa60a5: 64 passed in 50.18s
    SonarCloud: 0 new issues, 0.0% new duplication.

Mutants (each against all of test_scaffold_reuse_seeded_legs.py):

Merges (git merge-tree --write-tree), all clean:

NITs:

  • N1 scaffold-project.py:479-480: the rewrap leaves a short line "#: off. gh can".
  • N2 scaffold-project.py:1904-1905 and tests/test_scaffold_reuse_seeded_legs.py:829 still say each push runs under REMOTE_ENV. Name PUSH_ENV.
  • N3 scaffold-project.py:1966-1967 and the test literal at :874-875: "from your credential helper for one," reads like a count. Reword both together.
  • N4 Measured but not pinned: MB, and the re-run after git erased the refused login (it prints the no-login words). Optional in-process tests for both.

Skipped to save usage: a real sshd, a Windows run, test_dry_run_asks_remotes.py, the full suite.

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 20 hours by commenting @sourcery-ai review. Upgrade to get a review now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant