# auth: stop forcing GIT_TERMINAL_PROMPT=0 on the credential subprocess

`566de82`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·
  
Soph·1mo ago·3 files·+68 added/-20 removed

Issue #63 was about a *public* repo being prompted for a username and password because Resolve called the credential helper proactively. The right fix is the one that already shipped on this branch — defer Lookup until a real 401, so anonymous endpoints never reach the helper in the first place.

But on top of that, the original commit also forced `GIT_TERMINAL_PROMPT=0` on every `git credential` subprocess. That was belt-and-suspenders, and the suspenders are the problem: a user with a configured helper that simply doesn't have an entry for a new host yet gets a 401 with no path to authenticate, because we explicitly disallowed git from falling through to a terminal prompt.

That matches neither vanilla `git push` behaviour nor user expectation: the first time you push to a new host, git prompts and the helper remembers the result. We blocked that flow entirely.

This change drops the override. The credential subprocess inherits the parent environment as-is:

- GIT_TERMINAL_PROMPT unset / "1": git may prompt on the controlling tty when the helper has nothing. Same as vanilla git.
- GIT_TERMINAL_PROMPT=0 in the caller's env: passed through. CI, daemons, and the syncer's background loop already set this (or should — same way they would for plain git) and continue to see clean non-blocking failures on missing credentials.

Doc updates on Lookup and the CredentialHelper interface to reflect that they MAY block on user interaction now, and how callers control that.

The previous regression test asserted the override was present; rewrote it as TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt, which pins both directions (no entry when parent has none; pass-through when parent sets 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

## Sessions

433b3f82b2f6View transcript

## Changes

3

- internal

- auth

- Mauth.go+20/-8

- Mauth_test.go+40/-10

- gitproto

- Msmarthttp.go+8/-2

```
4 unmodified lines

5
6
7
8
8
9
10
60 unmodified lines

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
78
79
80
81
88
89
90
10 unmodified lines

101
102
103
98
99
100
104
105
106
107
108
109
110
111
112
113
114
115

4 unmodified lines

"fmt"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"strings"

60 unmodified lines

// newGitCredentialCmd builds the `git credential <op>` invocation. Extracted
// so tests can inspect the command's environment without exec'ing git.
//
// We inherit the parent environment unchanged — in particular, we do NOT
// force GIT_TERMINAL_PROMPT=0. The original #63 symptom (interactive prompt
// on a public-and-anonymous repo) is already prevented by Resolve no longer
// invoking the helper proactively: with no 401 there's no Lookup, no
// `git credential fill`, and so no prompt. Once the server actually
// challenges with a 401, prompting is the right behaviour when there's a
// terminal and a helper that has no entry for the host yet — same as
// vanilla `git push`. Non-interactive callers (CI, daemons, the syncer
// background loop) set GIT_TERMINAL_PROMPT=0 in their own environment the
// same way they would for plain git, and we pass that through.
func newGitCredentialCmd(ctx context.Context, op CredentialOp, input string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "git", "credential", string(op))
	cmd.Stdin = strings.NewReader(input)
	// Suppress git's interactive username/password fallback. Without this,
	// a host with no configured helper drops to a /dev/tty prompt and turns
	// git-sync into an interactive command (issue #63).
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
	return cmd
}

10 unmodified lines

// Lookup queries the git credential helper for credentials for ep. Returns
// ok=false if no credentials are available so the caller can surface a
// clean 401 rather than block. A non-nil error means the lookup itself
// couldn't complete (e.g. the context was cancelled) and the caller should
// surface that rather than fall back to the original 401.
// clean 401. A non-nil error means the lookup itself couldn't complete
// (e.g. the context was cancelled) and the caller should surface that
// rather than fall back to the original 401.
//
// Lookup may block on user interaction when the helper falls through to a
// terminal prompt (vanilla `git credential fill` behaviour). Callers that
// must not block should set GIT_TERMINAL_PROMPT=0 in the process
// environment; the credential subprocess inherits it. See
// newGitCredentialCmd for the rationale on not forcing that ourselves.
func (GitCredentialHelper) Lookup(ctx context.Context, ep *url.URL) (username, password string, ok bool, err error) {
	if !isHTTPEndpoint(ep) {
		return "", "", false, nil
	}
}

// TestGitCredentialCmdDisablesTerminalPrompt is a regression test for issue
// #63 — without GIT_TERMINAL_PROMPT=0 git drops into an interactive prompt.
func TestGitCredentialCmdDisablesTerminalPrompt(t *testing.T) {
// TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt locks in
// the corrected behaviour from issue #63: the proactive-Lookup path is what
// caused the original spurious prompt on a public repo (already fixed by
// deferring Lookup to a real 401), and we deliberately do NOT also force
// GIT_TERMINAL_PROMPT=0. Forcing it would block legitimate first-time
// authentication to a new host. Non-interactive callers (CI, daemons) set
// the env var in their own environment, and we inherit it as-is.
func TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt(t *testing.T) {
	// When the parent process has no GIT_TERMINAL_PROMPT set, the
	// subprocess must not have one either — letting git's default
	// (prompting allowed) take effect.
	t.Setenv("GIT_TERMINAL_PROMPT", "")
	os.Unsetenv("GIT_TERMINAL_PROMPT")
	cmd := newGitCredentialCmd(context.Background(), CredentialOpFill, "protocol=https\nhost=example.com\n\n")

var found bool
	// cmd.Env == nil means "inherit from parent" — equivalent to no override.
	// If the implementation sets cmd.Env explicitly we still want no
	// GIT_TERMINAL_PROMPT entry.
	for _, kv := range cmd.Env {
		if kv == "GIT_TERMINAL_PROMPT=0" {
			found = true
			break
		}
		if strings.HasPrefix(kv, "GIT_TERMINAL_PROMPT=") {
			t.Errorf("subprocess must not force GIT_TERMINAL_PROMPT; got %q", kv)
		}
	}
	if !found {
		t.Errorf("expected GIT_TERMINAL_PROMPT=0 in cmd.Env, got %v", cmd.Env)

// When the parent sets GIT_TERMINAL_PROMPT=0 (non-interactive callers),
	// the subprocess sees the same value — we pass it through, we don't
	// override or strip it.
	t.Setenv("GIT_TERMINAL_PROMPT", "0")
	cmd = newGitCredentialCmd(context.Background(), CredentialOpFill, "protocol=https\nhost=example.com\n\n")
	// cmd.Env == nil also satisfies this case (subprocess inherits the
	// parent env including the value we just Setenv'd). If cmd.Env is
	// populated, it must contain exactly the parent value.
	if cmd.Env != nil {
		var found, count int
		for _, kv := range cmd.Env {
			if strings.HasPrefix(kv, "GIT_TERMINAL_PROMPT=") {
				count++
				if kv == "GIT_TERMINAL_PROMPT=0" {
					found++
			}
			}
		}
		if count != 1 || found != 1 {
			t.Errorf("expected exactly one GIT_TERMINAL_PROMPT=0 entry passed through, got %d total / %d matching: %v", count, found, cmd.Env)
		}
	}
}`
