From 3e262c0f3605ad8502ced0b1021e196dbf10edab Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 3 Oct 2025 20:47:39 +0200 Subject: [PATCH 1/7] feat: add local subcommand to compare two chart folders --- README.md | 58 ++++++++++++ cmd/local.go | 234 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/local_test.go | 82 ++++++++++++++++ cmd/root.go | 1 + 4 files changed, 375 insertions(+) create mode 100644 cmd/local.go create mode 100644 cmd/local_test.go diff --git a/README.md b/README.md index 42d831f9..5296ee3c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Usage: Available Commands: completion Generate the autocompletion script for the specified shell + local Shows diff between two local chart directories release Shows diff between release's manifests revision Shows diff between revision's manifests rollback Show a diff explaining what a helm rollback could perform @@ -174,6 +175,63 @@ When a kind is suppressed via `--suppress`, `changesSuppressed` is set to `true` ## Commands: +### local: + +``` +$ helm diff local -h + +This command compares the manifests of two local chart directories. + +It renders both charts using 'helm template' and shows the differences +between the resulting manifests. + +This is useful for: + - Comparing different versions of a chart + - Previewing changes before committing + - Validating chart modifications + +Usage: + diff local [flags] CHART1 CHART2 + +Examples: + helm diff local ./chart-v1 ./chart-v2 + helm diff local ./chart-v1 ./chart-v2 -f values.yaml + helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3 + +Flags: + -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions + -C, --context int output NUM lines of context around changes (default -1) + --detailed-exitcode return a non-zero exit code when there are changes + --enable-dns enable DNS lookups when rendering templates + -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched + -h, --help help for local + --include-crds include CRDs in the diffing + --include-tests enable the diffing of the helm test hooks + --kube-version string Kubernetes version used for Capabilities.KubeVersion + --namespace string namespace to use for template rendering + --normalize-manifests normalize manifests before running diff to exclude style differences from the output + --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path + --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) + --release string release name to use for template rendering (default "release") + --set stringArray set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) + --set-file stringArray set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2) + --set-json stringArray set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2) + --set-literal stringArray set STRING literal values on the command line + --set-string stringArray set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) + --show-secrets do not redact secret values in the output + --show-secrets-decoded decode secret values in the output + --strip-trailing-cr strip trailing carriage return on input + --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') + --suppress-output-line-regex stringArray a regex to suppress diff output lines that match + -q, --suppress-secrets suppress secrets in the output + -f, --values valueFiles specify values in a YAML file (can specify multiple) (default []) + +Global Flags: + --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" + --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" +``` + ### upgrade: ``` diff --git a/cmd/local.go b/cmd/local.go new file mode 100644 index 00000000..bbcf2f02 --- /dev/null +++ b/cmd/local.go @@ -0,0 +1,234 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/databus23/helm-diff/v3/diff" + "github.com/databus23/helm-diff/v3/manifest" +) + +type local struct { + chart1 string + chart2 string + release string + namespace string + detailedExitCode bool + includeTests bool + includeCRDs bool + normalizeManifests bool + enableDNS bool + valueFiles valueFiles + values []string + stringValues []string + stringLiteralValues []string + jsonValues []string + fileValues []string + postRenderer string + postRendererArgs []string + extraAPIs []string + kubeVersion string + diff.Options +} + +const localCmdLongUsage = ` +This command compares the manifests of two local chart directories. + +It renders both charts using 'helm template' and shows the differences +between the resulting manifests. + +This is useful for: + - Comparing different versions of a chart + - Previewing changes before committing + - Validating chart modifications +` + +func localCmd() *cobra.Command { + diff := local{ + release: "release", + } + + localCmd := &cobra.Command{ + Use: "local [flags] CHART1 CHART2", + Short: "Shows diff between two local chart directories", + Long: localCmdLongUsage, + Example: strings.Join([]string{ + " helm diff local ./chart-v1 ./chart-v2", + " helm diff local ./chart-v1 ./chart-v2 -f values.yaml", + " helm diff local /path/to/chart-a /path/to/chart-b --set replicas=3", + }, "\n"), + RunE: func(cmd *cobra.Command, args []string) error { + // Suppress the command usage on error. See #77 for more info + cmd.SilenceUsage = true + + if v, _ := cmd.Flags().GetBool("version"); v { + fmt.Println(Version) + return nil + } + + if err := checkArgsLength(len(args), "chart1 path", "chart2 path"); err != nil { + return err + } + + ProcessDiffOptions(cmd.Flags(), &diff.Options) + + diff.chart1 = args[0] + diff.chart2 = args[1] + + if diff.namespace == "" { + diff.namespace = os.Getenv("HELM_NAMESPACE") + } + + return diff.run() + }, + } + + localCmd.Flags().StringVar(&diff.release, "release", "release", "release name to use for template rendering") + localCmd.Flags().StringVar(&diff.namespace, "namespace", "", "namespace to use for template rendering") + localCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") + localCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") + localCmd.Flags().BoolVar(&diff.includeCRDs, "include-crds", false, "include CRDs in the diffing") + localCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") + localCmd.Flags().BoolVar(&diff.enableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") + localCmd.Flags().VarP(&diff.valueFiles, "values", "f", "specify values in a YAML file (can specify multiple)") + localCmd.Flags().StringArrayVar(&diff.values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + localCmd.Flags().StringArrayVar(&diff.stringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + localCmd.Flags().StringArrayVar(&diff.stringLiteralValues, "set-literal", []string{}, "set STRING literal values on the command line") + localCmd.Flags().StringArrayVar(&diff.jsonValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)") + localCmd.Flags().StringArrayVar(&diff.fileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)") + localCmd.Flags().StringVar(&diff.postRenderer, "post-renderer", "", "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") + localCmd.Flags().StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)") + localCmd.Flags().StringArrayVarP(&diff.extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions") + localCmd.Flags().StringVar(&diff.kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") + + AddDiffOptions(localCmd.Flags(), &diff.Options) + + localCmd.SuggestionsMinimumDistance = 1 + + return localCmd +} + +func (l *local) run() error { + manifest1, err := l.renderChart(l.chart1) + if err != nil { + return fmt.Errorf("Failed to render chart %s: %w", l.chart1, err) + } + + manifest2, err := l.renderChart(l.chart2) + if err != nil { + return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) + } + + excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} + if l.includeTests { + excludes = []string{} + } + + specs1 := manifest.Parse(string(manifest1), l.namespace, l.normalizeManifests, excludes...) + specs2 := manifest.Parse(string(manifest2), l.namespace, l.normalizeManifests, excludes...) + + seenAnyChanges := diff.Manifests(specs1, specs2, &l.Options, os.Stdout) + + if l.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + + return nil +} + +func (l *local) renderChart(chartPath string) ([]byte, error) { + flags := []string{} + + if l.includeCRDs { + flags = append(flags, "--include-crds") + } + + if l.namespace != "" { + flags = append(flags, "--namespace", l.namespace) + } + + if l.postRenderer != "" { + flags = append(flags, "--post-renderer", l.postRenderer) + } + + for _, arg := range l.postRendererArgs { + flags = append(flags, "--post-renderer-args", arg) + } + + for _, valueFile := range l.valueFiles { + if strings.TrimSpace(valueFile) == "-" { + bytes, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, err + } + + tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") + if err != nil { + return nil, err + } + defer func() { + _ = os.Remove(tmpfile.Name()) + }() + + if _, err := tmpfile.Write(bytes); err != nil { + _ = tmpfile.Close() + return nil, err + } + + if err := tmpfile.Close(); err != nil { + return nil, err + } + + flags = append(flags, "--values", tmpfile.Name()) + } else { + flags = append(flags, "--values", valueFile) + } + } + + for _, value := range l.values { + flags = append(flags, "--set", value) + } + + for _, stringValue := range l.stringValues { + flags = append(flags, "--set-string", stringValue) + } + + for _, stringLiteralValue := range l.stringLiteralValues { + flags = append(flags, "--set-literal", stringLiteralValue) + } + + for _, jsonValue := range l.jsonValues { + flags = append(flags, "--set-json", jsonValue) + } + + for _, fileValue := range l.fileValues { + flags = append(flags, "--set-file", fileValue) + } + + if l.enableDNS { + flags = append(flags, "--enable-dns") + } + + for _, a := range l.extraAPIs { + flags = append(flags, "--api-versions", a) + } + + if l.kubeVersion != "" { + flags = append(flags, "--kube-version", l.kubeVersion) + } + + args := []string{"template", l.release, chartPath} + args = append(args, flags...) + + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return outputWithRichError(cmd) +} diff --git a/cmd/local_test.go b/cmd/local_test.go new file mode 100644 index 00000000..1a9ba8ad --- /dev/null +++ b/cmd/local_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "os" + "testing" +) + +func TestLocalCmdArgValidation(t *testing.T) { + cases := []struct { + name string + args []string + expectError bool + }{ + { + name: "no arguments", + args: []string{}, + expectError: true, + }, + { + name: "one argument", + args: []string{"chart1"}, + expectError: true, + }, + { + name: "three arguments", + args: []string{"chart1", "chart2", "chart3"}, + expectError: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := localCmd() + cmd.SetArgs(tc.args) + err := cmd.Execute() + + if tc.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tc.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestLocalCmdExecution(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + manifestYAML := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + + err := os.WriteFile(fakeHelm, []byte(`#!/bin/sh +cat < Date: Sat, 4 Oct 2025 12:49:18 +0200 Subject: [PATCH 2/7] fix: format --- cmd/local.go | 36 ++++++++++++++++++------------------ cmd/local_test.go | 1 - 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/cmd/local.go b/cmd/local.go index bbcf2f02..8ec8bad2 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -15,25 +15,25 @@ import ( ) type local struct { - chart1 string - chart2 string - release string - namespace string - detailedExitCode bool - includeTests bool - includeCRDs bool - normalizeManifests bool - enableDNS bool - valueFiles valueFiles - values []string - stringValues []string + chart1 string + chart2 string + release string + namespace string + detailedExitCode bool + includeTests bool + includeCRDs bool + normalizeManifests bool + enableDNS bool + valueFiles valueFiles + values []string + stringValues []string stringLiteralValues []string - jsonValues []string - fileValues []string - postRenderer string - postRendererArgs []string - extraAPIs []string - kubeVersion string + jsonValues []string + fileValues []string + postRenderer string + postRendererArgs []string + extraAPIs []string + kubeVersion string diff.Options } diff --git a/cmd/local_test.go b/cmd/local_test.go index 1a9ba8ad..7728682a 100644 --- a/cmd/local_test.go +++ b/cmd/local_test.go @@ -79,4 +79,3 @@ EOF t.Errorf("Expected no error but got: %v", err) } } - From c5e6f82861f687b729ec1befd9a7e0b2c0915156 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Mon, 30 Mar 2026 07:27:02 +0800 Subject: [PATCH 3/7] fix: address PR review comments for local subcommand - Lowercase error messages to follow Go conventions - Extract stdin reading into prepareStdinValues() to avoid double-read when rendering both charts - Add HELM_BIN env var validation with "helm" default fallback Signed-off-by: yxxhero --- cmd/local.go | 72 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/cmd/local.go b/cmd/local.go index 8ec8bad2..7176a9b4 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -115,14 +115,18 @@ func localCmd() *cobra.Command { } func (l *local) run() error { + if err := l.prepareStdinValues(); err != nil { + return err + } + manifest1, err := l.renderChart(l.chart1) if err != nil { - return fmt.Errorf("Failed to render chart %s: %w", l.chart1, err) + return fmt.Errorf("failed to render chart %s: %w", l.chart1, err) } manifest2, err := l.renderChart(l.chart2) if err != nil { - return fmt.Errorf("Failed to render chart %s: %w", l.chart2, err) + return fmt.Errorf("failed to render chart %s: %w", l.chart2, err) } excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} @@ -145,6 +149,36 @@ func (l *local) run() error { return nil } +func (l *local) prepareStdinValues() error { + for i, valueFile := range l.valueFiles { + if strings.TrimSpace(valueFile) == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") + if err != nil { + return err + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write(data); err != nil { + tmpfile.Close() + return err + } + + if err := tmpfile.Close(); err != nil { + return err + } + + l.valueFiles[i] = tmpfile.Name() + break + } + } + return nil +} + func (l *local) renderChart(chartPath string) ([]byte, error) { flags := []string{} @@ -165,33 +199,7 @@ func (l *local) renderChart(chartPath string) ([]byte, error) { } for _, valueFile := range l.valueFiles { - if strings.TrimSpace(valueFile) == "-" { - bytes, err := io.ReadAll(os.Stdin) - if err != nil { - return nil, err - } - - tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") - if err != nil { - return nil, err - } - defer func() { - _ = os.Remove(tmpfile.Name()) - }() - - if _, err := tmpfile.Write(bytes); err != nil { - _ = tmpfile.Close() - return nil, err - } - - if err := tmpfile.Close(); err != nil { - return nil, err - } - - flags = append(flags, "--values", tmpfile.Name()) - } else { - flags = append(flags, "--values", valueFile) - } + flags = append(flags, "--values", valueFile) } for _, value := range l.values { @@ -229,6 +237,10 @@ func (l *local) renderChart(chartPath string) ([]byte, error) { args := []string{"template", l.release, chartPath} args = append(args, flags...) - cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + helmBin := os.Getenv("HELM_BIN") + if helmBin == "" { + helmBin = "helm" + } + cmd := exec.Command(helmBin, args...) return outputWithRichError(cmd) } From cc56dae9ba791f3bf43a3875a27b0b14770583cb Mon Sep 17 00:00:00 2001 From: yxxhero Date: Mon, 30 Mar 2026 08:05:05 +0800 Subject: [PATCH 4/7] fix: pass []byte directly to manifest.Parse instead of converting to string Signed-off-by: yxxhero --- cmd/local.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/local.go b/cmd/local.go index 7176a9b4..9abd5899 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -134,8 +134,8 @@ func (l *local) run() error { excludes = []string{} } - specs1 := manifest.Parse(string(manifest1), l.namespace, l.normalizeManifests, excludes...) - specs2 := manifest.Parse(string(manifest2), l.namespace, l.normalizeManifests, excludes...) + specs1 := manifest.Parse(manifest1, l.namespace, l.normalizeManifests, excludes...) + specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...) seenAnyChanges := diff.Manifests(specs1, specs2, &l.Options, os.Stdout) From d4f745346698d2332174ced036e2dc96bfae38d8 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Mon, 30 Mar 2026 08:07:44 +0800 Subject: [PATCH 5/7] fix: check error return values for errcheck linter Signed-off-by: yxxhero --- cmd/local.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/local.go b/cmd/local.go index 9abd5899..4709c51c 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -161,10 +161,10 @@ func (l *local) prepareStdinValues() error { if err != nil { return err } - defer os.Remove(tmpfile.Name()) + defer func() { _ = os.Remove(tmpfile.Name()) }() if _, err := tmpfile.Write(data); err != nil { - tmpfile.Close() + _ = tmpfile.Close() return err } From 8edb615b57c0af0999279828301760c60e63da76 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Mon, 30 Mar 2026 08:23:42 +0800 Subject: [PATCH 6/7] test: add integration tests for local subcommand Signed-off-by: yxxhero --- cmd/local_test.go | 289 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/cmd/local_test.go b/cmd/local_test.go index 7728682a..56ce7bcb 100644 --- a/cmd/local_test.go +++ b/cmd/local_test.go @@ -1,7 +1,9 @@ package cmd import ( + "bytes" "os" + "strings" "testing" ) @@ -79,3 +81,290 @@ EOF t.Errorf("Expected no error but got: %v", err) } } + +func TestLocalCmdNoChanges(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + manifestYAML := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + err := os.WriteFile(fakeHelm, []byte(`#!/bin/sh +cat <<'EOF' +`+manifestYAML+` +EOF +`), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2}) + + err = cmd.Execute() + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Fatalf("Expected no error but got: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + if buf.String() != "" { + t.Errorf("Expected no output when charts are identical, got: %q", buf.String()) + } +} + +func TestLocalCmdWithChanges(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + + manifest1 := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value1 +` + manifest2 := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value2 +` + + script := `#!/bin/sh +CALL_COUNT="` + tmpDir + `/call_count" +COUNT=$(cat "$CALL_COUNT" 2>/dev/null || echo "0") +COUNT=$((COUNT + 1)) +echo "$COUNT" > "$CALL_COUNT" +if [ "$COUNT" = "1" ]; then +cat <<'MANIFEST1' +` + manifest1 + ` +MANIFEST1 +else +cat <<'MANIFEST2' +` + manifest2 + ` +MANIFEST2 +fi +` + err := os.WriteFile(fakeHelm, []byte(script), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2}) + + err = cmd.Execute() + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Fatalf("Expected no error but got: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "value1") || !strings.Contains(output, "value2") { + t.Errorf("Expected diff output containing value1 and value2, got: %q", output) + } +} + +func TestLocalCmdDetailedExitCode(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + + manifest1 := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value1 +` + manifest2 := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value2 +` + + script := `#!/bin/sh +CALL_COUNT="` + tmpDir + `/call_count" +COUNT=$(cat "$CALL_COUNT" 2>/dev/null || echo "0") +COUNT=$((COUNT + 1)) +echo "$COUNT" > "$CALL_COUNT" +if [ "$COUNT" = "1" ]; then +cat <<'MANIFEST1' +` + manifest1 + ` +MANIFEST1 +else +cat <<'MANIFEST2' +` + manifest2 + ` +MANIFEST2 +fi +` + err := os.WriteFile(fakeHelm, []byte(script), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2, "--detailed-exitcode"}) + + err = cmd.Execute() + if err == nil { + t.Fatal("Expected error with exit code 2 but got nil") + } + + diffErr, ok := err.(Error) + if !ok { + t.Fatalf("Expected Error type but got %T: %v", err, err) + } + if diffErr.Code != 2 { + t.Errorf("Expected exit code 2 but got %d", diffErr.Code) + } +} + +func TestLocalCmdDetailedExitCodeNoChanges(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + manifestYAML := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + err := os.WriteFile(fakeHelm, []byte(`#!/bin/sh +cat <<'EOF' +`+manifestYAML+` +EOF +`), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2, "--detailed-exitcode"}) + + err = cmd.Execute() + if err != nil { + t.Errorf("Expected no error when no changes, but got: %v", err) + } +} + +func TestLocalCmdNamespace(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + + script := `#!/bin/sh +echo "$@" > ` + tmpDir + `/args +cat <<'EOF' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value +EOF +` + err := os.WriteFile(fakeHelm, []byte(script), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2, "--namespace", "myns"}) + + err = cmd.Execute() + if err != nil { + t.Fatalf("Expected no error but got: %v", err) + } + + args1, _ := os.ReadFile(tmpDir + "/args") + if !strings.Contains(string(args1), "--namespace myns") { + t.Errorf("Expected --namespace myns in helm template args, got: %q", string(args1)) + } +} + +func TestLocalCmdHelmTemplateError(t *testing.T) { + tmpDir := t.TempDir() + fakeHelm := tmpDir + "/helm" + + script := `#!/bin/sh +echo "error: chart not found" >&2 +exit 1 +` + err := os.WriteFile(fakeHelm, []byte(script), 0755) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HELM_BIN", fakeHelm) + + chart1 := t.TempDir() + chart2 := t.TempDir() + + cmd := localCmd() + cmd.SetArgs([]string{chart1, chart2}) + + err = cmd.Execute() + if err == nil { + t.Fatal("Expected error when helm template fails but got nil") + } +} From 21e7fd1eb1ee9614892774e5af27dea04d742259 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Mon, 30 Mar 2026 08:29:43 +0800 Subject: [PATCH 7/7] fix: use errors.As instead of type assertion for errorlint Signed-off-by: yxxhero --- cmd/local_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/local_test.go b/cmd/local_test.go index 56ce7bcb..19b9e021 100644 --- a/cmd/local_test.go +++ b/cmd/local_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "errors" "os" "strings" "testing" @@ -258,8 +259,8 @@ fi t.Fatal("Expected error with exit code 2 but got nil") } - diffErr, ok := err.(Error) - if !ok { + var diffErr Error + if !errors.As(err, &diffErr) { t.Fatalf("Expected Error type but got %T: %v", err, err) } if diffErr.Code != 2 {