Reconcile uncertain tag pushes

This commit is contained in:
2026-10-05 18:53:44 +00:00
parent 3757d40ac4
commit 7481dd0fd2
7 changed files with 388 additions and 22 deletions

View File

@@ -3,8 +3,8 @@
ReleaseKit is an opinionated Go library and executable for a shared release ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package, process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, and `version` and `check` local candidate check, source-tag publication API, and `version` and `check`
commands are implemented. Push reconciliation, a publication command, builds, commands are implemented. A publication command, builds, delivery, and release
delivery, and release automation remain to be implemented. Their selected automation remain to be implemented. Their selected
behavior and sequence are described in the behavior and sequence are described in the
[product roadmap](docs/roadmap/releasekit.md) and [product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md). [implementation plan](docs/roadmap/implementation.md).

View File

@@ -43,7 +43,8 @@ tag object ID. Publication states distinguish an unattempted push, a tag that
was created locally but not pushed, a successful push, a guard conflict, and an was created locally but not pushed, a successful push, a guard conflict, and an
unresolved push error. unresolved push error.
When a push reports an error, the local annotated tag is retained and the When a push reports an error, ReleaseKit makes one bounded query for the
result is `unknown` until a later implementation adds authoritative authoritative tag. An exact tag object match confirms success. An absent tag is
reconciliation. Do not treat that state as success or proof that the remote reported as `not_published`, a different object as `conflict`, and an unreadable
lacks the tag. query as `unknown`. The local annotated tag is retained in every case; an
`unknown` result is neither success nor proof that the remote lacks the tag.

View File

@@ -614,11 +614,12 @@ validates once between exact remote guards, creates an unsigned annotated tag,
rechecks local and remote state, and pushes only the captured tag object. Bare rechecks local and remote state, and pushes only the captured tag object. Bare
remote tests verify the note bytes, peeled commit, tag object, unchanged branch remote tests verify the note bytes, peeled commit, tag object, unchanged branch
and unrelated refs, disabled follow-tags and mirror expansion, validation count, and unrelated refs, disabled follow-tags and mirror expansion, validation count,
remote conflicts, and retained local tags with `unknown` status after push error. remote conflicts, and retained local tags after push error; Stage 8 adds outcome
classification through remote reconciliation.
## Stage 8: Resolve rejected and ambiguous publication ## Stage 8: Resolve rejected and ambiguous publication
Status: Pending. Depends on Stage 7. Status: Complete. Depends on Stage 7.
1. Add the one-query reconciliation path and public error/result classification. 1. Add the one-query reconciliation path and public error/result classification.
Preserve the original push cause when resolution does not confirm success. Preserve the original push cause when resolution does not confirm success.
@@ -635,6 +636,17 @@ missing, conflicting, and unavailable remote tags have distinct outcomes. Failed
pushes issue one reconciliation query, successful pushes issue none, and no pushes issue one reconciliation query, successful pushes issue none, and no
recovery force-update or automatic tag deletion occurs. recovery force-update or automatic tag deletion occurs.
Evidence: failed tag pushes receive one bounded exact-tag query. Matching object
identity returns published; absence returns not_published with the original push
error; a different object returns conflict; query failure returns unknown while
preserving the push cause. Tests cover rejection by a bare remote's receive
hook, an installed conflicting tag, unavailable reconciliation, and a lost
acknowledgment after the bare remote accepted the tag while the caller context
was canceled. Query counts confirm one recovery read after errors and none after
a successful push.
Tests also verify that a remote branch change during validation and a changed
local tag before push prevent publication.
## Stage 9: Expose publication and document ReleaseKit bootstrap ## Stage 9: Expose publication and document ReleaseKit bootstrap
Status: Pending. Depends on Stage 8. Status: Pending. Depends on Stage 8.

View File

