ainb-tui · feature verification · PR #155

The GitHub auth pre-check, with no gh auth

rebased onto main CI green all review comments fixed verified: tripwire + recording
TL;DR — Pasting/selecting a remote GitHub repo with no gh auth used to hand straight to git clone, whose interactive Username for 'https://github.com': prompt blocked on the stdin that crossterm owns in raw mode — the whole TUI froze with no escape. The fix runs gh auth status as a pre-check (GitHub hosts only, 5 s timeout, fails closed) before any git. Logged out, it shows an inline amber “GitHub auth required” prompt with Enter=Retry · s=Skip · Esc=Back — and Esc drops you back to a fully responsive picker.

The recording — driven headless against a logged-out gh

This is the real ainb binary in a scripted PTY (vhs), with a stubbed gh on PATH that exits non-zero exactly like gh auth status when signed out. Watch: Enter on the GitHub favorite → the inline auth prompt appears (no credential hang) → Esc returns to the picker.

ainb TUI: pressing Enter on a GitHub favorite with no gh auth shows an inline 'GitHub auth required' prompt instead of hanging on a git username prompt; Esc returns to the picker.
ainb 1.4.2 · seeded $HOME · stubbed logged-out gh · captured with vhs (FontSize 15, 1700×950)
picker open, ainb-tui favorite highlighted, no prompt
The prompt frame. 🔑 GitHub auth required → gh auth login && gh auth setup-git · Enter=Retry s=Skip Esc=Back
diagram of the decision
The decision. Only github.com hosts probe gh; only a logged-out gh shows the prompt.
picker after Esc, prompt gone
Graceful exit. Esc clears the prompt; the picker is usable again — never frozen.

What happens, step by step

The whole pre-check sits between “you picked a repo” and “git runs”. Expand each step.

1 · You pick a remote GitHub repo pick_repo.rs · resolve_outcome

Enter on a GithubShorthand / HttpsUrl row (or smart-parsing a pasted URL) resolves to PickRepoOutcome::StartClone(source). Local paths and SSH sessions skip straight to Configure — they never need a clone.

2 · The host gate decides if gh is even relevant events.rs · StartClone

Only GitHub sources are routed through the check: a GithubShorthand, or an HttpsUrl whose parsed host is github.com (via repo_source::is_github_host). A GitLab / Bitbucket / self-hosted HTTPS remote is left alone — the gh auth status probe is GitHub-specific and would otherwise put an irrelevant failure screen in front of those clones. (CodeRabbit finding #1.)

3 · The pre-check runs gh auth status, bounded state.rs · check_git_auth

On a blocking thread, with a tokio::time::timeout(5s) wrapper. A hung gh (network stall, wedged credential helper) can’t freeze the picker — both the timeout and a task panic fail closed to “not authenticated” and log a warning. (CodeRabbit finding: add a timeout.)

4 · Logged out → inline amber prompt, not a git credential hang pick_repo.rs · GitAuthStatus::NotAuthenticated

The picker renders, under the highlighted row, 🔑 GitHub auth required. In another terminal run: / gh auth login && gh auth setup-git / Enter=Retry  s=Skip auth  Esc=Back. Because the check ran before git, git clone’s Username for… stdin prompt never happens.

5 · You recover — Retry, Skip, or Esc pick_repo.rs · handle_key

Enter re-runs the check (e.g. after you authenticate in another terminal). s skips the gate and proceeds to clone anyway — and even then git can’t hang, because every network-facing git call sets GIT_TERMINAL_PROMPT=0 / GIT_ASKPASS=echo and fails fast. Esc dismisses the prompt and returns to a responsive picker.

The code that matters

// git/repo_source.rs — only github.com routes through `gh`.
pub fn is_github_host(https_url: &str) -> bool {
    url::Url::parse(https_url)
        .ok()
        .and_then(|u| u.host_str()
            .map(|h| h.eq_ignore_ascii_case("github.com")))
        .unwrap_or(false)   // fails closed on junk URLs
}
// app/events.rs — StartClone gate.
let needs_auth_check = match &source {
    RepoSource::GithubShorthand { .. } => true,
    RepoSource::HttpsUrl(url) => is_github_host(url),
    _ => false,            // SSH, local, non-GitHub HTTPS: skip
};
// app/state.rs — bounded probe, fails closed.
let auth_ok = match tokio::time::timeout(
    Duration::from_secs(5), auth_check).await {
    Ok(Ok(ok)) => ok,
    Ok(Err(e)) => { tracing::warn!(?e, "join error"); false }
    Err(_)     => { tracing::warn!("gh auth timed out"); false }
};
The whole point in one line: the credential prompt never reaches the TUI. The recording’s key assertion — and the tripwire’s — is the absence of Username for / Password for on the pane.

How it’s proven — two independent ways

A tmux tripwire driving the real binary tests/tripwire_new_session_github_auth_no_gh.rs

Launches ainb in a detached tmux session with a stubbed logged-out gh on PATH, presses Enter on the GitHub favorite, and asserts: (1) the inline auth required + gh auth login prompt renders; (2) the pane never contains Username for / Password for; (3) Esc returns to the picker (Enter=Select back, prompt gone). Passes green locally in ~11 s.

The recording above vhs · auth-precheck.tape

Same flow, same stubbed gh, captured as a deterministic GIF/MP4 so the behaviour is visible, not just asserted. The .tape is the source of truth and re-runs identically.

Gotchas worth knowing

FAQ

How do I try this myself without logging out of gh?
Run ainb with a PATH whose first entry holds a fake gh that exit 1s — or just gh auth logout — then Enter on a GitHub repo in the picker.
Does a non-GitHub HTTPS remote get blocked?
No. is_github_host only matches github.com; GitLab / Bitbucket / self-hosted HTTPS clones skip the gh probe entirely.
What if gh hangs?
The 5-second timeout fires, logs a warning, and treats it as not-authenticated — the picker can’t get stuck on Checking.
What was the actual bug?
git clone over HTTPS with no credentials prints Username for 'https://github.com': and waits on stdin. In raw mode crossterm owns stdin, so that prompt was invisible and unanswerable — the TUI just froze. The pre-check makes git never get there.