From 04db23705a9d6fdefdabeb7f86801f79e2748401 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 22:20:53 +0000 Subject: [PATCH] Honor cancellation throughout publication recovery Git operations --- cmd/releasekit/publication_handoff_test.go | 2 +- cmd/releasekit/recovery_test.go | 6 +- docs/roadmap/remediation.md | 7 + internal/git/source.go | 22 +++ internal/publish/attempts.go | 23 +--- internal/publish/cancellation_test.go | 147 +++++++++++++++++++++ internal/publish/publish.go | 6 +- internal/publish/reconcile.go | 12 +- internal/publish/record.go | 47 +++---- internal/publish/record_test.go | 22 +-- 10 files changed, 231 insertions(+), 63 deletions(-) create mode 100644 internal/publish/cancellation_test.go diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index bcb698e..49bbc98 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -73,7 +73,7 @@ func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testin {RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"}, {RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"}, } { - if _, err := publish.SaveEvidence(repository, store, decoy); err != nil { + if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil { t.Fatal(err) } } diff --git a/cmd/releasekit/recovery_test.go b/cmd/releasekit/recovery_test.go index 56b6f19..c8ea0c4 100644 --- a/cmd/releasekit/recovery_test.go +++ b/cmd/releasekit/recovery_test.go @@ -36,7 +36,7 @@ func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing Remote: "origin", RecordedAt: "2026-11-02T00:00:00Z", } - record, err := publish.SaveEvidence(repository, store, evidence) + record, err := publish.SaveEvidence(t.Context(), repository, store, evidence) if err != nil { t.Fatal(err) } @@ -113,7 +113,7 @@ func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing partialEvidence := evidence partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") partialEvidence.CandidateCommit = "" - partialRecord, err := publish.SaveEvidence(repository, store, partialEvidence) + partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence) if err != nil { t.Fatal(err) } @@ -194,7 +194,7 @@ func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery( Remote: "origin", RecordedAt: "2026-11-02T00:00:00Z", } - if _, err := publish.SaveEvidence(repository, store, evidence); err != nil { + if _, err := publish.SaveEvidence(t.Context(), repository, store, evidence); err != nil { t.Fatal(err) } brokenPath := filepath.Join(store, "damaged.json") diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 7045bfc..353fbd5 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -128,6 +128,13 @@ unignored worktree contents. ## R04: Restore cancellation across storage and recovery phases — P2 +**Completed:** Storage and discovery share context-aware Git adapters; later +observation classifies canceled tag validation and queries consistently. Tests +passed for already canceled discovery and cancellation synchronized with active +root/metadata lookups, ignore checks, publication storage, and tag operations. +Focused Git, facade, and CLI recovery checks also passed; immediate failed-push +reconciliation retains its existing bounded context. + **Relevant code:** `internal/publish/record.go:112` and `record.go:152`; `internal/publish/attempts.go:31` and `attempts.go:74`; `internal/publish/reconcile.go:109` and `reconcile.go:130`. diff --git a/internal/git/source.go b/internal/git/source.go index 1a1265e..7639497 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -73,6 +73,28 @@ func MetadataDirectories(ctx context.Context, directory string) ([]string, error return directories, nil } +// CommonDirectory resolves the shared Git metadata directory. +func CommonDirectory(ctx context.Context, directory string) (string, error) { + output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", fmt.Errorf("resolve shared Git metadata directory: %w", err) + } + return normalizeRepositoryRoot(output) +} + +// IsIgnored checks whether Git excludes the selected worktree path. +func IsIgnored(ctx context.Context, directory, path string) (bool, error) { + _, err := run(ctx, directory, nil, "check-ignore", "-q", "--", path) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + // DiscoverRepositoryRoot finds a containing checkout or confirms standalone use. func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) { if directory == "" { diff --git a/internal/publish/attempts.go b/internal/publish/attempts.go index 55f17a2..dfdcd58 100644 --- a/internal/publish/attempts.go +++ b/internal/publish/attempts.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "sort" "strings" @@ -26,11 +25,14 @@ func ListAttempts(ctx context.Context, repositoryDir, override string) (Attempts repositoryDir = "." } result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}} + if err := ctx.Err(); err != nil { + return result, recoveryFailure(ctx, "storage_enumeration", err) + } var directory string { resolved, err := recordDirectoryFor(ctx, repositoryDir, override) if err != nil { - return result, &RecoveryError{Kind: "storage_enumeration", cause: err} + return result, recoveryFailure(ctx, "storage_enumeration", err) } directory = resolved } @@ -40,7 +42,7 @@ func ListAttempts(ctx context.Context, repositoryDir, override string) (Attempts } entries, err := os.ReadDir(directory) if err != nil { - return result, &RecoveryError{Kind: "storage_enumeration", cause: fmt.Errorf("enumerate records in %q: %w", directory, err)} + return result, recoveryFailure(ctx, "storage_enumeration", fmt.Errorf("enumerate records in %q: %w", directory, err)) } paths := make([]string, 0, len(entries)) for _, entry := range entries { @@ -75,17 +77,6 @@ func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (st if filepath.IsAbs(override) { return filepath.Clean(override), nil } - root, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", err - } - rootPath := strings.TrimSpace(string(root)) - if override != "" { - return filepath.Join(rootPath, override), nil - } - common, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output() - if err != nil { - return "", err - } - return filepath.Join(strings.TrimSpace(string(common)), "releasekit", "publication-attempts"), nil + directory, _, err := resolveRecordDir(ctx, repositoryDir, override) + return directory, err } diff --git a/internal/publish/cancellation_test.go b/internal/publish/cancellation_test.go new file mode 100644 index 0000000..602ac67 --- /dev/null +++ b/internal/publish/cancellation_test.go @@ -0,0 +1,147 @@ +package publish + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +func TestRecoveryAlreadyCanceledDiscovery(t *testing.T) { + for _, override := range []string{"", "relative"} { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + result, err := ListAttempts(ctx, t.TempDir(), override) + requireCanceledRecovery(t, err) + if len(result.Attempts) != 0 { + t.Fatalf("unexpected attempts: %#v", result) + } + } +} + +func requireCanceledRecovery(t *testing.T, err error) { + t.Helper() + var recovery *RecoveryError + if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v; want canceled kind and cause", err) + } +} + +func TestRecoveryCancelsActiveGitOperations(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture uses a POSIX Git launcher") + } + tests := []struct { + name, command string + run func(context.Context, string) error + }{ + {"default discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }}, + {"relative discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, "records"); return err }}, + {"default discovery metadata", "--git-common-dir", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }}, + {"record metadata", "--git-common-dir", func(ctx context.Context, repo string) error { + _, err := SaveEvidence(ctx, repo, "", Evidence{}) + return err + }}, + {"record ignore guard", "check-ignore", func(ctx context.Context, repo string) error { + _, err := SaveEvidence(ctx, repo, "records", Evidence{}) + return err + }}, + {"publication metadata", "--git-common-dir", func(ctx context.Context, repo string) error { + result, err := Publish(ctx, Request{RepositoryDir: repo, Product: "Sample", Version: "v1.2.3"}) + if !errors.Is(result.RecordError, context.Canceled) { + return fmt.Errorf("record cancellation lost: %v", result.RecordError) + } + return err + }}, + {"tag validation", "check-ref-format", func(ctx context.Context, repo string) error { + _, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"}) + return err + }}, + {"tag query", "ls-remote", func(ctx context.Context, repo string) error { + _, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"}) + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := recordRepo(t) + if test.name == "publication metadata" { + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/sample\n\ngo 1.26.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, "docs", "releases"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docs", "releases", "v1.2.3.md"), []byte("# Sample v1.2.3\n\nChanges.\n"), 0600); err != nil { + t.Fatal(err) + } + recordGit(t, repo, "checkout", "-B", "main") + recordGit(t, repo, "add", ".") + recordGit(t, repo, "commit", "-qm", "Release candidate") + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + ready := filepath.Join(t.TempDir(), "ready") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } + script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = " + quote(test.command) + " ]; then\n echo $$ > " + quote(ready) + "\n exec sleep 600\n fi\ndone\nexec " + quote(realGit) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- test.run(ctx, repo) }() + // Synchronize cancellation with the intercepted operation, rather than timing its start. + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + timeout := time.NewTimer(10 * time.Second) + defer timeout.Stop() + var pid int + waiting: + for { + select { + case err := <-done: + t.Fatalf("operation finished before interception: %v", err) + case <-timeout.C: + t.Fatal("Git operation was never intercepted") + case <-ticker.C: + data, err := os.ReadFile(ready) + if err == nil { + pid, err = strconv.Atoi(strings.TrimSpace(string(data))) + if err == nil { + break waiting + } + } + } + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("lost context cause: %v", err) + } + if strings.Contains(test.name, "discovery") || test.command == "check-ref-format" || test.command == "ls-remote" { + requireCanceledRecovery(t, err) + } + case <-time.After(5 * time.Second): + process, _ := os.FindProcess(pid) + if process != nil { + _ = process.Kill() + } + <-done + t.Fatal("cancellation did not stop the Git operation") + } + }) + } +} diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 116deae..a530c4a 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -87,7 +87,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err return } recorded = true - path, err := saveEvidenceInDir(result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence) + path, err := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence) if err != nil { result.RecordError = err return @@ -118,7 +118,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err initialized = true result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano) var storageErr error - recordDir, repositoryRoot, storageErr = resolveRecordDir(captured.RepositoryDir, request.RecordDir) + recordDir, repositoryRoot, storageErr = resolveRecordDir(ctx, captured.RepositoryDir, request.RecordDir) if storageErr != nil { result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr} recordDir = "" @@ -200,7 +200,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err result.State = Unknown if !recorded { recorded = true - path, recordErr := saveEvidenceInDir(result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence) + path, recordErr := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence) if recordErr != nil { result.RecordError = recordErr } else { diff --git a/internal/publish/reconcile.go b/internal/publish/reconcile.go index 0bcb4fc..4e8607a 100644 --- a/internal/publish/reconcile.go +++ b/internal/publish/reconcile.go @@ -108,7 +108,7 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) { } valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag) if err != nil { - return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "remote_observation", cause: err} + return Observation{Evidence: evidence, State: Unknown}, recoveryFailure(ctx, "remote_observation", err) } if !valid { return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"} @@ -129,7 +129,7 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) { } ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag) if err != nil { - return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "remote_observation", cause: err} + return Observation{Evidence: evidence, State: Unknown}, recoveryFailure(ctx, "remote_observation", err) } result, err := classify(evidence, ref) if err != nil && ctx.Err() != nil { @@ -142,3 +142,11 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) { func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) { return observe(ctx, evidence) } + +// recoveryFailure classifies failed operations while preserving caller cancellation. +func recoveryFailure(ctx context.Context, kind string, err error) *RecoveryError { + if contextErr := ctx.Err(); contextErr != nil { + return &RecoveryError{Kind: "canceled", cause: contextErr} + } + return &RecoveryError{Kind: kind, cause: err} +} diff --git a/internal/publish/record.go b/internal/publish/record.go index 160b3cd..0980546 100644 --- a/internal/publish/record.go +++ b/internal/publish/record.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" "strings" "time" @@ -53,19 +52,19 @@ func LoadEvidence(path string) (Evidence, error) { } // SaveEvidence writes one private attempt record and returns its absolute path. -func SaveEvidence(repositoryDir, override string, evidence Evidence) (string, error) { - recordDir, root, err := resolveRecordDir(repositoryDir, override) +func SaveEvidence(ctx context.Context, repositoryDir, override string, evidence Evidence) (string, error) { + recordDir, root, err := resolveRecordDir(ctx, repositoryDir, override) if err != nil { return "", &RecoveryError{Kind: "record_write", cause: err} } - return saveEvidenceInDir(repositoryDir, root, override, recordDir, evidence) + return saveEvidenceInDir(ctx, repositoryDir, root, override, recordDir, evidence) } -func saveEvidenceInDir(repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) { - return saveEvidenceNamed(repositoryDir, root, override, recordDir, evidence, proposedRecordPath) +func saveEvidenceInDir(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) { + return saveEvidenceNamed(ctx, repositoryDir, root, override, recordDir, evidence, proposedRecordPath) } -func saveEvidenceNamed(repositoryDir, root, override, recordDir string, evidence Evidence, nextPath func(string) (string, error)) (string, error) { +func saveEvidenceNamed(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence, nextPath func(string) (string, error)) (string, error) { if evidence.RecordedAt == "" { evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano) } @@ -86,7 +85,7 @@ func saveEvidenceNamed(repositoryDir, root, override, recordDir string, evidence return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)} } if override != "" && root != "" { - if err := rejectUnignoredWorktreePath(repositoryDir, root, path); err != nil { + if err := rejectUnignoredWorktreePath(ctx, repositoryDir, root, path); err != nil { return "", &RecoveryError{Kind: "record_write", cause: err} } } @@ -111,14 +110,10 @@ func saveEvidenceNamed(repositoryDir, root, override, recordDir string, evidence return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("could not allocate a unique record name in %q", recordDir)} } -func resolveRecordDir(repositoryDir, override string) (string, string, error) { - rootOutput, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output() +func resolveRecordDir(ctx context.Context, repositoryDir, override string) (string, string, error) { + root, err := git.RepositoryRoot(ctx, repositoryDir) if err != nil { - return "", "", fmt.Errorf("resolve source repository root: %w", err) - } - root, err := filepath.Abs(strings.TrimSpace(string(rootOutput))) - if err != nil { - return "", "", fmt.Errorf("resolve source repository root: %w", err) + return "", "", err } if override != "" { if filepath.IsAbs(override) { @@ -126,11 +121,11 @@ func resolveRecordDir(repositoryDir, override string) (string, string, error) { } return filepath.Join(root, override), root, nil } - common, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output() + common, err := git.CommonDirectory(ctx, repositoryDir) if err != nil { - return "", "", fmt.Errorf("resolve shared Git metadata directory: %w", err) + return "", "", err } - return filepath.Join(strings.TrimSpace(string(common)), "releasekit", "publication-attempts"), root, nil + return filepath.Join(common, "releasekit", "publication-attempts"), root, nil } func proposedRecordPath(directory string) (string, error) { @@ -141,7 +136,7 @@ func proposedRecordPath(directory string) (string, error) { return filepath.Join(directory, "attempt-"+hex.EncodeToString(nonce[:])+".json"), nil } -func rejectUnignoredWorktreePath(repositoryDir, root, proposed string) error { +func rejectUnignoredWorktreePath(ctx context.Context, repositoryDir, root, proposed string) error { parent, err := filepath.EvalSymlinks(filepath.Dir(proposed)) if err != nil { return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err) @@ -151,7 +146,7 @@ func rejectUnignoredWorktreePath(repositoryDir, root, proposed string) error { if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return nil } - metadata, err := git.MetadataDirectories(context.Background(), repositoryDir) + metadata, err := git.MetadataDirectories(ctx, repositoryDir) if err != nil { return err } @@ -161,14 +156,12 @@ func rejectUnignoredWorktreePath(repositoryDir, root, proposed string) error { return nil } } - cmd := exec.Command("git", "-C", repositoryDir, "check-ignore", "-q", "--", actual) - err = cmd.Run() - if err == nil { - return nil + ignored, err := git.IsIgnored(ctx, repositoryDir, actual) + if err != nil { + return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err) } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + if !ignored { return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual) } - return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err) + return nil } diff --git a/internal/publish/record_test.go b/internal/publish/record_test.go index 81b9db0..31abcde 100644 --- a/internal/publish/record_test.go +++ b/internal/publish/record_test.go @@ -74,11 +74,11 @@ func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testin worktree := filepath.Join(t.TempDir(), "linked") recordGit(t, repo, "worktree", "add", "--detach", worktree) evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"} - first, err := SaveEvidence(worktree, "", evidence) + first, err := SaveEvidence(t.Context(), worktree, "", evidence) if err != nil { t.Fatal(err) } - second, err := SaveEvidence(worktree, "", evidence) + second, err := SaveEvidence(t.Context(), worktree, "", evidence) if err != nil { t.Fatal(err) } @@ -129,14 +129,14 @@ func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil { t.Fatal(err) } - if _, err := SaveEvidence(repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil { + if _, err := SaveEvidence(t.Context(), repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil { t.Fatalf("ignored override: %v", err) } if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil { t.Fatal(err) } external := filepath.Join(t.TempDir(), "external-store") - path, err := SaveEvidence(repo, external, Evidence{Tag: "v2.0.0"}) + path, err := SaveEvidence(t.Context(), repo, external, Evidence{Tag: "v2.0.0"}) if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) { t.Fatalf("absolute external override path=%q error=%v", path, err) } @@ -154,13 +154,13 @@ func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) { case "separate": recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata")) } - defaultPath, err := SaveEvidence(repo, "", Evidence{}) + defaultPath, err := SaveEvidence(t.Context(), repo, "", Evidence{}) if err != nil { t.Fatal(err) } privateDir := strings.TrimSuffix(recordGit(t, repo, "rev-parse", "--absolute-git-dir"), "\n") for _, directory := range []string{filepath.Dir(defaultPath), filepath.Join(privateDir, "other-private-records")} { - path, err := SaveEvidence(repo, directory, Evidence{Tag: "v1.2.3"}) + path, err := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.2.3"}) if err != nil || filepath.Dir(path) != directory { t.Fatalf("explicit metadata store %q = %q, %v", directory, path, err) } @@ -174,7 +174,7 @@ func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) { func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) { repo := recordRepo(t) - _, err := SaveEvidence(repo, "records", Evidence{Tag: "v1.2.3"}) + _, err := SaveEvidence(t.Context(), repo, "records", Evidence{Tag: "v1.2.3"}) var recovery *RecoveryError if !errors.As(err, &recovery) || recovery.Kind != "record_write" { t.Fatalf("SaveEvidence() error=%v", err) @@ -198,14 +198,14 @@ func TestSaveEvidenceChecksEachActualFilename(t *testing.T) { next := func(name string) func(string) (string, error) { return func(directory string) (string, error) { return filepath.Join(directory, name), nil } } - path, err := saveEvidenceNamed(repo, repo, dir, dir, Evidence{Tag: "v1.0.0"}, next("attempt-0.json")) + path, err := saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v1.0.0"}, next("attempt-0.json")) if err != nil { t.Fatal(err) } recordGit(t, repo, "check-ignore", "--", path) // A collision must validate the replacement name as well. calls := 0 - _, err = saveEvidenceNamed(repo, repo, dir, dir, Evidence{Tag: "v2.0.0"}, func(directory string) (string, error) { + _, err = saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v2.0.0"}, func(directory string) (string, error) { calls++ name := "attempt-0.json" if calls > 1 { @@ -234,8 +234,8 @@ func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing. if err := os.Mkdir(directory, 0700); err != nil { t.Fatal(err) } - second, _ := SaveEvidence(repo, directory, Evidence{Tag: "v2.0.0"}) - first, _ := SaveEvidence(repo, directory, Evidence{Tag: "v1.0.0"}) + second, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v2.0.0"}) + first, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.0.0"}) if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil { t.Fatal(err) }