penguin/flatgit

flatgit showcase

commit e9582f881b4cc2462eb5e02b88174c52d5894805

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-14T04:13:57Z
subjectFail fast
commit e9582f881b4cc2462eb5e02b88174c52d5894805
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-14T04:13:57Z

    Fail fast
---
 internal/gitcmd/cancel_other.go |  7 +++++
 internal/gitcmd/cancel_unix.go  | 22 +++++++++++++
 internal/gitcmd/gitcmd.go       | 24 +++++++++++++-
 internal/gitcmd/gitcmd_test.go  | 70 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 122 insertions(+), 1 deletion(-)

diff --git a/internal/gitcmd/cancel_other.go b/internal/gitcmd/cancel_other.go
new file mode 100644
index 0000000..31719bd
--- /dev/null
+++ b/internal/gitcmd/cancel_other.go
@@ -0,0 +1,7 @@
+//go:build !unix
+
+package gitcmd
+
+import "os/exec"
+
+func configureCancellation(cmd *exec.Cmd) {}
diff --git a/internal/gitcmd/cancel_unix.go b/internal/gitcmd/cancel_unix.go
new file mode 100644
index 0000000..840fa0f
--- /dev/null
+++ b/internal/gitcmd/cancel_unix.go
@@ -0,0 +1,22 @@
+//go:build unix
+
+package gitcmd
+
+import (
+	"errors"
+	"os"
+	"os/exec"
+	"syscall"
+)
+
+func configureCancellation(cmd *exec.Cmd) {
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+	cmd.Cancel = func() error {
+		// Kill the whole process group, including git-remote-https / ssh.
+		err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+		if errors.Is(err, syscall.ESRCH) {
+			return os.ErrProcessDone
+		}
+		return err
+	}
+}
diff --git a/internal/gitcmd/gitcmd.go b/internal/gitcmd/gitcmd.go
index bb185f5..8a0c913 100644
--- a/internal/gitcmd/gitcmd.go
+++ b/internal/gitcmd/gitcmd.go
@@ -32,12 +32,19 @@ func New(gitCommand string, logger *slog.Logger) *Runner {
 
 func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Duration) error {
 	const attempts = 5
+	// A reachability probe must not consume the clone/fetch transfer budget.
+	if timeout <= 0 || timeout > 5*time.Second {
+		timeout = 5 * time.Second
+	}
 
 	backoff := 500 * time.Millisecond
 
 	var lastErr error
 
 	for i := 0; i < attempts; i++ {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
 		probeCtx, cancel := context.WithTimeout(ctx, timeout)
 
 		_, err := r.Output(probeCtx, "", "ls-remote", "--heads", url)
@@ -49,10 +56,17 @@ func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Du
 		}
 
 		lastErr = err
+		if err := ctx.Err(); err != nil {
+			return err
+		}
 
 		if i == attempts-1 {
 			break
 		}
+		if r.Logger != nil {
+			r.Logger.Warn("remote probe retry", "attempt", i+1,
+				"max_attempts", attempts, "timeout", timeout, "retry_in", backoff)
+		}
 
 		select {
 		case <-ctx.Done():
@@ -63,7 +77,7 @@ func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Du
 		backoff *= 2
 	}
 
-	return lastErr
+	return fmt.Errorf("remote probe failed after %d attempts: %w", attempts, lastErr)
 }
 
 func (r *Runner) EnsureMirror(ctx context.Context, repo config.Repo, cloneTimeout, fetchTimeout time.Duration) error {
@@ -123,6 +137,10 @@ func (r *Runner) EnsureMirror(ctx context.Context, repo config.Repo, cloneTimeou
 func (r *Runner) Output(ctx context.Context, dir string, args ...string) ([]byte, error) {
 	cmd := exec.CommandContext(ctx, r.GitCommand, args...)
 	cmd.Env = os.Environ()
+	// Git spawns transport helpers that can otherwise keep the output pipes open
+	// after CommandContext kills the Git process.
+	configureCancellation(cmd)
+	cmd.WaitDelay = time.Second
 
 	if dir != "" {
 		cmd.Dir = dir
@@ -157,9 +175,13 @@ func (r *Runner) Output(ctx context.Context, dir string, args ...string) ([]byte
 				"dur", dur,
 				"stdout_bytes", stdout.Len(),
 				"stderr", msg,
+				"context_error", ctx.Err(),
 			)
 		}
 
+		if ctx.Err() != nil {
+			return stdout.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), ctx.Err(), msg)
+		}
 		return stdout.Bytes(), fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
 	}
 
diff --git a/internal/gitcmd/gitcmd_test.go b/internal/gitcmd/gitcmd_test.go
new file mode 100644
index 0000000..a4da09e
--- /dev/null
+++ b/internal/gitcmd/gitcmd_test.go
@@ -0,0 +1,70 @@
+//go:build unix
+
+package gitcmd
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func fakeGit(t *testing.T, script string) *Runner {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "git")
+	if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	return New(path, nil)
+}
+
+func TestRemoteProbeRetriesThenSucceeds(t *testing.T) {
+	count := filepath.Join(t.TempDir(), "attempts")
+	t.Setenv("FLATGIT_TEST_ATTEMPTS", count)
+	r := fakeGit(t, `echo attempt >> "$FLATGIT_TEST_ATTEMPTS"
+if [ "$(wc -l < "$FLATGIT_TEST_ATTEMPTS")" -lt 2 ]; then exit 1; fi
+`)
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+	if err := r.waitRemoteLive(ctx, "test", time.Second); err != nil {
+		t.Fatal(err)
+	}
+	b, err := os.ReadFile(count)
+	if err != nil || strings.Count(string(b), "attempt") != 2 {
+		t.Fatalf("attempts = %q, error = %v", b, err)
+	}
+}
+
+func TestOutputTimeoutKillsTransportHelper(t *testing.T) {
+	// The child inherits stdout/stderr, like git-remote-https. Killing only
+	// the shell would leave cmd.Run waiting for the child's pipes to close.
+	r := fakeGit(t, "sleep 30 &\nwait\n")
+	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+	start := time.Now()
+	_, err := r.Output(ctx, "", "ls-remote")
+	if !errors.Is(err, context.DeadlineExceeded) {
+		t.Fatalf("error = %v", err)
+	}
+	if elapsed := time.Since(start); elapsed > 2*time.Second {
+		t.Fatalf("timeout took %s", elapsed)
+	}
+}
+
+func TestRemoteProbeCancellationStopsRetries(t *testing.T) {
+	count := filepath.Join(t.TempDir(), "attempts")
+	t.Setenv("FLATGIT_TEST_ATTEMPTS", count)
+	r := fakeGit(t, "echo attempt >> \"$FLATGIT_TEST_ATTEMPTS\"\nexit 1\n")
+	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+	if err := r.waitRemoteLive(ctx, "test", time.Second); !errors.Is(err, context.DeadlineExceeded) {
+		t.Fatalf("error = %v", err)
+	}
+	b, err := os.ReadFile(count)
+	if err != nil || strings.Count(string(b), "attempt") != 1 {
+		t.Fatalf("attempts = %q, error = %v", b, err)
+	}
+}