Honor cancellation throughout publication recovery Git operations
This commit is contained in:
@@ -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/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"},
|
{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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing
|
|||||||
Remote: "origin",
|
Remote: "origin",
|
||||||
RecordedAt: "2026-11-02T00:00:00Z",
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing
|
|||||||
partialEvidence := evidence
|
partialEvidence := evidence
|
||||||
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
partialEvidence.CandidateCommit = ""
|
partialEvidence.CandidateCommit = ""
|
||||||
partialRecord, err := publish.SaveEvidence(repository, store, partialEvidence)
|
partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(
|
|||||||
Remote: "origin",
|
Remote: "origin",
|
||||||
RecordedAt: "2026-11-02T00:00:00Z",
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
brokenPath := filepath.Join(store, "damaged.json")
|
brokenPath := filepath.Join(store, "damaged.json")
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ unignored worktree contents.
|
|||||||
|
|
||||||
## R04: Restore cancellation across storage and recovery phases — P2
|
## 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`;
|
**Relevant code:** `internal/publish/record.go:112` and `record.go:152`;
|
||||||
`internal/publish/attempts.go:31` and `attempts.go:74`;
|
`internal/publish/attempts.go:31` and `attempts.go:74`;
|
||||||
`internal/publish/reconcile.go:109` and `reconcile.go:130`.
|
`internal/publish/reconcile.go:109` and `reconcile.go:130`.
|
||||||
|
|||||||
@@ -73,6 +73,28 @@ func MetadataDirectories(ctx context.Context, directory string) ([]string, error
|
|||||||
return directories, nil
|
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.
|
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
|
||||||
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
||||||
if directory == "" {
|
if directory == "" {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,11 +25,14 @@ func ListAttempts(ctx context.Context, repositoryDir, override string) (Attempts
|
|||||||
repositoryDir = "."
|
repositoryDir = "."
|
||||||
}
|
}
|
||||||
result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}}
|
result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, recoveryFailure(ctx, "storage_enumeration", err)
|
||||||
|
}
|
||||||
var directory string
|
var directory string
|
||||||
{
|
{
|
||||||
resolved, err := recordDirectoryFor(ctx, repositoryDir, override)
|
resolved, err := recordDirectoryFor(ctx, repositoryDir, override)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, &RecoveryError{Kind: "storage_enumeration", cause: err}
|
return result, recoveryFailure(ctx, "storage_enumeration", err)
|
||||||
}
|
}
|
||||||
directory = resolved
|
directory = resolved
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ func ListAttempts(ctx context.Context, repositoryDir, override string) (Attempts
|
|||||||
}
|
}
|
||||||
entries, err := os.ReadDir(directory)
|
entries, err := os.ReadDir(directory)
|
||||||
if err != nil {
|
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))
|
paths := make([]string, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -75,17 +77,6 @@ func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (st
|
|||||||
if filepath.IsAbs(override) {
|
if filepath.IsAbs(override) {
|
||||||
return filepath.Clean(override), nil
|
return filepath.Clean(override), nil
|
||||||
}
|
}
|
||||||
root, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output()
|
directory, _, err := resolveRecordDir(ctx, repositoryDir, override)
|
||||||
if err != nil {
|
return directory, err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
147
internal/publish/cancellation_test.go
Normal file
147
internal/publish/cancellation_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recorded = true
|
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 {
|
if err != nil {
|
||||||
result.RecordError = err
|
result.RecordError = err
|
||||||
return
|
return
|
||||||
@@ -118,7 +118,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err
|
|||||||
initialized = true
|
initialized = true
|
||||||
result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
var storageErr error
|
var storageErr error
|
||||||
recordDir, repositoryRoot, storageErr = resolveRecordDir(captured.RepositoryDir, request.RecordDir)
|
recordDir, repositoryRoot, storageErr = resolveRecordDir(ctx, captured.RepositoryDir, request.RecordDir)
|
||||||
if storageErr != nil {
|
if storageErr != nil {
|
||||||
result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr}
|
result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr}
|
||||||
recordDir = ""
|
recordDir = ""
|
||||||
@@ -200,7 +200,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err
|
|||||||
result.State = Unknown
|
result.State = Unknown
|
||||||
if !recorded {
|
if !recorded {
|
||||||
recorded = true
|
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 {
|
if recordErr != nil {
|
||||||
result.RecordError = recordErr
|
result.RecordError = recordErr
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) {
|
|||||||
}
|
}
|
||||||
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
|
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
|
||||||
if err != nil {
|
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 {
|
if !valid {
|
||||||
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
|
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)
|
ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag)
|
||||||
if err != nil {
|
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)
|
result, err := classify(evidence, ref)
|
||||||
if err != nil && ctx.Err() != nil {
|
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) {
|
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
|
||||||
return observe(ctx, evidence)
|
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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,19 +52,19 @@ func LoadEvidence(path string) (Evidence, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveEvidence writes one private attempt record and returns its absolute path.
|
// SaveEvidence writes one private attempt record and returns its absolute path.
|
||||||
func SaveEvidence(repositoryDir, override string, evidence Evidence) (string, error) {
|
func SaveEvidence(ctx context.Context, repositoryDir, override string, evidence Evidence) (string, error) {
|
||||||
recordDir, root, err := resolveRecordDir(repositoryDir, override)
|
recordDir, root, err := resolveRecordDir(ctx, repositoryDir, override)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", &RecoveryError{Kind: "record_write", cause: err}
|
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) {
|
func saveEvidenceInDir(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) {
|
||||||
return saveEvidenceNamed(repositoryDir, root, override, recordDir, evidence, proposedRecordPath)
|
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 == "" {
|
if evidence.RecordedAt == "" {
|
||||||
evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
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)}
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
|
||||||
}
|
}
|
||||||
if override != "" && root != "" {
|
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}
|
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)}
|
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) {
|
func resolveRecordDir(ctx context.Context, repositoryDir, override string) (string, string, error) {
|
||||||
rootOutput, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output()
|
root, err := git.RepositoryRoot(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve source repository root: %w", err)
|
return "", "", err
|
||||||
}
|
|
||||||
root, err := filepath.Abs(strings.TrimSpace(string(rootOutput)))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("resolve source repository root: %w", err)
|
|
||||||
}
|
}
|
||||||
if override != "" {
|
if override != "" {
|
||||||
if filepath.IsAbs(override) {
|
if filepath.IsAbs(override) {
|
||||||
@@ -126,11 +121,11 @@ func resolveRecordDir(repositoryDir, override string) (string, string, error) {
|
|||||||
}
|
}
|
||||||
return filepath.Join(root, override), root, nil
|
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 {
|
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) {
|
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
|
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))
|
parent, err := filepath.EvalSymlinks(filepath.Dir(proposed))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err)
|
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)) {
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
metadata, err := git.MetadataDirectories(context.Background(), repositoryDir)
|
metadata, err := git.MetadataDirectories(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -161,14 +156,12 @@ func rejectUnignoredWorktreePath(repositoryDir, root, proposed string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd := exec.Command("git", "-C", repositoryDir, "check-ignore", "-q", "--", actual)
|
ignored, err := git.IsIgnored(ctx, repositoryDir, actual)
|
||||||
err = cmd.Run()
|
if err != nil {
|
||||||
if err == nil {
|
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
var exitErr *exec.ExitError
|
if !ignored {
|
||||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
|
||||||
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testin
|
|||||||
worktree := filepath.Join(t.TempDir(), "linked")
|
worktree := filepath.Join(t.TempDir(), "linked")
|
||||||
recordGit(t, repo, "worktree", "add", "--detach", worktree)
|
recordGit(t, repo, "worktree", "add", "--detach", worktree)
|
||||||
evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
second, err := SaveEvidence(worktree, "", evidence)
|
second, err := SaveEvidence(t.Context(), worktree, "", evidence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("ignored override: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
|
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
external := filepath.Join(t.TempDir(), "external-store")
|
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)) {
|
if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) {
|
||||||
t.Fatalf("absolute external override path=%q error=%v", path, err)
|
t.Fatalf("absolute external override path=%q error=%v", path, err)
|
||||||
}
|
}
|
||||||
@@ -154,13 +154,13 @@ func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) {
|
|||||||
case "separate":
|
case "separate":
|
||||||
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata"))
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
privateDir := strings.TrimSuffix(recordGit(t, repo, "rev-parse", "--absolute-git-dir"), "\n")
|
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")} {
|
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 {
|
if err != nil || filepath.Dir(path) != directory {
|
||||||
t.Fatalf("explicit metadata store %q = %q, %v", directory, path, err)
|
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) {
|
func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
|
||||||
repo := recordRepo(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
|
var recovery *RecoveryError
|
||||||
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
|
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
|
||||||
t.Fatalf("SaveEvidence() error=%v", err)
|
t.Fatalf("SaveEvidence() error=%v", err)
|
||||||
@@ -198,14 +198,14 @@ func TestSaveEvidenceChecksEachActualFilename(t *testing.T) {
|
|||||||
next := func(name string) func(string) (string, error) {
|
next := func(name string) func(string) (string, error) {
|
||||||
return func(directory string) (string, error) { return filepath.Join(directory, name), nil }
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
recordGit(t, repo, "check-ignore", "--", path)
|
recordGit(t, repo, "check-ignore", "--", path)
|
||||||
// A collision must validate the replacement name as well.
|
// A collision must validate the replacement name as well.
|
||||||
calls := 0
|
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++
|
calls++
|
||||||
name := "attempt-0.json"
|
name := "attempt-0.json"
|
||||||
if calls > 1 {
|
if calls > 1 {
|
||||||
@@ -234,8 +234,8 @@ func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.
|
|||||||
if err := os.Mkdir(directory, 0700); err != nil {
|
if err := os.Mkdir(directory, 0700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
second, _ := SaveEvidence(repo, directory, Evidence{Tag: "v2.0.0"})
|
second, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v2.0.0"})
|
||||||
first, _ := SaveEvidence(repo, directory, Evidence{Tag: "v1.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 {
|
if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user