diff --git a/cmd/main.go b/cmd/main.go index 6185d983..f0c3c5b1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -407,6 +407,7 @@ func run(cfg *Config) error { statsInfo, selectionInfo, cfg.ArgocdUIURL, + cfg.SummaryThreshold, ); err != nil { log.Error().Msg("❌ Failed to generate diff") return err diff --git a/cmd/options.go b/cmd/options.go index b3ab9ba7..bacc7385 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" @@ -103,6 +104,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"` @@ -149,6 +151,7 @@ type Config struct { KindInternal bool K3dOptions string MaxDiffLength uint + SummaryThreshold uint IgnoreInvalidWatchPattern bool WatchIfNoWatchPatternFound bool AutoDetectFilesChanged bool @@ -245,6 +248,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) @@ -310,6 +314,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 changed files list in summary when total exceeds 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") @@ -383,6 +388,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, @@ -659,6 +665,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/pkg/diff/diff_test.go b/pkg/diff/diff_test.go index a3e2117e..0f8e61a3 100644 --- a/pkg/diff/diff_test.go +++ b/pkg/diff/diff_test.go @@ -307,8 +307,8 @@ spec: } // Run the diff generation - summary, markdownSections, htmlSections, err := generateGitDiff( - basePath, targetPath, nil, 3, false, baseApps, targetApps, "", + summary, _, markdownSections, htmlSections, err := generateGitDiff( + basePath, targetPath, nil, 3, false, baseApps, targetApps, "", 0, ) if err != nil { @@ -430,8 +430,8 @@ spec: } // Run the diff generation - summary, markdownSections, htmlSections, err := generateGitDiff( - basePath, targetPath, nil, 3, false, baseApps, targetApps, "", + summary, _, markdownSections, htmlSections, err := generateGitDiff( + basePath, targetPath, nil, 3, false, baseApps, targetApps, "", 0, ) if err != nil { diff --git a/pkg/diff/generator.go b/pkg/diff/generator.go index d9442a6c..3d66abf3 100644 --- a/pkg/diff/generator.go +++ b/pkg/diff/generator.go @@ -41,6 +41,7 @@ func GenerateDiff( statsInfo StatsInfo, selectionInfo SelectionInfo, argocdUIURL string, + summaryThreshold uint, ) error { maxDiffMessageCharCount := maxCharCount @@ -59,7 +60,7 @@ func GenerateDiff( // Generate diffs using go-git by creating temporary git repos basePath := fmt.Sprintf("%s/%s", outputFolder, baseBranch.Type()) targetPath := fmt.Sprintf("%s/%s", outputFolder, targetBranch.Type()) - summary, markdownFileSections, htmlFileSections, err := generateGitDiff(basePath, targetPath, diffIgnoreRegex, lineCount, hideDeletedAppDiff, baseApps, targetApps, argocdUIURL) + inlineSummary, summaryDetails, markdownFileSections, htmlFileSections, err := generateGitDiff(basePath, targetPath, diffIgnoreRegex, lineCount, hideDeletedAppDiff, baseApps, targetApps, argocdUIURL, int(summaryThreshold)) if err != nil { return fmt.Errorf("failed to generate diff: %w", err) } @@ -67,11 +68,12 @@ func GenerateDiff( // Markdown log.Debug().Msg("Creating markdown output") MarkdownOutput := MarkdownOutput{ - title: title, - summary: summary, - sections: markdownFileSections, - statsInfo: statsInfo, - selectionInfo: selectionInfo, + title: title, + summary: inlineSummary, + summaryDetails: summaryDetails, + sections: markdownFileSections, + statsInfo: statsInfo, + selectionInfo: selectionInfo, } markdown := MarkdownOutput.printDiff(maxDiffMessageCharCount) markdownPath := fmt.Sprintf("%s/diff.md", outputFolder) @@ -85,7 +87,7 @@ func GenerateDiff( log.Debug().Msg("Creating html output") HTMLOutput := HTMLOutput{ title: title, - summary: summary, + summary: inlineSummary, sections: htmlFileSections, statsInfo: statsInfo, selectionInfo: selectionInfo, @@ -123,16 +125,17 @@ func generateGitDiff( baseApps []AppInfo, targetApps []AppInfo, argocdUIURL string, -) (string, []MarkdownSection, []HTMLSection, error) { + summaryThreshold int, +) (string, string, []MarkdownSection, []HTMLSection, error) { // Write base manifests to disk if err := writeManifestsToDisk(baseApps, basePath); err != nil { - return "", nil, nil, fmt.Errorf("failed to write base manifests: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to write base manifests: %w", err) } // Write target manifests to disk if err := writeManifestsToDisk(targetApps, targetPath); err != nil { - return "", nil, nil, fmt.Errorf("failed to write target manifests: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to write target manifests: %w", err) } baseAppsMap := make(map[string]AppInfo) @@ -148,7 +151,7 @@ func generateGitDiff( // Create temporary directory for single Git repository repoPath, err := os.MkdirTemp("", "diff-repo-*") if err != nil { - return "", nil, nil, fmt.Errorf("failed to create temp dir for repo: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to create temp dir for repo: %w", err) } defer func() { if err := os.RemoveAll(repoPath); err != nil { @@ -159,22 +162,22 @@ func generateGitDiff( // Initialize single Git repository repo, err := git.PlainInit(repoPath, false) if err != nil { - return "", nil, nil, fmt.Errorf("failed to init repo: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to init repo: %w", err) } // Get worktree worktree, err := repo.Worktree() if err != nil { - return "", nil, nil, fmt.Errorf("failed to get worktree: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get worktree: %w", err) } // Copy base files to repository and commit if err := copyFilesToRepo(basePath, repoPath); err != nil { - return "", nil, nil, fmt.Errorf("failed to copy base files: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to copy base files: %w", err) } if err := worktree.AddGlob("."); err != nil { - return "", nil, nil, fmt.Errorf("failed to add base files to repo: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to add base files to repo: %w", err) } author := &object.Signature{ @@ -188,20 +191,20 @@ func generateGitDiff( AllowEmptyCommits: true, }) if err != nil { - return "", nil, nil, fmt.Errorf("failed to commit base state: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to commit base state: %w", err) } // Clear the working directory and copy target files if err := clearWorkingDirectory(repoPath); err != nil { - return "", nil, nil, fmt.Errorf("failed to clear working directory: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to clear working directory: %w", err) } if err := copyFilesToRepo(targetPath, repoPath); err != nil { - return "", nil, nil, fmt.Errorf("failed to copy target files: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to copy target files: %w", err) } if err := worktree.AddGlob("."); err != nil { - return "", nil, nil, fmt.Errorf("failed to add target files to repo: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to add target files to repo: %w", err) } targetCommitHash, err := worktree.Commit("Target state", &git.CommitOptions{ @@ -209,35 +212,35 @@ func generateGitDiff( AllowEmptyCommits: true, }) if err != nil { - return "", nil, nil, fmt.Errorf("failed to commit target state: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to commit target state: %w", err) } // Retrieve commits baseCommit, err := repo.CommitObject(baseCommitHash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get base commit: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get base commit: %w", err) } targetCommit, err := repo.CommitObject(targetCommitHash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get target commit: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get target commit: %w", err) } // Get base and target trees baseTree, err := baseCommit.Tree() if err != nil { - return "", nil, nil, fmt.Errorf("failed to get base tree: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get base tree: %w", err) } targetTree, err := targetCommit.Tree() if err != nil { - return "", nil, nil, fmt.Errorf("failed to get target tree: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get target tree: %w", err) } // Compute diff between trees changes, err := baseTree.Diff(targetTree) if err != nil { - return "", nil, nil, fmt.Errorf("failed to compute diff: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to compute diff: %w", err) } // Keep track of file paths by change type @@ -246,12 +249,12 @@ func generateGitDiff( for _, change := range changes { action, err := change.Action() if err != nil { - return "", nil, nil, fmt.Errorf("failed to get change action: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get change action: %w", err) } from, to, err := change.Files() if err != nil { - return "", nil, nil, fmt.Errorf("failed to get files: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get files: %w", err) } changeInfo := changeInfo{} @@ -262,12 +265,12 @@ func generateGitDiff( if to != nil { blob, err := repo.BlobObject(to.Hash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get target blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get target blob: %w", err) } content, err := getBlobContent(blob) if err != nil { - return "", nil, nil, fmt.Errorf("failed to read target blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to read target blob: %w", err) } changeInfo = formatNewFileDiff(content, diffContextLines, diffIgnore) @@ -282,12 +285,12 @@ func generateGitDiff( } else if from != nil { blob, err := repo.BlobObject(from.Hash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get base blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get base blob: %w", err) } content, err := getBlobContent(blob) if err != nil { - return "", nil, nil, fmt.Errorf("failed to read base blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to read base blob: %w", err) } changeInfo = formatDeletedFileDiff(content, diffContextLines, diffIgnore) @@ -300,24 +303,24 @@ func generateGitDiff( if from != nil { blob, err := repo.BlobObject(from.Hash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get base blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get base blob: %w", err) } oldContent, err = getBlobContent(blob) if err != nil { - return "", nil, nil, fmt.Errorf("failed to read base blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to read base blob: %w", err) } } if to != nil { blob, err := repo.BlobObject(to.Hash) if err != nil { - return "", nil, nil, fmt.Errorf("failed to get target blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to get target blob: %w", err) } newContent, err = getBlobContent(blob) if err != nil { - return "", nil, nil, fmt.Errorf("failed to read target blob: %w", err) + return "", "", nil, nil, fmt.Errorf("failed to read target blob: %w", err) } } @@ -360,11 +363,10 @@ func generateGitDiff( } if len(changedFiles) == 0 { - return "No changes found", nil, nil, nil + return "No changes found", "", nil, nil, nil } - // Build summary - summary := buildSummary(changedFiles) + inlineSummary, summaryDetails := buildSummary(changedFiles, summaryThreshold) // Create arrays of formatted file sections markdownFileSections := make([]MarkdownSection, 0, len(changedFiles)) @@ -381,7 +383,7 @@ func generateGitDiff( htmlFileSections = append(htmlFileSections, diff.buildHTMLSection(argocdUIURL)) } - return summary, markdownFileSections, htmlFileSections, nil + return inlineSummary, summaryDetails, markdownFileSections, htmlFileSections, nil } // getBlobContent reads the content of a Git blob diff --git a/pkg/diff/generator_test.go b/pkg/diff/generator_test.go index 4551d121..f37bc941 100644 --- a/pkg/diff/generator_test.go +++ b/pkg/diff/generator_test.go @@ -43,7 +43,7 @@ data: baseApps := []AppInfo{} // No base apps - this is a new file targetApps := []AppInfo{{Id: "app.yaml", Name: "my-app", SourcePath: "/path/app", FileContent: targetContent}} - _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "") + _, _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "", 0) if err != nil { t.Fatalf("generateGitDiff failed: %v", err) } @@ -125,7 +125,7 @@ spec: baseApps := []AppInfo{{Id: "app.yaml", Name: "my-app", SourcePath: "/path/app", FileContent: baseContent}} targetApps := []AppInfo{} // App is deleted - _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "") + _, _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "", 0) if err != nil { t.Fatalf("generateGitDiff failed: %v", err) } @@ -216,7 +216,7 @@ spec: baseApps := []AppInfo{{Id: "app.yaml", Name: "my-app", SourcePath: "/path/app", FileContent: baseContent}} targetApps := []AppInfo{{Id: "app.yaml", Name: "my-app", SourcePath: "/path/app", FileContent: targetContent}} - _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "") + _, _, markdownSections, _, err := generateGitDiff(basePath, targetPath, nil, 10, false, baseApps, targetApps, "", 0) if err != nil { t.Fatalf("generateGitDiff failed: %v", err) } @@ -279,7 +279,7 @@ func TestGenerateGitDiff_HideDeletedAppDiffMessage(t *testing.T) { }, } - summary, markdownSections, htmlSections, err := generateGitDiff(basePath, targetPath, nil, 3, true, baseApps, nil, "") + summary, _, markdownSections, htmlSections, err := generateGitDiff(basePath, targetPath, nil, 3, true, baseApps, nil, "", 0) if err != nil { t.Fatalf("generateGitDiff failed: %v", err) } @@ -356,8 +356,8 @@ spec: hideDeletedAppDiff := false - summary, markdownSections, htmlSections, err := generateGitDiff( - basePath, targetPath, nil, 3, hideDeletedAppDiff, baseApps, targetApps, "", + summary, _, markdownSections, htmlSections, err := generateGitDiff( + basePath, targetPath, nil, 3, hideDeletedAppDiff, baseApps, targetApps, "", 0, ) if err != nil { @@ -401,8 +401,8 @@ spec: t.Run("hideDeletedAppDiff=true hides diff content for deleted apps", func(t *testing.T) { hideDeletedAppDiff := true - summary, markdownSections, htmlSections, err := generateGitDiff( - basePath, targetPath, nil, 3, hideDeletedAppDiff, baseApps, targetApps, "", + summary, _, markdownSections, htmlSections, err := generateGitDiff( + basePath, targetPath, nil, 3, hideDeletedAppDiff, baseApps, targetApps, "", 0, ) if err != nil { diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go index 633799f4..51033ca4 100644 --- a/pkg/diff/markdown.go +++ b/pkg/diff/markdown.go @@ -75,11 +75,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 = ` @@ -137,12 +138,16 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { sectionsDiff.WriteString(warningMessage) } - if sectionsDiff.Len() == 0 { - sectionsDiff.WriteString("No changes found") + appDiffs := strings.TrimSpace(sectionsDiff.String()) + if appDiffs == "" { + appDiffs = "No changes found" + } + 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/summary.go b/pkg/diff/summary.go index afe90a19..62678097 100644 --- a/pkg/diff/summary.go +++ b/pkg/diff/summary.go @@ -7,9 +7,11 @@ import ( "github.com/go-git/go-git/v5/utils/merkletrie" ) -func buildSummary(changedFiles []Diff) string { - var summaryBuilder strings.Builder - +// buildSummary returns (inlineContent, detailsBlock). +// inlineContent goes in the yaml block; when total > threshold it only contains counts. +// detailsBlock is a collapsible HTML details section with the full file list (empty when below threshold). +// Pass threshold=0 to always return the full list inline. +func buildSummary(changedFiles []Diff, threshold int) (string, string) { addedFilesCount := 0 deletedFilesCount := 0 modifiedFilesCount := 0 @@ -25,34 +27,51 @@ func buildSummary(changedFiles []Diff) string { } } - fmt.Fprintf(&summaryBuilder, "Total: %d files changed\n", addedFilesCount+deletedFilesCount+modifiedFilesCount) + total := addedFilesCount + deletedFilesCount + modifiedFilesCount + var listBuilder strings.Builder if 0 < addedFilesCount { - fmt.Fprintf(&summaryBuilder, "\nAdded (%d):\n", addedFilesCount) + fmt.Fprintf(&listBuilder, "\nAdded (%d):\n", addedFilesCount) for _, diff := range changedFiles { if diff.action == merkletrie.Insert { - fmt.Fprintf(&summaryBuilder, "+ %s%s\n", diff.prettyName(), diff.changeStats()) + fmt.Fprintf(&listBuilder, "+ %s%s\n", diff.prettyName(), diff.changeStats()) } } } - if 0 < deletedFilesCount { - fmt.Fprintf(&summaryBuilder, "\nDeleted (%d):\n", deletedFilesCount) + fmt.Fprintf(&listBuilder, "\nDeleted (%d):\n", deletedFilesCount) for _, diff := range changedFiles { if diff.action == merkletrie.Delete { - fmt.Fprintf(&summaryBuilder, "- %s%s\n", diff.prettyName(), diff.changeStats()) + fmt.Fprintf(&listBuilder, "- %s%s\n", diff.prettyName(), diff.changeStats()) } } } - if 0 < modifiedFilesCount { - fmt.Fprintf(&summaryBuilder, "\nModified (%d):\n", modifiedFilesCount) + fmt.Fprintf(&listBuilder, "\nModified (%d):\n", modifiedFilesCount) for _, diff := range changedFiles { if diff.action == merkletrie.Modify { - fmt.Fprintf(&summaryBuilder, "± %s%s\n", diff.prettyName(), diff.changeStats()) + fmt.Fprintf(&listBuilder, "± %s%s\n", diff.prettyName(), diff.changeStats()) } } } - return summaryBuilder.String() + header := fmt.Sprintf("Total: %d files changed\n", total) + + if threshold > 0 && total > threshold { + var compact strings.Builder + fmt.Fprint(&compact, header) + if 0 < addedFilesCount { + fmt.Fprintf(&compact, "\nAdded: %d\n", addedFilesCount) + } + if 0 < deletedFilesCount { + fmt.Fprintf(&compact, "Deleted: %d\n", deletedFilesCount) + } + if 0 < modifiedFilesCount { + fmt.Fprintf(&compact, "Modified: %d\n", modifiedFilesCount) + } + details := fmt.Sprintf("
\nChanged files (%d)\n\n```yaml\n%s```\n\n
\n", total, listBuilder.String()) + return compact.String(), details + } + + return header + listBuilder.String(), "" }