Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, json, structured, 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:

```
Expand Down
254 changes: 254 additions & 0 deletions cmd/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
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 {
cmd.SilenceUsage = true

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 {
cleanup, err := l.prepareStdinValues()
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}

excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook}
if l.includeTests {
excludes = []string{}
}

manifest1, err := l.renderChart(l.chart1)
if err != nil {
return fmt.Errorf("failed to render chart %q: %w", l.chart1, err)
}
specs1 := manifest.Parse(manifest1, l.namespace, l.normalizeManifests, excludes...)
manifest1 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before rendering the second chart

manifest2, err := l.renderChart(l.chart2)
if err != nil {
return fmt.Errorf("failed to render chart %q: %w", l.chart2, err)
}
specs2 := manifest.Parse(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) prepareStdinValues() (func(), error) {
var name string

for i, valueFile := range l.valueFiles {
if strings.TrimSpace(valueFile) == "-" {
if name == "" {
data, 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
}

if _, err := tmpfile.Write(data); err != nil {
_ = tmpfile.Close()
_ = os.Remove(tmpfile.Name())
return nil, err
}

if err := tmpfile.Close(); err != nil {
Comment on lines +167 to +172
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In prepareStdinValues, if creating the temp values file succeeds but writing to it or closing it fails, the function returns without removing the temp file. This can leak temporary files on error paths. Ensure the temp file is removed in all failure cases (e.g., call os.Remove(tmpfile.Name()) before returning on write/close errors).

Suggested change
_ = tmpfile.Close()
return nil, err
}
if err := tmpfile.Close(); err != nil {
_ = tmpfile.Close()
_ = os.Remove(tmpfile.Name())
return nil, err
}
if err := tmpfile.Close(); err != nil {
_ = os.Remove(tmpfile.Name())

Copilot uses AI. Check for mistakes.
_ = os.Remove(tmpfile.Name())
return nil, err
}
Comment on lines +161 to +175
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In prepareStdinValues, if creating the temp file succeeds but tmpfile.Write or tmpfile.Close returns an error, the function returns without removing the temp file. This can leave stray files in the system temp dir. Consider ensuring the temp file is removed on all error paths (e.g., defer os.Remove(tmpfile.Name()) immediately after CreateTemp and cancel it on success).

Copilot uses AI. Check for mistakes.

name = tmpfile.Name()
}

l.valueFiles[i] = name
}
}
Comment on lines +153 to +182
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareStdinValues returns immediately after handling the first "-" values file, so any subsequent "-" entries in l.valueFiles remain unchanged and will later be passed through to helm template as a literal filename "-". Consider reading stdin once, writing one temp file, and replacing all "-" entries (or otherwise ensure every "-" is handled) before returning.

Suggested change
for i, valueFile := range l.valueFiles {
if strings.TrimSpace(valueFile) == "-" {
data, 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
}
if _, err := tmpfile.Write(data); err != nil {
_ = tmpfile.Close()
return nil, err
}
if err := tmpfile.Close(); err != nil {
return nil, err
}
l.valueFiles[i] = tmpfile.Name()
name := tmpfile.Name()
return func() { _ = os.Remove(name) }, nil
}
}
var name string
for i, valueFile := range l.valueFiles {
if strings.TrimSpace(valueFile) == "-" {
if name == "" {
data, 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
}
if _, err := tmpfile.Write(data); err != nil {
_ = tmpfile.Close()
return nil, err
}
if err := tmpfile.Close(); err != nil {
return nil, err
}
name = tmpfile.Name()
}
l.valueFiles[i] = name
}
}
if name != "" {
return func() { _ = os.Remove(name) }, nil
}

Copilot uses AI. Check for mistakes.

if name != "" {
return func() { _ = os.Remove(name) }, nil
}
return nil, nil
}
Comment on lines +150 to +188
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareStdinValues adds non-trivial behavior for handling --values - (stdin) so that both charts can be rendered without re-reading stdin. There are currently no tests covering this path; add a test that sets --values - with a controlled stdin and asserts localCmd completes and passes a concrete temp file path (not '-') to both helm template invocations.

Copilot uses AI. Check for mistakes.

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)
Comment on lines +190 to +198
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderChart largely duplicates the helm template flag/arg construction logic that already exists in (*diffCmd).template (cmd/helm.go:186+), but without its Helm-version compatibility behavior (e.g., dry-run/validation handling) and other flags. This duplication risks the two code paths drifting and producing different rendered manifests for the same inputs. Consider extracting a shared helper for building/executing helm template (reused by both upgrade and local) so rendering semantics stay consistent.

Copilot uses AI. Check for mistakes.
}

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 {
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...)

helmBin := os.Getenv("HELM_BIN")
if helmBin == "" {
helmBin = "helm"
}
cmd := exec.Command(helmBin, args...)
return outputWithRichError(cmd)
}
Loading
Loading