package git import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) { repo := newRemoteFixture(t) configBefore := gitText(t, repo.work, "config", "--get-all", "remote.origin.url") + "\x00" + gitText(t, repo.work, "config", "--get-all", "remote.origin.pushurl") refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags") endpoint, err := Resolve(context.Background(), repo.work, "origin") if err != nil { t.Fatal(err) } if endpoint.PushURL != repo.bare { t.Fatalf("push URL = %q, want %q", endpoint.PushURL, repo.bare) } if err := endpoint.Recheck(context.Background(), repo.work); err != nil { t.Fatalf("Recheck() error = %v", err) } snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit) if err != nil { t.Fatalf("RequireCandidate() error = %v", err) } if snapshot.Main.State != RefPresent || snapshot.Main.Object != repo.commit || snapshot.Tag.State != RefAbsent { t.Fatalf("snapshot = %#v", snapshot) } configAfter := gitText(t, repo.work, "config", "--get-all", "remote.origin.url") + "\x00" + gitText(t, repo.work, "config", "--get-all", "remote.origin.pushurl") refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags") if configAfter != configBefore { t.Fatalf("persistent remote config changed: before %q, after %q", configBefore, configAfter) } if refsAfter != refsBefore { t.Fatalf("remote refs changed: before %q, after %q", refsBefore, refsAfter) } } func TestResolveAppliesGitURLRewrites(t *testing.T) { for _, kind := range []string{"insteadOf", "pushInsteadOf"} { t.Run(kind, func(t *testing.T) { repo := newRemoteFixture(t) alias := "releasekit-test:" if kind == "insteadOf" { gitRun(t, repo.work, "config", "url."+repo.bare+".insteadOf", alias) gitRun(t, repo.work, "remote", "set-url", "origin", alias) gitRun(t, repo.work, "config", "--unset-all", "remote.origin.pushurl") } else { gitRun(t, repo.work, "config", "url."+repo.bare+".pushInsteadOf", alias) gitRun(t, repo.work, "remote", "set-url", "origin", alias) gitRun(t, repo.work, "config", "--unset-all", "remote.origin.pushurl") } endpoint, err := Resolve(context.Background(), repo.work, "origin") if err != nil { t.Fatal(err) } if endpoint.PushURL != repo.bare { t.Fatalf("effective push URL = %q, want %q", endpoint.PushURL, repo.bare) } }) } } func TestResolveRejectsMultipleDistinctPushDestinations(t *testing.T) { repo := newRemoteFixture(t) second := filepath.Join(t.TempDir(), "second.git") gitRun(t, t.TempDir(), "init", "--bare", second) gitRun(t, repo.work, "config", "--add", "remote.origin.pushurl", second) _, err := Resolve(context.Background(), repo.work, "origin") if !errors.Is(err, ErrMultiplePushURLs) { t.Fatalf("Resolve() error = %v, want ErrMultiplePushURLs", err) } } func TestRequireCandidateDistinguishesMissingMainTagConflictAndMainMismatch(t *testing.T) { t.Run("missing main", func(t *testing.T) { repo := newRemoteFixture(t) gitRun(t, repo.bare, "update-ref", "-d", "refs/heads/main") endpoint := resolveFixture(t, repo) snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit) if !errors.Is(err, ErrRemoteMainMissing) || snapshot.Main.State != RefAbsent { t.Fatalf("snapshot = %#v, error = %v", snapshot, err) } }) t.Run("existing tag", func(t *testing.T) { repo := newRemoteFixture(t) gitRun(t, repo.work, "tag", "v1.2.3", repo.commit) gitRun(t, repo.work, "push", repo.bare, "refs/tags/v1.2.3:refs/tags/v1.2.3") endpoint := resolveFixture(t, repo) snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit) if !errors.Is(err, ErrRemoteTagConflict) || snapshot.Tag.State != RefConflict { t.Fatalf("snapshot = %#v, error = %v", snapshot, err) } }) for _, relation := range []string{"ahead", "behind"} { t.Run("main "+relation, func(t *testing.T) { repo := newRemoteFixture(t) candidate := repo.commit remoteMain := repo.commit if relation == "ahead" { writeRepoFile(t, repo.work, "ahead.txt", "ahead") gitRun(t, repo.work, "add", "ahead.txt") gitRun(t, repo.work, "commit", "-m", "Advance main") remoteMain = gitText(t, repo.work, "rev-parse", "HEAD") gitRun(t, repo.work, "push", repo.bare, "HEAD:refs/heads/main") } else { writeRepoFile(t, repo.work, "behind.txt", "candidate is ahead") gitRun(t, repo.work, "add", "behind.txt") gitRun(t, repo.work, "commit", "-m", "Candidate ahead of main") candidate = gitText(t, repo.work, "rev-parse", "HEAD") } if relation == "behind" { gitRun(t, repo.bare, "update-ref", "refs/heads/main", remoteMain) } endpoint := resolveFixture(t, repo) snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", candidate) if !errors.Is(err, ErrRemoteMainConflict) || snapshot.Main.State != RefConflict { t.Fatalf("snapshot = %#v, error = %v", snapshot, err) } }) } } func TestQueryFailureIsUnreadableAndEndpointChangesAreDetected(t *testing.T) { repo := newRemoteFixture(t) endpoint := resolveFixture(t, repo) missing := filepath.Join(t.TempDir(), "missing.git") gitRun(t, repo.work, "remote", "set-url", "--push", "origin", missing) if err := endpoint.Recheck(context.Background(), repo.work); !errors.Is(err, ErrEndpointChanged) { t.Fatalf("Recheck() error = %v, want ErrEndpointChanged", err) } gitRun(t, repo.work, "remote", "set-url", "--push", "origin", missing) broken, err := Resolve(context.Background(), repo.work, "origin") if err != nil { t.Fatalf("Resolve(missing bare path) error = %v", err) } snapshot, err := broken.Query(context.Background(), repo.work, "v1.2.3") if !errors.Is(err, ErrRemoteStateUnreadable) || snapshot.Main.State != RefUnreadable || snapshot.Tag.State != RefUnreadable { t.Fatalf("snapshot = %#v, error = %v", snapshot, err) } } func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) { first := strings.Repeat("a", 40) second := strings.Repeat("b", 40) ref := mergeRef(RemoteRef{State: RefAbsent}, first, false) ref = mergeRef(ref, second, false) if ref.State != RefConflict { t.Fatalf("state = %v, want RefConflict", ref.State) } } type remoteFixture struct { work string bare string commit string } func newRemoteFixture(t testing.TB) remoteFixture { t.Helper() base := t.TempDir() work := filepath.Join(base, "work") bare := filepath.Join(base, "authoritative.git") decoy := filepath.Join(base, "fetch-only.git") gitRun(t, base, "init", "-b", "main", work) gitRun(t, work, "config", "user.name", "ReleaseKit Test") gitRun(t, work, "config", "user.email", "releasekit@example.invalid") gitRun(t, work, "init", "--bare", bare) gitRun(t, work, "init", "--bare", decoy) writeRepoFile(t, work, "source.txt", "candidate") gitRun(t, work, "add", "source.txt") gitRun(t, work, "commit", "-m", "Candidate") commit := gitText(t, work, "rev-parse", "HEAD") gitRun(t, work, "remote", "add", "origin", decoy) gitRun(t, work, "config", "--add", "remote.origin.pushurl", bare) gitRun(t, work, "push", bare, "HEAD:refs/heads/main") return remoteFixture{work: work, bare: bare, commit: commit} } func resolveFixture(t *testing.T, repo remoteFixture) Endpoint { t.Helper() endpoint, err := Resolve(context.Background(), repo.work, "origin") if err != nil { t.Fatal(err) } return endpoint } func gitRun(t testing.TB, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) } } func gitText(t testing.TB, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir outputBytes, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, outputBytes) } return strings.TrimSuffix(string(outputBytes), "\n") } func writeRepoFile(t testing.TB, repoDir, name, contents string) { t.Helper() path := filepath.Join(repoDir, name) if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatal(err) } }