From c9f1ad4d7f5c55c4b28d7cfa708d5509d62fc05e Mon Sep 17 00:00:00 2001 From: Ben Schellenberger <2601492+bschellenberger2600@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:29:09 -0400 Subject: [PATCH 1/3] Disable interactive git credential prompts on fetch operations. Apply GIT_TERMINAL_PROMPT=0 to all network fetch paths so parallel git-rain runs fail fast with a frozen auth message instead of interleaved TTY prompts. Co-authored-by: Cursor --- cmd/root.go | 1 + cmd/root_test.go | 1 + internal/git/command.go | 31 +++++++++++++++ internal/git/command_test.go | 72 ++++++++++++++++++++++++++++++++++ internal/git/fetch_mainline.go | 1 + internal/git/rain.go | 1 + 6 files changed, 107 insertions(+) create mode 100644 internal/git/command.go create mode 100644 internal/git/command_test.go diff --git a/cmd/root.go b/cmd/root.go index cb37719..2b7c601 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -888,6 +888,7 @@ func fetchOnly(repoPath string, opts git.RainOptions) error { } cmd := exec.Command("git", fetchArgs...) cmd.Dir = repoPath + git.PrepareNetworkGit(cmd) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("%s: %w (output: %s)", strings.Join(append([]string{"git"}, fetchArgs...), " "), err, strings.TrimSpace(string(out))) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 269e3cf..6c1ab95 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -487,6 +487,7 @@ func TestFetchFailureMessage(t *testing.T) { {"authentication", "Authentication failed for git@github.com", "could not authenticate with remote — check your credentials and try again"}, {"permission denied", "git@github.com: Permission denied (publickey)", "could not authenticate with remote — check your credentials and try again"}, {"could not read", "fatal: could not read from remote", "could not authenticate with remote — check your credentials and try again"}, + {"terminal prompts disabled", "fatal: could not read Username for 'https://github.com': terminal prompts disabled", "could not authenticate with remote — check your credentials and try again"}, {"401", "fatal: HTTP 401: Unauthorized", "could not authenticate with remote — check your credentials and try again"}, {"403", "fatal: HTTP 403 forbidden", "could not authenticate with remote — check your credentials and try again"}, {"could not resolve", "fatal: unable to access ...: Could not resolve host", "could not reach remote — check your network and try again"}, diff --git a/internal/git/command.go b/internal/git/command.go new file mode 100644 index 0000000..f218b84 --- /dev/null +++ b/internal/git/command.go @@ -0,0 +1,31 @@ +package git + +import ( + "os" + "os/exec" + "strings" +) + +const gitTerminalPromptKey = "GIT_TERMINAL_PROMPT=" + +// nonInteractiveGitEnv returns a copy of env with GIT_TERMINAL_PROMPT=0 so git +// never prompts for credentials on the controlling TTY during batch operations. +func nonInteractiveGitEnv(env []string) []string { + if env == nil { + env = os.Environ() + } + out := make([]string, 0, len(env)+1) + for _, e := range env { + if strings.HasPrefix(e, gitTerminalPromptKey) { + continue + } + out = append(out, e) + } + return append(out, "GIT_TERMINAL_PROMPT=0") +} + +// PrepareNetworkGit configures cmd for fetch/push operations that may contact +// a remote. Callers must still set cmd.Dir (and stdout/stderr as needed). +func PrepareNetworkGit(cmd *exec.Cmd) { + cmd.Env = nonInteractiveGitEnv(cmd.Env) +} diff --git a/internal/git/command_test.go b/internal/git/command_test.go new file mode 100644 index 0000000..a3a86ff --- /dev/null +++ b/internal/git/command_test.go @@ -0,0 +1,72 @@ +package git + +import ( + "os/exec" + "strings" + "testing" + + testutil "github.com/git-fire/git-testkit" +) + +func TestNonInteractiveGitEnv_OverridesExistingPrompt(t *testing.T) { + env := nonInteractiveGitEnv([]string{ + "HOME=/tmp", + "GIT_TERMINAL_PROMPT=1", + "PATH=/bin", + }) + if !containsEnv(env, "GIT_TERMINAL_PROMPT=0") { + t.Fatalf("expected GIT_TERMINAL_PROMPT=0 in env, got %#v", env) + } + for _, e := range env { + if e == "GIT_TERMINAL_PROMPT=1" { + t.Fatalf("did not override existing GIT_TERMINAL_PROMPT: %#v", env) + } + } +} + +func TestPrepareNetworkGit_SetsEnvOnCommand(t *testing.T) { + cmd := exec.Command("git", "version") + PrepareNetworkGit(cmd) + if !containsEnv(cmd.Env, "GIT_TERMINAL_PROMPT=0") { + t.Fatalf("prepareNetworkGit did not set GIT_TERMINAL_PROMPT=0: %#v", cmd.Env) + } +} + +func TestFetchFailureReason_TerminalPromptsDisabled(t *testing.T) { + got := fetchFailureReason([]byte("fatal: could not read Username for 'https://github.com': terminal prompts disabled")) + want := "could not authenticate with remote — check your credentials and try again" + if got != want { + t.Fatalf("fetchFailureReason() = %q, want %q", got, want) + } +} + +func TestNetworkFetch_UnauthenticatedHTTPSFailsWithoutPrompt(t *testing.T) { + repo := testutil.CreateTestRepo(t, testutil.RepoOptions{ + Name: "https-fetch-repo", + Remotes: map[string]string{ + "origin": "https://github.com/git-rain/nonexistent-repo-auth-test.git", + }, + }) + + cmd := exec.Command("git", "fetch", "--all") + cmd.Dir = repo + PrepareNetworkGit(cmd) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("expected fetch to fail without credentials") + } + msg := strings.ToLower(string(output) + " " + err.Error()) + if !strings.Contains(msg, "terminal prompts disabled") && + !strings.Contains(msg, "could not read username") { + t.Fatalf("expected non-interactive auth failure, got output=%q err=%v", strings.TrimSpace(string(output)), err) + } +} + +func containsEnv(env []string, want string) bool { + for _, e := range env { + if e == want { + return true + } + } + return false +} diff --git a/internal/git/fetch_mainline.go b/internal/git/fetch_mainline.go index 4f640ff..14bf5c4 100644 --- a/internal/git/fetch_mainline.go +++ b/internal/git/fetch_mainline.go @@ -146,6 +146,7 @@ func MainlineFetchRemotes(repoPath string, opts RainOptions) (RainResult, error) } cmd := exec.Command("git", args...) cmd.Dir = repoPath + PrepareNetworkGit(cmd) if output, err := cmd.CombinedOutput(); err != nil { failedReason[remote] = fetchFailureReason(output) } diff --git a/internal/git/rain.go b/internal/git/rain.go index 7e08ebf..8780978 100644 --- a/internal/git/rain.go +++ b/internal/git/rain.go @@ -201,6 +201,7 @@ func RainRepository(repoPath string, opts RainOptions) (RainResult, error) { } cmd := exec.Command("git", fetchArgs...) cmd.Dir = repoPath + PrepareNetworkGit(cmd) if output, fetchErr := cmd.CombinedOutput(); fetchErr != nil { // Freeze gracefully — could not reach remote (auth, network, etc.). // This is not a hard failure; the repo is untouched. Try again later. From c71d45bcda3e3756625a7ed154f5b5d3477bbb00 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger <2601492+bschellenberger2600@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:53:31 -0400 Subject: [PATCH 2/3] Use git-harness v0.3.1 for non-interactive network git and fix GoReleaser CI. Delegate PrepareNetworkGit to git-harness instead of duplicating the helper, pin github.com/git-fire/git-harness v0.3.1, and migrate stable releases from deprecated brews to homebrew_casks so release config validation passes. Co-authored-by: Cursor --- .goreleaser.stable.yaml | 13 +++++++++---- go.mod | 1 + go.sum | 2 ++ internal/git/command.go | 26 ++++---------------------- internal/git/command_test.go | 33 --------------------------------- 5 files changed, 16 insertions(+), 59 deletions(-) diff --git a/.goreleaser.stable.yaml b/.goreleaser.stable.yaml index bf97aed..7d6ec85 100644 --- a/.goreleaser.stable.yaml +++ b/.goreleaser.stable.yaml @@ -97,7 +97,7 @@ changelog: - title: Other order: 999 -brews: +homebrew_casks: - name: git-rain repository: owner: git-fire @@ -105,7 +105,6 @@ brews: token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" pull_request: enabled: false - directory: Formula commit_author: name: goreleaserbot email: bot@goreleaser.com @@ -113,8 +112,14 @@ brews: homepage: "https://github.com/git-fire/git-rain" description: "Sync local git repositories from their remotes — the reverse of git-fire" license: MIT - install: bin.install "git-rain" - test: system "#{bin}/git-rain", "--version" + binaries: + - git-rain + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/git-rain"] + end release: github: diff --git a/go.mod b/go.mod index d170f7f..c4261c9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/git-fire/git-harness v0.3.1 github.com/git-fire/git-testkit v0.2.0 github.com/gofrs/flock v0.12.1 github.com/mattn/go-runewidth v0.0.19 diff --git a/go.sum b/go.sum index 9a50e76..ba1a21a 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/git-fire/git-harness v0.3.1 h1:+fCQ9nMS3YI7r9iVGrU95oefH8N86XjxcYSqsin/1aE= +github.com/git-fire/git-harness v0.3.1/go.mod h1:cNvjYdbowpoO/KdJwtGenhySx+EkgoQJujuANJpRpj4= github.com/git-fire/git-testkit v0.2.0 h1:IFzOxMdNTE5A4lnzbFz62h2R44+3qVy27Xj1KWyGi1Y= github.com/git-fire/git-testkit v0.2.0/go.mod h1:YlJlkY9JfGdYTe9o9W3l+gv9BPj05FGu6HK36Z5jwVA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= diff --git a/internal/git/command.go b/internal/git/command.go index f218b84..2808601 100644 --- a/internal/git/command.go +++ b/internal/git/command.go @@ -1,31 +1,13 @@ package git import ( - "os" "os/exec" - "strings" -) - -const gitTerminalPromptKey = "GIT_TERMINAL_PROMPT=" -// nonInteractiveGitEnv returns a copy of env with GIT_TERMINAL_PROMPT=0 so git -// never prompts for credentials on the controlling TTY during batch operations. -func nonInteractiveGitEnv(env []string) []string { - if env == nil { - env = os.Environ() - } - out := make([]string, 0, len(env)+1) - for _, e := range env { - if strings.HasPrefix(e, gitTerminalPromptKey) { - continue - } - out = append(out, e) - } - return append(out, "GIT_TERMINAL_PROMPT=0") -} + harnessgit "github.com/git-fire/git-harness/git" +) // PrepareNetworkGit configures cmd for fetch/push operations that may contact -// a remote. Callers must still set cmd.Dir (and stdout/stderr as needed). +// a remote. Delegates to git-harness so credential behavior stays aligned with git-fire. func PrepareNetworkGit(cmd *exec.Cmd) { - cmd.Env = nonInteractiveGitEnv(cmd.Env) + harnessgit.PrepareNetworkGit(cmd) } diff --git a/internal/git/command_test.go b/internal/git/command_test.go index a3a86ff..26d9cc0 100644 --- a/internal/git/command_test.go +++ b/internal/git/command_test.go @@ -8,30 +8,6 @@ import ( testutil "github.com/git-fire/git-testkit" ) -func TestNonInteractiveGitEnv_OverridesExistingPrompt(t *testing.T) { - env := nonInteractiveGitEnv([]string{ - "HOME=/tmp", - "GIT_TERMINAL_PROMPT=1", - "PATH=/bin", - }) - if !containsEnv(env, "GIT_TERMINAL_PROMPT=0") { - t.Fatalf("expected GIT_TERMINAL_PROMPT=0 in env, got %#v", env) - } - for _, e := range env { - if e == "GIT_TERMINAL_PROMPT=1" { - t.Fatalf("did not override existing GIT_TERMINAL_PROMPT: %#v", env) - } - } -} - -func TestPrepareNetworkGit_SetsEnvOnCommand(t *testing.T) { - cmd := exec.Command("git", "version") - PrepareNetworkGit(cmd) - if !containsEnv(cmd.Env, "GIT_TERMINAL_PROMPT=0") { - t.Fatalf("prepareNetworkGit did not set GIT_TERMINAL_PROMPT=0: %#v", cmd.Env) - } -} - func TestFetchFailureReason_TerminalPromptsDisabled(t *testing.T) { got := fetchFailureReason([]byte("fatal: could not read Username for 'https://github.com': terminal prompts disabled")) want := "could not authenticate with remote — check your credentials and try again" @@ -61,12 +37,3 @@ func TestNetworkFetch_UnauthenticatedHTTPSFailsWithoutPrompt(t *testing.T) { t.Fatalf("expected non-interactive auth failure, got output=%q err=%v", strings.TrimSpace(string(output)), err) } } - -func containsEnv(env []string, want string) bool { - for _, e := range env { - if e == want { - return true - } - } - return false -} From d4b3b7764594c9bf003b9fa1d7db0ac89001f9ea Mon Sep 17 00:00:00 2001 From: Ben Schellenberger <2601492+bschellenberger2600@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:01:52 -0400 Subject: [PATCH 3/3] Fix release gate to verify Homebrew cask publish path. GoReleaser stable config publishes homebrew_casks under Casks/, so the release workflow must wait on Casks/git-rain.rb instead of Formula/. Co-authored-by: Cursor --- .github/workflows/release.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ee44f2..1c1cfec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,7 +153,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - - name: Verify Homebrew formula publish + - name: Verify Homebrew cask publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ steps.release_meta.outputs.release_tag }} @@ -161,17 +161,17 @@ jobs: set -euo pipefail RELEASE_VERSION="${RELEASE_TAG#v}" for attempt in $(seq 1 18); do - FORMULA_B64="$(gh api repos/git-fire/homebrew-tap/contents/Formula/git-rain.rb --jq '.content' 2>/dev/null || true)" - if [ -n "$FORMULA_B64" ]; then - FORMULA_CONTENT="$(printf '%s' "$FORMULA_B64" | tr -d '\n' | base64 -d)" - if printf '%s' "$FORMULA_CONTENT" | grep -q "version \"${RELEASE_VERSION}\"" && \ - printf '%s' "$FORMULA_CONTENT" | grep -q "/download/${RELEASE_TAG}/"; then - echo "Homebrew formula updated for ${RELEASE_TAG}." + CASK_B64="$(gh api repos/git-fire/homebrew-tap/contents/Casks/git-rain.rb --jq '.content' 2>/dev/null || true)" + if [ -n "$CASK_B64" ]; then + CASK_CONTENT="$(printf '%s' "$CASK_B64" | tr -d '\n' | base64 -d)" + if printf '%s' "$CASK_CONTENT" | grep -q "version \"${RELEASE_VERSION}\"" && \ + printf '%s' "$CASK_CONTENT" | grep -q "/download/${RELEASE_TAG}/"; then + echo "Homebrew cask updated for ${RELEASE_TAG}." exit 0 fi fi - echo "Homebrew formula not updated for ${RELEASE_TAG} yet (attempt ${attempt}/18)." + echo "Homebrew cask not updated for ${RELEASE_TAG} yet (attempt ${attempt}/18)." sleep 10 done - echo "Homebrew formula did not update for ${RELEASE_TAG}; failing release gate." + echo "Homebrew cask did not update for ${RELEASE_TAG}; failing release gate." exit 1