From caa08545169302eea9a1547b2d6a656c638cdb73 Mon Sep 17 00:00:00 2001 From: Drew Bailey Date: Mon, 6 Apr 2026 09:57:37 -0400 Subject: [PATCH 1/7] feat: collapse large summary file lists behind
block When the total number of changed files exceeds --summary-threshold (default 20), the per-file listing is moved into a collapsible
block so the summary yaml block stays compact. Full list remains accessible on click. Also reminds users that context lines around diffs are already configurable via --line-count / -c (default 7). Signed-off-by: Drew Bailey --- cmd/main.go | 1 + cmd/options.go | 9 ++ docs/options.md | 1 + pkg/diff/generator.go | 79 +++++++++++------ pkg/diff/generator_test.go | 171 ++++++++++++++++++++++++++++--------- pkg/diff/markdown.go | 18 ++-- pkg/diff/markdown_test.go | 35 +++++++- 7 files changed, 241 insertions(+), 73 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 522af0e3..61128cfe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -399,6 +399,7 @@ func run(cfg *Config) error { statsInfo, selectionInfo, cfg.ArgocdUIURL, + cfg.SummaryThreshold, cfg.IgnoreResourceRules, ) if err != nil { diff --git a/cmd/options.go b/cmd/options.go index 86d20a2b..6cabed7f 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -57,6 +57,7 @@ var ( DefaultKindInternal = false DefaultK3dOptions = "" DefaultMaxDiffLength = uint(65536) + DefaultSummaryThreshold = uint(20) DefaultArgocdNamespace = "argocd" DefaultArgocdChartVersion = "latest" DefaultArgocdChartName = "argo" @@ -105,6 +106,7 @@ type RawOptions struct { KindInternal bool `mapstructure:"kind-internal"` K3dOptions string `mapstructure:"k3d-options"` MaxDiffLength uint `mapstructure:"max-diff-length"` + SummaryThreshold uint `mapstructure:"summary-threshold"` Selector string `mapstructure:"selector"` FilesChanged string `mapstructure:"files-changed"` IgnoreInvalidWatchPattern bool `mapstructure:"ignore-invalid-watch-pattern"` @@ -153,6 +155,7 @@ type Config struct { KindInternal bool K3dOptions string MaxDiffLength uint + SummaryThreshold uint IgnoreInvalidWatchPattern bool WatchIfNoWatchPatternFound bool AutoDetectFilesChanged bool @@ -252,6 +255,7 @@ func Parse() *Config { viper.SetDefault("cluster", DefaultCluster) viper.SetDefault("cluster-name", DefaultClusterName) viper.SetDefault("max-diff-length", DefaultMaxDiffLength) + viper.SetDefault("summary-threshold", DefaultSummaryThreshold) viper.SetDefault("argocd-namespace", DefaultArgocdNamespace) viper.SetDefault("argocd-chart-version", DefaultArgocdChartVersion) viper.SetDefault("argocd-chart-name", DefaultArgocdChartName) @@ -318,6 +322,7 @@ func Parse() *Config { // Other options rootCmd.Flags().String("max-diff-length", fmt.Sprintf("%d", DefaultMaxDiffLength), "Max diff message character count") + rootCmd.Flags().Uint("summary-threshold", DefaultSummaryThreshold, "Collapse summary details when total changed applications exceed this value (0 = always show inline)") rootCmd.Flags().StringP("selector", "l", "", "Label selector to filter on (e.g. key1=value1,key2=value2)") rootCmd.Flags().String("files-changed", "", "List of files changed between branches (comma, space or newline separated)") rootCmd.Flags().Bool("auto-detect-files-changed", DefaultAutoDetectFilesChanged, "Auto detect files changed between branches") @@ -394,6 +399,7 @@ func (o *RawOptions) ToConfig() (*Config, error) { KindInternal: o.KindInternal, K3dOptions: o.K3dOptions, MaxDiffLength: o.MaxDiffLength, + SummaryThreshold: o.SummaryThreshold, IgnoreInvalidWatchPattern: o.IgnoreInvalidWatchPattern, WatchIfNoWatchPatternFound: o.WatchIfNoWatchPatternFound, AutoDetectFilesChanged: o.AutoDetectFilesChanged, @@ -671,6 +677,9 @@ func (o *Config) LogConfig() { if o.MaxDiffLength != DefaultMaxDiffLength { log.Info().Msgf("✨ - max-diff-length: %d", o.MaxDiffLength) } + if o.SummaryThreshold != DefaultSummaryThreshold { + log.Info().Msgf("✨ - summary-threshold: %d", o.SummaryThreshold) + } if len(o.FilesChanged) > 0 { log.Info().Msgf("✨ - files-changed: %v", o.FilesChanged) } else if o.AutoDetectFilesChanged { diff --git a/docs/options.md b/docs/options.md index adfb8a7a..f43a08a3 100644 --- a/docs/options.md +++ b/docs/options.md @@ -61,6 +61,7 @@ argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch `, `-c` | `LINE_COUNT` | `5` | Generate diffs with \ lines of context | | `--log-format ` | `LOG_FORMAT` | `human` | Log format. Options: `human`, `json` | | `--max-diff-length ` | `MAX_DIFF_LENGTH` | `65536` | Max diff message character count (only limits the generated Markdown file) | +| `--summary-threshold ` | `SUMMARY_THRESHOLD` | `20` | Collapse summary details when total changed applications exceed this value (`0` = always show inline) | | `--output-folder `, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved | | `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect | | `--render-method ` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` | diff --git a/pkg/diff/generator.go b/pkg/diff/generator.go index f8a4b840..efe7e6b0 100644 --- a/pkg/diff/generator.go +++ b/pkg/diff/generator.go @@ -29,6 +29,7 @@ func GeneratePreview( statsInfo StatsInfo, selectionInfo SelectionInfo, argocdUIURL string, + summaryThreshold uint, ignoreResourceRules []resource_filter.IgnoreResourceRule, ) (time.Duration, error) { startTime := time.Now() @@ -64,7 +65,7 @@ func GeneratePreview( } // Build summary - summary := buildSummary(appDiffs) + inlineSummary, summaryDetails := buildSummary(appDiffs, int(summaryThreshold)) // Convert to markdown/HTML sections markdownSections, htmlSections := buildMatchingSections(appDiffs, argocdUIURL) @@ -72,11 +73,12 @@ func GeneratePreview( // Markdown log.Debug().Msg("Creating markdown output") markdownOutput := MarkdownOutput{ - title: title, - summary: summary, - sections: markdownSections, - statsInfo: statsInfo, - selectionInfo: selectionInfo, + title: title, + summary: inlineSummary, + summaryDetails: summaryDetails, + sections: markdownSections, + statsInfo: statsInfo, + selectionInfo: selectionInfo, } markdown := markdownOutput.printDiff(maxDiffMessageCharCount) markdownPath := fmt.Sprintf("%s/diff.md", outputFolder) @@ -90,7 +92,7 @@ func GeneratePreview( log.Debug().Msg("Creating html output") htmlOutput := HTMLOutput{ title: title, - summary: summary, + summary: inlineSummary, sections: htmlSections, statsInfo: statsInfo, selectionInfo: selectionInfo, @@ -108,14 +110,15 @@ func GeneratePreview( return time.Since(startTime), nil } -// buildSummary builds a summary string from AppDiffs -func buildSummary(diffs []matching.AppDiff) string { +// buildSummary returns (inlineContent, detailsBlock). +// inlineContent goes in the summary yaml block; when total > threshold it only contains counts. +// detailsBlock is a collapsible markdown details section with the full application list. +// Pass threshold=0 to always return the full list inline. +func buildSummary(diffs []matching.AppDiff, threshold int) (string, string) { if len(diffs) == 0 { - return "No changes found" + return "No changes found", "" } - var summaryBuilder strings.Builder - addedCount := 0 deletedCount := 0 modifiedCount := 0 @@ -131,42 +134,68 @@ func buildSummary(diffs []matching.AppDiff) string { } } + total := addedCount + deletedCount + modifiedCount + + var listBuilder strings.Builder + if addedCount > 0 { - fmt.Fprintf(&summaryBuilder, "Added (%d):\n", addedCount) + fmt.Fprintf(&listBuilder, "Added (%d):\n", addedCount) for _, d := range diffs { if d.Action == matching.ActionAdded { - fmt.Fprintf(&summaryBuilder, "+ %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "+ %s%s\n", d.PrettyName(), d.ChangeStats()) } } } if deletedCount > 0 { - // Add a newline before Deleted section if there was an Added section - if summaryBuilder.Len() > 0 { - fmt.Fprintln(&summaryBuilder) + if listBuilder.Len() > 0 { + fmt.Fprintln(&listBuilder) } - fmt.Fprintf(&summaryBuilder, "Deleted (%d):\n", deletedCount) + fmt.Fprintf(&listBuilder, "Deleted (%d):\n", deletedCount) for _, d := range diffs { if d.Action == matching.ActionDeleted { - fmt.Fprintf(&summaryBuilder, "- %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "- %s%s\n", d.PrettyName(), d.ChangeStats()) } } } if modifiedCount > 0 { - // Add a newline before Modified section if there was an Added or Deleted section - if summaryBuilder.Len() > 0 { - fmt.Fprintln(&summaryBuilder) + if listBuilder.Len() > 0 { + fmt.Fprintln(&listBuilder) } - fmt.Fprintf(&summaryBuilder, "Modified (%d):\n", modifiedCount) + fmt.Fprintf(&listBuilder, "Modified (%d):\n", modifiedCount) for _, d := range diffs { if d.Action == matching.ActionModified { - fmt.Fprintf(&summaryBuilder, "± %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "± %s%s\n", d.PrettyName(), d.ChangeStats()) } } } - return summaryBuilder.String() + header := fmt.Sprintf("Total: %d applications changed\n", total) + + if threshold > 0 && total > threshold { + var compact strings.Builder + fmt.Fprint(&compact, header) + if addedCount > 0 { + fmt.Fprintf(&compact, "\nAdded: %d\n", addedCount) + } + if deletedCount > 0 { + fmt.Fprintf(&compact, "Deleted: %d\n", deletedCount) + } + if modifiedCount > 0 { + fmt.Fprintf(&compact, "Modified: %d\n", modifiedCount) + } + + details := fmt.Sprintf( + "
\nChanged applications (%d)\n\n```yaml\n%s```\n\n
\n", + total, + listBuilder.String(), + ) + + return compact.String(), details + } + + return header + listBuilder.String(), "" } // buildMatchingSections converts AppDiffs to markdown and HTML sections diff --git a/pkg/diff/generator_test.go b/pkg/diff/generator_test.go index 693a1554..9df2a640 100644 --- a/pkg/diff/generator_test.go +++ b/pkg/diff/generator_test.go @@ -10,16 +10,22 @@ import ( // Tests for buildMatchingSummary func TestBuildMatchingSummary_NoDiffs(t *testing.T) { - result := buildSummary(nil) - if result != "No changes found" { - t.Errorf("expected 'No changes found', got %q", result) + summary, details := buildSummary(nil, 20) + if summary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", summary) + } + if details != "" { + t.Errorf("expected no details, got %q", details) } } func TestBuildMatchingSummary_EmptySlice(t *testing.T) { - result := buildSummary([]matching.AppDiff{}) - if result != "No changes found" { - t.Errorf("expected 'No changes found', got %q", result) + summary, details := buildSummary([]matching.AppDiff{}, 20) + if summary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", summary) + } + if details != "" { + t.Errorf("expected no details, got %q", details) } } @@ -29,23 +35,29 @@ func TestBuildMatchingSummary_OnlyAdded(t *testing.T) { {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) - if !strings.Contains(result, "Added (2):") { - t.Errorf("expected 'Added (2):', got:\n%s", result) + if !strings.Contains(summary, "Total: 2 applications changed") { + t.Errorf("expected total count, got:\n%s", summary) + } + if !strings.Contains(summary, "Added (2):") { + t.Errorf("expected 'Added (2):', got:\n%s", summary) } - if !strings.Contains(result, "+ app-1 (+10)") { - t.Errorf("expected '+ app-1 (+10)', got:\n%s", result) + if !strings.Contains(summary, "+ app-1 (+10)") { + t.Errorf("expected '+ app-1 (+10)', got:\n%s", summary) } - if !strings.Contains(result, "+ app-2 (+5)") { - t.Errorf("expected '+ app-2 (+5)', got:\n%s", result) + if !strings.Contains(summary, "+ app-2 (+5)") { + t.Errorf("expected '+ app-2 (+5)', got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } // Should NOT contain Deleted or Modified sections - if strings.Contains(result, "Deleted") { - t.Errorf("should not contain 'Deleted', got:\n%s", result) + if strings.Contains(summary, "Deleted") { + t.Errorf("should not contain 'Deleted', got:\n%s", summary) } - if strings.Contains(result, "Modified") { - t.Errorf("should not contain 'Modified', got:\n%s", result) + if strings.Contains(summary, "Modified") { + t.Errorf("should not contain 'Modified', got:\n%s", summary) } } @@ -54,13 +66,16 @@ func TestBuildMatchingSummary_OnlyDeleted(t *testing.T) { {OldName: "app-1", Action: matching.ActionDeleted, DeletedLines: 15}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) - if !strings.Contains(result, "Deleted (1):") { - t.Errorf("expected 'Deleted (1):', got:\n%s", result) + if !strings.Contains(summary, "Deleted (1):") { + t.Errorf("expected 'Deleted (1):', got:\n%s", summary) + } + if !strings.Contains(summary, "- app-1 (-15)") { + t.Errorf("expected '- app-1 (-15)', got:\n%s", summary) } - if !strings.Contains(result, "- app-1 (-15)") { - t.Errorf("expected '- app-1 (-15)', got:\n%s", result) + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -69,13 +84,16 @@ func TestBuildMatchingSummary_OnlyModified(t *testing.T) { {OldName: "app-1", NewName: "app-1", Action: matching.ActionModified, AddedLines: 3, DeletedLines: 2}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) - if !strings.Contains(result, "Modified (1):") { - t.Errorf("expected 'Modified (1):', got:\n%s", result) + if !strings.Contains(summary, "Modified (1):") { + t.Errorf("expected 'Modified (1):', got:\n%s", summary) } - if !strings.Contains(result, "± app-1 (+3|-2)") { - t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", result) + if !strings.Contains(summary, "± app-1 (+3|-2)") { + t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -86,16 +104,22 @@ func TestBuildMatchingSummary_MixedActions(t *testing.T) { {NewName: "new-app", Action: matching.ActionAdded, AddedLines: 12}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) - if !strings.Contains(result, "Added (1):") { - t.Errorf("expected 'Added (1):', got:\n%s", result) + if !strings.Contains(summary, "Total: 3 applications changed") { + t.Errorf("expected total count, got:\n%s", summary) + } + if !strings.Contains(summary, "Added (1):") { + t.Errorf("expected 'Added (1):', got:\n%s", summary) } - if !strings.Contains(result, "Deleted (1):") { - t.Errorf("expected 'Deleted (1):', got:\n%s", result) + if !strings.Contains(summary, "Deleted (1):") { + t.Errorf("expected 'Deleted (1):', got:\n%s", summary) } - if !strings.Contains(result, "Modified (1):") { - t.Errorf("expected 'Modified (1):', got:\n%s", result) + if !strings.Contains(summary, "Modified (1):") { + t.Errorf("expected 'Modified (1):', got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -104,11 +128,14 @@ func TestBuildMatchingSummary_RenamedApp(t *testing.T) { {OldName: "old-name", NewName: "new-name", Action: matching.ActionModified, AddedLines: 1}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) // PrettyName for renamed app should show "old-name -> new-name" - if !strings.Contains(result, "± old-name -> new-name") { - t.Errorf("expected renamed app in summary, got:\n%s", result) + if !strings.Contains(summary, "± old-name -> new-name") { + t.Errorf("expected renamed app in summary, got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -118,11 +145,75 @@ func TestBuildMatchingSummary_NoChangeStats(t *testing.T) { {OldName: "app-1", NewName: "app-1", Action: matching.ActionModified}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) // ChangeStats() returns "" when both are 0, so just the name - if !strings.Contains(result, "± app-1\n") { - t.Errorf("expected 'app-1' without stats, got:\n%s", result) + if !strings.Contains(summary, "± app-1\n") { + t.Errorf("expected 'app-1' without stats, got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) + } +} + +func TestBuildMatchingSummary_CollapsesLargeSummary(t *testing.T) { + diffs := []matching.AppDiff{ + {NewName: "new-app", Action: matching.ActionAdded, AddedLines: 12}, + {OldName: "deleted-app", Action: matching.ActionDeleted, DeletedLines: 20}, + {OldName: "mod-app", NewName: "mod-app", Action: matching.ActionModified, AddedLines: 5, DeletedLines: 3}, + } + + summary, details := buildSummary(diffs, 2) + + expectedSummary := []string{ + "Total: 3 applications changed", + "Added: 1", + "Deleted: 1", + "Modified: 1", + } + for _, expected := range expectedSummary { + if !strings.Contains(summary, expected) { + t.Errorf("expected compact summary to contain %q, got:\n%s", expected, summary) + } + } + + unexpectedInline := []string{"+ new-app (+12)", "- deleted-app (-20)", "± mod-app (+5|-3)"} + for _, unexpected := range unexpectedInline { + if strings.Contains(summary, unexpected) { + t.Errorf("expected compact summary to omit %q, got:\n%s", unexpected, summary) + } + } + + expectedDetails := []string{ + "
", + "Changed applications (3)", + "Added (1):", + "+ new-app (+12)", + "Deleted (1):", + "- deleted-app (-20)", + "Modified (1):", + "± mod-app (+5|-3)", + } + for _, expected := range expectedDetails { + if !strings.Contains(details, expected) { + t.Errorf("expected details to contain %q, got:\n%s", expected, details) + } + } +} + +func TestBuildMatchingSummary_ThresholdZeroAlwaysShowsInline(t *testing.T) { + diffs := []matching.AppDiff{ + {NewName: "app-1", Action: matching.ActionAdded, AddedLines: 10}, + {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, + } + + summary, details := buildSummary(diffs, 0) + + if !strings.Contains(summary, "+ app-1 (+10)") || !strings.Contains(summary, "+ app-2 (+5)") { + t.Errorf("expected full summary inline, got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details when threshold is zero, got:\n%s", details) } } diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go index f7b5cbe8..b8d70ed3 100644 --- a/pkg/diff/markdown.go +++ b/pkg/diff/markdown.go @@ -128,11 +128,12 @@ func (m *MarkdownSection) build(maxSize int) (string, bool) { } type MarkdownOutput struct { - title string - summary string - sections []MarkdownSection - statsInfo StatsInfo - selectionInfo SelectionInfo + title string + summary string + summaryDetails string + sections []MarkdownSection + statsInfo StatsInfo + selectionInfo SelectionInfo } const markdownTemplate = ` @@ -199,8 +200,13 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { } } + appDiffs := strings.TrimSpace(sectionsDiff.String()) + if m.summaryDetails != "" { + appDiffs = strings.TrimSpace(m.summaryDetails) + "\n\n" + appDiffs + } + output = strings.ReplaceAll(output, "%info_box%", m.statsInfo.String()) - output = strings.ReplaceAll(output, "%app_diffs%", strings.TrimSpace(sectionsDiff.String())) + output = strings.ReplaceAll(output, "%app_diffs%", appDiffs) output = strings.TrimSpace(output) + "\n" diff --git a/pkg/diff/markdown_test.go b/pkg/diff/markdown_test.go index b88a3fb6..d3c78ae7 100644 --- a/pkg/diff/markdown_test.go +++ b/pkg/diff/markdown_test.go @@ -148,7 +148,7 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { name: "Basic output with sections", output: MarkdownOutput{ title: "Test Diff", - summary: "Added: 1\nModified: 1", + summary: "Total: 2 applications changed\n\nAdded: 1\nModified: 1", sections: []MarkdownSection{ { appName: "App 1", @@ -176,7 +176,7 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { maxDiffMessageCharCount: 5000, expectedContains: []string{ "## Test Diff", - "Added: 1\nModified: 1", + "Total: 2 applications changed\n\nAdded: 1\nModified: 1", "App 1 (path/to/app1.yaml)", "App 2 (path/to/app2.yaml)", "@@ Application added: App 1 @@", @@ -190,6 +190,37 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { "No changes found", }, }, + { + name: "Summary details render before app diffs", + output: MarkdownOutput{ + title: "Summary Details", + summary: "Total: 3 applications changed\n\nAdded: 1\nModified: 2", + summaryDetails: "
\nChanged applications (3)\n\n```yaml\nAdded (1):\n+ app-1 (+2)\n```\n\n
\n", + sections: []MarkdownSection{ + { + appName: "App 1", + filePath: "path/to/app1.yaml", + appURL: "", + resources: []ResourceSection{ + {Header: "@@ Application modified: App 1 @@", Content: "- old content\n+ new content"}, + }, + }, + }, + statsInfo: StatsInfo{ + ApplicationCount: 1, + }, + }, + maxSize: 10000, + maxDiffMessageCharCount: 5000, + expectedContains: []string{ + "Changed applications (3)", + "```yaml\nAdded (1):\n+ app-1 (+2)\n```", + "App 1 (path/to/app1.yaml)", + }, + expectedNotContains: []string{ + "No changes found", + }, + }, { name: "Empty sections shows no changes", output: MarkdownOutput{ From 7876bae03924ee0df8f26fdad2483557ed133496 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 13:09:34 +0200 Subject: [PATCH 2/7] implement compactedSummary for html --- .../branch-9/target-4/output.html | 1119 +++++++++++++++++ integration-test/branch-9/target-4/output.md | 446 +++++++ integration-test/integration_test.go | 20 + pkg/diff/generator.go | 45 +- pkg/diff/generator_test.go | 129 +- pkg/diff/html.go | 43 +- pkg/diff/html_test.go | 81 +- pkg/diff/markdown.go | 57 +- pkg/diff/markdown_test.go | 60 +- 9 files changed, 1840 insertions(+), 160 deletions(-) create mode 100644 integration-test/branch-9/target-4/output.html create mode 100644 integration-test/branch-9/target-4/output.md diff --git a/integration-test/branch-9/target-4/output.html b/integration-test/branch-9/target-4/output.html new file mode 100644 index 00000000..303c7961 --- /dev/null +++ b/integration-test/branch-9/target-4/output.html @@ -0,0 +1,1119 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Total: 9 applications changed
+Deleted: 9
+ +
+Changed applications (9) +
Deleted (9):
+- app1 (-19)
+- app1 (-19)
+- app2 (-19)
+- app2 (-19)
+- custom-target-revision-example (-14)
+- my-app-set-dev (-79)
+- my-app-set-prod (-79)
+- my-app-set-staging (-79)
+- nginx-ingress (-470)
+
+
+
+ +app1 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app1 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +custom-target-revision-example (examples/custom-target-revision/app/app.yaml) + + +

Deployment: default/my-deployment

+
+ + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: my-deployment
-  namespace: default
-spec:
-  replicas: 2
-  template:
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: my-deployment
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +my-app-set-dev (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-dev
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-dev
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-prod (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-prod
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-prod
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-staging (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-staging
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-staging
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +nginx-ingress (examples/external-chart/nginx.yaml) + + +

ValidatingWebhookConfiguration: nginx-ingress-ingress-nginx-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: admissionregistration.k8s.io/v1
-kind: ValidatingWebhookConfiguration
-metadata:
-  labels:
-    app.kubernetes.io/component: admission-webhook
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-admission
-webhooks:
-- admissionReviewVersions:
-  - v1
-  clientConfig:
-    service:
-      name: nginx-ingress-ingress-nginx-controller-admission
-      namespace: default
-      path: /networking/v1/ingresses
-  failurePolicy: Fail
-  matchPolicy: Equivalent
-  name: validate.nginx.ingress.kubernetes.io
-  rules:
-  - apiGroups:
-    - networking.k8s.io
-    apiVersions:
-    - v1
-    operations:
-    - CREATE
-    - UPDATE
-    resources:
-    - ingresses
-  sideEffects: None
+
+ +

Deployment: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  minReadySeconds: 0
-  replicas: 1
-  revisionHistoryLimit: 10
-  selector:
-    matchLabels:
-      app.kubernetes.io/component: controller
-      app.kubernetes.io/instance: nginx-ingress
-      app.kubernetes.io/name: ingress-nginx
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/component: controller
-        app.kubernetes.io/instance: nginx-ingress
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: ingress-nginx
-        app.kubernetes.io/part-of: ingress-nginx
-        app.kubernetes.io/version: 1.10.0
-        helm.sh/chart: ingress-nginx-4.10.0
-    spec:
-      containers:
-      - args:
-        - /nginx-ingress-controller
-        - --publish-service=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --election-id=nginx-ingress-ingress-nginx-leader
-        - --controller-class=k8s.io/ingress-nginx
-        - --ingress-class=test
-        - --configmap=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --validating-webhook=:8443
-        - --validating-webhook-certificate=/usr/local/certificates/cert
-        - --validating-webhook-key=/usr/local/certificates/key
-        - --enable-metrics=false
-        env:
-        - name: POD_NAME
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.name
-        - name: POD_NAMESPACE
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.namespace
-        - name: LD_PRELOAD
-          value: /usr/local/lib/libmimalloc.so
-        image: registry.k8s.io/ingress-nginx/controller:v1.10.0@sha256:42b3f0e5d0846876b1791cd3afeb5f1cbbe4259d6f35651dcc1b5c980925379c
-        imagePullPolicy: IfNotPresent
-        lifecycle:
-          preStop:
-            exec:
-              command:
-              - /wait-shutdown
-        livenessProbe:
-          failureThreshold: 5
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        name: controller
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        - containerPort: 443
-          name: https
-          protocol: TCP
-        - containerPort: 8443
-          name: webhook
-          protocol: TCP
-        readinessProbe:
-          failureThreshold: 3
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        resources:
-          requests:
-            cpu: 100m
-            memory: 90Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            add:
-            - NET_BIND_SERVICE
-            drop:
-            - ALL
-          readOnlyRootFilesystem: false
-          runAsNonRoot: true
-          runAsUser: 101
-          seccompProfile:
-            type: RuntimeDefault
-        volumeMounts:
-        - mountPath: /usr/local/certificates/
-          name: webhook-cert
-          readOnly: true
-      dnsPolicy: ClusterFirst
-      nodeSelector:
-        kubernetes.io/os: linux
-      serviceAccountName: nginx-ingress-ingress-nginx
-      terminationGracePeriodSeconds: 300
-      volumes:
-      - name: webhook-cert
-        secret:
-          secretName: nginx-ingress-ingress-nginx-admission
+
+ +

IngressClass: nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: networking.k8s.io/v1
-kind: IngressClass
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx
-spec:
-  controller: k8s.io/ingress-nginx
+
+ +

ClusterRole: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - endpoints
-  - nodes
-  - pods
-  - secrets
-  - namespaces
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - nodes
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

ClusterRoleBinding: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

Role: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - namespaces
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - pods
-  - secrets
-  - endpoints
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resourceNames:
-  - nginx-ingress-ingress-nginx-leader
-  resources:
-  - leases
-  verbs:
-  - get
-  - update
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - create
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

RoleBinding: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

ConfigMap: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + +
-apiVersion: v1
-data:
-  allow-snippet-annotations: "false"
-kind: ConfigMap
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  ipFamilies:
-  - IPv4
-  ipFamilyPolicy: SingleStack
-  ports:
-  - appProtocol: http
-    name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  - appProtocol: https
-    name: https
-    port: 443
-    protocol: TCP
-    targetPort: https
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: LoadBalancer
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller-admission
-  namespace: default
-spec:
-  ports:
-  - appProtocol: https
-    name: https-webhook
-    port: 443
-    targetPort: webhook
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: ClusterIP
+
+ +

ServiceAccount: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-9/target-4/output.md b/integration-test/branch-9/target-4/output.md new file mode 100644 index 00000000..09ef8c09 --- /dev/null +++ b/integration-test/branch-9/target-4/output.md @@ -0,0 +1,446 @@ +## Argo CD Diff Preview + +Summary: +```yaml +Total: 9 applications changed +Deleted: 9 +``` + +
+Changed applications (9) + +```yaml +Deleted (9): +- app1 (-19) +- app1 (-19) +- app2 (-19) +- app2 (-19) +- custom-target-revision-example (-14) +- my-app-set-dev (-79) +- my-app-set-prod (-79) +- my-app-set-staging (-79) +- nginx-ingress (-470) +``` + +
+
+app1 (examples/duplicate-names/app/app-set-1.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app1 (examples/duplicate-names/app/app-set-2.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app2 (examples/duplicate-names/app/app-set-1.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app2 (examples/duplicate-names/app/app-set-2.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+custom-target-revision-example (examples/custom-target-revision/app/app.yaml) +
+ +#### Deployment: default/my-deployment +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: my-deployment +- namespace: default +-spec: +- replicas: 2 +- template: +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: my-deployment +- ports: +- - containerPort: 80 +``` +
+ +
+my-app-set-dev (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +``` +
+ +
+my-app-set-prod (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +``` +
+ +
+my-app-set-staging (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/ +``` + +🚨 Diff is too long +
+ +⚠️⚠️⚠️ Diff exceeds max length of 10000 characters. Truncating to fit. This can be adjusted with the `--max-diff-length` flag + +_Stats_: +[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index 5f01ea56..fcac1f65 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -64,6 +64,7 @@ type TestCase struct { ArgocdConfigDir string // Custom argocd-config directory (relative to integration-test/); overrides auto-derived path ArgocdUIURL string // Argo CD URL for generating application links in diff output TraverseAppOfApps string // If "true", enables recursive child app discovery (--traverse-app-of-apps) + SummaryThreshold string // Collapse summary details when total changed apps exceed this value ExpectFailure bool // If true, the test is expected to fail } @@ -224,6 +225,17 @@ var testCases = []TestCase{ Suffix: "-3", MaxDiffLength: "400", }, + // Tests the collapsible summary feature with --summary-threshold=2. + // Branch-9 deletes 9 apps, so with threshold=2 the summary should collapse + // the app list behind a
block. + { + Name: "branch-9/target-4", + TargetBranch: "integration-test/branch-9/target", + BaseBranch: "integration-test/branch-9/base", + Suffix: "-4", + MaxDiffLength: "10000", + SummaryThreshold: "5", + }, { Name: "branch-10/target-1", TargetBranch: "integration-test/branch-10/target", @@ -904,6 +916,10 @@ func runWithDocker(tc TestCase, createCluster bool) error { args = append(args, "-e", "TRAVERSE_APP_OF_APPS=true") } + if tc.SummaryThreshold != "" { + args = append(args, "-e", fmt.Sprintf("SUMMARY_THRESHOLD=%s", tc.SummaryThreshold)) + } + // Add image (no additional args needed - all config is via env vars) args = append(args, *dockerImage) @@ -999,6 +1015,10 @@ func buildArgs(tc TestCase, createCluster bool) []string { args = append(args, "--traverse-app-of-apps") } + if tc.SummaryThreshold != "" { + args = append(args, "--summary-threshold", tc.SummaryThreshold) + } + // When the test requires cluster roles to be disabled (API mode or DisableClusterRoles flag), // pass --argocd-config-dir pointing at the no-cluster-roles directory (createClusterRoles: false). // If ArgocdConfigDir is explicitly set, use that directory instead. diff --git a/pkg/diff/generator.go b/pkg/diff/generator.go index efe7e6b0..2c9b39e6 100644 --- a/pkg/diff/generator.go +++ b/pkg/diff/generator.go @@ -1,3 +1,6 @@ +// generator.go orchestrates diff generation and delegates to format-specific +// output files (markdown.go, html.go). Helper functions in this file should +// remain format-agnostic - avoid embedding HTML tags or Markdown syntax here. package diff import ( @@ -65,7 +68,7 @@ func GeneratePreview( } // Build summary - inlineSummary, summaryDetails := buildSummary(appDiffs, int(summaryThreshold)) + fullSummary, compactSummary := buildSummary(appDiffs, int(summaryThreshold)) // Convert to markdown/HTML sections markdownSections, htmlSections := buildMatchingSections(appDiffs, argocdUIURL) @@ -74,8 +77,8 @@ func GeneratePreview( log.Debug().Msg("Creating markdown output") markdownOutput := MarkdownOutput{ title: title, - summary: inlineSummary, - summaryDetails: summaryDetails, + fullSummary: fullSummary, + compactSummary: compactSummary, sections: markdownSections, statsInfo: statsInfo, selectionInfo: selectionInfo, @@ -91,11 +94,12 @@ func GeneratePreview( // HTML log.Debug().Msg("Creating html output") htmlOutput := HTMLOutput{ - title: title, - summary: inlineSummary, - sections: htmlSections, - statsInfo: statsInfo, - selectionInfo: selectionInfo, + title: title, + fullSummary: fullSummary, + compactSummary: compactSummary, + sections: htmlSections, + statsInfo: statsInfo, + selectionInfo: selectionInfo, } htmlDiff := htmlOutput.printDiff() htmlPath := fmt.Sprintf("%s/diff.html", outputFolder) @@ -110,10 +114,11 @@ func GeneratePreview( return time.Since(startTime), nil } -// buildSummary returns (inlineContent, detailsBlock). -// inlineContent goes in the summary yaml block; when total > threshold it only contains counts. -// detailsBlock is a collapsible markdown details section with the full application list. -// Pass threshold=0 to always return the full list inline. +// buildSummary returns (fullSummary, compactSummary). +// fullSummary is always the full application list with per-app details (plain text). +// compactSummary is a short counts-only summary returned when total > threshold; +// it is empty when the threshold is not exceeded. +// Pass threshold=0 to never produce a compact summary. func buildSummary(diffs []matching.AppDiff, threshold int) (string, string) { if len(diffs) == 0 { return "No changes found", "" @@ -171,13 +176,11 @@ func buildSummary(diffs []matching.AppDiff, threshold int) (string, string) { } } - header := fmt.Sprintf("Total: %d applications changed\n", total) - if threshold > 0 && total > threshold { var compact strings.Builder - fmt.Fprint(&compact, header) + fmt.Fprintf(&compact, "Total: %d applications changed\n", total) if addedCount > 0 { - fmt.Fprintf(&compact, "\nAdded: %d\n", addedCount) + fmt.Fprintf(&compact, "Added: %d\n", addedCount) } if deletedCount > 0 { fmt.Fprintf(&compact, "Deleted: %d\n", deletedCount) @@ -186,16 +189,10 @@ func buildSummary(diffs []matching.AppDiff, threshold int) (string, string) { fmt.Fprintf(&compact, "Modified: %d\n", modifiedCount) } - details := fmt.Sprintf( - "
\nChanged applications (%d)\n\n```yaml\n%s```\n\n
\n", - total, - listBuilder.String(), - ) - - return compact.String(), details + return listBuilder.String(), compact.String() } - return header + listBuilder.String(), "" + return listBuilder.String(), "" } // buildMatchingSections converts AppDiffs to markdown and HTML sections diff --git a/pkg/diff/generator_test.go b/pkg/diff/generator_test.go index 9df2a640..9042ee34 100644 --- a/pkg/diff/generator_test.go +++ b/pkg/diff/generator_test.go @@ -10,22 +10,22 @@ import ( // Tests for buildMatchingSummary func TestBuildMatchingSummary_NoDiffs(t *testing.T) { - summary, details := buildSummary(nil, 20) - if summary != "No changes found" { - t.Errorf("expected 'No changes found', got %q", summary) + fullSummary, compactSummary := buildSummary(nil, 20) + if fullSummary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", fullSummary) } - if details != "" { - t.Errorf("expected no details, got %q", details) + if compactSummary != "" { + t.Errorf("expected no compact summary, got %q", compactSummary) } } func TestBuildMatchingSummary_EmptySlice(t *testing.T) { - summary, details := buildSummary([]matching.AppDiff{}, 20) - if summary != "No changes found" { - t.Errorf("expected 'No changes found', got %q", summary) + fullSummary, compactSummary := buildSummary([]matching.AppDiff{}, 20) + if fullSummary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", fullSummary) } - if details != "" { - t.Errorf("expected no details, got %q", details) + if compactSummary != "" { + t.Errorf("expected no compact summary, got %q", compactSummary) } } @@ -35,29 +35,26 @@ func TestBuildMatchingSummary_OnlyAdded(t *testing.T) { {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, } - summary, details := buildSummary(diffs, 20) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(summary, "Total: 2 applications changed") { - t.Errorf("expected total count, got:\n%s", summary) + if !strings.Contains(fullSummary, "Added (2):") { + t.Errorf("expected 'Added (2):', got:\n%s", fullSummary) } - if !strings.Contains(summary, "Added (2):") { - t.Errorf("expected 'Added (2):', got:\n%s", summary) + if !strings.Contains(fullSummary, "+ app-1 (+10)") { + t.Errorf("expected '+ app-1 (+10)', got:\n%s", fullSummary) } - if !strings.Contains(summary, "+ app-1 (+10)") { - t.Errorf("expected '+ app-1 (+10)', got:\n%s", summary) + if !strings.Contains(fullSummary, "+ app-2 (+5)") { + t.Errorf("expected '+ app-2 (+5)', got:\n%s", fullSummary) } - if !strings.Contains(summary, "+ app-2 (+5)") { - t.Errorf("expected '+ app-2 (+5)', got:\n%s", summary) - } - if details != "" { - t.Errorf("expected no details, got:\n%s", details) + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } // Should NOT contain Deleted or Modified sections - if strings.Contains(summary, "Deleted") { - t.Errorf("should not contain 'Deleted', got:\n%s", summary) + if strings.Contains(fullSummary, "Deleted") { + t.Errorf("should not contain 'Deleted', got:\n%s", fullSummary) } - if strings.Contains(summary, "Modified") { - t.Errorf("should not contain 'Modified', got:\n%s", summary) + if strings.Contains(fullSummary, "Modified") { + t.Errorf("should not contain 'Modified', got:\n%s", fullSummary) } } @@ -66,16 +63,16 @@ func TestBuildMatchingSummary_OnlyDeleted(t *testing.T) { {OldName: "app-1", Action: matching.ActionDeleted, DeletedLines: 15}, } - summary, details := buildSummary(diffs, 20) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(summary, "Deleted (1):") { - t.Errorf("expected 'Deleted (1):', got:\n%s", summary) + if !strings.Contains(fullSummary, "Deleted (1):") { + t.Errorf("expected 'Deleted (1):', got:\n%s", fullSummary) } - if !strings.Contains(summary, "- app-1 (-15)") { - t.Errorf("expected '- app-1 (-15)', got:\n%s", summary) + if !strings.Contains(fullSummary, "- app-1 (-15)") { + t.Errorf("expected '- app-1 (-15)', got:\n%s", fullSummary) } - if details != "" { - t.Errorf("expected no details, got:\n%s", details) + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } } @@ -84,16 +81,16 @@ func TestBuildMatchingSummary_OnlyModified(t *testing.T) { {OldName: "app-1", NewName: "app-1", Action: matching.ActionModified, AddedLines: 3, DeletedLines: 2}, } - summary, details := buildSummary(diffs, 20) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(summary, "Modified (1):") { - t.Errorf("expected 'Modified (1):', got:\n%s", summary) + if !strings.Contains(fullSummary, "Modified (1):") { + t.Errorf("expected 'Modified (1):', got:\n%s", fullSummary) } - if !strings.Contains(summary, "± app-1 (+3|-2)") { - t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", summary) + if !strings.Contains(fullSummary, "± app-1 (+3|-2)") { + t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", fullSummary) } - if details != "" { - t.Errorf("expected no details, got:\n%s", details) + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } } @@ -106,9 +103,6 @@ func TestBuildMatchingSummary_MixedActions(t *testing.T) { summary, details := buildSummary(diffs, 20) - if !strings.Contains(summary, "Total: 3 applications changed") { - t.Errorf("expected total count, got:\n%s", summary) - } if !strings.Contains(summary, "Added (1):") { t.Errorf("expected 'Added (1):', got:\n%s", summary) } @@ -163,30 +157,31 @@ func TestBuildMatchingSummary_CollapsesLargeSummary(t *testing.T) { {OldName: "mod-app", NewName: "mod-app", Action: matching.ActionModified, AddedLines: 5, DeletedLines: 3}, } - summary, details := buildSummary(diffs, 2) + fullSummary, compactSummary := buildSummary(diffs, 2) - expectedSummary := []string{ + // compactSummary should contain only counts + expectedCompact := []string{ "Total: 3 applications changed", "Added: 1", "Deleted: 1", "Modified: 1", } - for _, expected := range expectedSummary { - if !strings.Contains(summary, expected) { - t.Errorf("expected compact summary to contain %q, got:\n%s", expected, summary) + for _, expected := range expectedCompact { + if !strings.Contains(compactSummary, expected) { + t.Errorf("expected compact summary to contain %q, got:\n%s", expected, compactSummary) } } - unexpectedInline := []string{"+ new-app (+12)", "- deleted-app (-20)", "± mod-app (+5|-3)"} - for _, unexpected := range unexpectedInline { - if strings.Contains(summary, unexpected) { - t.Errorf("expected compact summary to omit %q, got:\n%s", unexpected, summary) + // compactSummary should not contain per-app details + unexpectedCompact := []string{"+ new-app (+12)", "- deleted-app (-20)", "± mod-app (+5|-3)"} + for _, unexpected := range unexpectedCompact { + if strings.Contains(compactSummary, unexpected) { + t.Errorf("expected compact summary to omit %q, got:\n%s", unexpected, compactSummary) } } - expectedDetails := []string{ - "
", - "Changed applications (3)", + // fullSummary should contain the full per-app details (format-agnostic) + expectedFullSummary := []string{ "Added (1):", "+ new-app (+12)", "Deleted (1):", @@ -194,9 +189,17 @@ func TestBuildMatchingSummary_CollapsesLargeSummary(t *testing.T) { "Modified (1):", "± mod-app (+5|-3)", } - for _, expected := range expectedDetails { - if !strings.Contains(details, expected) { - t.Errorf("expected details to contain %q, got:\n%s", expected, details) + for _, expected := range expectedFullSummary { + if !strings.Contains(fullSummary, expected) { + t.Errorf("expected full summary to contain %q, got:\n%s", expected, fullSummary) + } + } + + // Should not contain any markdown/HTML formatting + unexpectedFullSummary := []string{"
", "", "```"} + for _, unexpected := range unexpectedFullSummary { + if strings.Contains(fullSummary, unexpected) { + t.Errorf("expected full summary to be format-agnostic, but found %q in:\n%s", unexpected, fullSummary) } } } @@ -207,13 +210,13 @@ func TestBuildMatchingSummary_ThresholdZeroAlwaysShowsInline(t *testing.T) { {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, } - summary, details := buildSummary(diffs, 0) + fullSummary, compactSummary := buildSummary(diffs, 0) - if !strings.Contains(summary, "+ app-1 (+10)") || !strings.Contains(summary, "+ app-2 (+5)") { - t.Errorf("expected full summary inline, got:\n%s", summary) + if !strings.Contains(fullSummary, "+ app-1 (+10)") || !strings.Contains(fullSummary, "+ app-2 (+5)") { + t.Errorf("expected full summary, got:\n%s", fullSummary) } - if details != "" { - t.Errorf("expected no details when threshold is zero, got:\n%s", details) + if compactSummary != "" { + t.Errorf("expected no compact summary when threshold is zero, got:\n%s", compactSummary) } } diff --git a/pkg/diff/html.go b/pkg/diff/html.go index 04a06629..f05642dd 100644 --- a/pkg/diff/html.go +++ b/pkg/diff/html.go @@ -9,11 +9,12 @@ import ( ) type HTMLOutput struct { - title string - summary string - sections []HTMLSection - statsInfo StatsInfo - selectionInfo SelectionInfo + title string + fullSummary string // full application list with per-app details + compactSummary string // short counts-only summary; empty if not collapsed + sections []HTMLSection + statsInfo StatsInfo + selectionInfo SelectionInfo } const htmlTemplate = ` @@ -78,8 +79,8 @@ pre {

%title%

Summary:

-
%summary%
- +
%inline_summary%
+%detailed_summary_section%
%app_diffs%
@@ -110,10 +111,19 @@ func emptyReasonHTML(reason matching.EmptyReason) string { } } +// htmlDetailedSummarySection wraps the full summary in a collapsible
block. +func htmlDetailedSummarySection(fullSummary string, appCount int) string { + return fmt.Sprintf( + "\n
\nChanged applications (%d)\n
%s
\n
", + appCount, + html.EscapeString(strings.TrimSpace(fullSummary)), + ) +} + const htmlSectionTemplate = `
-%summary% +%inline_summary% %body%
@@ -137,7 +147,7 @@ func (h *HTMLSection) printHTMLSection() string { html.EscapeString(h.appName), html.EscapeString(h.filePath)) } - s = strings.ReplaceAll(s, "%summary%", summary) + s = strings.ReplaceAll(s, "%inline_summary%", summary) var body strings.Builder @@ -187,8 +197,21 @@ func (h *HTMLOutput) printDiff() string { sectionsDiff.WriteString("No changes found") } + // When compactSummary is set, use it as the inline summary and render the + // full summary in a collapsible
block via %detailed_summary_section%. + var inline_summary string + var detailedSummarySection string + + if h.compactSummary != "" { + inline_summary = h.compactSummary + detailedSummarySection = htmlDetailedSummarySection(h.fullSummary, len(h.sections)) + } else { + inline_summary = h.fullSummary + } + output := strings.ReplaceAll(htmlTemplate, "%title%", h.title) - output = strings.ReplaceAll(output, "%summary%", strings.TrimSpace(h.summary)) + output = strings.ReplaceAll(output, "%inline_summary%", strings.TrimSpace(inline_summary)) + output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) output = strings.ReplaceAll(output, "%app_diffs%", strings.TrimSpace(sectionsDiff.String())) selection_changes := "" if s := h.selectionInfo.String(); s != "" { diff --git a/pkg/diff/html_test.go b/pkg/diff/html_test.go index 8ceb9cef..35ef54b9 100644 --- a/pkg/diff/html_test.go +++ b/pkg/diff/html_test.go @@ -187,8 +187,8 @@ func TestHTMLSection_PrintHTMLSection_MultipleResources(t *testing.T) { func TestHTMLOutput_PrintDiff_Basic(t *testing.T) { output := HTMLOutput{ - title: "Test Diff", - summary: "Added (1):\n+ my-app (+10)", + title: "Test Diff", + fullSummary: "Added (1):\n+ my-app (+10)", sections: []HTMLSection{ { appName: "my-app", @@ -225,9 +225,9 @@ func TestHTMLOutput_PrintDiff_Basic(t *testing.T) { func TestHTMLOutput_PrintDiff_NoSections(t *testing.T) { output := HTMLOutput{ - title: "Empty Diff", - summary: "No changes", - sections: []HTMLSection{}, + title: "Empty Diff", + fullSummary: "No changes", + sections: []HTMLSection{}, } result := output.printDiff() @@ -239,9 +239,9 @@ func TestHTMLOutput_PrintDiff_NoSections(t *testing.T) { func TestHTMLOutput_PrintDiff_SelectionInfo(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", - sections: []HTMLSection{}, + title: "Test", + fullSummary: "Summary", + sections: []HTMLSection{}, selectionInfo: SelectionInfo{ Base: AppSelectionInfo{SkippedApplications: 2, SkippedApplicationSets: 1}, Target: AppSelectionInfo{SkippedApplications: 5, SkippedApplicationSets: 1}, @@ -260,8 +260,8 @@ func TestHTMLOutput_PrintDiff_SelectionInfo(t *testing.T) { func TestHTMLOutput_PrintDiff_NoSelectionInfo(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", + title: "Test", + fullSummary: "Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+x\n"}}}, }, @@ -281,8 +281,8 @@ func TestHTMLOutput_PrintDiff_NoSelectionInfo(t *testing.T) { func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { output := HTMLOutput{ - title: "My Title", - summary: "My Summary", + title: "My Title", + fullSummary: "My Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+x\n"}}}, }, @@ -292,7 +292,7 @@ func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { result := output.printDiff() // All placeholders should be replaced - placeholders := []string{"%title%", "%summary%", "%app_diffs%", "%selection_changes%", "%info_box%"} + placeholders := []string{"%title%", "%summary%", "%detailed_summary_section%", "%app_diffs%", "%selection_changes%", "%info_box%"} for _, p := range placeholders { if strings.Contains(result, p) { t.Errorf("placeholder %q was not replaced in output", p) @@ -302,8 +302,8 @@ func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { func TestHTMLOutput_PrintDiff_ValidHTML(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", + title: "Test", + fullSummary: "Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+line\n"}}}, }, @@ -318,3 +318,54 @@ func TestHTMLOutput_PrintDiff_ValidHTML(t *testing.T) { t.Error("expected output to contain ") } } + +func TestHTMLOutput_PrintDiff_CollapsedAppList(t *testing.T) { + output := HTMLOutput{ + title: "Collapsed Test", + fullSummary: "Modified (2):\n± app-1 (+3|-2)\n± app-2 (+1|-1)\n", + compactSummary: "Total: 2 applications changed\nModified: 2", + sections: []HTMLSection{ + {appName: "app-1", filePath: "path/1", resources: []ResourceSection{{Header: "Deployment: app-1", Content: "+x\n"}}}, + {appName: "app-2", filePath: "path/2", resources: []ResourceSection{{Header: "Deployment: app-2", Content: "+y\n"}}}, + }, + } + + result := output.printDiff() + + // Should contain the collapsible details block + if !strings.Contains(result, "Changed applications (2)") { + t.Errorf("expected collapsible summary header, got:\n%s", result) + } + // App list should be inside a
 block
+	if !strings.Contains(result, "
Modified (2):") {
+		t.Errorf("expected app list in 
 block, got:\n%s", result)
+	}
+	// Content should be HTML-escaped
+	if !strings.Contains(result, "± app-1 (+3|-2)") {
+		t.Errorf("expected app-1 in collapsed details, got:\n%s", result)
+	}
+	// The per-app diff sections should still appear after the details block
+	if !strings.Contains(result, "app-1 (path/1)") {
+		t.Errorf("expected app-1 section, got:\n%s", result)
+	}
+	if !strings.Contains(result, "app-2 (path/2)") {
+		t.Errorf("expected app-2 section, got:\n%s", result)
+	}
+}
+
+func TestHTMLOutput_PrintDiff_NoCollapsedAppList(t *testing.T) {
+	output := HTMLOutput{
+		title:       "No Collapse",
+		fullSummary: "Modified (1):\n± app-1 (+3|-2)",
+		sections: []HTMLSection{
+			{appName: "app-1", filePath: "path/1", resources: []ResourceSection{{Header: "Deployment: app-1", Content: "+x\n"}}},
+		},
+	}
+
+	result := output.printDiff()
+
+	// Should NOT contain a collapsible details block for the app list
+	if strings.Contains(result, "Changed applications") {
+		t.Errorf("should not contain collapsible details when compactSummary is empty, got:\n%s", result)
+	}
+}
diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go
index 7ab53d97..85e02653 100644
--- a/pkg/diff/markdown.go
+++ b/pkg/diff/markdown.go
@@ -42,6 +42,15 @@ func markdownSectionFooter() string {
 	return "
\n\n" } +// markdownDetailedSummarySection wraps the full summary in a collapsible
block. +func markdownDetailedSummarySection(fullSummary string, appCount int) string { + return fmt.Sprintf( + "\n
\nChanged applications (%d)\n\n```yaml\n%s```\n\n
", + appCount, + fullSummary, + ) +} + var ( minSizeForSectionContent = 100 diffTooLongWarning = "\n🚨 Diff is too long" @@ -136,8 +145,8 @@ func (m *MarkdownSection) build(maxSize int) (string, bool) { type MarkdownOutput struct { title string - summary string - summaryDetails string + fullSummary string // full application list with per-app details + compactSummary string // short counts-only summary; empty if not collapsed sections []MarkdownSection statsInfo StatsInfo selectionInfo SelectionInfo @@ -148,9 +157,9 @@ const markdownTemplate = ` Summary: ` + "```yaml" + ` -%summary% +%inline_summary% ` + "```" + ` - +%detailed_summary_section% %app_diffs% %selection_changes% %info_box% @@ -190,27 +199,39 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { output := strings.ReplaceAll(markdownTemplate, "%title%", m.title) output = strings.ReplaceAll(output, "%selection_changes%", selection_changes) - // temp value to check if summary was truncated, to decide whether to log a warning about it - var summary string + // When compactSummary is set, use it as the inline summary and render the + // full summary in a collapsible
block via %detailed_summary_section%. + + var inline_summary string + var detailedSummarySection string + detailedSummarySectionSize := 0 + + if m.compactSummary != "" { + inline_summary = m.compactSummary + detailedSummarySection = markdownDetailedSummarySection(m.fullSummary, len(m.sections)) + detailedSummarySectionSize = len(detailedSummarySection) - len("%detailed_summary_section%") + } else { + inline_summary = m.fullSummary + } // Truncate summary upfront if it would consume the entire budget if 0 < maxDiffMessageCharCount { - diffLengthWithoutSummary := len(strings.ReplaceAll(output, "%summary%", "")) - summaryBudget := int(maxDiffMessageCharCount) - diffLengthWithoutSummary - len(warningMessage) - infoBoxBufferSize - truncatedSummary, truncated := truncateSummary(m.summary, summaryBudget) + diffLengthWithoutSummary := len(strings.ReplaceAll(output, "%inline_summary%", "")) + summaryBudget := int(maxDiffMessageCharCount) - diffLengthWithoutSummary - len(warningMessage) - infoBoxBufferSize - detailedSummarySectionSize + truncatedSummary, truncated := truncateSummary(inline_summary, summaryBudget) if truncated { log.Warn().Msgf("🚨 Markdown summary is too long, truncating to fit --max-diff-length (%d)", maxDiffMessageCharCount) - summary = truncatedSummary + inline_summary = truncatedSummary } else { - summary = strings.TrimSpace(m.summary) + inline_summary = strings.TrimSpace(inline_summary) } } else { - summary = strings.TrimSpace(m.summary) + inline_summary = strings.TrimSpace(inline_summary) } - output = strings.ReplaceAll(output, "%summary%", summary) + output = strings.ReplaceAll(output, "%inline_summary%", inline_summary) - availableSpaceForDetailedDiff := int(maxDiffMessageCharCount) - len(output) - len(warningMessage) - infoBoxBufferSize + availableSpaceForDetailedDiff := int(maxDiffMessageCharCount) - len(output) - len(warningMessage) - infoBoxBufferSize - detailedSummarySectionSize log.Debug().Msgf("availableSpaceForDetailedDiff: %d", availableSpaceForDetailedDiff) @@ -244,13 +265,9 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { } } - appDiffs := strings.TrimSpace(sectionsDiff.String()) - if m.summaryDetails != "" { - appDiffs = strings.TrimSpace(m.summaryDetails) + "\n\n" + appDiffs - } - + output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) output = strings.ReplaceAll(output, "%info_box%", m.statsInfo.String()) - output = strings.ReplaceAll(output, "%app_diffs%", appDiffs) + output = strings.ReplaceAll(output, "%app_diffs%", strings.TrimSpace(sectionsDiff.String())) output = strings.TrimSpace(output) + "\n" diff --git a/pkg/diff/markdown_test.go b/pkg/diff/markdown_test.go index e6ace019..2d4058ce 100644 --- a/pkg/diff/markdown_test.go +++ b/pkg/diff/markdown_test.go @@ -146,8 +146,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Basic output with sections", output: MarkdownOutput{ - title: "Test Diff", - summary: "Total: 2 applications changed\n\nAdded: 1\nModified: 1", + title: "Test Diff", + fullSummary: "Added: 1\nModified: 1", sections: []MarkdownSection{ { appName: "App 1", @@ -174,7 +174,7 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { maxDiffMessageCharCount: 5000, expectedContains: []string{ "## Test Diff", - "Total: 2 applications changed\n\nAdded: 1\nModified: 1", + "Added: 1\nModified: 1", "App 1 (path/to/app1.yaml)", "App 2 (path/to/app2.yaml)", "@@ Application added: App 1 @@", @@ -192,8 +192,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { name: "Summary details render before app diffs", output: MarkdownOutput{ title: "Summary Details", - summary: "Total: 3 applications changed\n\nAdded: 1\nModified: 2", - summaryDetails: "
\nChanged applications (3)\n\n```yaml\nAdded (1):\n+ app-1 (+2)\n```\n\n
\n", + fullSummary: "Added (1):\n+ app-1 (+2)\n", + compactSummary: "Total: 3 applications changed\nAdded: 1\nModified: 2", sections: []MarkdownSection{ { appName: "App 1", @@ -208,10 +208,9 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { ApplicationCount: 1, }, }, - maxSize: 10000, maxDiffMessageCharCount: 5000, expectedContains: []string{ - "Changed applications (3)", + "Changed applications (1)", "```yaml\nAdded (1):\n+ app-1 (+2)\n```", "App 1 (path/to/app1.yaml)", }, @@ -222,9 +221,9 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Empty sections shows no changes", output: MarkdownOutput{ - title: "Empty Diff", - summary: "No changes", - sections: []MarkdownSection{}, + title: "Empty Diff", + fullSummary: "No changes", + sections: []MarkdownSection{}, statsInfo: StatsInfo{ ApplicationCount: 0, }, @@ -243,8 +242,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Extremely small max-diff-length shows helpful message instead of no changes found", output: MarkdownOutput{ - title: "Tiny Diff", - summary: "Some changes", + title: "Tiny Diff", + fullSummary: "Some changes", sections: []MarkdownSection{ { appName: "My App", @@ -271,8 +270,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Truncated output shows warning", output: MarkdownOutput{ - title: "Large Diff", - summary: "Large changes", + title: "Large Diff", + fullSummary: "Large changes", sections: []MarkdownSection{ { appName: "Large App", @@ -322,6 +321,11 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { if !strings.HasSuffix(got, "\n") { t.Errorf("printDiff() should end with newline") } + + // Ensure no triple newlines (extra blank lines from empty placeholders) + if strings.Contains(got, "\n\n\n") { + t.Errorf("printDiff() should not contain triple newlines, got:\n%s", got) + } }) } } @@ -329,8 +333,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { func TestMarkdownOutput_PrintDiff_EdgeCases(t *testing.T) { t.Run("Very small max size", func(t *testing.T) { output := MarkdownOutput{ - title: "Test", - summary: "Test summary", + title: "Test", + fullSummary: "Test summary", sections: []MarkdownSection{ { appName: "App", @@ -355,8 +359,8 @@ func TestMarkdownOutput_PrintDiff_EdgeCases(t *testing.T) { t.Run("Zero max size", func(t *testing.T) { output := MarkdownOutput{ - title: "Test", - summary: "Test summary", + title: "Test", + fullSummary: "Test summary", sections: []MarkdownSection{ { appName: "App", @@ -575,8 +579,8 @@ func TestMarkdownSection_Build_TruncationBehavior(t *testing.T) { func TestMarkdownOutput_TemplateReplacement(t *testing.T) { output := MarkdownOutput{ - title: "Custom Title", - summary: "Custom Summary\nWith Multiple Lines", + title: "Custom Title", + fullSummary: "Custom Summary\nWith Multiple Lines", sections: []MarkdownSection{ { appName: "Test Section", @@ -628,8 +632,8 @@ func TestMarkdownOutput_TemplateReplacement(t *testing.T) { func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("No selection changes when counts are equal", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -657,8 +661,8 @@ func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("Shows selection changes when Application counts differ", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -693,8 +697,8 @@ func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("Shows selection changes when ApplicationSet counts differ", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -769,8 +773,8 @@ func TestMarkdownOutput_PrintDiff_TruncatesLargeSummary(t *testing.T) { summary := "Modified (11160):\n" + strings.Repeat("± spark-a--clark (+1)\n", 400) output := MarkdownOutput{ - title: "Large Summary Diff", - summary: summary, + title: "Large Summary Diff", + fullSummary: summary, sections: []MarkdownSection{ { appName: "spark-a--clark", From d936a9247bd2ed1f0557d589cc8100ac55077781 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 13:23:33 +0200 Subject: [PATCH 3/7] Fixed markdown formatting --- integration-test/branch-9/target-4/output.md | 3 ++- pkg/diff/html_test.go | 4 ++-- pkg/diff/markdown.go | 2 +- pkg/diff/markdown_test.go | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/integration-test/branch-9/target-4/output.md b/integration-test/branch-9/target-4/output.md index 09ef8c09..a1a1bb44 100644 --- a/integration-test/branch-9/target-4/output.md +++ b/integration-test/branch-9/target-4/output.md @@ -7,7 +7,7 @@ Deleted: 9 ```
-Changed applications (9) +Detailed Summary (9) ```yaml Deleted (9): @@ -23,6 +23,7 @@ Deleted (9): ```
+
app1 (examples/duplicate-names/app/app-set-1.yaml)
diff --git a/pkg/diff/html_test.go b/pkg/diff/html_test.go index 35ef54b9..6d3ea61c 100644 --- a/pkg/diff/html_test.go +++ b/pkg/diff/html_test.go @@ -333,7 +333,7 @@ func TestHTMLOutput_PrintDiff_CollapsedAppList(t *testing.T) { result := output.printDiff() // Should contain the collapsible details block - if !strings.Contains(result, "Changed applications (2)") { + if !strings.Contains(result, "Detailed Summary (2)") { t.Errorf("expected collapsible summary header, got:\n%s", result) } // App list should be inside a
 block
@@ -365,7 +365,7 @@ func TestHTMLOutput_PrintDiff_NoCollapsedAppList(t *testing.T) {
 	result := output.printDiff()
 
 	// Should NOT contain a collapsible details block for the app list
-	if strings.Contains(result, "Changed applications") {
+	if strings.Contains(result, "Detailed Summary") {
 		t.Errorf("should not contain collapsible details when compactSummary is empty, got:\n%s", result)
 	}
 }
diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go
index 85e02653..f87cd9a9 100644
--- a/pkg/diff/markdown.go
+++ b/pkg/diff/markdown.go
@@ -45,7 +45,7 @@ func markdownSectionFooter() string {
 // markdownDetailedSummarySection wraps the full summary in a collapsible 
block. func markdownDetailedSummarySection(fullSummary string, appCount int) string { return fmt.Sprintf( - "\n
\nChanged applications (%d)\n\n```yaml\n%s```\n\n
", + "\n
\nDetailed Summary (%d)\n\n```yaml\n%s```\n\n
\n", appCount, fullSummary, ) diff --git a/pkg/diff/markdown_test.go b/pkg/diff/markdown_test.go index 2d4058ce..816e751c 100644 --- a/pkg/diff/markdown_test.go +++ b/pkg/diff/markdown_test.go @@ -210,7 +210,7 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { }, maxDiffMessageCharCount: 5000, expectedContains: []string{ - "Changed applications (1)", + "Detailed Summary (1)", "```yaml\nAdded (1):\n+ app-1 (+2)\n```", "App 1 (path/to/app1.yaml)", }, From b54bd4a5400eed58a050db2f5c8bbc97d8bf0b05 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 13:29:59 +0200 Subject: [PATCH 4/7] Fixed html formatting --- integration-test/branch-9/target-4/output.html | 4 +++- integration-test/branch-9/target-4/output.md | 3 ++- pkg/diff/html.go | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/integration-test/branch-9/target-4/output.html b/integration-test/branch-9/target-4/output.html index 303c7961..6d07e0fc 100644 --- a/integration-test/branch-9/target-4/output.html +++ b/integration-test/branch-9/target-4/output.html @@ -62,8 +62,9 @@

Argo CD Diff Preview

Total: 9 applications changed
 Deleted: 9
+
-Changed applications (9) +Detailed Summary (9)
Deleted (9):
 - app1 (-19)
 - app1 (-19)
@@ -75,6 +76,7 @@ 

Argo CD Diff Preview

- my-app-set-staging (-79) - nginx-ingress (-470)
+
diff --git a/integration-test/branch-9/target-4/output.md b/integration-test/branch-9/target-4/output.md index a1a1bb44..a2792701 100644 --- a/integration-test/branch-9/target-4/output.md +++ b/integration-test/branch-9/target-4/output.md @@ -435,7 +435,8 @@ Deleted (9): - app.kubernetes.io/managed-by: Helm - app.kubernetes.io/name: myApp - app.kubernetes.io/version: 1.16.0 -- helm.sh/ +- helm.sh/chart: myApp-0.1.0 +- name: sup ``` 🚨 Diff is too long diff --git a/pkg/diff/html.go b/pkg/diff/html.go index f05642dd..b547a163 100644 --- a/pkg/diff/html.go +++ b/pkg/diff/html.go @@ -114,7 +114,7 @@ func emptyReasonHTML(reason matching.EmptyReason) string { // htmlDetailedSummarySection wraps the full summary in a collapsible
block. func htmlDetailedSummarySection(fullSummary string, appCount int) string { return fmt.Sprintf( - "\n
\nChanged applications (%d)\n
%s
\n
", + "\n
\n
\nDetailed Summary (%d)\n
%s
\n
\n
", appCount, html.EscapeString(strings.TrimSpace(fullSummary)), ) From 02b9c736e4980177b2138984c36561e5a7908e78 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 14:14:27 +0200 Subject: [PATCH 5/7] Fix length issue related to detailed_summary_section --- pkg/diff/markdown.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go index f87cd9a9..ced48522 100644 --- a/pkg/diff/markdown.go +++ b/pkg/diff/markdown.go @@ -204,20 +204,20 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { var inline_summary string var detailedSummarySection string - detailedSummarySectionSize := 0 if m.compactSummary != "" { inline_summary = m.compactSummary detailedSummarySection = markdownDetailedSummarySection(m.fullSummary, len(m.sections)) - detailedSummarySectionSize = len(detailedSummarySection) - len("%detailed_summary_section%") } else { inline_summary = m.fullSummary } + output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) + // Truncate summary upfront if it would consume the entire budget if 0 < maxDiffMessageCharCount { diffLengthWithoutSummary := len(strings.ReplaceAll(output, "%inline_summary%", "")) - summaryBudget := int(maxDiffMessageCharCount) - diffLengthWithoutSummary - len(warningMessage) - infoBoxBufferSize - detailedSummarySectionSize + summaryBudget := int(maxDiffMessageCharCount) - diffLengthWithoutSummary - len(warningMessage) - infoBoxBufferSize truncatedSummary, truncated := truncateSummary(inline_summary, summaryBudget) if truncated { log.Warn().Msgf("🚨 Markdown summary is too long, truncating to fit --max-diff-length (%d)", maxDiffMessageCharCount) @@ -231,7 +231,7 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { output = strings.ReplaceAll(output, "%inline_summary%", inline_summary) - availableSpaceForDetailedDiff := int(maxDiffMessageCharCount) - len(output) - len(warningMessage) - infoBoxBufferSize - detailedSummarySectionSize + availableSpaceForDetailedDiff := int(maxDiffMessageCharCount) - len(output) - len(warningMessage) - infoBoxBufferSize log.Debug().Msgf("availableSpaceForDetailedDiff: %d", availableSpaceForDetailedDiff) @@ -265,7 +265,6 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { } } - output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) output = strings.ReplaceAll(output, "%info_box%", m.statsInfo.String()) output = strings.ReplaceAll(output, "%app_diffs%", strings.TrimSpace(sectionsDiff.String())) From 28b9d7d31e84cdc0be181a704e56c1646ffef452 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 14:38:57 +0200 Subject: [PATCH 6/7] Fix failing test --- pkg/diff/markdown_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/diff/markdown_test.go b/pkg/diff/markdown_test.go index 816e751c..15e6532c 100644 --- a/pkg/diff/markdown_test.go +++ b/pkg/diff/markdown_test.go @@ -322,8 +322,10 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { t.Errorf("printDiff() should end with newline") } - // Ensure no triple newlines (extra blank lines from empty placeholders) - if strings.Contains(got, "\n\n\n") { + // Ensure no triple newlines from empty placeholders. + // Truncated sections can legitimately produce triple newlines + // (section header \n\n + warning \n), so skip this check then. + if !strings.Contains(got, "Diff exceeds max length") && strings.Contains(got, "\n\n\n") { t.Errorf("printDiff() should not contain triple newlines, got:\n%s", got) } }) From bccb2779ae41b88867f7402754f0a42e679b6638 Mon Sep 17 00:00:00 2001 From: Dag Andersen Date: Sat, 11 Apr 2026 15:06:00 +0200 Subject: [PATCH 7/7] Add extra integration test --- .../branch-9/target-5/output.html | 1121 +++++++++++++++++ integration-test/branch-9/target-5/output.md | 29 + integration-test/integration_test.go | 15 +- 3 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 integration-test/branch-9/target-5/output.html create mode 100644 integration-test/branch-9/target-5/output.md diff --git a/integration-test/branch-9/target-5/output.html b/integration-test/branch-9/target-5/output.html new file mode 100644 index 00000000..6d07e0fc --- /dev/null +++ b/integration-test/branch-9/target-5/output.html @@ -0,0 +1,1121 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Total: 9 applications changed
+Deleted: 9
+ +
+
+Detailed Summary (9) +
Deleted (9):
+- app1 (-19)
+- app1 (-19)
+- app2 (-19)
+- app2 (-19)
+- custom-target-revision-example (-14)
+- my-app-set-dev (-79)
+- my-app-set-prod (-79)
+- my-app-set-staging (-79)
+- nginx-ingress (-470)
+
+
+
+
+ +app1 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app1 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +custom-target-revision-example (examples/custom-target-revision/app/app.yaml) + + +

Deployment: default/my-deployment

+
+ + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: my-deployment
-  namespace: default
-spec:
-  replicas: 2
-  template:
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: my-deployment
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +my-app-set-dev (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-dev
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-dev
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-prod (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-prod
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-prod
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-staging (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-staging
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-staging
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +nginx-ingress (examples/external-chart/nginx.yaml) + + +

ValidatingWebhookConfiguration: nginx-ingress-ingress-nginx-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: admissionregistration.k8s.io/v1
-kind: ValidatingWebhookConfiguration
-metadata:
-  labels:
-    app.kubernetes.io/component: admission-webhook
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-admission
-webhooks:
-- admissionReviewVersions:
-  - v1
-  clientConfig:
-    service:
-      name: nginx-ingress-ingress-nginx-controller-admission
-      namespace: default
-      path: /networking/v1/ingresses
-  failurePolicy: Fail
-  matchPolicy: Equivalent
-  name: validate.nginx.ingress.kubernetes.io
-  rules:
-  - apiGroups:
-    - networking.k8s.io
-    apiVersions:
-    - v1
-    operations:
-    - CREATE
-    - UPDATE
-    resources:
-    - ingresses
-  sideEffects: None
+
+ +

Deployment: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  minReadySeconds: 0
-  replicas: 1
-  revisionHistoryLimit: 10
-  selector:
-    matchLabels:
-      app.kubernetes.io/component: controller
-      app.kubernetes.io/instance: nginx-ingress
-      app.kubernetes.io/name: ingress-nginx
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/component: controller
-        app.kubernetes.io/instance: nginx-ingress
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: ingress-nginx
-        app.kubernetes.io/part-of: ingress-nginx
-        app.kubernetes.io/version: 1.10.0
-        helm.sh/chart: ingress-nginx-4.10.0
-    spec:
-      containers:
-      - args:
-        - /nginx-ingress-controller
-        - --publish-service=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --election-id=nginx-ingress-ingress-nginx-leader
-        - --controller-class=k8s.io/ingress-nginx
-        - --ingress-class=test
-        - --configmap=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --validating-webhook=:8443
-        - --validating-webhook-certificate=/usr/local/certificates/cert
-        - --validating-webhook-key=/usr/local/certificates/key
-        - --enable-metrics=false
-        env:
-        - name: POD_NAME
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.name
-        - name: POD_NAMESPACE
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.namespace
-        - name: LD_PRELOAD
-          value: /usr/local/lib/libmimalloc.so
-        image: registry.k8s.io/ingress-nginx/controller:v1.10.0@sha256:42b3f0e5d0846876b1791cd3afeb5f1cbbe4259d6f35651dcc1b5c980925379c
-        imagePullPolicy: IfNotPresent
-        lifecycle:
-          preStop:
-            exec:
-              command:
-              - /wait-shutdown
-        livenessProbe:
-          failureThreshold: 5
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        name: controller
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        - containerPort: 443
-          name: https
-          protocol: TCP
-        - containerPort: 8443
-          name: webhook
-          protocol: TCP
-        readinessProbe:
-          failureThreshold: 3
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        resources:
-          requests:
-            cpu: 100m
-            memory: 90Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            add:
-            - NET_BIND_SERVICE
-            drop:
-            - ALL
-          readOnlyRootFilesystem: false
-          runAsNonRoot: true
-          runAsUser: 101
-          seccompProfile:
-            type: RuntimeDefault
-        volumeMounts:
-        - mountPath: /usr/local/certificates/
-          name: webhook-cert
-          readOnly: true
-      dnsPolicy: ClusterFirst
-      nodeSelector:
-        kubernetes.io/os: linux
-      serviceAccountName: nginx-ingress-ingress-nginx
-      terminationGracePeriodSeconds: 300
-      volumes:
-      - name: webhook-cert
-        secret:
-          secretName: nginx-ingress-ingress-nginx-admission
+
+ +

IngressClass: nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: networking.k8s.io/v1
-kind: IngressClass
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx
-spec:
-  controller: k8s.io/ingress-nginx
+
+ +

ClusterRole: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - endpoints
-  - nodes
-  - pods
-  - secrets
-  - namespaces
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - nodes
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

ClusterRoleBinding: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

Role: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - namespaces
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - pods
-  - secrets
-  - endpoints
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resourceNames:
-  - nginx-ingress-ingress-nginx-leader
-  resources:
-  - leases
-  verbs:
-  - get
-  - update
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - create
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

RoleBinding: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

ConfigMap: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + +
-apiVersion: v1
-data:
-  allow-snippet-annotations: "false"
-kind: ConfigMap
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  ipFamilies:
-  - IPv4
-  ipFamilyPolicy: SingleStack
-  ports:
-  - appProtocol: http
-    name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  - appProtocol: https
-    name: https
-    port: 443
-    protocol: TCP
-    targetPort: https
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: LoadBalancer
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller-admission
-  namespace: default
-spec:
-  ports:
-  - appProtocol: https
-    name: https-webhook
-    port: 443
-    targetPort: webhook
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: ClusterIP
+
+ +

ServiceAccount: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-9/target-5/output.md b/integration-test/branch-9/target-5/output.md new file mode 100644 index 00000000..25fde589 --- /dev/null +++ b/integration-test/branch-9/target-5/output.md @@ -0,0 +1,29 @@ +## Argo CD Diff Preview + +Summary: +```yaml + +``` + +
+Detailed Summary (9) + +```yaml +Deleted (9): +- app1 (-19) +- app1 (-19) +- app2 (-19) +- app2 (-19) +- custom-target-revision-example (-14) +- my-app-set-dev (-79) +- my-app-set-prod (-79) +- my-app-set-staging (-79) +- nginx-ingress (-470) +``` + +
+ +⚠️ Changes were found but `--max-diff-length` (400) is too small to display them. Increase the value or check the HTML output instead. + +_Stats_: +[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index fcac1f65..32b331c2 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -225,8 +225,8 @@ var testCases = []TestCase{ Suffix: "-3", MaxDiffLength: "400", }, - // Tests the collapsible summary feature with --summary-threshold=2. - // Branch-9 deletes 9 apps, so with threshold=2 the summary should collapse + // Tests the collapsible summary feature with --summary-threshold=5. + // Branch-9 deletes 9 apps, so with threshold=5 the summary should collapse // the app list behind a
block. { Name: "branch-9/target-4", @@ -236,6 +236,17 @@ var testCases = []TestCase{ MaxDiffLength: "10000", SummaryThreshold: "5", }, + // Tests the collapsible summary combined with a tight max-diff-length. + // With threshold=5 the summary collapses, and max-diff-length=400 forces + // truncation of both the summary and diff sections. + { + Name: "branch-9/target-5", + TargetBranch: "integration-test/branch-9/target", + BaseBranch: "integration-test/branch-9/base", + Suffix: "-5", + MaxDiffLength: "400", + SummaryThreshold: "5", + }, { Name: "branch-10/target-1", TargetBranch: "integration-test/branch-10/target",