@@ -92,6 +92,34 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
return nil return nil
} }
// QueryTag reads one exact authoritative version tag and its peeled commit.
// Failed reads are unreadable and never interpreted as absence.
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
if !validVersionRef(version) {
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
}
ref := "refs/tags/" + version
peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
if err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err)
}
remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) {
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
}
switch name {
case ref:
remoteTag = mergeRef(remoteTag, object, false)
case peeledRef:
remoteTag = mergeRef(remoteTag, object, true)
}
}
return remoteTag, nil
}
// PushTag publishes only the captured tag object to its exact tag ref. // PushTag publishes only the captured tag object to its exact tag ref.
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error { func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
if !validVersionRef(version) || !validObjectID(tagObject) { if !validVersionRef(version) || !validObjectID(tagObject) {

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"time"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -24,13 +25,16 @@ const (
type Phase string type Phase string
const ( const (
Preflight Phase = "preflight" Preflight Phase = "preflight"
Validation Phase = "validation" Validation Phase = "validation"
TagCreation Phase = "tag_creation" TagCreation Phase = "tag_creation"
Push Phase = "push" Push Phase = "push"
Complete Phase = "complete" Reconciliation Phase = "reconciliation"
Complete Phase = "complete"
) )
const recoveryTimeout = 30 * time.Second
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
@@ -50,8 +54,9 @@ type Result struct {
} }
// Publish validates one captured candidate and pushes only its annotated tag // Publish validates one captured candidate and pushes only its annotated tag
// object. A failed push remains Unknown until authoritative reconciliation is // object. A failed push receives one bounded authoritative tag query. Exact
// added by the following implementation stage. // object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error.
func Publish(ctx context.Context, request Request) (Result, error) { func Publish(ctx context.Context, request Request) (Result, error) {
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted} result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
remoteName := request.Remote remoteName := request.Remote
@@ -129,13 +134,42 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = Push result.Phase = Push
result.State = Unknown result.State = Unknown
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil { if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
return result, err return reconcilePush(endpoint, captured, tagObject, result, err)
} }
result.Phase = Complete result.Phase = Complete
result.State = Published result.State = Published
return result, nil return result, nil
} }
func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObject string, result Result, pushErr error) (Result, error) {
result.Phase = Reconciliation
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel()
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version)
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
result.State = Unknown
if queryErr == nil {
if remoteTag.State == git.RefConflict {
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
} else {
queryErr = git.ErrRemoteStateUnreadable
}
}
return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%v): %w", captured.Version, queryErr, pushErr)
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent: %w", captured.Version, pushErr)
}
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete
result.State = Published
return result, nil
}
result.State = Conflict
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s: %w", captured.Version, remoteTag.Object, tagObject, pushErr)
}
func isRemoteConflict(err error) bool { func isRemoteConflict(err error) bool {
return errors.Is(err, git.ErrRemoteMainMissing) || return errors.Is(err, git.ErrRemoteMainMissing) ||
errors.Is(err, git.ErrRemoteMainConflict) || errors.Is(err, git.ErrRemoteMainConflict) ||

View File

@@ -33,6 +33,8 @@ const (
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
// PublicationPush identifies the remote tag push attempt. // PublicationPush identifies the remote tag push attempt.
PublicationPush PublicationPhase = PublicationPhase(publish.Push) PublicationPush PublicationPhase = PublicationPhase(publish.Push)
// PublicationReconciliation identifies the single authoritative tag query after a push error.
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
// PublicationComplete identifies a successful tag push. // PublicationComplete identifies a successful tag push.
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete) PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
) )
@@ -51,8 +53,10 @@ type PublicationResult struct {
// object to the selected remote. It returns a result with errors: preflight // object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and // failures are not_attempted, failed authoritative guards are conflict, and
// errors after local tag creation but before push are not_published. A failed // errors after local tag creation but before push are not_published. A failed
// push is unknown until remote reconciliation is available. A nil error means // push gets one bounded authoritative tag query: an exact object match confirms
// the push succeeded and the result state is published. // publication, absence is not_published, a different object is conflict, and
// an unreadable result is unknown. A nil error means publication is confirmed
// by the successful push or exact remote object match.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
var validate func(context.Context, candidate.Candidate) error var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil { if request.Validate != nil {

View File

@@ -2,11 +2,14 @@ package releasekit_test
import ( import (
"context" "context"
"errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
) )
@@ -15,6 +18,8 @@ const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes s
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags") refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
commit := repo.commit commit := repo.commit
var validations int var validations int
@@ -36,6 +41,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if validations != 1 { if validations != 1 {
t.Fatalf("validation calls = %d, want 1", validations) t.Fatalf("validation calls = %d, want 1", validations)
} }
if queries := countLines(t, wrapper.queryLog); queries != 3 {
t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries)
}
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete { if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result) t.Fatalf("publication result = %#v", result)
} }
@@ -75,10 +83,12 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
} }
} }
func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) { func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push") wrapper := installGitWrapper(t, repo, "pass")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
@@ -90,9 +100,15 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("Publish() succeeded despite rejected push hook") t.Fatal("Publish() succeeded despite rejected push hook")
} }
if result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationPush || result.TagObject == "" { if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
t.Fatalf("publication result = %#v", result) t.Fatalf("publication result = %#v", result)
} }
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
t.Fatalf("push error was not preserved with absence evidence: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
} }
@@ -101,6 +117,169 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
} }
} }
func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "lost-ack-cancel")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
finished := make(chan struct{})
watcherDone := make(chan struct{})
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
go func() {
defer close(watcherDone)
for {
if _, statErr := os.Stat(wrapper.pushDone); statErr == nil {
cancel()
return
}
select {
case <-finished:
return
case <-time.After(time.Millisecond):
}
}
}()
return nil
},
})
close(finished)
<-watcherDone
if err != nil {
t.Fatal(err)
}
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one cancellation recovery query", queries)
}
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("remote tag object = %s, want %s", got, result.TagObject)
}
}
func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
script := fmt.Sprintf("#!/bin/sh\n%s --git-dir=%s update-ref refs/tags/v1.2.3 %s\nexit 1\n", shellQuote(mustLookPath(t, "git")), shellQuote(repo.bare), conflictingObject)
if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil },
})
if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "authoritative tag identifies object") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not distinguish conflict and preserve push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
}
}
func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) {
repo := newPublishFixture(t)
gitRun(t, repo.work, "-c", "tag.gpgSign=false", "tag", "--annotate", "--message", "different tag", "v0.8.0", repo.commit)
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.8.0")
wrapper := installGitWrapperWithMutation(t, repo, conflictingObject)
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil },
})
if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != conflictingObject {
t.Fatalf("changed local tag object = %s, want %s", got, conflictingObject)
}
if result.TagObject == "" || result.TagObject == conflictingObject {
t.Fatalf("captured tag object = %q, want the prior object; publish error: %v", result.TagObject, err)
}
if queries := countLines(t, wrapper.queryLog); queries != 2 {
t.Fatalf("remote query count = %d, want only the pre-validation and prepublication guards", queries)
}
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("changed local tag was pushed")
}
}
func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *testing.T) {
repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "query-failure")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil },
})
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not preserve both query and push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one failed reconciliation query", queries)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 {
t.Fatalf("push cause was not preserved in error chain: %v", err)
}
}
func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
repo := newPublishFixture(t)
other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
wrapper := installGitWrapperWithMutationMode(t, repo, "query-conflict", other)
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil },
})
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err)
}
if !strings.Contains(err.Error(), "inconsistent exact ref records") || !strings.Contains(err.Error(), "failed to push some refs") {
t.Fatalf("error does not preserve uninterpretable remote state and push failure: %v", err)
}
if queries := countLines(t, wrapper.queryLog); queries != 4 {
t.Fatalf("authoritative tag query count = %d, want one uninterpretable reconciliation query", queries)
}
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
}
}
func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) { func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit) gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
@@ -151,6 +330,114 @@ type publishFixture struct {
unrelatedCommit string unrelatedCommit string
} }
type gitWrapper struct {
bin string
queryLog string
pushDone string
realGit string
}
func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrapper {
t.Helper()
return installGitWrapperWithMutationMode(t, repo, mode, "")
}
func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper {
t.Helper()
return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)
}
func installGitWrapperWithMutationMode(t *testing.T, repo publishFixture, mode, mutationObject string) gitWrapper {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
queryLog := filepath.Join(t.TempDir(), "tag-queries.log")
pushDone := filepath.Join(t.TempDir(), "push-attempted")
contents := fmt.Sprintf(`#!/bin/sh
REAL_GIT=%s
QUERY_LOG=%s
PUSH_DONE=%s
MODE=%s
MUTATE_OBJECT=%s
ARGS=" $* "
IS_PUSH=0
IS_LS_REMOTE=0
for ARG in "$@"; do
[ "$ARG" = "push" ] && IS_PUSH=1
[ "$ARG" = "ls-remote" ] && IS_LS_REMOTE=1
done
if [ "$IS_LS_REMOTE" = "1" ]; then
case "$ARGS" in *refs/tags/v1.2.3*) echo query >> "$QUERY_LOG";; esac
if [ "$MODE" = "query-failure" ] && [ -f "$PUSH_DONE" ]; then
case "$ARGS" in *refs/tags/v1.2.3*) echo "reconciliation unavailable" >&2; exit 77;; esac
fi
if [ "$MODE" = "query-conflict" ] && [ -f "$PUSH_DONE" ]; then
case "$ARGS" in
*refs/tags/v1.2.3*)
OBJECT=$("$REAL_GIT" rev-parse refs/tags/v1.2.3)
printf '%%s\trefs/tags/v1.2.3\n' "$OBJECT"
printf '%%s\trefs/tags/v1.2.3\n' "$MUTATE_OBJECT"
exit 0
;;
esac
fi
fi
if [ "$IS_PUSH" = "1" ]; then
"$REAL_GIT" "$@"
STATUS=$?
: > "$PUSH_DONE"
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi
exit "$STATUS"
fi
if [ "$MODE" = "mutate-tag" ]; then
case "$ARGS" in
*"cat-file tag refs/tags/v1.2.3"*)
"$REAL_GIT" "$@"
STATUS=$?
[ "$STATUS" = "0" ] && "$REAL_GIT" update-ref refs/tags/v1.2.3 "$MUTATE_OBJECT"
exit "$STATUS"
;;
esac
fi
exec "$REAL_GIT" "$@"
`, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject))
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
t.Fatal(err)
}
return gitWrapper{bin: bin, queryLog: queryLog, pushDone: pushDone, realGit: realGit}
}
func countLines(t *testing.T, path string) int {
t.Helper()
contents, err := os.ReadFile(path)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(strings.Fields(string(contents)))
}
func mustLookPath(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Fatal(err)
}
return path
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func newPublishFixture(t *testing.T) publishFixture { func newPublishFixture(t *testing.T) publishFixture {
t.Helper() t.Helper()
base := t.TempDir() base := t.TempDir()