From 26487f4a353bd3cfd58558371a6ba14dd3ea5d68 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 15 Jul 2026 04:59:59 -0400 Subject: [PATCH] test: cover reset deletion, change detection, git write paths, and simulate what-ifs Signed-off-by: Joshua Temple (cherry picked from commit 173e29881ea42a43a46c7e77357535e56a2b9651) --- e2e/simulate_actions_test.go | 147 +++++++++ internal/changes/detect_integration_test.go | 246 +++++++++++++++ internal/git/workingtree_test.go | 313 ++++++++++++++++++++ internal/reset/reset_destructive_test.go | 228 ++++++++++++++ 4 files changed, 934 insertions(+) create mode 100644 e2e/simulate_actions_test.go create mode 100644 internal/changes/detect_integration_test.go create mode 100644 internal/git/workingtree_test.go create mode 100644 internal/reset/reset_destructive_test.go diff --git a/e2e/simulate_actions_test.go b/e2e/simulate_actions_test.go new file mode 100644 index 00000000..c608bc72 --- /dev/null +++ b/e2e/simulate_actions_test.go @@ -0,0 +1,147 @@ +package e2e + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSimulateActionsPreviewPlans builds the real cascade CLI and runs +// `cascade simulate promote|rollback|release` against a three-environment +// manifest, asserting each what-if renders its state diff and ordered effect +// sequence and leaves the manifest byte-identical. Together with the hotfix +// chain test this gives every simulate subcommand a binary-level scenario. +// +// The what-if simulator is in-process, so unlike the act + gitea scenarios this +// exercises the shipped binary end to end (build the CLI, run it against a real +// manifest, parse stdout) with no containers, and therefore runs under -short. +func TestSimulateActionsPreviewPlans(t *testing.T) { + t.Parallel() + + projectRoot, err := filepath.Abs("..") + require.NoError(t, err, "resolve project root") + + // Build the CLI for the host so the subtests can run it directly. + binDir := t.TempDir() + bin := filepath.Join(binDir, "cascade") + build := exec.Command("go", "build", "-o", bin, "./cmd/cascade") + build.Dir = projectRoot + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build cascade CLI: %v\n%s", err, out) + } + + // A dev -> uat -> prod ladder: dev carries a newer rc than uat (so a + // promotion has real work), and prod carries a deploy-history ring snapshot + // (so a rollback has a real prior target). + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + - uat + - prod + state: + dev: + sha: devbase00000000000000000000000000000000c + version: v1.2.0-rc.3 + committed_at: "2026-01-03T10:00:00Z" + committed_by: seed + uat: + sha: uatbase0000000000000000000000000000000a + version: v1.1.0-rc.1 + committed_at: "2026-01-02T10:00:00Z" + committed_by: seed + prod: + sha: prodnew00000000000000000000000000000000d + version: v1.0.1 + committed_at: "2026-01-04T10:00:00Z" + committed_by: seed + previous: + - sha: prodold00000000000000000000000000000000e + version: v1.0.0 + committed_at: "2026-01-01T10:00:00Z" + committed_by: seed +` + manifestPath := filepath.Join(t.TempDir(), "manifest.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0o600)) + + // runSimulate runs one subcommand against the shared manifest and asserts + // the record-only engine left the manifest bytes untouched. + runSimulate := func(t *testing.T, args ...string) string { + t.Helper() + run := exec.Command(bin, append([]string{"simulate"}, args...)...) + out, err := run.CombinedOutput() + require.NoErrorf(t, err, "simulate %s failed: %s", strings.Join(args, " "), out) + + after, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.Equal(t, manifest, string(after), + "a simulation must never mutate the on-disk manifest") + return string(out) + } + + t.Run("promote", func(t *testing.T) { + got := runSimulate(t, "promote", "--config", manifestPath) + + require.Contains(t, got, "Simulating: promote (mode=default)") + + // State diff: uat advances to dev's rc, and the release row appears. + require.Contains(t, got, "v1.1.0-rc.1 -> v1.2.0-rc.3", + "uat must advance to the rc dev currently holds") + require.Contains(t, got, "uatbase -> devbase") + require.Contains(t, got, "(none) -> v1.1.0", + "promoting past uat must surface the release the crossing publishes") + + // Effects: deploy before state write before release publish. + require.Contains(t, got, "deploy uat from dev") + require.Contains(t, got, "release publish v1.1.0 (rc v1.1.0-rc.1, sha uatbase)") + deployIdx := strings.Index(got, "deploy uat from dev") + writeIdx := strings.Index(got, "write state uat") + publishIdx := strings.Index(got, "release publish v1.1.0") + require.GreaterOrEqual(t, deployIdx, 0) + require.GreaterOrEqual(t, writeIdx, 0) + require.Greater(t, writeIdx, deployIdx, "state is written only after the deploy effect") + require.Greater(t, publishIdx, writeIdx, "the release publishes only after state is written") + }) + + t.Run("rollback", func(t *testing.T) { + got := runSimulate(t, "rollback", "--env", "prod", "--config", manifestPath) + + require.Contains(t, got, "Simulating: rollback (env=prod, to=previous)") + + // The target resolves from the deploy-history ring, not trunk state. + require.Contains(t, got, "v1.0.1 -> v1.0.0", "prod must revert to the ring snapshot") + require.Contains(t, got, "prodnew -> prodold") + require.Contains(t, got, "revert prod (to sha prodold, version v1.0.0 (from previous-ring))") + require.Contains(t, got, "write state prod (sha prodold, version v1.0.0)") + require.Contains(t, got, "no -> yes", + "a rollback must mark the environment diverged off trunk") + }) + + t.Run("rollback without a prior target fails", func(t *testing.T) { + run := exec.Command(bin, "simulate", "rollback", "--env", "uat", "--config", manifestPath) + out, err := run.CombinedOutput() + require.Error(t, err, "uat has no deploy history, so the rollback what-if must fail") + require.Contains(t, string(out), "no prior version to roll back to") + }) + + t.Run("rollback of the first environment is refused", func(t *testing.T) { + run := exec.Command(bin, "simulate", "rollback", "--env", "dev", "--config", manifestPath) + out, err := run.CombinedOutput() + require.Error(t, err, "the first environment tracks trunk and is never rolled back") + require.Contains(t, string(out), "Revert it with a merge to the trunk branch") + }) + + t.Run("release", func(t *testing.T) { + got := runSimulate(t, "release", "--config", manifestPath) + + require.Contains(t, got, "Simulating: release (prerelease/publish crossing)") + require.Contains(t, got, "(none) -> v1.1.0", + "the crossing must strip uat's rc suffix into the published version") + require.Contains(t, got, "release publish v1.1.0 (rc v1.1.0-rc.1, sha uatbase)") + }) +} diff --git a/internal/changes/detect_integration_test.go b/internal/changes/detect_integration_test.go new file mode 100644 index 00000000..ba706ca5 --- /dev/null +++ b/internal/changes/detect_integration_test.go @@ -0,0 +1,246 @@ +package changes + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +// nullSHA is the all-zero base SHA GitHub sends for a new branch's first push; +// Detect treats it as "everything at head changed". +const nullSHA = "0000000000000000000000000000000000000000" + +// newDetectRepo initializes a git repository in a temp directory and changes +// the working directory to it for the duration of the test, since +// git.GetChangedFiles runs against the process working directory. It returns +// the SHAs of a four-commit history covering source, infra, and docs changes. +func newDetectRepo(t *testing.T) (base, srcChange, infraChange, docsChange string) { + t.Helper() + + dir := t.TempDir() + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(orig)) + }) + + runGit := func(args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + commit := func(name, message string) string { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(name), 0o750)) + require.NoError(t, os.WriteFile(name, []byte(message+"\n"), 0o600)) + runGit("add", name) + runGit("commit", "-m", message) + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + require.NoError(t, err) + return strings.TrimSpace(string(out)) + } + + runGit("init") + runGit("config", "user.email", "test@example.com") + runGit("config", "user.name", "Test User") + runGit("config", "commit.gpgsign", "false") + + base = commit("README.md", "chore: init") + srcChange = commit("src/app/main.go", "feat: app change") + infraChange = commit("infra/deploy.yaml", "feat: infra change") + docsChange = commit("docs/guide.md", "docs: guide") + return base, srcChange, infraChange, docsChange +} + +// detectConfig declares one path-scoped build, one negation-filtered build, one +// path-scoped deploy, and one trigger-less (always-on) deploy, so Detect's +// per-build and per-deploy fan-out is observable. +func detectConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Builds: []config.BuildConfig{ + {Name: "app", Triggers: []string{"src/**"}}, + {Name: "everything-but-docs", Triggers: []string{"**", "!docs/**", "!**/*.md"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "infra", Triggers: []string{"infra/**"}}, + {Name: "always", Triggers: []string{}}, + }, + } +} + +func TestDetect_FansOutTriggeredBuildsAndDeploys(t *testing.T) { + base, srcChange, infraChange, docsChange := newDetectRepo(t) + cfg := detectConfig() + + tests := []struct { + name string + baseSHA string + headSHA string + wantChanged []string + wantBuilds []string + wantDeploys []string + wantHasFiles bool + }{ + { + name: "source change triggers path build, catch-all build, and always deploy", + baseSHA: base, + headSHA: srcChange, + wantChanged: []string{"src/app/main.go"}, + wantBuilds: []string{"app", "everything-but-docs"}, + wantDeploys: []string{"always"}, + wantHasFiles: true, + }, + { + name: "infra change triggers infra deploy but not the app build", + baseSHA: srcChange, + headSHA: infraChange, + wantChanged: []string{"infra/deploy.yaml"}, + wantBuilds: []string{"everything-but-docs"}, + wantDeploys: []string{"infra", "always"}, + wantHasFiles: true, + }, + { + name: "docs-only change is excluded by negation yet still counts as a change", + baseSHA: infraChange, + headSHA: docsChange, + wantChanged: []string{"docs/guide.md"}, + wantBuilds: []string{}, + wantDeploys: []string{"always"}, + wantHasFiles: true, + }, + { + name: "identical SHAs mean no changes and nothing triggered", + baseSHA: docsChange, + headSHA: docsChange, + wantChanged: nil, + wantBuilds: []string{}, + wantDeploys: []string{}, + wantHasFiles: false, + }, + { + name: "multi-commit range accumulates every changed file", + baseSHA: base, + headSHA: infraChange, + wantChanged: []string{"infra/deploy.yaml", "src/app/main.go"}, + wantBuilds: []string{"app", "everything-but-docs"}, + wantDeploys: []string{"infra", "always"}, + wantHasFiles: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Detect(cfg, tt.baseSHA, tt.headSHA) + require.NoError(t, err) + + assert.Equal(t, tt.wantHasFiles, result.HasChanges) + assert.ElementsMatch(t, tt.wantChanged, result.ChangedFiles) + assert.Equal(t, tt.wantBuilds, result.TriggeredBuilds) + assert.Equal(t, tt.wantDeploys, result.TriggeredDeploys) + }) + } +} + +func TestDetect_NullBaseSHATreatsWholeTreeAsChanged(t *testing.T) { + _, srcChange, _, _ := newDetectRepo(t) + + result, err := Detect(detectConfig(), nullSHA, srcChange) + require.NoError(t, err) + + assert.True(t, result.HasChanges) + assert.ElementsMatch(t, []string{"README.md", "src/app/main.go"}, result.ChangedFiles, + "a null base means every file at head is a change") + assert.Equal(t, []string{"app", "everything-but-docs"}, result.TriggeredBuilds) + assert.Equal(t, []string{"always"}, result.TriggeredDeploys) +} + +func TestDetect_UnknownSHASurfacesGitError(t *testing.T) { + _, srcChange, _, _ := newDetectRepo(t) + + _, err := Detect(detectConfig(), "b8d6a54e6fbbf2a4a89ae0f0b1a76c9bb1e13a00", srcChange) + require.Error(t, err, "an unresolvable base SHA must surface, not read as no changes") +} + +func TestNewCommand_RequiresSHAFlags(t *testing.T) { + cmd := NewCommand() + cmd.SetArgs([]string{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "base-sha") +} + +func TestDetectChangesCommand_EmitsResultJSON(t *testing.T) { + base, srcChange, _, _ := newDetectRepo(t) + + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + builds: + - name: app + workflow: .github/workflows/build.yaml + triggers: + - "src/**" + deploys: + - name: site + workflow: .github/workflows/deploy.yaml + triggers: + - "docs/**" +` + manifestPath := filepath.Join(t.TempDir(), "manifest.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0o600)) + + // runDetectChanges writes to os.Stdout directly; capture it via a pipe. + readEnd, writeEnd, err := os.Pipe() + require.NoError(t, err) + origStdout := os.Stdout + os.Stdout = writeEnd + defer func() { os.Stdout = origStdout }() + + cmd := NewCommand() + cmd.SetArgs([]string{"--config", manifestPath, "--base-sha", base, "--head-sha", srcChange}) + execErr := cmd.Execute() + + require.NoError(t, writeEnd.Close()) + os.Stdout = origStdout + var out bytes.Buffer + _, err = out.ReadFrom(readEnd) + require.NoError(t, err) + require.NoError(t, execErr) + + var result DetectResult + require.NoError(t, json.Unmarshal(out.Bytes(), &result), "output must be valid JSON: %s", out.String()) + assert.True(t, result.HasChanges) + assert.Equal(t, []string{"src/app/main.go"}, result.ChangedFiles) + assert.Equal(t, []string{"app"}, result.TriggeredBuilds) + assert.Equal(t, []string{}, result.TriggeredDeploys) +} + +func TestDetectChangesCommand_BadConfigPathErrors(t *testing.T) { + cmd := NewCommand() + cmd.SetArgs([]string{"--config", filepath.Join(t.TempDir(), "missing.yaml"), + "--base-sha", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--head-sha", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing config") +} diff --git a/internal/git/workingtree_test.go b/internal/git/workingtree_test.go new file mode 100644 index 00000000..6666b069 --- /dev/null +++ b/internal/git/workingtree_test.go @@ -0,0 +1,313 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// gitNullSHA is the all-zero base SHA GitHub delivers for a branch's first push. +const gitNullSHA = "0000000000000000000000000000000000000000" + +// newCloneWithRemote creates a bare origin plus a working clone, changes the +// working directory into the clone for the duration of the test (the functions +// under test operate on the process working directory), and returns both paths. +func newCloneWithRemote(t *testing.T) (bare, clone string) { + t.Helper() + + root := t.TempDir() + bare = filepath.Join(root, "origin.git") + seed := filepath.Join(root, "seed") + clone = filepath.Join(root, "clone") + + gitAt(t, "", "init", "--bare", bare) + // Pin the bare HEAD so clones check out main regardless of the host's + // init.defaultBranch setting. + gitAt(t, bare, "symbolic-ref", "HEAD", "refs/heads/main") + gitAt(t, "", "init", "-b", "main", seed) + configRepo(t, seed) + writeFileAt(t, seed, "README.md", "seed\n") + gitAt(t, seed, "add", "README.md") + gitAt(t, seed, "commit", "-m", "chore: seed") + gitAt(t, seed, "remote", "add", "origin", bare) + gitAt(t, seed, "push", "-u", "origin", "main") + + gitAt(t, "", "clone", bare, clone) + configRepo(t, clone) + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(clone); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + + return bare, clone +} + +func gitOutAt(t *testing.T, dir string, args ...string) string { + t.Helper() + if dir != "" { + args = append([]string{"-C", dir}, args...) + } + out, err := exec.Command("git", args...).Output() + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)) +} + +func writeFileAt(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func TestGetChangedFiles_DiffBetweenCommits(t *testing.T) { + newScratchRepo(t) + + base := commitFile(t, "a.txt", "one", "chore: base") + if err := os.Mkdir("sub", 0o750); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + commitFile(t, "sub/b.txt", "two", "feat: add b") + head := commitFile(t, "a.txt", "one-changed", "fix: change a") + + got, err := GetChangedFiles(base, head) + if err != nil { + t.Fatalf("GetChangedFiles: %v", err) + } + want := map[string]bool{"a.txt": true, "sub/b.txt": true} + if len(got) != len(want) { + t.Fatalf("GetChangedFiles = %v, want files %v", got, want) + } + for _, f := range got { + if !want[f] { + t.Errorf("unexpected changed file %q", f) + } + } +} + +func TestGetChangedFiles_NullBaseReturnsWholeTree(t *testing.T) { + newScratchRepo(t) + + commitFile(t, "a.txt", "one", "chore: base") + if err := os.Mkdir("sub", 0o750); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + head := commitFile(t, "sub/b.txt", "two", "feat: add b") + + got, err := GetChangedFiles(gitNullSHA, head) + if err != nil { + t.Fatalf("GetChangedFiles: %v", err) + } + want := map[string]bool{"a.txt": true, "sub/b.txt": true} + if len(got) != len(want) { + t.Fatalf("GetChangedFiles = %v, want the whole tree %v", got, want) + } + for _, f := range got { + if !want[f] { + t.Errorf("unexpected file %q in tree listing", f) + } + } +} + +func TestGetChangedFiles_UnknownSHAErrors(t *testing.T) { + newScratchRepo(t) + head := commitFile(t, "a.txt", "one", "chore: base") + + if _, err := GetChangedFiles("b8d6a54e6fbbf2a4a89ae0f0b1a76c9bb1e13a00", head); err == nil { + t.Fatal("GetChangedFiles with an unresolvable base must error, not report no changes") + } +} + +func TestGetInitialCommit(t *testing.T) { + newScratchRepo(t) + + first := commitFile(t, "a.txt", "one", "chore: first") + commitFile(t, "b.txt", "two", "chore: second") + + got, err := GetInitialCommit() + if err != nil { + t.Fatalf("GetInitialCommit: %v", err) + } + if got != first { + t.Errorf("GetInitialCommit = %q, want %q", got, first) + } +} + +func TestCurrentBranch(t *testing.T) { + newScratchRepo(t) + commitFile(t, "a.txt", "one", "chore: base") + runGit(t, "checkout", "-b", "feature/current-branch") + + got, err := CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + if got != "feature/current-branch" { + t.Errorf("CurrentBranch = %q, want feature/current-branch", got) + } +} + +func TestCurrentBranch_DetachedHeadErrors(t *testing.T) { + newScratchRepo(t) + sha := commitFile(t, "a.txt", "one", "chore: base") + runGit(t, "checkout", "--detach", sha) + + if _, err := CurrentBranch(); err == nil { + t.Fatal("CurrentBranch on a detached HEAD must error") + } +} + +func TestCommitAndPush_LandsOnRemote(t *testing.T) { + bare, clone := newCloneWithRemote(t) + + writeFileAt(t, clone, "state.yaml", "state: v1\n") + if err := CommitAndPush("state.yaml", "chore: write state"); err != nil { + t.Fatalf("CommitAndPush: %v", err) + } + + if got := gitOutAt(t, bare, "log", "-1", "--format=%s", "main"); got != "chore: write state" { + t.Errorf("remote tip subject = %q, want the pushed commit", got) + } + if got := gitOutAt(t, bare, "show", "main:state.yaml"); got != "state: v1" { + t.Errorf("remote state.yaml = %q, want the pushed content", got) + } +} + +func TestCommitAndPush_NothingStagedErrors(t *testing.T) { + newCloneWithRemote(t) + + // README.md is unchanged, so the commit has nothing to record. + if err := CommitAndPush("README.md", "chore: no-op"); err == nil { + t.Fatal("CommitAndPush with no changes must surface the git commit failure") + } +} + +func TestRefetchAndReset_DropsLocalCommitAndAdoptsUpstream(t *testing.T) { + bare, clone := newCloneWithRemote(t) + + // A second clone advances trunk. + other := filepath.Join(t.TempDir(), "other") + gitAt(t, "", "clone", bare, other) + configRepo(t, other) + writeFileAt(t, other, "upstream.txt", "from other\n") + gitAt(t, other, "add", "upstream.txt") + gitAt(t, other, "commit", "-m", "feat: upstream change") + gitAt(t, other, "push") + + // The local clone commits on the stale base without pushing. + writeFileAt(t, clone, "local.txt", "stale local\n") + gitAt(t, clone, "add", "local.txt") + gitAt(t, clone, "commit", "-m", "chore: stale local commit") + + if err := RefetchAndReset(clone); err != nil { + t.Fatalf("RefetchAndReset: %v", err) + } + + localTip := gitOutAt(t, clone, "rev-parse", "HEAD") + remoteTip := gitOutAt(t, bare, "rev-parse", "main") + if localTip != remoteTip { + t.Errorf("HEAD = %s, want the upstream tip %s", localTip, remoteTip) + } + if _, err := os.Stat(filepath.Join(clone, "local.txt")); !os.IsNotExist(err) { + t.Error("the stale local commit's file must be gone after the hard reset") + } + if _, err := os.Stat(filepath.Join(clone, "upstream.txt")); err != nil { + t.Error("the upstream change must be present after the reset") + } +} + +// TestPushWithRebaseRetry_ReapplyConvergesRejectedPush proves the WithReapply +// path: when a concurrent writer advances trunk between checkout and push, the +// retry loop re-fetches, hard-resets onto the new tip, invokes the re-apply +// callback to re-derive the owned change, and pushes successfully, so both +// writers' changes land. +func TestPushWithRebaseRetry_ReapplyConvergesRejectedPush(t *testing.T) { + bare, clone := newCloneWithRemote(t) + + // The owned change, committed against the soon-to-be-stale trunk tip. + writeFileAt(t, clone, "owned.txt", "owned leaf\n") + gitAt(t, clone, "add", "owned.txt") + gitAt(t, clone, "commit", "-m", "chore: owned leaf") + + // A concurrent sibling lands on trunk first, so the push is rejected. + other := filepath.Join(t.TempDir(), "other") + gitAt(t, "", "clone", bare, other) + configRepo(t, other) + writeFileAt(t, other, "sibling.txt", "sibling leaf\n") + gitAt(t, other, "add", "sibling.txt") + gitAt(t, other, "commit", "-m", "chore: sibling leaf") + gitAt(t, other, "push") + + reapplyCalls := 0 + err := PushWithRebaseRetry( + WithDir(clone), + WithBackoff(time.Millisecond), + WithReapply(func() error { + reapplyCalls++ + // Re-derive the owned leaf onto the freshly reset trunk and recommit. + writeFileAt(t, clone, "owned.txt", "owned leaf\n") + gitAt(t, clone, "add", "owned.txt") + gitAt(t, clone, "commit", "-m", "chore: owned leaf (reapplied)") + return nil + }), + ) + if err != nil { + t.Fatalf("PushWithRebaseRetry with reapply: %v", err) + } + + if reapplyCalls != 1 { + t.Errorf("reapply calls = %d, want exactly 1", reapplyCalls) + } + for _, f := range []string{"owned.txt", "sibling.txt"} { + if out, err := exec.Command("git", "-C", bare, "cat-file", "-e", "main:"+f).CombinedOutput(); err != nil { + t.Errorf("trunk must carry %s after convergence: %v\n%s", f, err, out) + } + } +} + +// TestPushWithRebaseRetry_ReapplyErrorSurfaces proves a failing re-apply +// callback aborts the retry loop with the callback's error rather than looping. +func TestPushWithRebaseRetry_ReapplyErrorSurfaces(t *testing.T) { + bare, clone := newCloneWithRemote(t) + + writeFileAt(t, clone, "owned.txt", "owned leaf\n") + gitAt(t, clone, "add", "owned.txt") + gitAt(t, clone, "commit", "-m", "chore: owned leaf") + + other := filepath.Join(t.TempDir(), "other") + gitAt(t, "", "clone", bare, other) + configRepo(t, other) + writeFileAt(t, other, "sibling.txt", "sibling leaf\n") + gitAt(t, other, "add", "sibling.txt") + gitAt(t, other, "commit", "-m", "chore: sibling leaf") + gitAt(t, other, "push") + + err := PushWithRebaseRetry( + WithDir(clone), + WithBackoff(time.Millisecond), + WithReapply(func() error { return os.ErrPermission }), + ) + if err == nil { + t.Fatal("a failing reapply callback must surface an error") + } + if !strings.Contains(err.Error(), "re-apply") { + t.Errorf("error %q must attribute the failure to the re-apply step", err) + } +} diff --git a/internal/reset/reset_destructive_test.go b/internal/reset/reset_destructive_test.go new file mode 100644 index 00000000..6fb2621f --- /dev/null +++ b/internal/reset/reset_destructive_test.go @@ -0,0 +1,228 @@ +package reset + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeGH installs a scripted gh CLI on PATH that records every invocation (one +// line of argv per call) and serves canned release data, so the destructive +// delete-all-releases path can be proven hermetically. The reset command drives +// the real gh binary against the GitHub API, which the act+gitea e2e harness +// has no compatible host for, so this recorded-runner level is where the +// deletion loop is verified. +// +// Behavior is driven by environment variables: +// - RESET_GH_LOG: file the stub appends each invocation's argv to +// - RESET_GH_RELEASES: file whose contents "gh release list" prints +// - RESET_GH_LIST_FAIL: when non-empty, "gh release list" exits 1 +// - RESET_GH_FAIL_TAG: "gh release delete " exits 1 for this tag +func fakeGH(t *testing.T, releases string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the scripted gh stub needs a POSIX shell") + } + + binDir := t.TempDir() + dataDir := t.TempDir() + + logPath := filepath.Join(dataDir, "invocations.log") + releasesPath := filepath.Join(dataDir, "releases.txt") + require.NoError(t, os.WriteFile(releasesPath, []byte(releases), 0o600)) + + script := `#!/bin/sh +printf '%s\n' "$*" >> "$RESET_GH_LOG" +case "$*" in + *"release list"*) + if [ -n "$RESET_GH_LIST_FAIL" ]; then exit 1; fi + cat "$RESET_GH_RELEASES" + ;; + *"release delete ${RESET_GH_FAIL_TAG:-} "*) + exit 1 + ;; +esac +exit 0 +` + require.NoError(t, os.WriteFile(filepath.Join(binDir, "gh"), []byte(script), 0o755)) + + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RESET_GH_LOG", logPath) + t.Setenv("RESET_GH_RELEASES", releasesPath) + return logPath +} + +// ghInvocations reads the stub's recorded argv lines. +func ghInvocations(t *testing.T, logPath string) []string { + t.Helper() + data, err := os.ReadFile(logPath) + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + return strings.Split(strings.TrimSpace(string(data)), "\n") +} + +// deleteInvocations filters the recorded gh calls down to release deletions. +func deleteInvocations(calls []string) []string { + var deletes []string + for _, call := range calls { + if strings.Contains(call, "release delete") { + deletes = append(deletes, call) + } + } + return deletes +} + +func TestDeleteAllReleases_NoReleases(t *testing.T) { + logPath := fakeGH(t, "") + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + + deleted, err := r.deleteAllReleases() + require.NoError(t, err) + assert.Equal(t, 0, deleted) + + calls := ghInvocations(t, logPath) + require.Len(t, calls, 1, "an empty repo needs exactly one gh call (the list)") + assert.Contains(t, calls[0], "release list") + assert.Empty(t, deleteInvocations(calls)) +} + +func TestDeleteAllReleases_DryRunDeletesNothing(t *testing.T) { + logPath := fakeGH(t, "v1.0.0\nv1.1.0\n") + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir, DryRun: true}) + require.NoError(t, err) + + deleted, err := r.deleteAllReleases() + require.NoError(t, err) + assert.Equal(t, 2, deleted, "dry-run reports the count it would delete") + + assert.Empty(t, deleteInvocations(ghInvocations(t, logPath)), + "dry-run must never invoke gh release delete") +} + +func TestDeleteAllReleases_DeletesEveryReleaseInResolvedRepo(t *testing.T) { + logPath := fakeGH(t, "v1.0.0\nv1.1.0\nv2.0.0-rc.1\n") + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + + deleted, err := r.deleteAllReleases() + require.NoError(t, err) + assert.Equal(t, 3, deleted) + + // Every gh call must be pinned to the repo resolved from the origin remote + // (-R owner/name), so the wipe can never land on a default/ambient repo. + calls := ghInvocations(t, logPath) + for _, call := range calls { + assert.True(t, strings.HasPrefix(call, "-R test/repo "), + "gh call not pinned to the resolved repo: %q", call) + } + + deletes := deleteInvocations(calls) + require.Len(t, deletes, 3) + assert.Equal(t, "-R test/repo release delete v1.0.0 --yes", deletes[0]) + assert.Equal(t, "-R test/repo release delete v1.1.0 --yes", deletes[1]) + assert.Equal(t, "-R test/repo release delete v2.0.0-rc.1 --yes", deletes[2]) +} + +func TestDeleteAllReleases_ContinuesPastSingleFailure(t *testing.T) { + logPath := fakeGH(t, "v1.0.0\nv1.1.0\nv2.0.0\n") + t.Setenv("RESET_GH_FAIL_TAG", "v1.1.0") + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + + deleted, err := r.deleteAllReleases() + require.NoError(t, err, "one failed deletion must not abort the wipe") + assert.Equal(t, 2, deleted, "the failed tag is not counted as deleted") + + assert.Len(t, deleteInvocations(ghInvocations(t, logPath)), 3, + "every release must still be attempted") +} + +func TestDeleteAllReleases_ListErrorSurfaces(t *testing.T) { + fakeGH(t, "") + t.Setenv("RESET_GH_LIST_FAIL", "1") + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + + deleted, err := r.deleteAllReleases() + require.Error(t, err, "a failed release listing must surface, not read as empty") + assert.Equal(t, 0, deleted) +} + +// blockNetworkGit forces every git transport attempt to fail fast so tests that +// run the full Reset flow against a github-style origin URL stay hermetic: the +// remote-side steps (fetch --tags, push --delete) fail immediately and reset's +// warn-and-continue handling is what gets exercised. +func blockNetworkGit(t *testing.T) { + t.Helper() + t.Setenv("GIT_SSH_COMMAND", "false") +} + +func TestReset_DryRunMutatesNothing(t *testing.T) { + logPath := fakeGH(t, "v1.0.0\n") + blockNetworkGit(t) + dir := newResetRepo(t) + gitInDir(t, dir, "tag", "v1.0.0") + gitInDir(t, dir, "tag", "v1.1.0") + + r, err := New(Options{RepoPath: dir, DryRun: true}) + require.NoError(t, err) + + result, err := r.Reset() + require.NoError(t, err) + assert.Equal(t, 1, result.ReleasesDeleted) + assert.Equal(t, 2, result.TagsDeleted) + assert.False(t, result.StateReset) + assert.False(t, result.Pushed) + + assert.Empty(t, deleteInvocations(ghInvocations(t, logPath)), + "dry-run must not delete releases") + + out, err := exec.Command("git", "-C", dir, "tag", "-l").Output() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"v1.0.0", "v1.1.0"}, + strings.Fields(string(out)), "dry-run must leave every local tag in place") +} + +func TestReset_DeletesReleasesAndLocalTags(t *testing.T) { + logPath := fakeGH(t, "v1.0.0\n") + blockNetworkGit(t) + dir := newResetRepo(t) + gitInDir(t, dir, "tag", "v1.0.0") + gitInDir(t, dir, "tag", "v1.1.0") + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + + result, err := r.Reset() + require.NoError(t, err) + assert.Equal(t, 1, result.ReleasesDeleted) + assert.Equal(t, 2, result.TagsDeleted) + assert.False(t, result.StateReset, "state reset was not requested") + + deletes := deleteInvocations(ghInvocations(t, logPath)) + require.Len(t, deletes, 1) + assert.Equal(t, "-R test/repo release delete v1.0.0 --yes", deletes[0]) + + out, err := exec.Command("git", "-C", dir, "tag", "-l").Output() + require.NoError(t, err) + assert.Empty(t, strings.TrimSpace(string(out)), "every local tag must be gone") +}