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