From 4bfe59ec3e6a7d1ea7145a05438baca37e7c75e8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 21 Jun 2026 14:39:26 -0400 Subject: [PATCH] feat: add cascade plan command to preview workflow diffs Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + docs/src/content/docs/cli-reference.md | 31 ++++ e2e/harness/multistep.go | 21 +++ e2e/harness/runner.go | 88 ++++++++++ e2e/scenarios/33-plan-diff.yaml | 50 ++++++ go.mod | 2 +- internal/plan/command.go | 46 +++++ internal/plan/plan.go | 129 ++++++++++++++ internal/plan/plan_test.go | 224 +++++++++++++++++++++++++ 9 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 e2e/scenarios/33-plan-diff.yaml create mode 100644 internal/plan/command.go create mode 100644 internal/plan/plan.go create mode 100644 internal/plan/plan_test.go diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index c5285ef..aa89e7b 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -19,6 +19,7 @@ import ( initcmd "github.com/stablekernel/cascade/internal/initcmd" "github.com/stablekernel/cascade/internal/log" "github.com/stablekernel/cascade/internal/orchestrate" + "github.com/stablekernel/cascade/internal/plan" "github.com/stablekernel/cascade/internal/promote" "github.com/stablekernel/cascade/internal/release" "github.com/stablekernel/cascade/internal/reset" @@ -79,6 +80,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(external.NewCommand()) rootCmd.AddCommand(generate.NewCommand()) rootCmd.AddCommand(verify.NewCommand()) + rootCmd.AddCommand(plan.NewCommand()) rootCmd.AddCommand(hotfix.NewCommand()) rootCmd.AddCommand(initcmd.NewCommand()) rootCmd.AddCommand(orchestrate.NewCommand()) diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index d6d77fa..65e78ba 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -267,6 +267,37 @@ cascade verify Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and `generate-workflow` emits the drift-check workflow for you. See [Drift-check workflow](/configuration/#drift-check-workflow-opt-in). +### plan + +Preview, as a per-file unified diff, what `generate-workflow` would change in the committed workflow and action files, without writing anything. `plan` is read-only: it never writes files, runs git, or modifies the repository. + +```bash +cascade plan +``` + +`plan` is the human-facing preview counterpart to `verify`. For every file the manifest would generate, it prints the diff between the committed bytes and the generated bytes: a new file appears as a whole-file add, a changed file as a unified hunk, and a file already in sync produces no diff. When nothing is pending it prints a single `plan: N files, no pending changes` line; otherwise it prints the diffs followed by a summary of how many files would change. + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config`, `-c` | string | auto-detect | Path to manifest file | +| `--manifest-key` | string | `ci` | Top-level key inside the manifest | +| `--action-folder` | string | `manage-release` | Folder for the manage-release composite action | +| `--output`, `-o` | string | `.github/workflows/orchestrate.yaml` | Path of the orchestrate workflow | +| `--promote-output` | string | `.github/workflows/promote.yaml` | Path of the promote workflow | + +#### Exit codes + +| Exit | Meaning | +|------|---------| +| 0 | Success, whether or not any diff was printed. `plan` is informational, so a pending change does not change the exit code | +| non-zero | Error: the manifest is missing or invalid, or another operational failure prevented the preview from running | + +#### plan versus verify + +`plan` and `verify` are separate commands with separate contracts. `plan` is the human preview you read before regenerating: it shows the actual diff and always exits 0 on success, so it never fails a build on its own. `verify` is the pass/fail gate you wire into CI: it prints a terse drift report and exits 1 on drift, 2 on an operational failure, so it fails the build when committed workflows fall out of sync. Reach for `plan` at the terminal to see what would change, and for `verify` in a CI job to enforce that nothing has. + ### branch-protection Emit the JSON body an operator applies to GitHub's branch-protection API for a cascade-managed trunk. cascade emits the file; the operator applies it. cascade never calls the GitHub API. diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index ba1a028..31dd349 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -90,6 +90,10 @@ type Step struct { // asserts the committed workflows match the manifest, exercising verify's // exit-code contract. Verify *VerifyStep `yaml:"verify,omitempty"` + // Plan configures a "plan" action: a read-only `cascade plan` run that prints + // a per-file unified diff of committed-vs-planned workflows and always exits 0 + // on success, exercising plan's informational (non-gate) contract. + Plan *PlanStep `yaml:"plan,omitempty"` // ExpectFailure marks a step whose workflow is expected to conclude in // failure (for example an orchestrate run whose build exits non-zero). When // set, a failure conclusion is the success path and a success conclusion is @@ -221,6 +225,23 @@ type VerifyStep struct { ExpectExit int `yaml:"expect_exit"` } +// PlanStep defines a plan action: a read-only `cascade plan` run that prints a +// per-file unified diff of committed-vs-planned workflows and always exits 0 on +// success. Regenerate runs `cascade generate-workflow -f` first so plan previews +// against pristine generated output. MutatePath/MutateAppend optionally append to +// a generated file before planning so a scenario can drive a specific diff. +// ExpectExit is the exit code `cascade plan` must return (0 on success). +// ExpectContains, when set, are substrings the plan stdout must contain. +// ExpectNotContains, when set, are substrings the stdout must NOT contain. +type PlanStep struct { + Regenerate bool `yaml:"regenerate,omitempty"` + MutatePath string `yaml:"mutate_path,omitempty"` + MutateAppend string `yaml:"mutate_append,omitempty"` + ExpectExit int `yaml:"expect_exit"` + ExpectContains []string `yaml:"expect_contains,omitempty"` + ExpectNotContains []string `yaml:"expect_not_contains,omitempty"` +} + // StepExpect defines expected outcomes for a step type StepExpect struct { State map[string]*StateExpect `yaml:"state,omitempty"` diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index acdc728..bb9a439 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -131,6 +131,13 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if (step.Verify.CreatePath == "") != (step.Verify.CreateFrom == "") { return fmt.Errorf("step %d (%s): verify create_path and create_from must be set together", i, step.Name) } + case "plan": + if step.Plan == nil { + return fmt.Errorf("step %d (%s): plan action requires plan config", i, step.Name) + } + if step.Plan.MutatePath != "" && step.Plan.MutateAppend == "" { + return fmt.Errorf("step %d (%s): plan mutate_path requires mutate_append", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -367,6 +374,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeRollback(ctx, step.Rollback, config) case "verify": return r.executeVerify(ctx, step.Verify) + case "plan": + return r.executePlan(ctx, step.Plan) default: return fmt.Errorf("unknown action: %s", step.Action) } @@ -463,6 +472,85 @@ func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error { return nil } +// executePlan runs `cascade plan` in the synced repo and asserts the exit code +// matches the step's ExpectExit (0 on success, since plan is informational and +// never a gate). When Regenerate is set it first runs `cascade generate-workflow +// -f` so plan previews against pristine generated output rather than the +// harness's localized copies. When MutatePath is set it appends MutateAppend to +// that file before planning, driving a specific diff. The captured stdout is +// checked against ExpectContains/ExpectNotContains. The whole step is +// read-through-the-CLI and never asserts on workflow execution. +func (r *Runner) executePlan(ctx context.Context, step *PlanStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Logf(" Would run cascade plan (expect exit %d, no harness)", step.ExpectExit) + return nil + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("plan: failed to sync repo: %w", err) + } + + if step.Regenerate { + regenCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade generate-workflow -f"} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, regenCmd) + if err != nil { + return fmt.Errorf("plan: regenerate exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("plan: regenerate failed (exit %d): %s", exitCode, out.String()) + } + } + + if step.MutatePath != "" { + mutateCmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && printf '%%s' %s >> %s", + shellQuote(step.MutateAppend), shellQuote(step.MutatePath), + )} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, mutateCmd) + if err != nil { + return fmt.Errorf("plan: mutate exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("plan: mutate failed (exit %d): %s", exitCode, out.String()) + } + } + + planCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade plan"} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, planCmd) + if err != nil { + return fmt.Errorf("plan: exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + output := out.String() + r.t.Logf(" Plan: exit=%d (expected %d): %s", exitCode, step.ExpectExit, output) + + if exitCode != step.ExpectExit { + return fmt.Errorf("plan: expected exit %d, got %d: %s", step.ExpectExit, exitCode, output) + } + for _, want := range step.ExpectContains { + if !strings.Contains(output, want) { + return fmt.Errorf("plan: stdout missing expected substring %q: %s", want, output) + } + } + for _, unwant := range step.ExpectNotContains { + if strings.Contains(output, unwant) { + return fmt.Errorf("plan: stdout contains unexpected substring %q: %s", unwant, output) + } + } + return nil +} + // shellQuote wraps a string in single quotes for safe interpolation into a // bash -c command, escaping embedded single quotes. func shellQuote(s string) string { diff --git a/e2e/scenarios/33-plan-diff.yaml b/e2e/scenarios/33-plan-diff.yaml new file mode 100644 index 0000000..b1221ed --- /dev/null +++ b/e2e/scenarios/33-plan-diff.yaml @@ -0,0 +1,50 @@ +name: "Plan Diff Preview" +description: | + Exercises the read-only `cascade plan` command end to end. After generating + workflows from a two-environment manifest, plan against pristine generated + output reports no pending changes and exits 0. Appending to a committed + workflow makes plan render a per-file unified diff with the new line and a + trailing summary, and it still exits 0 because plan is informational rather + than a gate. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: cdk + workflow: deploy.yaml + triggers: ["cdk/**"] + +steps: + - name: "Initial feature commit" + action: commit + commit: + message: "feat: add app feature" + files: + src/app.go: | + package main + + func main() {} + + - name: "Plan clean against pristine generated output" + action: plan + plan: + regenerate: true + expect_exit: 0 + expect_contains: + - "no pending changes" + + - name: "Append to a generated workflow; plan shows a unified diff" + action: plan + plan: + mutate_path: ".github/workflows/orchestrate.yaml" + mutate_append: "\n# pending change\n" + expect_exit: 0 + expect_contains: + - "a/.github/workflows/orchestrate.yaml" + - "# pending change" + - "would change" diff --git a/go.mod b/go.mod index 53b242a..072196d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stablekernel/cascade go 1.25 require ( + github.com/pmezard/go-difflib v1.0.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -12,7 +13,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/internal/plan/command.go b/internal/plan/command.go new file mode 100644 index 0000000..96d558e --- /dev/null +++ b/internal/plan/command.go @@ -0,0 +1,46 @@ +package plan + +import ( + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" +) + +// NewCommand creates the plan command, a read-only preview that renders, as a +// per-file unified diff, what generate-workflow would change in the committed +// workflow and action files. +func NewCommand() *cobra.Command { + var o Options + + cmd := &cobra.Command{ + Use: "plan", + Short: "Preview the workflow diff the manifest would generate", + Long: `Preview, as a per-file unified diff, what generate-workflow would change in the +committed GitHub Actions workflow and action files, without writing anything. + +plan is the human-facing preview counterpart to verify. It prints the diff +between the committed bytes and what the manifest would currently generate: a +new file appears as a whole-file add, a changed file as a unified hunk, and a +file already in sync produces no diff. + +plan always exits 0 on success whether or not a diff exists, because it is +informational rather than a gate. It exits non-zero only for an operational +failure, such as a missing or invalid manifest. This is the key difference from +verify: plan is the human preview you read, while verify is the pass/fail gate +you wire into CI. + +plan is read-only: it never writes files, runs git, or modifies the repository.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return Run(o, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") + cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") + cmd.Flags().StringVar(&o.ActionFolder, "action-folder", "manage-release", "Folder name for the manage-release composite action") + cmd.Flags().StringVarP(&o.OutputPath, "output", "o", ".github/workflows/orchestrate.yaml", "Path of the orchestrate workflow") + cmd.Flags().StringVar(&o.PromoteOutputPath, "promote-output", ".github/workflows/promote.yaml", "Path of the promote workflow") + + return cmd +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go new file mode 100644 index 0000000..b34e11a --- /dev/null +++ b/internal/plan/plan.go @@ -0,0 +1,129 @@ +// Package plan implements the read-only "cascade plan" command. It renders, as a +// per-file unified diff, what "cascade generate-workflow" would change in the +// committed workflow and action files, writing nothing. plan is the human-facing +// preview counterpart to "cascade verify": where verify is a pass/fail CI gate, +// plan shows the actual diff and is purely informational, always exiting 0 on +// success. It reuses the same side-effect-free producer, generate.Plan. +package plan + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pmezard/go-difflib/difflib" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// Options configures a plan run. The fields mirror the generate-workflow flags +// that determine which files the manifest emits and where they live. +type Options struct { + ConfigPath string + ManifestKey string + ActionFolder string + OutputPath string + PromoteOutputPath string +} + +// Run renders a per-file unified diff of every file the manifest would generate +// against the bytes committed on disk and writes nothing. A planned file that is +// absent on disk is rendered as a whole-file add (every line an addition); a +// planned file whose bytes differ is rendered as a unified hunk; a byte-identical +// planned file is skipped. +// +// Run always returns nil on success regardless of whether any diff exists: the +// preview is informational, not a gate. It returns a plain error only for an +// operational failure (the manifest is missing or invalid, or a planned file +// cannot be read for a reason other than not existing), which cmd/cascade/main.go +// maps to exit code 1. +// +// Run is read-only: it reads the manifest, the reusable-workflow stubs the +// generators inspect, and the committed files, and writes nothing. +func Run(o Options, stdout, stderr io.Writer) error { + planned, err := generate.Plan(generate.PlanOptions{ + ConfigPath: o.ConfigPath, + ManifestKey: o.ManifestKey, + ActionFolder: o.ActionFolder, + OutputPath: o.OutputPath, + PromoteOutputPath: o.PromoteOutputPath, + }) + if err != nil { + return fmt.Errorf("planning workflows: %w", err) + } + + // Anchor relative planned paths to the manifest's repo root so plan reads the + // committed files where the manifest lives, independent of the process working + // directory. Absolute planned paths (the composite action) are read as-is. The + // config path is resolved the same way Plan resolves it, so an auto-detected + // manifest yields the same base directory. + configPath := o.ConfigPath + if configPath == "" { + configPath = config.FindConfigFile("") + } + baseDir := generate.ResolveBaseDir(configPath) + + changed := 0 + for _, p := range planned { + readPath := p.Path + if !filepath.IsAbs(readPath) { + readPath = filepath.Join(baseDir, readPath) + } + + committed, rerr := os.ReadFile(readPath) + switch { + case rerr != nil && errors.Is(rerr, os.ErrNotExist): + // New file: render a whole-file add with an empty committed side. + committed = nil + case rerr != nil: + return fmt.Errorf("reading %s: %w", p.Path, rerr) + case bytes.Equal(committed, []byte(p.Content)): + // No pending change for this file. + continue + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(string(committed)), + B: difflib.SplitLines(p.Content), + FromFile: "a/" + displayPath(p.Path), + ToFile: "b/" + displayPath(p.Path), + Context: 3, + } + text, derr := difflib.GetUnifiedDiffString(diff) + if derr != nil { + return fmt.Errorf("rendering diff for %s: %w", p.Path, derr) + } + _, _ = io.WriteString(stdout, text) + changed++ + } + + if changed == 0 { + _, _ = fmt.Fprintf(stdout, "plan: %d files, no pending changes\n", len(planned)) + return nil + } + + _, _ = fmt.Fprintf(stdout, "\n%d file(s) would change. Run `cascade generate-workflow` to apply.\n", changed) + return nil +} + +// displayPath renders an absolute planned path relative to the current working +// directory when possible, so the diff headers read as repo-relative paths. It +// falls back to the original path on any error. +func displayPath(path string) string { + if !filepath.IsAbs(path) { + return path + } + cwd, err := os.Getwd() + if err != nil { + return path + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return path + } + return rel +} diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go new file mode 100644 index 0000000..8e35698 --- /dev/null +++ b/internal/plan/plan_test.go @@ -0,0 +1,224 @@ +package plan + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// newRepo lays down a representative multi-environment manifest plus the +// reusable-workflow stubs it references, then materializes the full generated +// set on disk so a clean repo plans with no pending changes. It returns the repo +// root. All paths are absolute, so the tests never change the process working +// directory and stay parallel-safe. +func newRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755)) + + stubs := map[string]string{ + ".github/workflows/image-build.yaml": "" + + "name: Image Build\non:\n workflow_call:\n inputs:\n os:\n type: string\n", + ".github/workflows/deploy.yaml": "" + + "name: Deploy\non:\n workflow_call:\n inputs:\n environment:\n type: string\n", + } + for path, body := range stubs { + require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte(body), 0o644)) + } + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Builds: []config.BuildConfig{ + {Name: "image", Workflow: ".github/workflows/image-build.yaml", Triggers: []string{"src/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}, DependsOn: []string{"image"}}, + }, + } + manifest := map[string]any{config.DefaultManifestKey: config.CICDFile{Config: cfg}} + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), body, 0o644)) + + // Materialize the full generated set so a clean repo has zero pending change. + // The plan options carry absolute paths rooted at dir, so the planned files + // land in the temp repo without changing the working directory. + planned, err := generate.Plan(planOpts(dir)) + require.NoError(t, err) + for _, p := range planned { + path := p.Path + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(p.Content), 0o644)) + } + return dir +} + +// planOpts builds the generate plan options for a repo rooted at dir. Every path +// is absolute so Plan resolves the manifest, base directory, and emitted files +// without consulting the process working directory. +func planOpts(dir string) generate.PlanOptions { + return generate.PlanOptions{ + ConfigPath: filepath.Join(dir, ".github", "manifest.yaml"), + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: filepath.Join(dir, ".github", "workflows", "orchestrate.yaml"), + PromoteOutputPath: filepath.Join(dir, ".github", "workflows", "promote.yaml"), + } +} + +// opts builds the plan options for a repo rooted at dir, mirroring planOpts so +// the plan run reads the same absolute paths the plan emitted. +func opts(dir string) Options { + return Options{ + ConfigPath: filepath.Join(dir, ".github", "manifest.yaml"), + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: filepath.Join(dir, ".github", "workflows", "orchestrate.yaml"), + PromoteOutputPath: filepath.Join(dir, ".github", "workflows", "promote.yaml"), + } +} + +func TestRun_CleanRepo_NoPendingChanges(t *testing.T) { + t.Parallel() + dir := newRepo(t) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err) + require.Contains(t, out.String(), "no pending changes") + require.Empty(t, strings.TrimSpace(errOut.String()), "plan writes nothing to stderr on success") +} + +func TestRun_MutatedFile_ReturnsNil_ShowsUnifiedDiff(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x', '\n'), 0o644)) + + var out, errOut bytes.Buffer + err = Run(opts(dir), &out, &errOut) + require.NoError(t, err, "a pending change is informational, not an error") + + report := out.String() + require.Contains(t, report, "a/") + require.Contains(t, report, "b/") + require.Contains(t, report, "orchestrate.yaml") + require.Contains(t, report, "would change") +} + +func TestRun_MissingFile_RendersWholeFileAdd(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "promote.yaml") + require.NoError(t, os.Remove(target)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err) + + report := out.String() + require.Contains(t, report, "promote.yaml") + require.Contains(t, report, "would change") + require.Contains(t, report, "+++ b/", "missing file must render with a unified to-file header") + + // A whole-file add renders the generated body as additions. Read the planned + // promote content and assert each of its non-empty lines appears as a "+" + // addition in the diff, proving the committed (empty) side produced no real + // context or removal lines for the file body. + planned, perr := generate.Plan(planOpts(dir)) + require.NoError(t, perr) + var promoteBody string + for _, p := range planned { + if strings.HasSuffix(p.Path, "promote.yaml") { + promoteBody = p.Content + break + } + } + require.NotEmpty(t, promoteBody, "fixture must plan a promote.yaml") + for _, line := range strings.Split(promoteBody, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + require.Contains(t, report, "+"+line, "every promote line must appear as an addition") + } +} + +func TestRun_Deterministic_SameStdoutTwice(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x', '\n'), 0o644)) + + var first, second bytes.Buffer + var e1, e2 bytes.Buffer + require.NoError(t, Run(opts(dir), &first, &e1)) + require.NoError(t, Run(opts(dir), &second, &e2)) + require.Equal(t, first.String(), second.String(), "plan stdout must be byte-identical across runs") +} + +// snapshotTree records every regular file under root and its bytes, so a test +// can assert plan changed nothing on disk. +func snapshotTree(t *testing.T, root string) map[string][]byte { + t.Helper() + snap := map[string][]byte{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + b, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + snap[path] = b + return nil + }) + require.NoError(t, err) + return snap +} + +func TestRun_ReadOnly_NothingChangesOnDisk(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // Drive a real diff so plan has work to do, then prove it wrote nothing. + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x', '\n'), 0o644)) + + before := snapshotTree(t, dir) + + var out, errOut bytes.Buffer + require.NoError(t, Run(opts(dir), &out, &errOut)) + + after := snapshotTree(t, dir) + require.Equal(t, before, after, "plan must not write, create, or delete any file") +} + +func TestRun_ManifestAbsent_OperationalError(t *testing.T) { + t.Parallel() + dir := t.TempDir() // No manifest: the plan cannot run. + o := opts(dir) + o.ConfigPath = filepath.Join(dir, "does-not-exist.yaml") + + var out, errOut bytes.Buffer + err := Run(o, &out, &errOut) + require.Error(t, err, "a missing manifest is an operational failure") +}