changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 13 kB view raw
1// TODO(determinism): Make changeset file generation deterministic using diff-based identity 2// 3// Current implementation uses [time.Now] for filenames, causing duplicate entries 4// when generate is run multiple times on the same commit range. 5// 6// Store commit metadata in .changes/data/<diff-hash>.json: 7// - Compute hash of git diff content (not commit message) 8// - Use diff hash as stable identifier across rebases 9// - Store JSON metadata: {commit_hash, diff_hash, type, scope, summary, breaking, author, date} 10// - Generate .changes/<diff-hash-7>-<slug>.md from metadata 11// 12// Implementation: 13// 1. Add DiffHash field to Entry struct 14// 2. Add CommitHash field for tracking source (optional, for reference) 15// 3. Create ComputeDiffHash(commit) function: 16// - Get commit.Tree() and parent.Tree() 17// - Compute diff between trees 18// - Hash the diff content (files changed + line changes) 19// - Return hex string 20// 21// 4. Update Write() to: 22// - Accept diff hash as parameter 23// - Use format: .changes/<diff-hash-7>-<slug>.md 24// - Write JSON to .changes/data/<diff-hash>.json 25// - Check if diff hash exists before writing (deduplication) 26// 27// 5. Add Read() function to parse existing entries by diff hash 28// 29// Directory structure: 30// 31// .changes/ 32// a1b2c3d-add-authentication.md # Human-readable entry 33// e5f6a7b-fix-memory-leak.md 34// data/ 35// a1b2c3d4e5f6...json # Full metadata 36// e5f6a7b8c9d0...json 37// 38// Related: See cmd/generate.go TODO for deduplication logic 39package changeset 40 41import ( 42 "bytes" 43 "context" 44 "crypto/sha256" 45 "encoding/hex" 46 "encoding/json" 47 "fmt" 48 "os" 49 "path/filepath" 50 "regexp" 51 "sort" 52 "strings" 53 "time" 54 55 "github.com/go-git/go-git/v6/plumbing/object" 56 "github.com/goccy/go-yaml" 57) 58 59// Entry represents a single changelog entry to be written to .changes/*.md 60type Entry struct { 61 Type string `yaml:"type"` // added, changed, fixed, removed, security 62 Scope string `yaml:"scope"` // optional scope 63 Summary string `yaml:"summary"` // description 64 Breaking bool `yaml:"breaking"` // true if breaking change 65 CommitHash string `yaml:"commit_hash,omitempty"` // source commit hash (for reference) 66 DiffHash string `yaml:"diff_hash,omitempty"` // hash of git diff content (for deduplication) 67} 68 69// Metadata stores complete entry information in .changes/data/*.json for deduplication 70type Metadata struct { 71 CommitHash string `json:"commit_hash"` // current commit hash 72 DiffHash string `json:"diff_hash"` // stable diff content hash 73 Filename string `json:"filename"` // relative path to .md file 74 Type string `json:"type"` 75 Scope string `json:"scope"` 76 Summary string `json:"summary"` 77 Breaking bool `json:"breaking"` 78 Author string `json:"author"` 79 Date time.Time `json:"date"` 80} 81 82// Write creates a new .changes/<timestamp>-<slug>.md file with YAML frontmatter. 83// Creates the .changes directory if it doesn't exist. 84func Write(dir string, entry Entry) (string, error) { 85 if err := os.MkdirAll(dir, 0755); err != nil { 86 return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 87 } 88 89 timestamp := time.Now().Format("20060102-150405") 90 slug := slugify(entry.Summary) 91 filename := fmt.Sprintf("%s-%s.md", timestamp, slug) 92 filePath := filepath.Join(dir, filename) 93 94 counter := 1 95 for { 96 if _, err := os.Stat(filePath); os.IsNotExist(err) { 97 break 98 } 99 filename = fmt.Sprintf("%s-%s-%d.md", timestamp, slug, counter) 100 filePath = filepath.Join(dir, filename) 101 counter++ 102 } 103 104 yamlBytes, err := yaml.Marshal(entry) 105 if err != nil { 106 return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) 107 } 108 109 content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 110 111 if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 112 return "", fmt.Errorf("failed to write file %s: %w", filePath, err) 113 } 114 115 return filePath, nil 116} 117 118// WritePartial creates a .changes/<filename> file with the specified name and YAML frontmatter. 119// This is used by the `unreleased partial` command to create entries with commit-hash based names. 120// Creates the .changes directory if it doesn't exist. 121func WritePartial(dir string, filename string, entry Entry) (string, error) { 122 if err := os.MkdirAll(dir, 0755); err != nil { 123 return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 124 } 125 126 filePath := filepath.Join(dir, filename) 127 128 if _, err := os.Stat(filePath); err == nil { 129 return "", fmt.Errorf("file %s already exists", filename) 130 } 131 132 yamlBytes, err := yaml.Marshal(entry) 133 if err != nil { 134 return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) 135 } 136 137 content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 138 139 if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 140 return "", fmt.Errorf("failed to write file %s: %w", filePath, err) 141 } 142 143 return filePath, nil 144} 145 146// WriteWithMetadata creates a new .changes/<diffHash7>-<slug>.md file with YAML 147// frontmatter and saves corresponding metadata to .changes/data/<diffHash>.json. 148// 149// The filename uses the first 7 characters of the diff hash for human-readable 150// identification, while the JSON metadata file uses the full hash for 151// deduplication lookups. 152func WriteWithMetadata(dir string, meta Metadata) (string, error) { 153 if err := os.MkdirAll(dir, 0755); err != nil { 154 return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 155 } 156 157 diffHashShort := meta.DiffHash[:7] 158 slug := slugify(meta.Summary) 159 filename := fmt.Sprintf("%s-%s.md", diffHashShort, slug) 160 filePath := filepath.Join(dir, filename) 161 162 entry := Entry{ 163 Type: meta.Type, 164 Scope: meta.Scope, 165 Summary: meta.Summary, 166 Breaking: meta.Breaking, 167 CommitHash: meta.CommitHash, 168 DiffHash: meta.DiffHash, 169 } 170 171 yamlBytes, err := yaml.Marshal(entry) 172 if err != nil { 173 return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) 174 } 175 176 content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 177 if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 178 return "", fmt.Errorf("failed to write file %s: %w", filePath, err) 179 } 180 181 meta.Filename = filename 182 if err := SaveMetadata(dir, meta); err != nil { 183 return "", fmt.Errorf("failed to save metadata: %w", err) 184 } 185 return filePath, nil 186} 187 188// slugify converts a string into a URL-friendly slug by converting to lowercase, 189// replaces spaces and special chars with hyphens. 190func slugify(input string) string { 191 s := strings.ToLower(input) 192 reg := regexp.MustCompile(`[^a-z0-9]+`) 193 s = reg.ReplaceAllString(s, "-") 194 s = strings.Trim(s, "-") 195 196 if len(s) > 50 { 197 s = s[:50] 198 } 199 200 s = strings.TrimRight(s, "-") 201 202 return s 203} 204 205// EntryWithFile pairs an Entry with its source filename for display/processing. 206type EntryWithFile struct { 207 Entry Entry 208 Filename string 209} 210 211// List reads all .changes/*.md files and returns their parsed entries. 212func List(dir string) ([]EntryWithFile, error) { 213 entries, err := os.ReadDir(dir) 214 if err != nil { 215 if os.IsNotExist(err) { 216 return []EntryWithFile{}, nil 217 } 218 return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) 219 } 220 221 var results []EntryWithFile 222 223 for _, entry := range entries { 224 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 225 continue 226 } 227 228 filePath := filepath.Join(dir, entry.Name()) 229 content, err := os.ReadFile(filePath) 230 if err != nil { 231 return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) 232 } 233 234 parsed, err := parseEntry(content) 235 if err != nil { 236 return nil, fmt.Errorf("failed to parse %s: %w", entry.Name(), err) 237 } 238 239 results = append(results, EntryWithFile{ 240 Entry: parsed, 241 Filename: entry.Name(), 242 }) 243 } 244 245 return results, nil 246} 247 248// parseEntry extracts YAML frontmatter from a markdown file and unmarshals it into an Entry. 249func parseEntry(content []byte) (Entry, error) { 250 var entry Entry 251 252 parts := bytes.Split(content, []byte("---")) 253 if len(parts) < 3 { 254 return entry, fmt.Errorf("invalid frontmatter format: expected ---...--- delimiters") 255 } 256 257 yamlContent := parts[1] 258 if err := yaml.Unmarshal(yamlContent, &entry); err != nil { 259 return entry, fmt.Errorf("failed to unmarshal YAML: %w", err) 260 } 261 262 return entry, nil 263} 264 265// ComputeDiffHash calculates a stable hash of the commit's diff content. This 266// hash is independent of the commit hash, so rebased commits with identical 267// diffs will produce the same hash. 268// 269// The hash is computed from: 270// - Sorted list of changed file paths 271// - For each file: the full diff content (additions and deletions) 272func ComputeDiffHash(commit *object.Commit) (string, error) { 273 tree, err := commit.Tree() 274 if err != nil { 275 return "", fmt.Errorf("failed to get commit tree: %w", err) 276 } 277 278 var parentTree *object.Tree 279 if commit.NumParents() > 0 { 280 parent, err := commit.Parent(0) 281 if err != nil { 282 return "", fmt.Errorf("failed to get parent commit: %w", err) 283 } 284 parentTree, err = parent.Tree() 285 if err != nil { 286 return "", fmt.Errorf("failed to get parent tree: %w", err) 287 } 288 } 289 290 var changes object.Changes 291 if parentTree != nil { 292 changes, err = parentTree.Diff(tree) 293 if err != nil { 294 return "", fmt.Errorf("failed to compute diff: %w", err) 295 } 296 } else { 297 emptyTree := &object.Tree{} 298 changes, err = object.DiffTreeWithOptions(context.TODO(), emptyTree, tree, &object.DiffTreeOptions{}) 299 if err != nil { 300 return "", fmt.Errorf("failed to compute diff for initial commit: %w", err) 301 } 302 } 303 304 var diffParts []string 305 for _, change := range changes { 306 patch, err := change.Patch() 307 if err != nil { 308 return "", fmt.Errorf("failed to get patch for %s: %w", change.To.Name, err) 309 } 310 311 diffParts = append(diffParts, fmt.Sprintf("FILE:%s\n%s", change.To.Name, patch.String())) 312 } 313 314 sort.Strings(diffParts) 315 316 hasher := sha256.New() 317 for _, part := range diffParts { 318 hasher.Write([]byte(part)) 319 } 320 321 return hex.EncodeToString(hasher.Sum(nil)), nil 322} 323 324// SaveMetadata writes metadata to .changes/data/<diffHash>.json 325func SaveMetadata(dir string, meta Metadata) error { 326 dataDir := filepath.Join(dir, "data") 327 if err := os.MkdirAll(dataDir, 0755); err != nil { 328 return fmt.Errorf("failed to create data directory: %w", err) 329 } 330 331 filePath := filepath.Join(dataDir, meta.DiffHash+".json") 332 data, err := json.MarshalIndent(meta, "", " ") 333 if err != nil { 334 return fmt.Errorf("failed to marshal metadata: %w", err) 335 } 336 337 if err := os.WriteFile(filePath, data, 0644); err != nil { 338 return fmt.Errorf("failed to write metadata file: %w", err) 339 } 340 341 return nil 342} 343 344// LoadExistingMetadata reads all metadata files from .changes/data/*.json 345// and creates a map of diff hash -> metadata for O(1) lookups. 346func LoadExistingMetadata(dir string) (map[string]Metadata, error) { 347 dataDir := filepath.Join(dir, "data") 348 result := make(map[string]Metadata) 349 entries, err := os.ReadDir(dataDir) 350 if err != nil { 351 if os.IsNotExist(err) { 352 return result, nil 353 } 354 return nil, fmt.Errorf("failed to read data directory: %w", err) 355 } 356 357 for _, entry := range entries { 358 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { 359 continue 360 } 361 362 filePath := filepath.Join(dataDir, entry.Name()) 363 data, err := os.ReadFile(filePath) 364 if err != nil { 365 return nil, fmt.Errorf("failed to read metadata file %s: %w", entry.Name(), err) 366 } 367 368 var meta Metadata 369 if err := json.Unmarshal(data, &meta); err != nil { 370 return nil, fmt.Errorf("failed to unmarshal metadata from %s: %w", entry.Name(), err) 371 } 372 373 result[meta.DiffHash] = meta 374 } 375 return result, nil 376} 377 378// UpdateMetadata updates an existing metadata file with a new commit hash when 379// a rebased commit is detected (same diff, different commit hash). 380func UpdateMetadata(dir string, diffHash string, newCommitHash string) error { 381 dataDir := filepath.Join(dir, "data") 382 filePath := filepath.Join(dataDir, diffHash+".json") 383 data, err := os.ReadFile(filePath) 384 if err != nil { 385 return fmt.Errorf("failed to read existing metadata: %w", err) 386 } 387 388 var meta Metadata 389 if err := json.Unmarshal(data, &meta); err != nil { 390 return fmt.Errorf("failed to unmarshal metadata: %w", err) 391 } 392 393 meta.CommitHash = newCommitHash 394 395 updatedData, err := json.MarshalIndent(meta, "", " ") 396 if err != nil { 397 return fmt.Errorf("failed to marshal updated metadata: %w", err) 398 } 399 400 if err := os.WriteFile(filePath, updatedData, 0644); err != nil { 401 return fmt.Errorf("failed to write updated metadata: %w", err) 402 } 403 return nil 404} 405 406// Delete removes a changelog entry file from the .changes/ directory. 407func Delete(dir, filename string) error { 408 filePath := filepath.Join(dir, filename) 409 if _, err := os.Stat(filePath); os.IsNotExist(err) { 410 return fmt.Errorf("file %s does not exist", filename) 411 } 412 413 if err := os.Remove(filePath); err != nil { 414 return fmt.Errorf("failed to delete file %s: %w", filename, err) 415 } 416 return nil 417} 418 419// Update modifies an existing changelog entry file with new values. 420func Update(dir, filename string, entry Entry) error { 421 filePath := filepath.Join(dir, filename) 422 423 if _, err := os.Stat(filePath); os.IsNotExist(err) { 424 return fmt.Errorf("file %s does not exist", filename) 425 } 426 427 yamlBytes, err := yaml.Marshal(entry) 428 if err != nil { 429 return fmt.Errorf("failed to marshal entry to YAML: %w", err) 430 } 431 432 content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 433 434 if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 435 return fmt.Errorf("failed to update file %s: %w", filename, err) 436 } 437 438 return nil 439}