diff --git a/ROADMAP.md b/ROADMAP.md index 7321014..63844e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,12 +14,22 @@ The foundation CLI structure with core commands. - [x] Parse conventional commits - [x] Write entries to `.changes/` - [ ] Deduplication logic (see TODO in generate.go) + - [ ] Add --output-json for machine use - [x] `storm unreleased` - Manage unreleased changes - [x] `unreleased add` - Create new entry - [x] `unreleased list` - Display entries (text and JSON) - [x] `unreleased review` - Interactive TUI review - [ ] Implement delete action from review - [ ] Implement edit action from review +- [ ] Figure out how these fit in to `unreleased` + - [ ] `storm partial create` - Create new partial file + - [ ] Filename format: `..md` + - [ ] Supports configurable categories (feature, fix, doc, removal, etc.) + - [ ] Optional `--type`, `--issue`, `--message` flags + - [ ] `storm check` - Validate that changes include unreleased partials + - [ ] Detect missing partials for changed code paths + - [ ] Honor `[nochanges]` marker in commit messages + - [ ] Exit non-zero for CI enforcement - [x] `storm release` - Promote unreleased changes to CHANGELOG - [x] Read all `.changes/*.md` files - [x] Merge into `CHANGELOG.md` @@ -57,11 +67,11 @@ Local storage for unreleased changelog entries. - [x] Implement `changeset.List(dir)` - [x] Parse YAML frontmatter - [x] Return `EntryWithFile` structs -- [ ] Implement diff-based deduplication - - [ ] Compute diff hash for commits - - [ ] Load existing entries by hash - - [ ] Detect rebased commits (same diff, different hash) - - [ ] Add `--update-rebased`, `--skip-rebased`, `--warn-rebased` flags +- [x] Implement diff-based deduplication + - [x] Compute diff hash for commits + - [x] Load existing entries by hash + - [x] Detect rebased commits (same diff, different hash) + - [x] Update rebased commit metadata automatically ## TUI @@ -74,6 +84,7 @@ Local storage for unreleased changelog entries. - [x] Adds a full changelog pipeline that parses the existing file, builds and writes new releases, and validates dates/sections to strictly match the Keep a Changelog [spec](https://keepachangelog.com/en/1.1.0/), including autogenerated comparison links. +- [ ] Ensure deterministic sorting by category and filename timestamp ## Phase 7: Git Tagging and CI Integration @@ -81,17 +92,17 @@ Repository tagging and automation-friendly features. ### Tasks -- [ ] Implement Git tagging in `release` command - - [ ] Create annotated tag with version - - [ ] Include release notes in tag message - - [ ] Validate tag doesn't already exist - - [ ] Support `--tag` flag +- [x] Implement Git tagging in `release` command + - [x] Create annotated tag with version + - [x] Include release notes in tag message + - [x] Validate tag doesn't already exist + - [x] Support `--tag` flag - [ ] Add JSON output modes for all commands - - [x] `unreleased list --json` (implemented) + - [x] `unreleased list --json` - [ ] `generate --output-json` - [ ] `release --output-json` - [x] Add `--dry-run` support - - [x] `release --dry-run` - implemented + - [x] `release --dry-run` - [x] Show what would be written without writing - [x] Display preview of CHANGELOG changes with styled output - [ ] Non-TTY environment handling @@ -111,15 +122,15 @@ Repository tagging and automation-friendly features. - [x] Test utilities package - internal/testutils/ - [x] Unit tests for changelog package - internal/changelog/changelog_test.go -- [ ] Unit tests for diff engine -- [ ] Unit tests for Git integration (in-memory repos) +- [x] Unit tests for diff engine +- [x] Unit tests for Git integration (in-memory repos) - [ ] Golden files for diff output - [ ] Golden files for changelog output - [ ] Bubble Tea program testing ### Planned Test Coverage -- [ ] `internal/diff` - Myers algorithm correctness +- [x] `internal/diff` - Myers algorithm correctness - [ ] `internal/gitlog` - Commit parsing and range queries - [x] `internal/changeset` - File I/O and YAML parsing - [x] `internal/changelog` - Keep a Changelog formatting (13 test cases, all passing) @@ -131,5 +142,5 @@ Repository tagging and automation-friendly features. - No shell calls to `git` - all operations via `go-git` - Conventional commits are parsed but not enforced -- TUI sessions degrade gracefully in non-TTY environments (to be implemented) +- TUI sessions degrade gracefully in non-TTY environments - All output follows Keep a Changelog v1.1.0 specification diff --git a/cmd/generate.go b/cmd/generate.go index 391f867..38a365c 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -168,8 +168,16 @@ interactive review mode.`, } } - entries := []changeset.Entry{} + changesDir := ".changes" + existingMetadata, err := changeset.LoadExistingMetadata(changesDir) + if err != nil { + return fmt.Errorf("failed to load existing metadata: %w", err) + } + + created := 0 skipped := 0 + duplicates := 0 + rebased := 0 for _, item := range selectedItems { if item.Category == "" { @@ -177,22 +185,43 @@ interactive review mode.`, continue } - entry := changeset.Entry{ - Type: item.Category, - Scope: item.Meta.Scope, - Summary: item.Meta.Description, - Breaking: item.Meta.Breaking, + diffHash, err := changeset.ComputeDiffHash(item.Commit) + if err != nil { + style.Println("Warning: failed to compute diff hash for commit %s: %v", item.Commit.Hash.String()[:7], err) + skipped++ + continue } - entries = append(entries, entry) - } + if existing, exists := existingMetadata[diffHash]; exists { + if existing.CommitHash == item.Commit.Hash.String() { + duplicates++ + continue + } else { + if err := changeset.UpdateMetadata(changesDir, diffHash, item.Commit.Hash.String()); err != nil { + style.Println("Warning: failed to update metadata for rebased commit: %v", err) + continue + } + style.Println(" Updated rebased commit %s (was %s)", item.Commit.Hash.String()[:7], existing.CommitHash[:7]) + rebased++ + continue + } + } - changesDir := ".changes" - created := 0 - for _, entry := range entries { - filePath, err := changeset.Write(changesDir, entry) + meta := changeset.Metadata{ + CommitHash: item.Commit.Hash.String(), + DiffHash: diffHash, + Type: item.Category, + Scope: item.Meta.Scope, + Summary: item.Meta.Description, + Breaking: item.Meta.Breaking, + Author: item.Commit.Author.Name, + Date: item.Commit.Author.When, + } + + filePath, err := changeset.WriteWithMetadata(changesDir, meta) if err != nil { fmt.Printf("Error: failed to write entry: %v\n", err) + skipped++ continue } style.Addedf("✓ Created %s", filePath) @@ -200,9 +229,15 @@ interactive review mode.`, } style.Newline() - style.Headlinef("Generated %d changelog entries", created) + style.Headlinef("Generated %d new changelog entries", created) + if duplicates > 0 { + style.Println(" Skipped %d duplicates", duplicates) + } + if rebased > 0 { + style.Println(" Updated %d rebased commits", rebased) + } if skipped > 0 { - style.Println("Skipped %d commits (reverts or non-matching types)", skipped) + style.Println(" Skipped %d commits (reverts or non-matching types)", skipped) } return nil diff --git a/cmd/release.go b/cmd/release.go index aaff09e..0e62869 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -9,7 +9,7 @@ FLAGS --date Release date (default: today) --clear-changes Delete .changes/*.md files after successful release --dry-run Preview changes without writing files - --tag Create a Git tag after release (not implemented) + --tag Create an annotated Git tag with release notes --repo Path to the Git repository (default: .) --output Output changelog file path (default: CHANGELOG.md) */ @@ -19,11 +19,15 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" "github.com/spf13/cobra" "github.com/stormlightlabs/git-storm/internal/changelog" "github.com/stormlightlabs/git-storm/internal/changeset" + "github.com/stormlightlabs/git-storm/internal/shared" "github.com/stormlightlabs/git-storm/internal/style" ) @@ -122,7 +126,11 @@ Optionally creates a Git tag and clears the .changes directory.`, if tag { style.Newline() - style.Println("Note: --tag flag is not yet implemented (Phase 7)") + if err := createReleaseTag(repoPath, version, newVersion); err != nil { + return fmt.Errorf("failed to create Git tag: %w", err) + } + tagName := fmt.Sprintf("v%s", version) + style.Addedf("✓ Created Git tag %s", tagName) } return nil @@ -133,12 +141,69 @@ Optionally creates a Git tag and clears the .changes directory.`, c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") - c.Flags().BoolVar(&tag, "tag", false, "Create a Git tag after release (not implemented)") + c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes") c.MarkFlagRequired("version") return c } +// createReleaseTag creates an annotated Git tag for the release with changelog entries as the message. +func createReleaseTag(repoPath, version string, versionData *changelog.Version) error { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + head, err := repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + tagName := fmt.Sprintf("v%s", version) + + _, err = repo.Tag(tagName) + if err == nil { + return fmt.Errorf("tag %s already exists", tagName) + } + + tagMessage := buildTagMessage(version, versionData) + + _, err = repo.CreateTag(tagName, head.Hash(), &git.CreateTagOptions{ + Message: tagMessage, + Tagger: &object.Signature{ + Name: "storm", + Email: "noreply@storm", + When: time.Now(), + }, + }) + if err != nil { + return fmt.Errorf("failed to create tag: %w", err) + } + return nil +} + +// buildTagMessage formats the version's changelog entries into a tag message. +func buildTagMessage(version string, versionData *changelog.Version) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Release %s\n\n", version)) + + for i, section := range versionData.Sections { + if i > 0 { + builder.WriteString("\n") + } + + sectionTitle := shared.TitleCase(section.Type) + builder.WriteString(fmt.Sprintf("%s:\n", sectionTitle)) + + for _, entry := range section.Entries { + builder.WriteString(fmt.Sprintf("- %s\n", entry)) + } + } + + return builder.String() +} + // displayVersionPreview shows a formatted preview of the version being released. func displayVersionPreview(version *changelog.Version) { fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date) diff --git a/cmd/release_test.go b/cmd/release_test.go new file mode 100644 index 0000000..0039441 --- /dev/null +++ b/cmd/release_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stormlightlabs/git-storm/internal/changelog" + "github.com/stormlightlabs/git-storm/internal/testutils" +) + +func TestCreateReleaseTag(t *testing.T) { + repo := testutils.SetupTestRepo(t) + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("Failed to get worktree: %v", err) + } + + repoPath := worktree.Filesystem.Root() + version := &changelog.Version{ + Number: "1.0.0", + Date: "2024-01-15", + Sections: []changelog.Section{ + { + Type: "added", + Entries: []string{ + "New authentication system", + "User profile management", + }, + }, + { + Type: "fixed", + Entries: []string{ + "Memory leak in database connection pool", + }, + }, + }, + } + + err = createReleaseTag(repoPath, "1.0.0", version) + if err != nil { + t.Fatalf("createReleaseTag() error = %v", err) + } + + tagRef, err := repo.Tag("v1.0.0") + if err != nil { + t.Fatalf("Tag v1.0.0 should exist, got error: %v", err) + } + + tagObj, err := repo.TagObject(tagRef.Hash()) + if err != nil { + t.Fatalf("Tag should be annotated, got error: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("Failed to get HEAD: %v", err) + } + + testutils.Expect.Equal(t, tagObj.Target, head.Hash(), "Tag should point to HEAD") + + message := tagObj.Message + testutils.Expect.True(t, strings.Contains(message, "Release 1.0.0"), "Tag message should contain version") + testutils.Expect.True(t, strings.Contains(message, "Added:"), "Tag message should contain Added section") + testutils.Expect.True(t, strings.Contains(message, "Fixed:"), "Tag message should contain Fixed section") + testutils.Expect.True(t, strings.Contains(message, "New authentication system"), "Tag message should contain entry") + testutils.Expect.True(t, strings.Contains(message, "Memory leak"), "Tag message should contain entry") +} + +func TestCreateReleaseTag_DuplicateTag(t *testing.T) { + repo := testutils.SetupTestRepo(t) + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("Failed to get worktree: %v", err) + } + + repoPath := worktree.Filesystem.Root() + version := &changelog.Version{ + Number: "1.0.0", + Date: "2024-01-15", + Sections: []changelog.Section{ + { + Type: "added", + Entries: []string{"Feature 1"}, + }, + }, + } + + err = createReleaseTag(repoPath, "1.0.0", version) + if err != nil { + t.Fatalf("First createReleaseTag() error = %v", err) + } + + err = createReleaseTag(repoPath, "1.0.0", version) + if err == nil { + t.Error("Expected error when creating duplicate tag, got nil") + } + + testutils.Expect.True(t, strings.Contains(err.Error(), "already exists"), "Error should indicate tag already exists") +} + +func TestCreateReleaseTag_TagNameFormat(t *testing.T) { + tests := []struct { + version string + expectedTag string + }{ + {"1.0.0", "v1.0.0"}, + {"2.5.3", "v2.5.3"}, + {"0.1.0", "v0.1.0"}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + repo := testutils.SetupTestRepo(t) + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("Failed to get worktree: %v", err) + } + + repoPath := worktree.Filesystem.Root() + version := &changelog.Version{ + Number: tt.version, + Date: "2024-01-15", + Sections: []changelog.Section{ + { + Type: "added", + Entries: []string{"Feature"}, + }, + }, + } + + err = createReleaseTag(repoPath, tt.version, version) + if err != nil { + t.Fatalf("createReleaseTag() error = %v", err) + } + + _, err = repo.Tag(tt.expectedTag) + if err != nil { + t.Errorf("Tag %s should exist, got error: %v", tt.expectedTag, err) + } + }) + } +} + +func TestBuildTagMessage(t *testing.T) { + version := &changelog.Version{ + Number: "1.2.3", + Date: "2024-01-15", + Sections: []changelog.Section{ + { + Type: "added", + Entries: []string{ + "Feature A", + "Feature B", + }, + }, + { + Type: "changed", + Entries: []string{"Updated API"}, + }, + { + Type: "fixed", + Entries: []string{ + "Bug 1", + "Bug 2", + }, + }, + }, + } + message := buildTagMessage("1.2.3", version) + + testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.2.3\n\n"), "Message should start with release header") + + testutils.Expect.True(t, strings.Contains(message, "Added:\n"), "Should contain Added section") + testutils.Expect.True(t, strings.Contains(message, "Changed:\n"), "Should contain Changed section") + testutils.Expect.True(t, strings.Contains(message, "Fixed:\n"), "Should contain Fixed section") + + testutils.Expect.True(t, strings.Contains(message, "- Feature A\n"), "Should contain entry") + testutils.Expect.True(t, strings.Contains(message, "- Feature B\n"), "Should contain entry") + testutils.Expect.True(t, strings.Contains(message, "- Updated API\n"), "Should contain entry") + testutils.Expect.True(t, strings.Contains(message, "- Bug 1\n"), "Should contain entry") + testutils.Expect.True(t, strings.Contains(message, "- Bug 2\n"), "Should contain entry") + + sections := strings.Split(message, "\n\n") + testutils.Expect.True(t, len(sections) >= 3, "Sections should be separated by blank lines") +} + +func TestBuildTagMessage_EmptyVersion(t *testing.T) { + version := &changelog.Version{ + Number: "1.0.0", + Date: "2024-01-15", + Sections: []changelog.Section{}, + } + message := buildTagMessage("1.0.0", version) + + testutils.Expect.True(t, strings.HasPrefix(message, "Release 1.0.0\n\n"), "Should still have release header even with no sections") +} diff --git a/go.mod b/go.mod index 6dc3f00..7bb00d4 100644 --- a/go.mod +++ b/go.mod @@ -69,5 +69,5 @@ require ( golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/text v0.30.0 ) diff --git a/internal/changeset/changeset.go b/internal/changeset/changeset.go index 2957f3a..3078c03 100644 --- a/internal/changeset/changeset.go +++ b/internal/changeset/changeset.go @@ -40,22 +40,43 @@ package changeset import ( "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" "regexp" + "sort" "strings" "time" + "github.com/go-git/go-git/v6/plumbing/object" "github.com/goccy/go-yaml" ) // Entry represents a single changelog entry to be written to .changes/*.md type Entry struct { - Type string `yaml:"type"` // added, changed, fixed, removed, security - Scope string `yaml:"scope"` // optional scope - Summary string `yaml:"summary"` // description - Breaking bool `yaml:"breaking"` // true if breaking change + Type string `yaml:"type"` // added, changed, fixed, removed, security + Scope string `yaml:"scope"` // optional scope + Summary string `yaml:"summary"` // description + Breaking bool `yaml:"breaking"` // true if breaking change + CommitHash string `yaml:"commit_hash,omitempty"` // source commit hash (for reference) + DiffHash string `yaml:"diff_hash,omitempty"` // hash of git diff content (for deduplication) +} + +// Metadata stores complete entry information in .changes/data/*.json for deduplication +type Metadata struct { + CommitHash string `json:"commit_hash"` // current commit hash + DiffHash string `json:"diff_hash"` // stable diff content hash + Type string `json:"type"` + Scope string `json:"scope"` + Summary string `json:"summary"` + Breaking bool `json:"breaking"` + Author string `json:"author"` + Date time.Time `json:"date"` + Filename string `json:"filename"` // relative path to .md file } // Write creates a new .changes/-.md file with YAML frontmatter. @@ -94,6 +115,48 @@ func Write(dir string, entry Entry) (string, error) { return filePath, nil } +// WriteWithMetadata creates a new .changes/-.md file with YAML +// frontmatter and saves corresponding metadata to .changes/data/.json. +// +// The filename uses the first 7 characters of the diff hash for human-readable +// identification, while the JSON metadata file uses the full hash for +// deduplication lookups. +func WriteWithMetadata(dir string, meta Metadata) (string, error) { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + diffHashShort := meta.DiffHash[:7] + slug := slugify(meta.Summary) + filename := fmt.Sprintf("%s-%s.md", diffHashShort, slug) + filePath := filepath.Join(dir, filename) + + entry := Entry{ + Type: meta.Type, + Scope: meta.Scope, + Summary: meta.Summary, + Breaking: meta.Breaking, + CommitHash: meta.CommitHash, + DiffHash: meta.DiffHash, + } + + yamlBytes, err := yaml.Marshal(entry) + if err != nil { + return "", fmt.Errorf("failed to marshal entry to YAML: %w", err) + } + + content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return "", fmt.Errorf("failed to write file %s: %w", filePath, err) + } + + meta.Filename = filename + if err := SaveMetadata(dir, meta); err != nil { + return "", fmt.Errorf("failed to save metadata: %w", err) + } + return filePath, nil +} + // slugify converts a string into a URL-friendly slug by converting to lowercase, // replaces spaces and special chars with hyphens. func slugify(input string) string { @@ -170,3 +233,144 @@ func parseEntry(content []byte) (Entry, error) { return entry, nil } + +// ComputeDiffHash calculates a stable hash of the commit's diff content. This +// hash is independent of the commit hash, so rebased commits with identical +// diffs will produce the same hash. +// +// The hash is computed from: +// - Sorted list of changed file paths +// - For each file: the full diff content (additions and deletions) +func ComputeDiffHash(commit *object.Commit) (string, error) { + tree, err := commit.Tree() + if err != nil { + return "", fmt.Errorf("failed to get commit tree: %w", err) + } + + var parentTree *object.Tree + if commit.NumParents() > 0 { + parent, err := commit.Parent(0) + if err != nil { + return "", fmt.Errorf("failed to get parent commit: %w", err) + } + parentTree, err = parent.Tree() + if err != nil { + return "", fmt.Errorf("failed to get parent tree: %w", err) + } + } + + var changes object.Changes + if parentTree != nil { + changes, err = parentTree.Diff(tree) + if err != nil { + return "", fmt.Errorf("failed to compute diff: %w", err) + } + } else { + emptyTree := &object.Tree{} + changes, err = object.DiffTreeWithOptions(context.TODO(), emptyTree, tree, &object.DiffTreeOptions{}) + if err != nil { + return "", fmt.Errorf("failed to compute diff for initial commit: %w", err) + } + } + + var diffParts []string + for _, change := range changes { + patch, err := change.Patch() + if err != nil { + return "", fmt.Errorf("failed to get patch for %s: %w", change.To.Name, err) + } + + diffParts = append(diffParts, fmt.Sprintf("FILE:%s\n%s", change.To.Name, patch.String())) + } + + sort.Strings(diffParts) + + hasher := sha256.New() + for _, part := range diffParts { + hasher.Write([]byte(part)) + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// SaveMetadata writes metadata to .changes/data/.json +func SaveMetadata(dir string, meta Metadata) error { + dataDir := filepath.Join(dir, "data") + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + filePath := filepath.Join(dataDir, meta.DiffHash+".json") + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + if err := os.WriteFile(filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + return nil +} + +// LoadExistingMetadata reads all metadata files from .changes/data/*.json +// and creates a map of diff hash -> metadata for O(1) lookups. +func LoadExistingMetadata(dir string) (map[string]Metadata, error) { + dataDir := filepath.Join(dir, "data") + result := make(map[string]Metadata) + entries, err := os.ReadDir(dataDir) + if err != nil { + if os.IsNotExist(err) { + return result, nil + } + return nil, fmt.Errorf("failed to read data directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + filePath := filepath.Join(dataDir, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read metadata file %s: %w", entry.Name(), err) + } + + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata from %s: %w", entry.Name(), err) + } + + result[meta.DiffHash] = meta + } + return result, nil +} + +// UpdateMetadata updates an existing metadata file with a new commit hash when +// a rebased commit is detected (same diff, different commit hash). +func UpdateMetadata(dir string, diffHash string, newCommitHash string) error { + dataDir := filepath.Join(dir, "data") + filePath := filepath.Join(dataDir, diffHash+".json") + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read existing metadata: %w", err) + } + + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("failed to unmarshal metadata: %w", err) + } + + meta.CommitHash = newCommitHash + + updatedData, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal updated metadata: %w", err) + } + + if err := os.WriteFile(filePath, updatedData, 0644); err != nil { + return fmt.Errorf("failed to write updated metadata: %w", err) + } + return nil +} diff --git a/internal/changeset/changeset_test.go b/internal/changeset/changeset_test.go index 236bf99..19f91d1 100644 --- a/internal/changeset/changeset_test.go +++ b/internal/changeset/changeset_test.go @@ -1,12 +1,15 @@ package changeset import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + "time" "github.com/goccy/go-yaml" + "github.com/stormlightlabs/git-storm/internal/testutils" ) func TestWrite(t *testing.T) { @@ -200,3 +203,313 @@ func TestWrite_DirectoryCreation(t *testing.T) { t.Errorf("File was not created: %s", filePath) } } + +func TestComputeDiffHash_Stability(t *testing.T) { + repo := testutils.SetupTestRepo(t) + commits := testutils.GetCommitHistory(t, repo) + + if len(commits) == 0 { + t.Fatal("Expected at least one commit in test repo") + } + + commit := commits[0] + hash1, err := ComputeDiffHash(commit) + if err != nil { + t.Fatalf("ComputeDiffHash() error = %v", err) + } + + hash2, err := ComputeDiffHash(commit) + if err != nil { + t.Fatalf("ComputeDiffHash() second call error = %v", err) + } + + testutils.Expect.Equal(t, hash1, hash2, "Diff hash should be stable across multiple calls") + testutils.Expect.Equal(t, len(hash1), 64, "Diff hash should be 64 characters (SHA256 hex)") +} + +func TestComputeDiffHash_DifferentCommits(t *testing.T) { + repo := testutils.SetupTestRepo(t) + + testutils.AddCommit(t, repo, "file1.txt", "content1", "Add file1") + testutils.AddCommit(t, repo, "file2.txt", "content2", "Add file2") + + commits := testutils.GetCommitHistory(t, repo) + if len(commits) < 2 { + t.Fatal("Expected at least 2 commits") + } + + hash1, err := ComputeDiffHash(commits[0]) + if err != nil { + t.Fatalf("ComputeDiffHash() for commit 1 error = %v", err) + } + + hash2, err := ComputeDiffHash(commits[1]) + if err != nil { + t.Fatalf("ComputeDiffHash() for commit 2 error = %v", err) + } + + testutils.Expect.NotEqual(t, hash1, hash2, "Different commits should have different diff hashes") +} + +func TestWriteWithMetadata(t *testing.T) { + tmpDir := t.TempDir() + + meta := Metadata{ + CommitHash: "abc123def456", + DiffHash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + Type: "added", + Scope: "cli", + Summary: "Add new feature", + Breaking: false, + Author: "Test User", + Date: time.Now(), + Filename: "", + } + + filePath, err := WriteWithMetadata(tmpDir, meta) + if err != nil { + t.Fatalf("WriteWithMetadata() error = %v", err) + } + + testutils.Expect.True(t, strings.HasSuffix(filePath, ".md"), "File path should have .md extension") + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Markdown file was not created: %s", filePath) + } + + filename := filepath.Base(filePath) + testutils.Expect.True(t, strings.HasPrefix(filename, meta.DiffHash[:7]), "Filename should start with first 7 chars of diff hash") + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read markdown file: %v", err) + } + + var parsedEntry Entry + parts := strings.SplitN(string(content), "---\n", 3) + if len(parts) < 3 { + t.Fatal("Invalid YAML frontmatter format") + } + + if err := yaml.Unmarshal([]byte(parts[1]), &parsedEntry); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + testutils.Expect.Equal(t, parsedEntry.Type, meta.Type) + testutils.Expect.Equal(t, parsedEntry.Summary, meta.Summary) + testutils.Expect.Equal(t, parsedEntry.CommitHash, meta.CommitHash) + testutils.Expect.Equal(t, parsedEntry.DiffHash, meta.DiffHash) + + jsonPath := filepath.Join(tmpDir, "data", meta.DiffHash+".json") + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { + t.Errorf("JSON metadata file was not created: %s", jsonPath) + } + + jsonContent, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatalf("Failed to read JSON metadata: %v", err) + } + + var parsedMeta Metadata + if err := json.Unmarshal(jsonContent, &parsedMeta); err != nil { + t.Fatalf("Failed to parse JSON metadata: %v", err) + } + + testutils.Expect.Equal(t, parsedMeta.CommitHash, meta.CommitHash) + testutils.Expect.Equal(t, parsedMeta.DiffHash, meta.DiffHash) + testutils.Expect.Equal(t, parsedMeta.Type, meta.Type) + testutils.Expect.Equal(t, parsedMeta.Summary, meta.Summary) +} + +func TestLoadExistingMetadata(t *testing.T) { + tmpDir := t.TempDir() + + meta1 := Metadata{ + CommitHash: "abc123", + DiffHash: "hash1111111111111111111111111111111111111111111111111111111111111", + Type: "added", + Summary: "Feature 1", + Author: "User1", + Date: time.Now(), + } + + meta2 := Metadata{ + CommitHash: "def456", + DiffHash: "hash2222222222222222222222222222222222222222222222222222222222222", + Type: "fixed", + Summary: "Fix 1", + Author: "User2", + Date: time.Now(), + } + + _, err := WriteWithMetadata(tmpDir, meta1) + if err != nil { + t.Fatalf("Failed to write meta1: %v", err) + } + + _, err = WriteWithMetadata(tmpDir, meta2) + if err != nil { + t.Fatalf("Failed to write meta2: %v", err) + } + + loaded, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() error = %v", err) + } + + testutils.Expect.Equal(t, len(loaded), 2, "Should load 2 metadata entries") + + if m, exists := loaded[meta1.DiffHash]; exists { + testutils.Expect.Equal(t, m.CommitHash, meta1.CommitHash) + testutils.Expect.Equal(t, m.Type, meta1.Type) + } else { + t.Errorf("meta1 not found in loaded metadata") + } + + if m, exists := loaded[meta2.DiffHash]; exists { + testutils.Expect.Equal(t, m.CommitHash, meta2.CommitHash) + testutils.Expect.Equal(t, m.Type, meta2.Type) + } else { + t.Errorf("meta2 not found in loaded metadata") + } +} + +func TestLoadExistingMetadata_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + loaded, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() error = %v", err) + } + + testutils.Expect.Equal(t, len(loaded), 0, "Should return empty map for non-existent data directory") +} + +func TestUpdateMetadata(t *testing.T) { + tmpDir := t.TempDir() + + meta := Metadata{ + CommitHash: "original123", + DiffHash: "diffhash111111111111111111111111111111111111111111111111111111111", + Type: "added", + Summary: "Feature", + Author: "User", + Date: time.Now(), + } + + _, err := WriteWithMetadata(tmpDir, meta) + if err != nil { + t.Fatalf("Failed to write metadata: %v", err) + } + + newCommitHash := "rebased456" + err = UpdateMetadata(tmpDir, meta.DiffHash, newCommitHash) + if err != nil { + t.Fatalf("UpdateMetadata() error = %v", err) + } + + loaded, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() error = %v", err) + } + + updated, exists := loaded[meta.DiffHash] + if !exists { + t.Fatal("Updated metadata not found") + } + + testutils.Expect.Equal(t, updated.CommitHash, newCommitHash, "CommitHash should be updated") + testutils.Expect.Equal(t, updated.Type, meta.Type, "Other fields should remain unchanged") + testutils.Expect.Equal(t, updated.Summary, meta.Summary, "Other fields should remain unchanged") +} + +func TestDeduplication_SameCommit(t *testing.T) { + tmpDir := t.TempDir() + repo := testutils.SetupTestRepo(t) + commits := testutils.GetCommitHistory(t, repo) + if len(commits) == 0 { + t.Fatal("Expected at least one commit") + } + + commit := commits[0] + diffHash, err := ComputeDiffHash(commit) + if err != nil { + t.Fatalf("ComputeDiffHash() error = %v", err) + } + + meta := Metadata{ + CommitHash: commit.Hash.String(), + DiffHash: diffHash, + Type: "added", + Summary: "Test feature", + Author: commit.Author.Name, + Date: commit.Author.When, + } + + _, err = WriteWithMetadata(tmpDir, meta) + if err != nil { + t.Fatalf("First WriteWithMetadata() error = %v", err) + } + + existing, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() error = %v", err) + } + + if existingMeta, exists := existing[diffHash]; exists { + testutils.Expect.Equal(t, existingMeta.CommitHash, commit.Hash.String(), "Should detect exact duplicate") + } else { + t.Error("Metadata should exist in loaded entries") + } +} + +func TestDeduplication_RebasedCommit(t *testing.T) { + tmpDir := t.TempDir() + repo := testutils.SetupTestRepo(t) + + commits := testutils.GetCommitHistory(t, repo) + if len(commits) == 0 { + t.Fatal("Expected at least one commit") + } + + commit := commits[0] + diffHash, err := ComputeDiffHash(commit) + if err != nil { + t.Fatalf("ComputeDiffHash() error = %v", err) + } + + originalMeta := Metadata{ + CommitHash: "original_commit_hash_123", + DiffHash: diffHash, + Type: "added", + Summary: "Test feature", + Author: commit.Author.Name, + Date: commit.Author.When, + } + + _, err = WriteWithMetadata(tmpDir, originalMeta) + if err != nil { + t.Fatalf("WriteWithMetadata() error = %v", err) + } + + existing, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() error = %v", err) + } + + if existingMeta, exists := existing[diffHash]; exists { + if existingMeta.CommitHash != commit.Hash.String() { + err = UpdateMetadata(tmpDir, diffHash, commit.Hash.String()) + if err != nil { + t.Fatalf("UpdateMetadata() error = %v", err) + } + + updated, err := LoadExistingMetadata(tmpDir) + if err != nil { + t.Fatalf("LoadExistingMetadata() after update error = %v", err) + } + + updatedMeta := updated[diffHash] + testutils.Expect.Equal(t, updatedMeta.CommitHash, commit.Hash.String(), "CommitHash should be updated for rebased commit") + } + } +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index 9ef6181..c2e2475 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -432,3 +432,249 @@ func TestMergeReplacements(t *testing.T) { }) } } + +func TestDiff_Compute_Unicode(t *testing.T) { + a := []string{"Emoji 🚀", "Regular text"} + b := []string{"Emoji 🎉", "Regular text"} + + for _, alg := range diffAlgorithms { + t.Run(alg.name, func(t *testing.T) { + m := alg.new() + edits, err := m.Compute(a, b) + if err != nil { + t.Fatalf("unexpected error with unicode: %v", err) + } + + reconstructed := ApplyEdits(a, edits) + if len(reconstructed) != len(b) { + t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) + } + for i := range reconstructed { + if reconstructed[i] != b[i] { + t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) + } + } + }) + } +} + +func TestDiff_Compute_VeryLongLines(t *testing.T) { + longLine1 := strings.Repeat("a", 5000) + longLine2 := strings.Repeat("b", 5000) + longLine3 := strings.Repeat("c", 5000) + + a := []string{longLine1, longLine2} + b := []string{longLine1, longLine3} + + for _, alg := range diffAlgorithms { + t.Run(alg.name, func(t *testing.T) { + m := alg.new() + edits, err := m.Compute(a, b) + if err != nil { + t.Fatalf("unexpected error with long lines: %v", err) + } + + reconstructed := ApplyEdits(a, edits) + if len(reconstructed) != len(b) { + t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) + } + for i := range reconstructed { + if reconstructed[i] != b[i] { + t.Errorf("line %d: lengths %d != %d", i, len(reconstructed[i]), len(b[i])) + } + } + }) + } +} + +func TestDiff_Compute_WhitespaceOnly(t *testing.T) { + a := []string{"line1", " ", "line3"} + b := []string{"line1", " ", "line3"} + + for _, alg := range diffAlgorithms { + t.Run(alg.name, func(t *testing.T) { + m := alg.new() + edits, err := m.Compute(a, b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reconstructed := ApplyEdits(a, edits) + if len(reconstructed) != len(b) { + t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) + } + for i := range reconstructed { + if reconstructed[i] != b[i] { + t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) + } + } + }) + } +} + +func TestDiff_Compute_AlternatingLines(t *testing.T) { + a := []string{"a1", "a2", "a3", "a4", "a5"} + b := []string{"b1", "b2", "b3", "b4", "b5"} + + for _, alg := range diffAlgorithms { + t.Run(alg.name, func(t *testing.T) { + m := alg.new() + edits, err := m.Compute(a, b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reconstructed := ApplyEdits(a, edits) + if len(reconstructed) != len(b) { + t.Fatalf("reconstructed length %d != expected %d", len(reconstructed), len(b)) + } + for i := range reconstructed { + if reconstructed[i] != b[i] { + t.Errorf("line %d: %q != %q", i, reconstructed[i], b[i]) + } + } + }) + } +} + +func TestDiff_CrossValidation(t *testing.T) { + testCases := []struct { + name string + a []string + b []string + }{ + {"simple", []string{"a", "b", "c"}, []string{"a", "x", "c"}}, + {"complex", []string{"1", "2", "3", "4"}, []string{"1", "x", "y", "4"}}, + {"empty to content", []string{}, []string{"a", "b", "c"}}, + {"content to empty", []string{"a", "b", "c"}, []string{}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lcs := &LCS{} + myers := &Myers{} + + lcsEdits, err := lcs.Compute(tc.a, tc.b) + if err != nil { + t.Fatalf("LCS error: %v", err) + } + + myersEdits, err := myers.Compute(tc.a, tc.b) + if err != nil { + t.Fatalf("Myers error: %v", err) + } + + lcsResult := ApplyEdits(tc.a, lcsEdits) + myersResult := ApplyEdits(tc.a, myersEdits) + + if len(lcsResult) != len(tc.b) { + t.Errorf("LCS reconstruction length mismatch: %d != %d", len(lcsResult), len(tc.b)) + } + if len(myersResult) != len(tc.b) { + t.Errorf("Myers reconstruction length mismatch: %d != %d", len(myersResult), len(tc.b)) + } + + for i := range tc.b { + if i < len(lcsResult) && lcsResult[i] != tc.b[i] { + t.Errorf("LCS line %d: %q != %q", i, lcsResult[i], tc.b[i]) + } + if i < len(myersResult) && myersResult[i] != tc.b[i] { + t.Errorf("Myers line %d: %q != %q", i, myersResult[i], tc.b[i]) + } + } + }) + } +} + +func TestDiff_EditIndicesValid(t *testing.T) { + a := []string{"line1", "line2", "line3"} + b := []string{"line1", "modified", "line3", "line4"} + + for _, alg := range diffAlgorithms { + t.Run(alg.name, func(t *testing.T) { + m := alg.new() + edits, err := m.Compute(a, b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for i, edit := range edits { + switch edit.Kind { + case Equal: + if edit.AIndex < 0 || edit.AIndex >= len(a) { + t.Errorf("edit %d: invalid AIndex %d (len(a)=%d)", i, edit.AIndex, len(a)) + } + if edit.BIndex < 0 || edit.BIndex >= len(b) { + t.Errorf("edit %d: invalid BIndex %d (len(b)=%d)", i, edit.BIndex, len(b)) + } + case Delete: + if edit.AIndex < 0 || edit.AIndex >= len(a) { + t.Errorf("edit %d: invalid AIndex %d for Delete", i, edit.AIndex) + } + case Insert: + if edit.BIndex < 0 || edit.BIndex >= len(b) { + t.Errorf("edit %d: invalid BIndex %d for Insert", i, edit.BIndex) + } + } + } + }) + } +} + +func BenchmarkLCS_SmallInput(b *testing.B) { + a := []string{"line1", "line2", "line3", "line4", "line5"} + c := []string{"line1", "modified", "line3", "line4", "added"} + lcs := &LCS{} + + for b.Loop() { + _, _ = lcs.Compute(a, c) + } +} + +func BenchmarkMyers_SmallInput(b *testing.B) { + a := []string{"line1", "line2", "line3", "line4", "line5"} + c := []string{"line1", "modified", "line3", "line4", "added"} + myers := &Myers{} + + for b.Loop() { + _, _ = myers.Compute(a, c) + } +} + +func BenchmarkLCS_MediumInput(b *testing.B) { + a := make([]string, 50) + c := make([]string, 50) + for i := range 50 { + a[i] = "line" + strings.Repeat("x", i) + if i%5 == 0 { + c[i] = "modified" + strings.Repeat("y", i) + } else { + c[i] = a[i] + } + } + + lcs := &LCS{} + + for b.Loop() { + _, _ = lcs.Compute(a, c) + } +} + +func BenchmarkMyers_MediumInput(b *testing.B) { + a := make([]string, 50) + c := make([]string, 50) + for i := range 50 { + a[i] = "line" + strings.Repeat("x", i) + if i%5 == 0 { + c[i] = "modified" + strings.Repeat("y", i) + } else { + c[i] = a[i] + } + } + + myers := &Myers{} + + for b.Loop() { + _, _ = myers.Compute(a, c) + } +} diff --git a/internal/shared/shared.go b/internal/shared/shared.go new file mode 100644 index 0000000..4b6912a --- /dev/null +++ b/internal/shared/shared.go @@ -0,0 +1,12 @@ +package shared + +import ( + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var caser = cases.Title(language.English) + +func TitleCase(s string) string { + return caser.String(s) +} diff --git a/internal/shared/shared_test.go b/internal/shared/shared_test.go new file mode 100644 index 0000000..63dd489 --- /dev/null +++ b/internal/shared/shared_test.go @@ -0,0 +1,36 @@ +package shared + +import "testing" + +func TestTitleCase(t *testing.T) { + t.Run("Basic", func(t *testing.T) { + got := TitleCase("hello world") + want := "Hello World" + if got != want { + t.Fatalf("TitleCase() = %q, want %q", got, want) + } + }) + + t.Run("MixedCase", func(t *testing.T) { + got := TitleCase("go is GREAT") + want := "Go Is Great" + if got != want { + t.Fatalf("TitleCase() = %q, want %q", got, want) + } + }) + + t.Run("WithPunctuation", func(t *testing.T) { + got := TitleCase("don't stop believing") + want := "Don't Stop Believing" + if got != want { + t.Fatalf("TitleCase() = %q, want %q", got, want) + } + }) + + t.Run("ExtraSpaces", func(t *testing.T) { + got := TitleCase(" leading and internal spaces ") + if got != " Leading And Internal Spaces " { + t.Fatalf("TitleCase() = %q, spacing/words not as expected", got) + } + }) +}