a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
359 lines
10 KiB
Go
359 lines
10 KiB
Go
package gitcmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// withFakeGit installs a shell script named "git" at the front of PATH
|
|
// for the duration of the test and returns the directory it lives in.
|
|
// The script body is provided by the caller.
|
|
func withFakeGit(t *testing.T, body string) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("fake-git shim is a POSIX shell script")
|
|
}
|
|
dir := t.TempDir()
|
|
script := "#!/bin/sh\n" + body
|
|
gitPath := filepath.Join(dir, "git")
|
|
if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake git: %v", err)
|
|
}
|
|
oldPath := os.Getenv("PATH")
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+oldPath)
|
|
return dir
|
|
}
|
|
|
|
// TestConcurrencyCapNeverExceeded launches N+k concurrent Run calls
|
|
// against a fake git that signals on start, blocks on a barrier, then
|
|
// exits. It asserts the number simultaneously in-flight never exceeds
|
|
// the configured concurrency cap.
|
|
func TestConcurrencyCapNeverExceeded(t *testing.T) {
|
|
const cap = 3
|
|
const callers = cap + 4
|
|
|
|
// The fake git appends a "start" line to $TRACK, then spins until
|
|
// $RELEASE exists, then appends a "stop" line. This lets the test
|
|
// hold every spawned process open simultaneously and watch the
|
|
// live count.
|
|
dir := withFakeGit(t, `
|
|
echo "start $$" >> "$GITCMD_TRACK"
|
|
while [ ! -f "$GITCMD_RELEASE" ]; do
|
|
sleep 0.005
|
|
done
|
|
echo "stop $$" >> "$GITCMD_TRACK"
|
|
exit 0
|
|
`)
|
|
_ = dir
|
|
|
|
trackFile := filepath.Join(t.TempDir(), "track")
|
|
releaseFile := filepath.Join(t.TempDir(), "release")
|
|
t.Setenv("GITCMD_TRACK", trackFile)
|
|
t.Setenv("GITCMD_RELEASE", releaseFile)
|
|
if err := os.WriteFile(trackFile, nil, 0o644); err != nil {
|
|
t.Fatalf("seed track file: %v", err)
|
|
}
|
|
|
|
// Reset the limiter to a known cap, restore the default afterwards.
|
|
SetConcurrency(cap)
|
|
t.Cleanup(func() { SetConcurrency(int(defaultConcurrency())) })
|
|
|
|
var inFlight int64
|
|
var maxInFlight int64
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < callers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
// Run will block inside the fake git until releaseFile
|
|
// appears; while blocked it holds a semaphore slot.
|
|
_, _ = Run(context.Background(), "", "status")
|
|
}()
|
|
}
|
|
|
|
// Sampler: while callers are blocked in the fake git, count the
|
|
// processes that have logged "start" but not yet "stop" and track
|
|
// the peak. We wait until cap processes are concurrently running
|
|
// (proving the cap is reached) then release everyone.
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
released := false
|
|
for time.Now().Before(deadline) {
|
|
starts, stops := countTrack(t, trackFile)
|
|
cur := int64(starts - stops)
|
|
atomic.StoreInt64(&inFlight, cur)
|
|
if cur > atomic.LoadInt64(&maxInFlight) {
|
|
atomic.StoreInt64(&maxInFlight, cur)
|
|
}
|
|
if !released && starts >= cap {
|
|
// Cap-many processes are concurrently alive; release.
|
|
if err := os.WriteFile(releaseFile, nil, 0o644); err != nil {
|
|
t.Fatalf("write release file: %v", err)
|
|
}
|
|
released = true
|
|
}
|
|
if released && stops >= callers {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
if !released {
|
|
// Make sure no goroutine leaks if we timed out before reaching cap.
|
|
_ = os.WriteFile(releaseFile, nil, 0o644)
|
|
}
|
|
wg.Wait()
|
|
|
|
peak := atomic.LoadInt64(&maxInFlight)
|
|
if peak > cap {
|
|
t.Fatalf("concurrency cap exceeded: peak in-flight %d > cap %d", peak, cap)
|
|
}
|
|
if peak < 1 {
|
|
t.Fatalf("expected at least one in-flight git process, saw %d", peak)
|
|
}
|
|
}
|
|
|
|
// countTrack returns (#start lines, #stop lines) in the track file.
|
|
func countTrack(t *testing.T, path string) (int, int) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read track file: %v", err)
|
|
}
|
|
var starts, stops int
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
switch {
|
|
case strings.HasPrefix(line, "start "):
|
|
starts++
|
|
case strings.HasPrefix(line, "stop "):
|
|
stops++
|
|
}
|
|
}
|
|
return starts, stops
|
|
}
|
|
|
|
// TestCancelBeforeAcquireDoesNotSpawn asserts that a Run with an
|
|
// already-cancelled context returns ctx.Err() and never spawns git.
|
|
func TestCancelBeforeAcquireDoesNotSpawn(t *testing.T) {
|
|
var spawned int32
|
|
withFakeGit(t, `
|
|
echo spawned >> "$GITCMD_SPAWN_LOG"
|
|
exit 0
|
|
`)
|
|
spawnLog := filepath.Join(t.TempDir(), "spawn.log")
|
|
t.Setenv("GITCMD_SPAWN_LOG", spawnLog)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel before calling Run
|
|
|
|
out, err := Run(ctx, "", "status")
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context.Canceled, got %v", err)
|
|
}
|
|
if len(out) != 0 {
|
|
t.Fatalf("expected empty stdout on pre-cancel, got %q", out)
|
|
}
|
|
// The fake git, if it ran, would have created the spawn log.
|
|
if _, statErr := os.Stat(spawnLog); statErr == nil {
|
|
t.Fatalf("git was spawned despite cancelled context")
|
|
}
|
|
_ = atomic.LoadInt32(&spawned)
|
|
}
|
|
|
|
// TestCancelWhileWaitingForSlot asserts that when the limiter is
|
|
// saturated, a Run whose ctx is cancelled while waiting for a slot
|
|
// returns ctx.Err() and never spawns its own git.
|
|
func TestCancelWhileWaitingForSlot(t *testing.T) {
|
|
withFakeGit(t, `
|
|
echo "start $$" >> "$GITCMD_TRACK"
|
|
while [ ! -f "$GITCMD_RELEASE" ]; do
|
|
sleep 0.005
|
|
done
|
|
exit 0
|
|
`)
|
|
trackFile := filepath.Join(t.TempDir(), "track")
|
|
releaseFile := filepath.Join(t.TempDir(), "release")
|
|
t.Setenv("GITCMD_TRACK", trackFile)
|
|
t.Setenv("GITCMD_RELEASE", releaseFile)
|
|
if err := os.WriteFile(trackFile, nil, 0o644); err != nil {
|
|
t.Fatalf("seed track file: %v", err)
|
|
}
|
|
|
|
SetConcurrency(1)
|
|
t.Cleanup(func() { SetConcurrency(int(defaultConcurrency())) })
|
|
|
|
// Saturate the single slot.
|
|
blockerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(blockerDone)
|
|
_, _ = Run(context.Background(), "", "status")
|
|
}()
|
|
// Wait until the blocker is actually running git.
|
|
waitForStarts(t, trackFile, 1, 5*time.Second)
|
|
|
|
// Now a waiter whose ctx we cancel while it blocks on Acquire.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
waiterErr := make(chan error, 1)
|
|
go func() {
|
|
_, err := Run(ctx, "", "status")
|
|
waiterErr <- err
|
|
}()
|
|
// Give the waiter a moment to enter Acquire, then cancel.
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-waiterErr:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("waiter: expected context.Canceled, got %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waiter did not return after ctx cancel")
|
|
}
|
|
|
|
// Only the blocker should have spawned git; the waiter must not.
|
|
if starts, _ := countTrack(t, trackFile); starts != 1 {
|
|
t.Fatalf("expected exactly 1 spawned git (the blocker), saw %d", starts)
|
|
}
|
|
|
|
// Release the blocker.
|
|
if err := os.WriteFile(releaseFile, nil, 0o644); err != nil {
|
|
t.Fatalf("write release file: %v", err)
|
|
}
|
|
<-blockerDone
|
|
}
|
|
|
|
func waitForStarts(t *testing.T, path string, want int, timeout time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if starts, _ := countTrack(t, path); starts >= want {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %d git starts in %s", want, path)
|
|
}
|
|
|
|
// TestFailingGitSurfacesStderr asserts the wrapped error carries git's
|
|
// stderr and the failing subcommand name.
|
|
func TestFailingGitSurfacesStderr(t *testing.T) {
|
|
withFakeGit(t, `
|
|
echo "fatal: not a git repository" 1>&2
|
|
exit 128
|
|
`)
|
|
out, err := Run(context.Background(), "", "rev-parse", "HEAD")
|
|
if err == nil {
|
|
t.Fatal("expected error from failing git, got nil")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "fatal: not a git repository") {
|
|
t.Fatalf("stderr not surfaced in error: %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "git rev-parse") {
|
|
t.Fatalf("subcommand name not in error: %q", msg)
|
|
}
|
|
// stdout is still returned (empty here).
|
|
_ = out
|
|
}
|
|
|
|
// TestOutputTrimsAndCarriesError verifies Output trims stdout and
|
|
// propagates the wrapped error.
|
|
func TestOutputTrimsAndCarriesError(t *testing.T) {
|
|
withFakeGit(t, `
|
|
if [ "$1" = "ok" ]; then
|
|
printf ' deadbeef\n\n'
|
|
exit 0
|
|
fi
|
|
echo "boom" 1>&2
|
|
exit 1
|
|
`)
|
|
got, err := Output(context.Background(), "", "ok")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "deadbeef" {
|
|
t.Fatalf("Output did not trim: %q", got)
|
|
}
|
|
|
|
_, err = Output(context.Background(), "", "fail")
|
|
if err == nil || !strings.Contains(err.Error(), "boom") {
|
|
t.Fatalf("Output should surface stderr, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunPassesDirAsDashC ensures the -C dir flag is prepended when a
|
|
// dir is supplied and omitted when it is empty.
|
|
func TestRunPassesDirAsDashC(t *testing.T) {
|
|
withFakeGit(t, `
|
|
printf '%s\n' "$@" > "$GITCMD_ARGS"
|
|
exit 0
|
|
`)
|
|
argsFile := filepath.Join(t.TempDir(), "args")
|
|
t.Setenv("GITCMD_ARGS", argsFile)
|
|
|
|
if _, err := Run(context.Background(), "/some/dir", "status"); err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
data, err := os.ReadFile(argsFile)
|
|
if err != nil {
|
|
t.Fatalf("read args file: %v", err)
|
|
}
|
|
lines := splitNonEmpty(string(data))
|
|
want := []string{"-C", "/some/dir", "status"}
|
|
if len(lines) != len(want) {
|
|
t.Fatalf("args = %v, want %v", lines, want)
|
|
}
|
|
for i := range want {
|
|
if lines[i] != want[i] {
|
|
t.Fatalf("arg[%d] = %q, want %q (full %v)", i, lines[i], want[i], lines)
|
|
}
|
|
}
|
|
|
|
// Empty dir: no -C prefix.
|
|
if _, err := Run(context.Background(), "", "status"); err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
data, _ = os.ReadFile(argsFile)
|
|
lines = splitNonEmpty(string(data))
|
|
if len(lines) != 1 || lines[0] != "status" {
|
|
t.Fatalf("empty-dir args = %v, want [status]", lines)
|
|
}
|
|
}
|
|
|
|
func splitNonEmpty(s string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(s, "\n") {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSetConcurrencyClamps verifies SetConcurrency clamps sub-1 values
|
|
// to 1 and that defaultConcurrency stays within [1,8].
|
|
func TestSetConcurrencyClamps(t *testing.T) {
|
|
SetConcurrency(0)
|
|
if got := currentSem(); got == nil {
|
|
t.Fatal("limiter is nil after SetConcurrency(0)")
|
|
}
|
|
SetConcurrency(-5)
|
|
if got := currentSem(); got == nil {
|
|
t.Fatal("limiter is nil after SetConcurrency(-5)")
|
|
}
|
|
// Restore default for other tests.
|
|
SetConcurrency(int(defaultConcurrency()))
|
|
|
|
d := defaultConcurrency()
|
|
if d < 1 || d > 8 {
|
|
t.Fatalf("defaultConcurrency() = %d, want within [1,8]", d)
|
|
}
|
|
}
|