changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1/*
2USAGE
3
4 storm check [from] [to] [options]
5
6FLAGS
7
8 --since <tag> Check changes since the given tag
9 --repo <path> Path to the Git repository (default: .)
10
11# DESCRIPTION
12
13Validates that all commits in the specified range have corresponding unreleased
14changelog entries. This is useful for CI enforcement to ensure developers
15document their changes.
16
17Commits containing [nochanges] or [skip changelog] in the message are skipped.
18
19Exit codes:
20
21 0 - All commits have changelog entries
22 1 - One or more commits are missing changelog entries
23 2 - Command execution error
24
25TODO(issue-linking): Support checking for issue numbers in entries when --issue flag is implemented in `unreleased partial`.
26
27 - This requires integrating with at Gitea/Forgejo, Github, Gitlab, and Tangled
28*/
29package main
30
31import (
32 "fmt"
33 "strings"
34
35 "github.com/go-git/go-git/v6"
36 "github.com/spf13/cobra"
37 "github.com/stormlightlabs/git-storm/internal/changeset"
38 "github.com/stormlightlabs/git-storm/internal/gitlog"
39 "github.com/stormlightlabs/git-storm/internal/style"
40)
41
42// checkCmd validates that all commits in a range have corresponding changelog entries.
43func checkCmd() *cobra.Command {
44 var sinceTag string
45
46 c := &cobra.Command{
47 Use: "check [from] [to]",
48 Short: "Validate changelog entries exist for all commits",
49 Long: `Checks that all commits in the specified range have corresponding
50.changes/*.md entries. Useful for CI enforcement.
51
52Commits with [nochanges] or [skip changelog] in their message are skipped.`,
53 Args: cobra.MaximumNArgs(2),
54 RunE: func(cmd *cobra.Command, args []string) error {
55 var from, to string
56
57 if sinceTag != "" {
58 from = sinceTag
59 if len(args) > 0 {
60 to = args[0]
61 } else {
62 to = "HEAD"
63 }
64 } else if len(args) == 0 {
65 return fmt.Errorf("must specify either --since flag or [from] [to] arguments")
66 } else {
67 from, to = gitlog.ParseRefArgs(args)
68 }
69
70 repo, err := git.PlainOpen(repoPath)
71 if err != nil {
72 return fmt.Errorf("failed to open repository: %w", err)
73 }
74
75 commits, err := gitlog.GetCommitRange(repo, from, to)
76 if err != nil {
77 return err
78 }
79
80 if len(commits) == 0 {
81 style.Headlinef("No commits found between %s and %s", from, to)
82 return nil
83 }
84
85 changesDir := ".changes"
86 existingMetadata, err := changeset.LoadExistingMetadata(changesDir)
87 if err != nil {
88 return fmt.Errorf("failed to load existing metadata: %w", err)
89 }
90
91 style.Headlinef("Checking %d commits between %s and %s", len(commits), from, to)
92 style.Newline()
93
94 var missingEntries []string
95 skippedCount := 0
96
97 for _, commit := range commits {
98 message := strings.ToLower(commit.Message)
99 if strings.Contains(message, "[nochanges]") || strings.Contains(message, "[skip changelog]") {
100 skippedCount++
101 continue
102 }
103
104 diffHash, err := changeset.ComputeDiffHash(commit)
105 if err != nil {
106 style.Println("Warning: failed to compute diff hash for commit %s: %v", commit.Hash.String()[:7], err)
107 continue
108 }
109
110 if _, exists := existingMetadata[diffHash]; !exists {
111 sha7 := commit.Hash.String()[:7]
112 subject := strings.Split(commit.Message, "\n")[0]
113 missingEntries = append(missingEntries, fmt.Sprintf("%s - %s", sha7, subject))
114 }
115 }
116
117 if len(missingEntries) == 0 {
118 style.Addedf("✓ All commits have changelog entries")
119 if skippedCount > 0 {
120 style.Println(" Skipped %d commits with [nochanges] marker", skippedCount)
121 }
122 return nil
123 }
124
125 style.Println("%s", style.StyleRemoved.Render(fmt.Sprintf("✗ %d commits missing changelog entries:", len(missingEntries))))
126 style.Newline()
127
128 for _, entry := range missingEntries {
129 style.Println(" - %s", entry)
130 }
131
132 style.Newline()
133 style.Println("To create entries, run:")
134 style.Println(" storm generate %s %s --interactive", from, to)
135 style.Println("Or manually create entries with:")
136 style.Println(" storm unreleased partial <commit-ref>")
137
138 return fmt.Errorf("changelog validation failed")
139 },
140 }
141
142 c.Flags().StringVar(&sinceTag, "since", "", "Check changes since the given tag")
143 return c
144}