Inspect authoritative Git remote state

This commit is contained in:
2026-10-05 18:04:58 +00:00
parent 617c243923
commit 7d2c0c10d4
4 changed files with 562 additions and 1 deletions

View File

@@ -17,6 +17,8 @@ Implemented internal packages:
- `internal/candidate` captures and validates one clean local candidate, checks - `internal/candidate` captures and validates one clean local candidate, checks
its committed release note and local tag state, runs validation once, and its committed release note and local tag state, runs validation once, and
rechecks mutable repository state. rechecks mutable repository state.
- `internal/git` resolves one effective push destination, applies it to a
command-scoped remote, and reads exact authoritative branch and tag refs.
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON - `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, and adapts configured checks to the process runner. profiles, and adapts configured checks to the process runner.

View File

@@ -567,7 +567,7 @@ diagnostic. Temporary Git repositories cover the acceptance cases; offline
## Stage 6: Resolve and inspect the authoritative endpoint ## Stage 6: Resolve and inspect the authoritative endpoint
Status: Pending. Depends on Stage 5. Status: Complete. Depends on Stage 5.
1. Implement effective push-URL capture, command-scoped remote configuration, 1. Implement effective push-URL capture, command-scoped remote configuration,
matching read/write resolution, and endpoint rechecks described above. matching read/write resolution, and endpoint rechecks described above.
@@ -581,6 +581,14 @@ ordinary URL rewrites, multiple push destinations, missing main, conflicting
tags, main ahead/behind the candidate, endpoint changes, and remote failure. tags, main ahead/behind the candidate, endpoint changes, and remote failure.
Queries leave local tags, persistent remote configuration, and remote refs intact. Queries leave local tags, persistent remote configuration, and remote refs intact.
Evidence: `internal/git` captures effective push destinations through Git,
rejects multiple distinct destinations, verifies scoped URL rewriting, and
queries exact main and version-tag refs. A candidate check requires main to
equal the captured commit and the version tag to be absent. Local bare remote
tests cover separate fetch/push URLs, both rewrite modes, missing main, an
existing tag, main ahead and behind, endpoint changes, unreadable remote state,
and unchanged persistent config and refs.
## Stage 7: Create and push the exact annotated tag ## Stage 7: Create and push the exact annotated tag
Status: Pending. Depends on Stage 6. Status: Pending. Depends on Stage 6.

328
internal/git/remote.go Normal file
View File

@@ -0,0 +1,328 @@
// Package git resolves and inspects a repository's authoritative Git remote.
package git
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
var (
ErrInvalidRemote = errors.New("invalid Git remote")
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
ErrEndpointChanged = errors.New("Git publication endpoint changed")
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
)
// Endpoint identifies the single effective publication destination captured
// from a selected remote. ScopedName is a transient remote name used with
// command-scoped configuration; it is never written to the repository.
type Endpoint struct {
RemoteName string
PushURL string
ScopedName string
transport []string
}
// RefState records whether an exact remote ref was found by a successful query.
type RefState uint8
const (
RefUnreadable RefState = iota
RefAbsent
RefPresent
RefConflict
)
// RemoteRef holds an exact ref's object and optional peeled commit. State is
// RefConflict only when more than one exact result or malformed data is found.
type RemoteRef struct {
State RefState
Object string
Peeled string
}
// Snapshot is the result of querying refs/heads/main and one exact version tag.
type Snapshot struct {
Main RemoteRef
Tag RemoteRef
}
// Resolve captures the selected remote's effective push URL and establishes a
// command-scoped remote whose read and push URLs resolve to that same endpoint.
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName)
}
urls, err := pushURLs(ctx, repositoryDir, remoteName)
if err != nil {
return Endpoint{}, err
}
if len(urls) != 1 {
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls))
}
scopedName, err := newScopedName()
if err != nil {
return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err)
}
endpoint := Endpoint{RemoteName: remoteName, PushURL: urls[0], ScopedName: scopedName}
endpoint.transport, err = transportConfig(ctx, repositoryDir, remoteName, scopedName)
if err != nil {
return Endpoint{}, err
}
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// Recheck confirms both the selected remote's effective push destination and
// the scoped remote's effective read and push URLs still identify this endpoint.
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
if err != nil {
return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err)
}
if len(urls) != 1 || urls[0] != endpoint.PushURL {
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", "))
}
return endpoint.verifyScopedURLs(ctx, repositoryDir)
}
// Query reads exact main and version-tag refs. A command failure is unreadable,
// never evidence that a ref is absent.
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) {
if !validVersionRef(version) {
return Snapshot{}, fmt.Errorf("invalid version tag name %q", version)
}
mainRef := "refs/heads/main"
tagRef := "refs/tags/" + version
peeledRef := tagRef + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
if err != nil {
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
}
main := RemoteRef{State: RefAbsent}
tag := RemoteRef{State: RefAbsent}
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
if line == "" {
continue
}
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
}
switch name {
case mainRef:
main = mergeRef(main, object, false)
case tagRef:
tag = mergeRef(tag, object, false)
case peeledRef:
tag = mergeRef(tag, object, true)
}
}
return Snapshot{Main: main, Tag: tag}, nil
}
// RequireCandidate requires exact main equality and an absent remote version
// tag. It deliberately does not accept a main branch that merely contains the
// candidate as an ancestor.
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) {
snapshot, err := endpoint.Query(ctx, repositoryDir, version)
if err != nil {
return snapshot, err
}
if snapshot.Main.State == RefAbsent {
return snapshot, ErrRemoteMainMissing
}
if snapshot.Main.State != RefPresent || snapshot.Main.Object != commit {
snapshot.Main.State = RefConflict
return snapshot, fmt.Errorf("%w: candidate %s, remote main %s", ErrRemoteMainConflict, commit, snapshot.Main.Object)
}
if snapshot.Tag.State != RefAbsent {
if snapshot.Tag.State == RefUnreadable {
return snapshot, ErrRemoteStateUnreadable
}
snapshot.Tag.State = RefConflict
return snapshot, fmt.Errorf("%w: %s", ErrRemoteTagConflict, snapshot.Tag.Object)
}
return snapshot, nil
}
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
if err != nil {
return fmt.Errorf("resolve scoped read URL: %w", err)
}
resolved := outputLines(readURL)
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL)
}
// The scoped remote sets its push URL explicitly to the same captured value.
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
// applies only when no push URL exists and therefore cannot change this one.
return nil
}
func (endpoint Endpoint) scopedConfig() []string {
config := []string{
"remote." + endpoint.ScopedName + ".url=" + endpoint.PushURL,
"remote." + endpoint.ScopedName + ".pushurl=" + endpoint.PushURL,
"remote." + endpoint.ScopedName + ".mirror=false",
}
// These options affect Git transport behavior and are carried into the
// scoped remote without copying refspecs, mirror mode, or push destinations.
config = append(config, endpoint.transport...)
return config
}
// transportConfig evaluates Git's normal configuration, including includes,
// then copies transport settings under the transient remote's name.
func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName string) ([]string, error) {
var config []string
for _, key := range []string{"proxy", "uploadpack", "receivepack", "vcs", "serveroption"} {
output, err := run(ctx, repositoryDir, nil, "config", "--get-all", "remote."+remoteName+"."+key)
if err != nil {
// git config exits 1 when the key is absent; that is expected.
var exitErr interface{ ExitCode() int }
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
continue
}
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err)
}
for _, value := range outputLines(output) {
config = append(config, "remote."+scopedName+"."+key+"="+value)
}
}
return config, nil
}
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
if err != nil {
return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err)
}
values := outputLines(output)
unique := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique, nil
}
func run(ctx context.Context, dir string, config []string, args ...string) (string, error) {
commandArgs := make([]string, 0, len(config)*2+len(args))
for _, value := range config {
commandArgs = append(commandArgs, "-c", value)
}
commandArgs = append(commandArgs, args...)
var stdout, stderr strings.Builder
err := process.Run(ctx, process.Invocation{
Args: append([]string{"git"}, commandArgs...),
Dir: dir,
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return stdout.String(), fmt.Errorf("%w: %s", err, message)
}
return stdout.String(), err
}
return stdout.String(), nil
}
func newScopedName() (string, error) {
var bytes [12]byte
if _, err := rand.Read(bytes[:]); err != nil {
return "", err
}
return "releasekit-" + hex.EncodeToString(bytes[:]), nil
}
func outputLines(output string) []string {
output = strings.TrimSuffix(output, "\n")
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
for i := range lines {
lines[i] = strings.TrimSuffix(lines[i], "\r")
}
return lines
}
func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
if ref.State == RefConflict {
return ref
}
if peeled {
if ref.State == RefAbsent {
ref.State = RefPresent
ref.Peeled = object
return ref
}
if ref.Peeled != "" {
return RemoteRef{State: RefConflict, Object: ref.Object}
}
ref.Peeled = object
return ref
}
if ref.State == RefAbsent {
ref.State, ref.Object = RefPresent, object
return ref
}
if ref.Object != "" {
return RemoteRef{State: RefConflict, Object: ref.Object}
}
ref.Object = object
return ref
}
func validVersionRef(version string) bool {
if len(version) < 6 || !strings.HasPrefix(version, "v") {
return false
}
parts := strings.Split(version[1:], ".")
if len(parts) != 3 {
return false
}
for _, part := range parts {
if part == "" || (len(part) > 1 && part[0] == '0') {
return false
}
for _, char := range part {
if char < '0' || char > '9' {
return false
}
}
}
return true
}
func validObjectID(object string) bool {
if len(object) != 40 && len(object) != 64 {
return false
}
for _, char := range object {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
return false
}
}
return true
}

223
internal/git/remote_test.go Normal file
View File

@@ -0,0 +1,223 @@
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.T) 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.T, 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.T, 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.T, repoDir, name, contents string) {
t.Helper()
path := filepath.Join(repoDir, name)
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}