diff --git a/README.md b/README.md index 1bb622f..2ffbe1d 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ ReleaseKit is an opinionated Go library and executable for a shared release process. The Go module, root `releasekit` facade, `buildinfo` runtime package, local candidate check, source-tag publication API, and `version` and `check` -commands are implemented. Push reconciliation, a publication command, builds, -delivery, and release automation remain to be implemented. Their selected +commands are implemented. A publication command, builds, delivery, and release +automation remain to be implemented. Their selected behavior and sequence are described in the [product roadmap](docs/roadmap/releasekit.md) and [implementation plan](docs/roadmap/implementation.md). diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 6f4358c..4d25eb1 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -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 unresolved push error. -When a push reports an error, the local annotated tag is retained and the -result is `unknown` until a later implementation adds authoritative -reconciliation. Do not treat that state as success or proof that the remote -lacks the tag. +When a push reports an error, ReleaseKit makes one bounded query for the +authoritative tag. An exact tag object match confirms success. An absent tag is +reported as `not_published`, a different object as `conflict`, and an unreadable +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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index fdf934e..136e107 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 remote tests verify the note bytes, peeled commit, tag object, unchanged branch 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 -Status: Pending. Depends on Stage 7. +Status: Complete. Depends on Stage 7. 1. Add the one-query reconciliation path and public error/result classification. 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 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 Status: Pending. Depends on Stage 8. diff --git a/internal/git/tag.go b/internal/git/tag.go index 8856ba2..9eaee10 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -92,6 +92,34 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject 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. func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error { if !validVersionRef(version) || !validObjectID(tagObject) { diff --git a/internal/publish/publish.go b/internal/publish/publish.go index e2c2cdc..a97fc29 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "time" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" @@ -24,13 +25,16 @@ const ( type Phase string const ( - Preflight Phase = "preflight" - Validation Phase = "validation" - TagCreation Phase = "tag_creation" - Push Phase = "push" - Complete Phase = "complete" + Preflight Phase = "preflight" + Validation Phase = "validation" + TagCreation Phase = "tag_creation" + Push Phase = "push" + Reconciliation Phase = "reconciliation" + Complete Phase = "complete" ) +const recoveryTimeout = 30 * time.Second + type Request struct { RepositoryDir string Product string @@ -50,8 +54,9 @@ type Result struct { } // Publish validates one captured candidate and pushes only its annotated tag -// object. A failed push remains Unknown until authoritative reconciliation is -// added by the following implementation stage. +// object. A failed push receives one bounded authoritative tag query. Exact +// 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) { result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted} remoteName := request.Remote @@ -129,13 +134,42 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.Phase = Push result.State = Unknown 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.State = Published 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 { return errors.Is(err, git.ErrRemoteMainMissing) || errors.Is(err, git.ErrRemoteMainConflict) || diff --git a/publish.go b/publish.go index a77906a..2e83571 100644 --- a/publish.go +++ b/publish.go @@ -33,6 +33,8 @@ const ( PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) // PublicationPush identifies the remote tag push attempt. 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 PublicationPhase = PublicationPhase(publish.Complete) ) @@ -51,8 +53,10 @@ type PublicationResult struct { // object to the selected remote. It returns a result with errors: preflight // failures are not_attempted, failed authoritative guards are conflict, and // 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 -// the push succeeded and the result state is published. +// push gets one bounded authoritative tag query: an exact object match confirms +// 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) { var validate func(context.Context, candidate.Candidate) error if request.Validate != nil { diff --git a/publish_test.go b/publish_test.go index 7a54b77..3dd08b4 100644 --- a/publish_test.go +++ b/publish_test.go @@ -2,11 +2,14 @@ package releasekit_test import ( "context" + "errors" + "fmt" "os" "os/exec" "path/filepath" "strings" "testing" + "time" 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) { 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") commit := repo.commit var validations int @@ -36,6 +41,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { if validations != 1 { 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 { 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) - hook := filepath.Join(repo.work, ".git", "hooks", "pre-push") - if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + wrapper := installGitWrapper(t, repo, "pass") + 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{ @@ -90,9 +100,15 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) { if err == nil { 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) } + 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 { 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) { repo := newPublishFixture(t) gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit) @@ -151,6 +330,114 @@ type publishFixture struct { 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 { t.Helper() base := t.TempDir()