changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(generate): add generate command for changeset files

+1038 -39
+1 -1
Taskfile.yml
··· 3 3 vars: 4 4 BINARY_NAME: storm 5 5 BUILD_DIR: ./tmp 6 - MAIN_PATH: ./cmd/main.go 6 + MAIN_PATH: ./cmd 7 7 VERSION: 8 8 sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" 9 9
+215
cmd/generate.go
··· 1 + /* 2 + USAGE 3 + 4 + storm generate [from] [to] [options] 5 + 6 + FLAGS 7 + 8 + -i, --interactive Review generated entries in a TUI 9 + --since <tag> Generate changes since the given tag 10 + -o, --output <path> Write generated changelog to path 11 + --repo <path> Path to the Git repository (default: .) 12 + */ 13 + package main 14 + 15 + import ( 16 + "fmt" 17 + "strings" 18 + 19 + "github.com/go-git/go-git/v6" 20 + "github.com/go-git/go-git/v6/plumbing" 21 + "github.com/go-git/go-git/v6/plumbing/object" 22 + "github.com/spf13/cobra" 23 + "github.com/stormlightlabs/git-storm/internal/changeset" 24 + "github.com/stormlightlabs/git-storm/internal/gitlog" 25 + "github.com/stormlightlabs/git-storm/internal/style" 26 + ) 27 + 28 + var ( 29 + interactive bool 30 + sinceTag string 31 + ) 32 + 33 + // getCommitRange returns commits reachable from toRef but not from fromRef. 34 + // This implements the git log from..to range semantics. 35 + func getCommitRange(repo *git.Repository, fromRef, toRef string) ([]*object.Commit, error) { 36 + fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 37 + if err != nil { 38 + return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 39 + } 40 + 41 + toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 44 + } 45 + 46 + toCommits := make(map[plumbing.Hash]bool) 47 + toIter, err := repo.Log(&git.LogOptions{From: *toHash}) 48 + if err != nil { 49 + return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 50 + } 51 + 52 + err = toIter.ForEach(func(c *object.Commit) error { 53 + toCommits[c.Hash] = true 54 + return nil 55 + }) 56 + if err != nil { 57 + return nil, fmt.Errorf("failed to iterate commits from %s: %w", toRef, err) 58 + } 59 + 60 + fromCommits := make(map[plumbing.Hash]bool) 61 + fromIter, err := repo.Log(&git.LogOptions{From: *fromHash}) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to get commits from %s: %w", fromRef, err) 64 + } 65 + 66 + err = fromIter.ForEach(func(c *object.Commit) error { 67 + fromCommits[c.Hash] = true 68 + return nil 69 + }) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to iterate commits from %s: %w", fromRef, err) 72 + } 73 + 74 + // Collect commits that are in toCommits but not in fromCommits 75 + result := []*object.Commit{} 76 + toIter, err = repo.Log(&git.LogOptions{From: *toHash}) 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 79 + } 80 + 81 + err = toIter.ForEach(func(c *object.Commit) error { 82 + if !fromCommits[c.Hash] { 83 + result = append(result, c) 84 + } 85 + return nil 86 + }) 87 + if err != nil { 88 + return nil, fmt.Errorf("failed to collect commit range: %w", err) 89 + } 90 + 91 + // Reverse to get chronological order (oldest first) 92 + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { 93 + result[i], result[j] = result[j], result[i] 94 + } 95 + 96 + return result, nil 97 + } 98 + 99 + func generateCmd() *cobra.Command { 100 + c := &cobra.Command{ 101 + Use: "generate [from] [to]", 102 + Short: "Generate changelog entries from Git commits", 103 + Long: `Scans commits between two Git refs (tags or hashes) and outputs draft 104 + entries in .changes/. Supports conventional commit parsing and 105 + interactive review mode.`, 106 + Args: cobra.MaximumNArgs(2), 107 + RunE: func(cmd *cobra.Command, args []string) error { 108 + var from, to string 109 + 110 + if sinceTag != "" { 111 + from = sinceTag 112 + if len(args) > 0 { 113 + to = args[0] 114 + } else { 115 + to = "HEAD" 116 + } 117 + } else if len(args) == 0 { 118 + return fmt.Errorf("must specify either --since flag or [from] [to] arguments") 119 + } else if len(args) == 1 { 120 + parts := strings.Split(args[0], "..") 121 + if len(parts) == 2 { 122 + from, to = parts[0], parts[1] 123 + } else { 124 + from, to = args[0], "HEAD" 125 + } 126 + } else { 127 + from, to = args[0], args[1] 128 + } 129 + 130 + if interactive { 131 + style.Headline("Interactive mode not yet implemented") 132 + fmt.Println("Will generate entries in non-interactive mode...") 133 + } 134 + 135 + repo, err := git.PlainOpen(repoPath) 136 + if err != nil { 137 + return fmt.Errorf("failed to open repository: %w", err) 138 + } 139 + 140 + commits, err := getCommitRange(repo, from, to) 141 + if err != nil { 142 + return err 143 + } 144 + 145 + if len(commits) == 0 { 146 + style.Headline(fmt.Sprintf("No commits found between %s and %s", from, to)) 147 + return nil 148 + } 149 + 150 + style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) 151 + 152 + parser := &gitlog.ConventionalParser{} 153 + entries := []changeset.Entry{} 154 + skipped := 0 155 + 156 + for _, commit := range commits { 157 + subject := commit.Message 158 + body := "" 159 + lines := strings.Split(commit.Message, "\n") 160 + if len(lines) > 0 { 161 + subject = lines[0] 162 + if len(lines) > 1 { 163 + body = strings.Join(lines[1:], "\n") 164 + } 165 + } 166 + 167 + meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) 168 + if err != nil { 169 + fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) 170 + continue 171 + } 172 + 173 + category := parser.Categorize(meta) 174 + if category == "" { 175 + skipped++ 176 + continue 177 + } 178 + 179 + entry := changeset.Entry{ 180 + Type: category, 181 + Scope: meta.Scope, 182 + Summary: meta.Description, 183 + Breaking: meta.Breaking, 184 + } 185 + 186 + entries = append(entries, entry) 187 + } 188 + 189 + changesDir := ".changes" 190 + created := 0 191 + for _, entry := range entries { 192 + filePath, err := changeset.Write(changesDir, entry) 193 + if err != nil { 194 + fmt.Printf("Error: failed to write entry: %v\n", err) 195 + continue 196 + } 197 + style.Addedf("✓ Created %s", filePath) 198 + created++ 199 + } 200 + 201 + fmt.Println() 202 + style.Headline(fmt.Sprintf("Generated %d changelog entries", created)) 203 + if skipped > 0 { 204 + style.Println("Skipped %d commits (reverts or non-matching types)", skipped) 205 + } 206 + 207 + return nil 208 + }, 209 + } 210 + 211 + c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 212 + c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 213 + 214 + return c 215 + }
+70
cmd/generate_test.go
··· 1 + package main 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stormlightlabs/git-storm/internal/testutils" 7 + ) 8 + 9 + func TestGetCommitRange(t *testing.T) { 10 + repo := testutils.SetupTestRepo(t) 11 + 12 + // Initial setup creates 3 commits (a.txt, b.txt, c.txt) 13 + // Let's add a tag at the second commit 14 + commits := testutils.GetCommitHistory(t, repo) 15 + if len(commits) < 3 { 16 + t.Fatalf("Expected at least 3 commits, got %d", len(commits)) 17 + } 18 + 19 + // Tag the oldest commit (which is at index len-1 due to reverse order) 20 + oldCommit := commits[len(commits)-2] // Second oldest commit 21 + if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil { 22 + t.Fatalf("Failed to create tag: %v", err) 23 + } 24 + 25 + // Add more commits after the tag 26 + testutils.AddCommit(t, repo, "d.txt", "content d", "feat: add d feature") 27 + testutils.AddCommit(t, repo, "e.txt", "content e", "fix: fix e bug") 28 + 29 + // Get commits between tag and HEAD 30 + rangeCommits, err := getCommitRange(repo, "v1.0.0", "HEAD") 31 + if err != nil { 32 + t.Fatalf("getCommitRange() error = %v", err) 33 + } 34 + 35 + // Should include the new commits (d.txt, e.txt) and commits after the tagged one 36 + if len(rangeCommits) < 2 { 37 + t.Errorf("Expected at least 2 commits in range, got %d", len(rangeCommits)) 38 + } 39 + 40 + // Verify commits are in chronological order (oldest first) 41 + for i := 1; i < len(rangeCommits); i++ { 42 + if rangeCommits[i].Author.When.Before(rangeCommits[i-1].Author.When) { 43 + t.Errorf("Commits are not in chronological order") 44 + } 45 + } 46 + } 47 + 48 + func TestGetCommitRange_SameRef(t *testing.T) { 49 + repo := testutils.SetupTestRepo(t) 50 + 51 + // Get commits between HEAD and HEAD (should be empty) 52 + rangeCommits, err := getCommitRange(repo, "HEAD", "HEAD") 53 + if err != nil { 54 + t.Fatalf("getCommitRange() error = %v", err) 55 + } 56 + 57 + if len(rangeCommits) != 0 { 58 + t.Errorf("Expected 0 commits when from and to are the same, got %d", len(rangeCommits)) 59 + } 60 + } 61 + 62 + func TestGetCommitRange_InvalidRef(t *testing.T) { 63 + repo := testutils.SetupTestRepo(t) 64 + 65 + // Try to get commits with invalid ref 66 + _, err := getCommitRange(repo, "invalid-ref", "HEAD") 67 + if err == nil { 68 + t.Errorf("Expected error for invalid ref, got nil") 69 + } 70 + }
-28
cmd/main.go
··· 21 21 ) 22 22 23 23 var ( 24 - fromRef string 25 - toRef string 26 - interactive bool 27 - sinceTag string 28 - ) 29 - 30 - var ( 31 24 changeType string 32 25 scope string 33 26 summary string ··· 307 300 root.AddCommand(add, list, review) 308 301 309 302 return root 310 - } 311 - 312 - func generateCmd() *cobra.Command { 313 - c := &cobra.Command{ 314 - Use: "generate [from] [to]", 315 - Short: "Generate changelog entries from Git commits", 316 - Long: `Scans commits between two Git refs (tags or hashes) and outputs draft 317 - entries in .changes/. Supports conventional commit parsing and 318 - interactive review mode.`, 319 - Args: cobra.MaximumNArgs(2), 320 - RunE: func(cmd *cobra.Command, args []string) error { 321 - fmt.Println("generate command not implemented") 322 - fmt.Printf("from=%v to=%v interactive=%v sinceTag=%v\n", fromRef, toRef, interactive, sinceTag) 323 - return nil 324 - }, 325 - } 326 - 327 - c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 328 - c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 329 - 330 - return c 331 303 } 332 304 333 305 func main() {
+41
cmd/unreleased.go
··· 1 + /* 2 + USAGE 3 + 4 + storm unreleased <subcommand> [options] 5 + 6 + SUBCOMMANDS 7 + 8 + add Add a new unreleased change entry 9 + list List all unreleased changes 10 + review Review unreleased changes interactively 11 + 12 + USAGE 13 + 14 + storm unreleased add [options] 15 + 16 + FLAGS 17 + 18 + --type <type> Change type (added, changed, fixed, removed, security) 19 + --scope <scope> Optional subsystem or module name 20 + --summary <text> Short description of the change 21 + --repo <path> Path to the repository (default: .) 22 + 23 + USAGE 24 + 25 + storm unreleased list [options] 26 + 27 + FLAGS 28 + 29 + --json Output as JSON 30 + --repo <path> Path to the repository (default: .) 31 + 32 + USAGE 33 + 34 + storm unreleased review [options] 35 + 36 + FLAGS 37 + 38 + --repo <path> Path to the repository (default: .) 39 + --output <file> Optional file to export reviewed notes 40 + */ 41 + package main
+2
go.mod
··· 11 11 github.com/spf13/cobra v1.10.1 12 12 ) 13 13 14 + require github.com/goccy/go-yaml v1.18.0 15 + 14 16 require ( 15 17 github.com/clipperhouse/displaywidth v0.4.1 // indirect 16 18 github.com/clipperhouse/stringish v0.1.1 // indirect
+2
go.sum
··· 74 74 github.com/go-git/go-git/v6 v6.0.0-20251103200709-47b1ed2930c9/go.mod h1:z9pQiXCfyOZIs/8qa5zmozzbcsDPtGN91UD7+qeX3hk= 75 75 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 76 76 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 77 + github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 78 + github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 77 79 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 78 80 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 79 81 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+74
internal/changeset/changeset.go
··· 1 + package changeset 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "regexp" 8 + "strings" 9 + "time" 10 + 11 + "github.com/goccy/go-yaml" 12 + ) 13 + 14 + // Entry represents a single changelog entry to be written to .changes/*.md 15 + type Entry struct { 16 + Type string `yaml:"type"` // added, changed, fixed, removed, security 17 + Scope string `yaml:"scope"` // optional scope 18 + Summary string `yaml:"summary"` // description 19 + Breaking bool `yaml:"breaking"` // true if breaking change 20 + } 21 + 22 + // Write creates a new .changes/<timestamp>-<slug>.md file with YAML frontmatter. 23 + // Creates the .changes directory if it doesn't exist. 24 + func Write(dir string, entry Entry) (string, error) { 25 + if err := os.MkdirAll(dir, 0755); err != nil { 26 + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 27 + } 28 + 29 + timestamp := time.Now().Format("20060102-150405") 30 + slug := slugify(entry.Summary) 31 + filename := fmt.Sprintf("%s-%s.md", timestamp, slug) 32 + filePath := filepath.Join(dir, filename) 33 + 34 + counter := 1 35 + for { 36 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 37 + break 38 + } 39 + // File exists, add counter 40 + filename = fmt.Sprintf("%s-%s-%d.md", timestamp, slug, counter) 41 + filePath = filepath.Join(dir, filename) 42 + counter++ 43 + } 44 + 45 + yamlBytes, err := yaml.Marshal(entry) 46 + if err != nil { 47 + return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) 48 + } 49 + 50 + content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 51 + 52 + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 53 + return "", fmt.Errorf("failed to write file %s: %w", filePath, err) 54 + } 55 + 56 + return filePath, nil 57 + } 58 + 59 + // slugify converts a string into a URL-friendly slug. 60 + // Converts to lowercase, replaces spaces and special chars with hyphens. 61 + func slugify(input string) string { 62 + s := strings.ToLower(input) 63 + reg := regexp.MustCompile(`[^a-z0-9]+`) 64 + s = reg.ReplaceAllString(s, "-") 65 + s = strings.Trim(s, "-") 66 + 67 + if len(s) > 50 { 68 + s = s[:50] 69 + } 70 + 71 + s = strings.TrimRight(s, "-") 72 + 73 + return s 74 + }
+202
internal/changeset/changeset_test.go
··· 1 + package changeset 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/goccy/go-yaml" 10 + ) 11 + 12 + func TestWrite(t *testing.T) { 13 + tmpDir := t.TempDir() 14 + 15 + tests := []struct { 16 + name string 17 + entry Entry 18 + wantType string 19 + wantScope string 20 + wantSummary string 21 + }{ 22 + { 23 + name: "basic entry", 24 + entry: Entry{ 25 + Type: "added", 26 + Scope: "cli", 27 + Summary: "Add changelog command", 28 + Breaking: false, 29 + }, 30 + wantType: "added", 31 + wantScope: "cli", 32 + wantSummary: "Add changelog command", 33 + }, 34 + { 35 + name: "entry without scope", 36 + entry: Entry{ 37 + Type: "fixed", 38 + Scope: "", 39 + Summary: "Fix bug in parser", 40 + Breaking: false, 41 + }, 42 + wantType: "fixed", 43 + wantScope: "", 44 + wantSummary: "Fix bug in parser", 45 + }, 46 + { 47 + name: "breaking change", 48 + entry: Entry{ 49 + Type: "changed", 50 + Scope: "api", 51 + Summary: "Remove legacy endpoints", 52 + Breaking: true, 53 + }, 54 + wantType: "changed", 55 + wantScope: "api", 56 + wantSummary: "Remove legacy endpoints", 57 + }, 58 + } 59 + 60 + for _, tt := range tests { 61 + t.Run(tt.name, func(t *testing.T) { 62 + filePath, err := Write(tmpDir, tt.entry) 63 + if err != nil { 64 + t.Fatalf("Write() error = %v", err) 65 + } 66 + 67 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 68 + t.Errorf("File was not created: %s", filePath) 69 + } 70 + 71 + content, err := os.ReadFile(filePath) 72 + if err != nil { 73 + t.Fatalf("Failed to read file: %v", err) 74 + } 75 + 76 + contentStr := string(content) 77 + if !strings.HasPrefix(contentStr, "---\n") { 78 + t.Errorf("File should start with YAML frontmatter delimiter") 79 + } 80 + 81 + parts := strings.SplitN(contentStr, "---\n", 3) 82 + if len(parts) < 3 { 83 + t.Fatalf("Invalid YAML frontmatter format") 84 + } 85 + 86 + var parsed Entry 87 + if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil { 88 + t.Fatalf("Failed to parse YAML: %v", err) 89 + } 90 + 91 + if parsed.Type != tt.wantType { 92 + t.Errorf("Type = %v, want %v", parsed.Type, tt.wantType) 93 + } 94 + if parsed.Scope != tt.wantScope { 95 + t.Errorf("Scope = %v, want %v", parsed.Scope, tt.wantScope) 96 + } 97 + if parsed.Summary != tt.wantSummary { 98 + t.Errorf("Summary = %v, want %v", parsed.Summary, tt.wantSummary) 99 + } 100 + if parsed.Breaking != tt.entry.Breaking { 101 + t.Errorf("Breaking = %v, want %v", parsed.Breaking, tt.entry.Breaking) 102 + } 103 + }) 104 + } 105 + } 106 + 107 + func TestWrite_CollisionHandling(t *testing.T) { 108 + tmpDir := t.TempDir() 109 + 110 + entry := Entry{ 111 + Type: "added", 112 + Scope: "test", 113 + Summary: "Test collision handling", 114 + } 115 + 116 + path1, err := Write(tmpDir, entry) 117 + if err != nil { 118 + t.Fatalf("First Write() error = %v", err) 119 + } 120 + 121 + path2, err := Write(tmpDir, entry) 122 + if err != nil { 123 + t.Fatalf("Second Write() error = %v", err) 124 + } 125 + 126 + if path1 == path2 { 127 + t.Errorf("Expected different file paths for collision, got same path: %s", path1) 128 + } 129 + 130 + if _, err := os.Stat(path1); os.IsNotExist(err) { 131 + t.Errorf("First file was not created: %s", path1) 132 + } 133 + if _, err := os.Stat(path2); os.IsNotExist(err) { 134 + t.Errorf("Second file was not created: %s", path2) 135 + } 136 + } 137 + 138 + func TestSlugify(t *testing.T) { 139 + tests := []struct { 140 + name string 141 + input string 142 + want string 143 + }{ 144 + { 145 + name: "simple text", 146 + input: "Add new feature", 147 + want: "add-new-feature", 148 + }, 149 + { 150 + name: "text with special chars", 151 + input: "Fix: bug in parser!", 152 + want: "fix-bug-in-parser", 153 + }, 154 + { 155 + name: "text with numbers", 156 + input: "Update version 1.2.3", 157 + want: "update-version-1-2-3", 158 + }, 159 + { 160 + name: "text with underscores", 161 + input: "Add user_profile field", 162 + want: "add-user-profile-field", 163 + }, 164 + { 165 + name: "long text gets truncated", 166 + input: "This is a very long summary that should be truncated to fifty characters maximum", 167 + want: "this-is-a-very-long-summary-that-should-be-truncat", 168 + }, 169 + } 170 + 171 + for _, tt := range tests { 172 + t.Run(tt.name, func(t *testing.T) { 173 + got := slugify(tt.input) 174 + if got != tt.want { 175 + t.Errorf("slugify() = %v, want %v", got, tt.want) 176 + } 177 + }) 178 + } 179 + } 180 + 181 + func TestWrite_DirectoryCreation(t *testing.T) { 182 + tmpDir := t.TempDir() 183 + changesDir := filepath.Join(tmpDir, "nested", "changes") 184 + 185 + entry := Entry{ 186 + Type: "added", 187 + Summary: "Test directory creation", 188 + } 189 + 190 + filePath, err := Write(changesDir, entry) 191 + if err != nil { 192 + t.Fatalf("Write() error = %v", err) 193 + } 194 + 195 + if _, err := os.Stat(changesDir); os.IsNotExist(err) { 196 + t.Errorf("Directory was not created: %s", changesDir) 197 + } 198 + 199 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 200 + t.Errorf("File was not created: %s", filePath) 201 + } 202 + }
+180
internal/gitlog/gitlog.go
··· 86 86 // ConventionalParser implements [CommitParser] and parses 87 87 // conventional commits into one or more [CommitMeta] 88 88 type ConventionalParser struct{} 89 + 90 + // Parse parses a conventional commit message into structured metadata. 91 + // Format: type(scope): description or type(scope)!: description 92 + // Breaking changes can also be indicated by BREAKING CHANGE: in footer. 93 + func (p *ConventionalParser) Parse(hash, subject, body string, date time.Time) (CommitMeta, error) { 94 + meta := CommitMeta{ 95 + Footers: make(map[string]string), 96 + } 97 + 98 + rest := subject 99 + 100 + colonIdx := -1 101 + for i := 0; i < len(rest); i++ { 102 + if rest[i] == ':' { 103 + colonIdx = i 104 + break 105 + } 106 + } 107 + 108 + if colonIdx == -1 { 109 + return CommitMeta{ 110 + Type: "unknown", 111 + Description: subject, 112 + Body: body, 113 + }, nil 114 + } 115 + 116 + prefix := rest[:colonIdx] 117 + description := "" 118 + if colonIdx+1 < len(rest) { 119 + description = rest[colonIdx+1:] 120 + if len(description) > 0 && description[0] == ' ' { 121 + description = description[1:] 122 + } 123 + } 124 + 125 + breaking := false 126 + if len(prefix) > 0 && prefix[len(prefix)-1] == '!' { 127 + breaking = true 128 + prefix = prefix[:len(prefix)-1] 129 + } 130 + 131 + scope := "" 132 + commitType := prefix 133 + 134 + parenStart := -1 135 + for i := 0; i < len(prefix); i++ { 136 + if prefix[i] == '(' { 137 + parenStart = i 138 + break 139 + } 140 + } 141 + 142 + if parenStart != -1 { 143 + commitType = prefix[:parenStart] 144 + parenEnd := -1 145 + for i := parenStart + 1; i < len(prefix); i++ { 146 + if prefix[i] == ')' { 147 + parenEnd = i 148 + break 149 + } 150 + } 151 + if parenEnd != -1 { 152 + scope = prefix[parenStart+1 : parenEnd] 153 + } 154 + } 155 + 156 + meta.Type = commitType 157 + meta.Scope = scope 158 + meta.Description = description 159 + meta.Breaking = breaking 160 + meta.Body = body 161 + 162 + if body != "" { 163 + lines := splitLines(body) 164 + inFooter := false 165 + currentFooter := "" 166 + currentValue := "" 167 + 168 + for _, line := range lines { 169 + if len(line) > 0 && !inFooter { 170 + colonIdx := -1 171 + for i := 0; i < len(line); i++ { 172 + if line[i] == ':' { 173 + colonIdx = i 174 + break 175 + } 176 + } 177 + if colonIdx != -1 { 178 + key := line[:colonIdx] 179 + value := "" 180 + if colonIdx+1 < len(line) { 181 + value = line[colonIdx+1:] 182 + if len(value) > 0 && value[0] == ' ' { 183 + value = value[1:] 184 + } 185 + } 186 + 187 + if key == "BREAKING CHANGE" || key == "BREAKING-CHANGE" { 188 + meta.Breaking = true 189 + inFooter = true 190 + currentFooter = key 191 + currentValue = value 192 + continue 193 + } 194 + } 195 + } 196 + 197 + if inFooter { 198 + if line == "" { 199 + if currentFooter != "" { 200 + meta.Footers[currentFooter] = currentValue 201 + } 202 + inFooter = false 203 + currentFooter = "" 204 + currentValue = "" 205 + } else { 206 + if currentValue != "" { 207 + currentValue += "\n" 208 + } 209 + currentValue += line 210 + } 211 + } 212 + } 213 + 214 + if inFooter && currentFooter != "" { 215 + meta.Footers[currentFooter] = currentValue 216 + } 217 + } 218 + 219 + return meta, nil 220 + } 221 + 222 + // IsValidType returns true if the given CommitKind is a valid conventional commit type. 223 + func (p *ConventionalParser) IsValidType(kind CommitKind) bool { 224 + return kind != CommitTypeUnknown 225 + } 226 + 227 + // Categorize maps a CommitMeta to a changelog category. 228 + func (p *ConventionalParser) Categorize(meta CommitMeta) string { 229 + switch meta.Type { 230 + case "feat": 231 + return "added" 232 + case "fix": 233 + return "fixed" 234 + case "perf", "refactor": 235 + return "changed" 236 + case "docs", "style", "test", "build", "ci", "chore": 237 + return "changed" 238 + case "revert": 239 + return "" // Skip reverts 240 + default: 241 + return "" // Unknown types are skipped 242 + } 243 + } 244 + 245 + // splitLines splits a string into lines, handling both \n and \r\n. 246 + func splitLines(s string) []string { 247 + if s == "" { 248 + return nil 249 + } 250 + 251 + lines := []string{} 252 + start := 0 253 + for i := 0; i < len(s); i++ { 254 + if s[i] == '\n' { 255 + line := s[start:i] 256 + if len(line) > 0 && line[len(line)-1] == '\r' { 257 + line = line[:len(line)-1] 258 + } 259 + lines = append(lines, line) 260 + start = i + 1 261 + } 262 + } 263 + 264 + if start < len(s) { 265 + lines = append(lines, s[start:]) 266 + } 267 + return lines 268 + }
+220
internal/gitlog/gitlog_test.go
··· 1 + package gitlog 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + ) 7 + 8 + func TestConventionalParser_Parse(t *testing.T) { 9 + parser := &ConventionalParser{} 10 + testTime := time.Now() 11 + 12 + tests := []struct { 13 + name string 14 + subject string 15 + body string 16 + wantType string 17 + wantScope string 18 + wantDesc string 19 + wantBreak bool 20 + }{ 21 + { 22 + name: "simple feat", 23 + subject: "feat: add new feature", 24 + body: "", 25 + wantType: "feat", 26 + wantScope: "", 27 + wantDesc: "add new feature", 28 + wantBreak: false, 29 + }, 30 + { 31 + name: "feat with scope", 32 + subject: "feat(api): add pagination endpoint", 33 + body: "", 34 + wantType: "feat", 35 + wantScope: "api", 36 + wantDesc: "add pagination endpoint", 37 + wantBreak: false, 38 + }, 39 + { 40 + name: "fix with scope", 41 + subject: "fix(ui): correct button alignment issue", 42 + body: "", 43 + wantType: "fix", 44 + wantScope: "ui", 45 + wantDesc: "correct button alignment issue", 46 + wantBreak: false, 47 + }, 48 + { 49 + name: "breaking change with !", 50 + subject: "feat(api)!: remove support for legacy endpoints", 51 + body: "", 52 + wantType: "feat", 53 + wantScope: "api", 54 + wantDesc: "remove support for legacy endpoints", 55 + wantBreak: true, 56 + }, 57 + { 58 + name: "breaking change without scope", 59 + subject: "feat!: major API redesign", 60 + body: "", 61 + wantType: "feat", 62 + wantScope: "", 63 + wantDesc: "major API redesign", 64 + wantBreak: true, 65 + }, 66 + { 67 + name: "breaking change in footer", 68 + subject: "feat(api): update authentication", 69 + body: "Some details here\n\nBREAKING CHANGE: API no longer accepts XML-formatted requests.", 70 + wantType: "feat", 71 + wantScope: "api", 72 + wantDesc: "update authentication", 73 + wantBreak: true, 74 + }, 75 + { 76 + name: "docs commit", 77 + subject: "docs: update README installation instructions", 78 + body: "", 79 + wantType: "docs", 80 + wantScope: "", 81 + wantDesc: "update README installation instructions", 82 + wantBreak: false, 83 + }, 84 + { 85 + name: "chore commit", 86 + subject: "chore: update .gitignore", 87 + body: "", 88 + wantType: "chore", 89 + wantScope: "", 90 + wantDesc: "update .gitignore", 91 + wantBreak: false, 92 + }, 93 + { 94 + name: "non-conventional commit", 95 + subject: "some random commit message", 96 + body: "", 97 + wantType: "unknown", 98 + wantScope: "", 99 + wantDesc: "some random commit message", 100 + wantBreak: false, 101 + }, 102 + } 103 + 104 + for _, tt := range tests { 105 + t.Run(tt.name, func(t *testing.T) { 106 + meta, err := parser.Parse("abc123", tt.subject, tt.body, testTime) 107 + if err != nil { 108 + t.Fatalf("Parse() error = %v", err) 109 + } 110 + 111 + if meta.Type != tt.wantType { 112 + t.Errorf("Type = %v, want %v", meta.Type, tt.wantType) 113 + } 114 + if meta.Scope != tt.wantScope { 115 + t.Errorf("Scope = %v, want %v", meta.Scope, tt.wantScope) 116 + } 117 + if meta.Description != tt.wantDesc { 118 + t.Errorf("Description = %v, want %v", meta.Description, tt.wantDesc) 119 + } 120 + if meta.Breaking != tt.wantBreak { 121 + t.Errorf("Breaking = %v, want %v", meta.Breaking, tt.wantBreak) 122 + } 123 + }) 124 + } 125 + } 126 + 127 + func TestConventionalParser_Categorize(t *testing.T) { 128 + parser := &ConventionalParser{} 129 + 130 + tests := []struct { 131 + name string 132 + meta CommitMeta 133 + wantCat string 134 + }{ 135 + { 136 + name: "feat -> added", 137 + meta: CommitMeta{Type: "feat"}, 138 + wantCat: "added", 139 + }, 140 + { 141 + name: "fix -> fixed", 142 + meta: CommitMeta{Type: "fix"}, 143 + wantCat: "fixed", 144 + }, 145 + { 146 + name: "perf -> changed", 147 + meta: CommitMeta{Type: "perf"}, 148 + wantCat: "changed", 149 + }, 150 + { 151 + name: "refactor -> changed", 152 + meta: CommitMeta{Type: "refactor"}, 153 + wantCat: "changed", 154 + }, 155 + { 156 + name: "docs -> changed", 157 + meta: CommitMeta{Type: "docs"}, 158 + wantCat: "changed", 159 + }, 160 + { 161 + name: "test -> changed", 162 + meta: CommitMeta{Type: "test"}, 163 + wantCat: "changed", 164 + }, 165 + { 166 + name: "revert -> skip", 167 + meta: CommitMeta{Type: "revert"}, 168 + wantCat: "", 169 + }, 170 + { 171 + name: "unknown -> skip", 172 + meta: CommitMeta{Type: "unknown"}, 173 + wantCat: "", 174 + }, 175 + } 176 + 177 + for _, tt := range tests { 178 + t.Run(tt.name, func(t *testing.T) { 179 + got := parser.Categorize(tt.meta) 180 + if got != tt.wantCat { 181 + t.Errorf("Categorize() = %v, want %v", got, tt.wantCat) 182 + } 183 + }) 184 + } 185 + } 186 + 187 + func TestConventionalParser_IsValidType(t *testing.T) { 188 + parser := &ConventionalParser{} 189 + 190 + tests := []struct { 191 + name string 192 + kind CommitKind 193 + want bool 194 + }{ 195 + { 196 + name: "feat is valid", 197 + kind: CommitTypeFeat, 198 + want: true, 199 + }, 200 + { 201 + name: "fix is valid", 202 + kind: CommitTypeFix, 203 + want: true, 204 + }, 205 + { 206 + name: "unknown is invalid", 207 + kind: CommitTypeUnknown, 208 + want: false, 209 + }, 210 + } 211 + 212 + for _, tt := range tests { 213 + t.Run(tt.name, func(t *testing.T) { 214 + got := parser.IsValidType(tt.kind) 215 + if got != tt.want { 216 + t.Errorf("IsValidType() = %v, want %v", got, tt.want) 217 + } 218 + }) 219 + } 220 + }
+14 -6
internal/style/style.go
··· 43 43 fmt.Println(v) 44 44 } 45 45 46 + func Addedf(format string, args ...any) { 47 + s := fmt.Sprintf(format, args...) 48 + v := StyleAdded.Render(s) 49 + fmt.Println(v) 50 + } 51 + 46 52 func Fixed(s string) { 47 53 v := StyleFixed.Render(s) 48 54 fmt.Println(v) 49 55 } 50 56 51 57 func Styled(st lipgloss.Style) func(s string, a ...any) { 52 - return func(s string, a ...any) { 53 - fmt.Printf(s, a...) 54 - } 58 + return func(s string, a ...any) { fmt.Printf(s, a...) } 55 59 } 56 60 57 61 func Styledln(st lipgloss.Style) func(s string, a ...any) { 58 - return func(s string, a ...any) { 59 - fmt.Println(fmt.Sprintf(s, a...)) 60 - } 62 + return func(s string, a ...any) { fmt.Println(fmt.Sprintf(s, a...)) } 63 + } 64 + 65 + // Println wraps [fmt.Println] & [fmt.Sprintf] 66 + func Println(format string, args ...any) { 67 + msg := fmt.Sprintf(format, args...) 68 + fmt.Println(msg) 61 69 }
+17 -4
internal/testutils/testutils.go
··· 7 7 "time" 8 8 9 9 "github.com/go-git/go-git/v6" 10 + "github.com/go-git/go-git/v6/plumbing" 10 11 "github.com/go-git/go-git/v6/plumbing/object" 11 12 ) 12 13 13 - // SetupTestRepo creates a git repository in a temporary directory with sample commits. 14 - // The repository contains multiple commits with different types of changes to support 15 - // testing diff algorithms, changelog generation, and git log parsing. 14 + // SetupTestRepo creates a git repository in a temporary directory with sample 15 + // commits. The repository contains multiple commits with different types of 16 + // changes to support testing diff algorithms, changelog generation, and git log 17 + // parsing. 16 18 func SetupTestRepo(t *testing.T) *git.Repository { 17 19 t.Helper() 18 20 dir := t.TempDir() ··· 27 29 t.Fatalf("failed to get worktree: %v", err) 28 30 } 29 31 30 - // Create commits with varied content for diff testing 31 32 commits := []struct { 32 33 name, content, message string 33 34 }{ ··· 72 73 if err != nil { 73 74 t.Fatalf("failed to create tag %s: %v", tagName, err) 74 75 } 76 + } 77 + 78 + // CreateTagAtCommit creates a lightweight tag at a specific commit hash. 79 + func CreateTagAtCommit(t *testing.T, repo *git.Repository, tagName, commitHash string) error { 80 + t.Helper() 81 + hash, err := repo.ResolveRevision(plumbing.Revision(commitHash)) 82 + if err != nil { 83 + return err 84 + } 85 + 86 + _, err = repo.CreateTag(tagName, *hash, nil) 87 + return err 75 88 } 76 89 77 90 // GetCommitHistory returns all commits in the repository from HEAD backwards.