changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 10 kB view raw
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}