Skip to content
Open
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, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff")
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The --output flag help text here omits supported formats (json, structured) that are listed for other commands and are defined in AddDiffOptions (see cmd/options.go). This makes the README inconsistent with the actual CLI/help output. Update this line to reflect the complete set of supported output formats.

Suggested change
--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")
--output string Possible values: diff, simple, template, dyff, json, structured. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff")

Copilot uses AI. Check for mistakes.
--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
246 changes: 246 additions & 0 deletions cmd/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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 {
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)
}

manifest2, err := l.renderChart(l.chart2)
if err != nil {
return fmt.Errorf("failed to render chart %s: %w", l.chart2, err)
}
Comment on lines +122 to +130
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The renderChart method is called twice (for chart1 and chart2), but it reads from stdin when -f - is used (line 169). Since stdin can only be read once, the second call to renderChart will fail or receive empty input.

This is different from the upgrade command where renderChart is only called once. For the local command, you would need to either:

  1. Read stdin once before calling renderChart and pass the content to both calls, or
  2. Document that stdin values (-f -) are not supported for this command, or
  3. Reuse the same temp file for both chart renders

Consider refactoring to handle stdin reading outside of the loop or before multiple renderChart calls.

Copilot uses AI. Check for mistakes.

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

specs1 := manifest.Parse(manifest1, l.namespace, l.normalizeManifests, excludes...)
specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

manifest1/manifest2 can be large. Other commands in this repo set the raw manifest byte slices to nil after manifest.Parse(...) to allow GC to reclaim memory before diff computation. Consider doing the same here after parsing to reduce peak memory usage when diffing large charts.

Suggested change
specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...)
specs2 := manifest.Parse(manifest2, l.namespace, l.normalizeManifests, excludes...)
// Allow GC to reclaim memory used by raw manifests before diff computation.
manifest1 = nil
manifest2 = nil

Copilot uses AI. Check for mistakes.

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() 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 func() { _ = os.Remove(tmpfile.Name()) }()
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

prepareStdinValues creates a temp file for -f - but defers os.Remove inside this function. Because the defer runs when prepareStdinValues returns (before renderChart is executed), the temp file path stored in l.valueFiles[i] can be deleted before helm template tries to read it, causing helm template to fail. Keep the temp file alive until after both renderChart calls complete (e.g., defer cleanup in run() or handle stdin values within renderChart).

Suggested change
defer func() { _ = os.Remove(tmpfile.Name()) }()

Copilot uses AI. Check for mistakes.

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
Comment on lines +153 to +176
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

prepareStdinValues stops after the first - values file (break). If the user provides multiple -f - entries, later occurrences remain as "-" and will cause helm template to read from stdin after this function already consumed it via io.ReadAll(os.Stdin). Consider either replacing all "-" entries with the same temp file path (read stdin once) or returning an explicit error when more than one stdin values file is provided.

Suggested change
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 func() { _ = 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
var (
stdinData []byte
stdinRead bool
stdinTmpFilePath string
)
for i, valueFile := range l.valueFiles {
if strings.TrimSpace(valueFile) == "-" {
// Read from stdin and create the temp file only once, even if
// "-" appears multiple times in the values files list.
if !stdinRead {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values")
if err != nil {
return err
}
if _, err := tmpfile.Write(data); err != nil {
_ = tmpfile.Close()
return err
}
if err := tmpfile.Close(); err != nil {
return err
}
stdinData = data
stdinTmpFilePath = tmpfile.Name()
stdinRead = true
_ = stdinData // avoid unused variable if not referenced elsewhere
}
l.valueFiles[i] = stdinTmpFilePath

Copilot uses AI. Check for mistakes.
}
}
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 {
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