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") } }) } }