changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package gitlog
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/go-git/go-git/v6"
9 "github.com/go-git/go-git/v6/plumbing"
10 "github.com/go-git/go-git/v6/plumbing/object"
11)
12
13const ShaLen = 7
14
15// CommitKind represents the kind of commit according to Conventional Commits.
16type CommitKind int
17
18const (
19 CommitTypeUnknown CommitKind = iota
20 CommitTypeFeat
21 CommitTypeFix
22 CommitTypeDocs
23 CommitTypeStyle
24 CommitTypeRefactor
25 CommitTypePerf
26 CommitTypeTest
27 CommitTypeBuild
28 CommitTypeCI
29 CommitTypeChore
30 CommitTypeRevert
31)
32
33// String returns the string representation of the CommitType.
34func (kind CommitKind) String() string {
35 switch kind {
36 case CommitTypeFeat:
37 return "feat"
38 case CommitTypeFix:
39 return "fix"
40 case CommitTypeDocs:
41 return "docs"
42 case CommitTypeStyle:
43 return "style"
44 case CommitTypeRefactor:
45 return "refactor"
46 case CommitTypePerf:
47 return "perf"
48 case CommitTypeTest:
49 return "test"
50 case CommitTypeBuild:
51 return "build"
52 case CommitTypeCI:
53 return "ci"
54 case CommitTypeChore:
55 return "chore"
56 case CommitTypeRevert:
57 return "revert"
58 default:
59 return "unknown"
60 }
61}
62
63func Log() { fmt.Println(git.GitDirName) }
64
65type CommitMeta struct {
66 Type string // feat, fix, docs, etc.
67 Scope string // optional
68 Description string
69 Breaking bool
70 Body string
71 Footers map[string]string
72}
73
74// CommitParser defines parsing of raw commit message strings into structured metadata.
75type CommitParser interface {
76 // Parse takes a commit hash, subject line, body (including footers)
77 // and the commit date, and returns a structured [CommitMeta]
78 Parse(hash, subject, body string, date time.Time) (CommitMeta, error)
79
80 // IsValidType returns true if the given [CommitKind] is recognised / allowed by your tooling.
81 IsValidType(kind CommitKind) bool
82
83 // Categorize returns the category (e.g., "Added", "Fixed", "Changed") for the given CommitMeta.
84 Categorize(meta CommitMeta) string
85}
86
87// DefaultParser implements [CommitParser] and parses single
88// or multi-line commits into one or more [CommitMeta]
89type DefaultParser struct{}
90
91// ConventionalParser implements [CommitParser] and parses
92// conventional commits into one or more [CommitMeta]
93type ConventionalParser struct{}
94
95// Parse parses a conventional commit message into structured metadata.
96//
97// Format:
98//
99// type(scope): description or type(scope)!: description
100//
101// Breaking changes can also be indicated by BREAKING CHANGE: in footer.
102func (p *ConventionalParser) Parse(hash, subject, body string, date time.Time) (CommitMeta, error) {
103 meta := CommitMeta{
104 Footers: make(map[string]string),
105 }
106
107 rest := subject
108
109 colonIdx := -1
110 for i := 0; i < len(rest); i++ {
111 if rest[i] == ':' {
112 colonIdx = i
113 break
114 }
115 }
116
117 if colonIdx == -1 {
118 return CommitMeta{
119 Type: "unknown",
120 Description: subject,
121 Body: body,
122 }, nil
123 }
124
125 prefix := rest[:colonIdx]
126 description := ""
127 if colonIdx+1 < len(rest) {
128 description = rest[colonIdx+1:]
129 if len(description) > 0 && description[0] == ' ' {
130 description = description[1:]
131 }
132 }
133
134 breaking := false
135 if len(prefix) > 0 && prefix[len(prefix)-1] == '!' {
136 breaking = true
137 prefix = prefix[:len(prefix)-1]
138 }
139
140 scope := ""
141 commitType := prefix
142
143 parenStart := -1
144 for i := 0; i < len(prefix); i++ {
145 if prefix[i] == '(' {
146 parenStart = i
147 break
148 }
149 }
150
151 if parenStart != -1 {
152 commitType = prefix[:parenStart]
153 parenEnd := -1
154 for i := parenStart + 1; i < len(prefix); i++ {
155 if prefix[i] == ')' {
156 parenEnd = i
157 break
158 }
159 }
160 if parenEnd != -1 {
161 scope = prefix[parenStart+1 : parenEnd]
162 }
163 }
164
165 meta.Type = commitType
166 meta.Scope = scope
167 meta.Description = description
168 meta.Breaking = breaking
169 meta.Body = body
170
171 if body != "" {
172 lines := splitLines(body)
173 inFooter := false
174 currentFooter := ""
175 currentValue := ""
176
177 for _, line := range lines {
178 if len(line) > 0 && !inFooter {
179 colonIdx := -1
180 for i := 0; i < len(line); i++ {
181 if line[i] == ':' {
182 colonIdx = i
183 break
184 }
185 }
186 if colonIdx != -1 {
187 key := line[:colonIdx]
188 value := ""
189 if colonIdx+1 < len(line) {
190 value = line[colonIdx+1:]
191 if len(value) > 0 && value[0] == ' ' {
192 value = value[1:]
193 }
194 }
195
196 if key == "BREAKING CHANGE" || key == "BREAKING-CHANGE" {
197 meta.Breaking = true
198 inFooter = true
199 currentFooter = key
200 currentValue = value
201 continue
202 }
203 }
204 }
205
206 if inFooter {
207 if line == "" {
208 if currentFooter != "" {
209 meta.Footers[currentFooter] = currentValue
210 }
211 inFooter = false
212 currentFooter = ""
213 currentValue = ""
214 } else {
215 if currentValue != "" {
216 currentValue += "\n"
217 }
218 currentValue += line
219 }
220 }
221 }
222
223 if inFooter && currentFooter != "" {
224 meta.Footers[currentFooter] = currentValue
225 }
226 }
227
228 return meta, nil
229}
230
231// IsValidType returns true if the given CommitKind is a valid conventional commit type.
232func (p *ConventionalParser) IsValidType(kind CommitKind) bool {
233 return kind != CommitTypeUnknown
234}
235
236// Categorize maps a CommitMeta to a changelog category.
237func (p *ConventionalParser) Categorize(meta CommitMeta) string {
238 switch meta.Type {
239 case "feat":
240 return "added"
241 case "fix":
242 return "fixed"
243 case "perf", "refactor":
244 return "changed"
245 case "docs", "style", "test", "build", "ci", "chore":
246 return "changed"
247 case "revert":
248 return ""
249 default:
250 return ""
251 }
252}
253
254// splitLines splits a string into lines, handling both \n and \r\n.
255func splitLines(s string) []string {
256 if s == "" {
257 return nil
258 }
259
260 lines := []string{}
261 start := 0
262 for i := 0; i < len(s); i++ {
263 if s[i] == '\n' {
264 line := s[start:i]
265 if len(line) > 0 && line[len(line)-1] == '\r' {
266 line = line[:len(line)-1]
267 }
268 lines = append(lines, line)
269 start = i + 1
270 }
271 }
272
273 if start < len(s) {
274 lines = append(lines, s[start:])
275 }
276 return lines
277}
278
279// ParseRefArgs parses command arguments to extract from/to refs.
280//
281// Supports both "from..to" and "from to" syntax.
282// If only one arg, treats it as from with to=HEAD.
283func ParseRefArgs(args []string) (from, to string) {
284 if len(args) == 0 {
285 return "", ""
286 }
287 if len(args) == 1 {
288 parts := strings.Split(args[0], "..")
289 if len(parts) == 2 {
290 return parts[0], parts[1]
291 }
292 return args[0], "HEAD"
293 }
294 return args[0], args[1]
295}
296
297// GetCommitRange returns commits reachable from toRef but not from fromRef.
298// This implements git log from..to range semantics.
299func GetCommitRange(repo *git.Repository, fromRef, toRef string) ([]*object.Commit, error) {
300 fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef))
301 if err != nil {
302 return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err)
303 }
304
305 toHash, err := repo.ResolveRevision(plumbing.Revision(toRef))
306 if err != nil {
307 return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err)
308 }
309
310 toCommits := make(map[plumbing.Hash]bool)
311 toIter, err := repo.Log(&git.LogOptions{From: *toHash})
312 if err != nil {
313 return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err)
314 }
315
316 err = toIter.ForEach(func(c *object.Commit) error {
317 toCommits[c.Hash] = true
318 return nil
319 })
320 if err != nil {
321 return nil, fmt.Errorf("failed to iterate commits from %s: %w", toRef, err)
322 }
323
324 fromCommits := make(map[plumbing.Hash]bool)
325 fromIter, err := repo.Log(&git.LogOptions{From: *fromHash})
326 if err != nil {
327 return nil, fmt.Errorf("failed to get commits from %s: %w", fromRef, err)
328 }
329
330 err = fromIter.ForEach(func(c *object.Commit) error {
331 fromCommits[c.Hash] = true
332 return nil
333 })
334 if err != nil {
335 return nil, fmt.Errorf("failed to iterate commits from %s: %w", fromRef, err)
336 }
337
338 // Collect commits that are in toCommits but not in fromCommits
339 result := []*object.Commit{}
340 toIter, err = repo.Log(&git.LogOptions{From: *toHash})
341 if err != nil {
342 return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err)
343 }
344
345 err = toIter.ForEach(func(c *object.Commit) error {
346 if !fromCommits[c.Hash] {
347 result = append(result, c)
348 }
349 return nil
350 })
351 if err != nil {
352 return nil, fmt.Errorf("failed to collect commit range: %w", err)
353 }
354
355 // Reverse to get chronological order (oldest first)
356 for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
357 result[i], result[j] = result[j], result[i]
358 }
359
360 return result, nil
361}
362
363// GetFileContent reads the content of a file at a specific ref (commit, tag, or branch).
364func GetFileContent(repo *git.Repository, ref, filePath string) (string, error) {
365 hash, err := repo.ResolveRevision(plumbing.Revision(ref))
366 if err != nil {
367 return "", fmt.Errorf("failed to resolve %s: %w", ref, err)
368 }
369
370 commit, err := repo.CommitObject(*hash)
371 if err != nil {
372 return "", fmt.Errorf("failed to get commit: %w", err)
373 }
374
375 tree, err := commit.Tree()
376 if err != nil {
377 return "", fmt.Errorf("failed to get tree: %w", err)
378 }
379
380 file, err := tree.File(filePath)
381 if err != nil {
382 return "", fmt.Errorf("file not found: %w", err)
383 }
384
385 content, err := file.Contents()
386 if err != nil {
387 return "", fmt.Errorf("failed to read file content: %w", err)
388 }
389
390 return content, nil
391}
392
393// GetChangedFiles returns the list of files that changed between two commits.
394func GetChangedFiles(repo *git.Repository, fromRef, toRef string) ([]string, error) {
395 fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef))
396 if err != nil {
397 return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err)
398 }
399
400 toHash, err := repo.ResolveRevision(plumbing.Revision(toRef))
401 if err != nil {
402 return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err)
403 }
404
405 fromCommit, err := repo.CommitObject(*fromHash)
406 if err != nil {
407 return nil, fmt.Errorf("failed to get commit %s: %w", fromRef, err)
408 }
409
410 toCommit, err := repo.CommitObject(*toHash)
411 if err != nil {
412 return nil, fmt.Errorf("failed to get commit %s: %w", toRef, err)
413 }
414
415 fromTree, err := fromCommit.Tree()
416 if err != nil {
417 return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef, err)
418 }
419
420 toTree, err := toCommit.Tree()
421 if err != nil {
422 return nil, fmt.Errorf("failed to get tree for %s: %w", toRef, err)
423 }
424
425 changes, err := fromTree.Diff(toTree)
426 if err != nil {
427 return nil, fmt.Errorf("failed to compute diff: %w", err)
428 }
429
430 files := make([]string, 0, len(changes))
431 for _, change := range changes {
432 if change.To.Name != "" {
433 files = append(files, change.To.Name)
434 } else {
435 files = append(files, change.From.Name)
436 }
437 }
438
439 return files, nil
440}