changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(gitlog): move shared git utilities to package gitlog

* move diff command to separate submod

* remove deprecated model method calls in diff ui

+469 -461
+129
cmd/diff.go
··· 1 + /* 2 + NAME 3 + 4 + storm diff — Display an inline diff between two refs or commits. 5 + 6 + SYNOPSIS 7 + 8 + storm diff <from>..<to> [options] 9 + storm diff <from> <to> [options] 10 + 11 + DESCRIPTION 12 + 13 + Displays an inline diff highlighting added, removed, and unchanged lines 14 + between two refs or commits. 15 + 16 + Supports multiple input formats: 17 + • Range syntax: commit1..commit2 18 + • Separate arguments: commit1 commit2 19 + • Truncated hashes: 7de6f6d..18363c2 20 + 21 + If --file is not specified, storm shows all changed files with pagination. 22 + 23 + By default, large blocks of unchanged lines are compressed. Use --expanded 24 + to show all lines, or toggle this interactively with ‘e’ in the TUI. 25 + */ 26 + package main 27 + 28 + import ( 29 + "fmt" 30 + "strings" 31 + 32 + tea "github.com/charmbracelet/bubbletea" 33 + "github.com/go-git/go-git/v6" 34 + "github.com/spf13/cobra" 35 + "github.com/stormlightlabs/git-storm/internal/diff" 36 + "github.com/stormlightlabs/git-storm/internal/gitlog" 37 + "github.com/stormlightlabs/git-storm/internal/ui" 38 + ) 39 + 40 + func diffCmd() *cobra.Command { 41 + var filePath string 42 + var expanded bool 43 + 44 + c := &cobra.Command{ 45 + Use: "diff <from>..<to> | diff <from> <to>", 46 + Short: "Show a line-based diff between two commits or tags", 47 + Long: `Displays an inline diff (added/removed/unchanged lines) between two refs. 48 + 49 + Supports multiple input formats: 50 + - Range syntax: commit1..commit2 51 + - Separate args: commit1 commit2 52 + - Truncated hashes: 7de6f6d..18363c2 53 + 54 + If --file is not specified, shows all changed files with pagination. 55 + 56 + By default, large blocks of unchanged lines are compressed. Use --expanded 57 + to show all lines. You can also toggle this with 'e' in the TUI.`, 58 + Args: cobra.RangeArgs(1, 2), 59 + RunE: func(cmd *cobra.Command, args []string) error { 60 + from, to := gitlog.ParseRefArgs(args) 61 + return runDiff(from, to, filePath, expanded) 62 + }, 63 + } 64 + 65 + c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)") 66 + c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)") 67 + 68 + return c 69 + } 70 + 71 + // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 72 + func runDiff(fromRef, toRef, filePath string, expanded bool) error { 73 + repo, err := git.PlainOpen(repoPath) 74 + if err != nil { 75 + return fmt.Errorf("failed to open repository: %w", err) 76 + } 77 + 78 + var filesToDiff []string 79 + if filePath != "" { 80 + filesToDiff = []string{filePath} 81 + } else { 82 + filesToDiff, err = gitlog.GetChangedFiles(repo, fromRef, toRef) 83 + if err != nil { 84 + return fmt.Errorf("failed to get changed files: %w", err) 85 + } 86 + if len(filesToDiff) == 0 { 87 + fmt.Println("No files changed between", fromRef, "and", toRef) 88 + return nil 89 + } 90 + } 91 + 92 + allDiffs := make([]ui.FileDiff, 0, len(filesToDiff)) 93 + 94 + for _, file := range filesToDiff { 95 + oldContent, err := gitlog.GetFileContent(repo, fromRef, file) 96 + if err != nil { 97 + oldContent = "" 98 + } 99 + 100 + newContent, err := gitlog.GetFileContent(repo, toRef, file) 101 + if err != nil { 102 + newContent = "" 103 + } 104 + 105 + oldLines := strings.Split(oldContent, "\n") 106 + newLines := strings.Split(newContent, "\n") 107 + 108 + myers := &diff.Myers{} 109 + edits, err := myers.Compute(oldLines, newLines) 110 + if err != nil { 111 + return fmt.Errorf("diff computation failed for %s: %w", file, err) 112 + } 113 + 114 + allDiffs = append(allDiffs, ui.FileDiff{ 115 + Edits: edits, 116 + OldPath: fromRef + ":" + file, 117 + NewPath: toRef + ":" + file, 118 + }) 119 + } 120 + 121 + model := ui.NewMultiFileDiffModel(allDiffs, expanded) 122 + 123 + p := tea.NewProgram(model, tea.WithAltScreen()) 124 + if _, err := p.Run(); err != nil { 125 + return fmt.Errorf("TUI failed: %w", err) 126 + } 127 + 128 + return nil 129 + }
+2 -77
cmd/generate.go
··· 17 17 "strings" 18 18 19 19 "github.com/go-git/go-git/v6" 20 - "github.com/go-git/go-git/v6/plumbing" 21 - "github.com/go-git/go-git/v6/plumbing/object" 22 20 "github.com/spf13/cobra" 23 21 "github.com/stormlightlabs/git-storm/internal/changeset" 24 22 "github.com/stormlightlabs/git-storm/internal/gitlog" ··· 30 28 sinceTag string 31 29 ) 32 30 33 - // getCommitRange returns commits reachable from toRef but not from fromRef. 34 - // This implements the git log from..to range semantics. 35 - func getCommitRange(repo *git.Repository, fromRef, toRef string) ([]*object.Commit, error) { 36 - fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 37 - if err != nil { 38 - return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 39 - } 40 - 41 - toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 42 - if err != nil { 43 - return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 44 - } 45 - 46 - toCommits := make(map[plumbing.Hash]bool) 47 - toIter, err := repo.Log(&git.LogOptions{From: *toHash}) 48 - if err != nil { 49 - return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 50 - } 51 - 52 - err = toIter.ForEach(func(c *object.Commit) error { 53 - toCommits[c.Hash] = true 54 - return nil 55 - }) 56 - if err != nil { 57 - return nil, fmt.Errorf("failed to iterate commits from %s: %w", toRef, err) 58 - } 59 - 60 - fromCommits := make(map[plumbing.Hash]bool) 61 - fromIter, err := repo.Log(&git.LogOptions{From: *fromHash}) 62 - if err != nil { 63 - return nil, fmt.Errorf("failed to get commits from %s: %w", fromRef, err) 64 - } 65 - 66 - err = fromIter.ForEach(func(c *object.Commit) error { 67 - fromCommits[c.Hash] = true 68 - return nil 69 - }) 70 - if err != nil { 71 - return nil, fmt.Errorf("failed to iterate commits from %s: %w", fromRef, err) 72 - } 73 - 74 - // Collect commits that are in toCommits but not in fromCommits 75 - result := []*object.Commit{} 76 - toIter, err = repo.Log(&git.LogOptions{From: *toHash}) 77 - if err != nil { 78 - return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 79 - } 80 - 81 - err = toIter.ForEach(func(c *object.Commit) error { 82 - if !fromCommits[c.Hash] { 83 - result = append(result, c) 84 - } 85 - return nil 86 - }) 87 - if err != nil { 88 - return nil, fmt.Errorf("failed to collect commit range: %w", err) 89 - } 90 - 91 - // Reverse to get chronological order (oldest first) 92 - for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { 93 - result[i], result[j] = result[j], result[i] 94 - } 95 - 96 - return result, nil 97 - } 98 - 99 31 func generateCmd() *cobra.Command { 100 32 c := &cobra.Command{ 101 33 Use: "generate [from] [to]", ··· 116 48 } 117 49 } else if len(args) == 0 { 118 50 return fmt.Errorf("must specify either --since flag or [from] [to] arguments") 119 - } else if len(args) == 1 { 120 - parts := strings.Split(args[0], "..") 121 - if len(parts) == 2 { 122 - from, to = parts[0], parts[1] 123 - } else { 124 - from, to = args[0], "HEAD" 125 - } 126 51 } else { 127 - from, to = args[0], args[1] 52 + from, to = gitlog.ParseRefArgs(args) 128 53 } 129 54 130 55 if interactive { ··· 137 62 return fmt.Errorf("failed to open repository: %w", err) 138 63 } 139 64 140 - commits, err := getCommitRange(repo, from, to) 65 + commits, err := gitlog.GetCommitRange(repo, from, to) 141 66 if err != nil { 142 67 return err 143 68 }
+7 -16
cmd/generate_test.go
··· 3 3 import ( 4 4 "testing" 5 5 6 + "github.com/stormlightlabs/git-storm/internal/gitlog" 6 7 "github.com/stormlightlabs/git-storm/internal/testutils" 7 8 ) 8 9 9 10 func TestGetCommitRange(t *testing.T) { 10 11 repo := testutils.SetupTestRepo(t) 11 - 12 - // Initial setup creates 3 commits (a.txt, b.txt, c.txt) 13 - // Let's add a tag at the second commit 14 12 commits := testutils.GetCommitHistory(t, repo) 15 13 if len(commits) < 3 { 16 14 t.Fatalf("Expected at least 3 commits, got %d", len(commits)) 17 15 } 18 16 19 - // Tag the oldest commit (which is at index len-1 due to reverse order) 20 - oldCommit := commits[len(commits)-2] // Second oldest commit 17 + oldCommit := commits[len(commits)-2] 21 18 if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil { 22 19 t.Fatalf("Failed to create tag: %v", err) 23 20 } 24 21 25 - // Add more commits after the tag 26 22 testutils.AddCommit(t, repo, "d.txt", "content d", "feat: add d feature") 27 23 testutils.AddCommit(t, repo, "e.txt", "content e", "fix: fix e bug") 28 24 29 - // Get commits between tag and HEAD 30 - rangeCommits, err := getCommitRange(repo, "v1.0.0", "HEAD") 25 + rangeCommits, err := gitlog.GetCommitRange(repo, "v1.0.0", "HEAD") 31 26 if err != nil { 32 - t.Fatalf("getCommitRange() error = %v", err) 27 + t.Fatalf("gitlog.GetCommitRange() error = %v", err) 33 28 } 34 29 35 - // Should include the new commits (d.txt, e.txt) and commits after the tagged one 36 30 if len(rangeCommits) < 2 { 37 31 t.Errorf("Expected at least 2 commits in range, got %d", len(rangeCommits)) 38 32 } 39 33 40 - // Verify commits are in chronological order (oldest first) 41 34 for i := 1; i < len(rangeCommits); i++ { 42 35 if rangeCommits[i].Author.When.Before(rangeCommits[i-1].Author.When) { 43 36 t.Errorf("Commits are not in chronological order") ··· 48 41 func TestGetCommitRange_SameRef(t *testing.T) { 49 42 repo := testutils.SetupTestRepo(t) 50 43 51 - // Get commits between HEAD and HEAD (should be empty) 52 - rangeCommits, err := getCommitRange(repo, "HEAD", "HEAD") 44 + rangeCommits, err := gitlog.GetCommitRange(repo, "HEAD", "HEAD") 53 45 if err != nil { 54 - t.Fatalf("getCommitRange() error = %v", err) 46 + t.Fatalf("gitlog.GetCommitRange() error = %v", err) 55 47 } 56 48 57 49 if len(rangeCommits) != 0 { ··· 62 54 func TestGetCommitRange_InvalidRef(t *testing.T) { 63 55 repo := testutils.SetupTestRepo(t) 64 56 65 - // Try to get commits with invalid ref 66 - _, err := getCommitRange(repo, "invalid-ref", "HEAD") 57 + _, err := gitlog.GetCommitRange(repo, "invalid-ref", "HEAD") 67 58 if err == nil { 68 59 t.Errorf("Expected error for invalid ref, got nil") 69 60 }
-189
cmd/main.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "strings" 7 6 8 - tea "github.com/charmbracelet/bubbletea" 9 7 "github.com/charmbracelet/fang" 10 8 "github.com/charmbracelet/log" 11 - "github.com/go-git/go-git/v6" 12 - "github.com/go-git/go-git/v6/plumbing" 13 9 "github.com/spf13/cobra" 14 - "github.com/stormlightlabs/git-storm/internal/diff" 15 - "github.com/stormlightlabs/git-storm/internal/ui" 16 10 ) 17 11 18 12 var ( ··· 35 29 36 30 const versionString string = "0.1.0-dev" 37 31 38 - // parseRefArgs parses command arguments to extract from/to refs. 39 - // Supports both "from..to" and "from to" syntax. 40 - func parseRefArgs(args []string) (from, to string) { 41 - if len(args) == 1 { 42 - parts := strings.Split(args[0], "..") 43 - if len(parts) == 2 { 44 - return parts[0], parts[1] 45 - } 46 - return args[0], "HEAD" 47 - } 48 - return args[0], args[1] 49 - } 50 - 51 - // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 52 - func runDiff(fromRef, toRef, filePath string, expanded bool) error { 53 - repo, err := git.PlainOpen(repoPath) 54 - if err != nil { 55 - return fmt.Errorf("failed to open repository: %w", err) 56 - } 57 - 58 - var filesToDiff []string 59 - if filePath != "" { 60 - filesToDiff = []string{filePath} 61 - } else { 62 - filesToDiff, err = getChangedFiles(repo, fromRef, toRef) 63 - if err != nil { 64 - return fmt.Errorf("failed to get changed files: %w", err) 65 - } 66 - if len(filesToDiff) == 0 { 67 - fmt.Println("No files changed between", fromRef, "and", toRef) 68 - return nil 69 - } 70 - } 71 - 72 - allDiffs := make([]ui.FileDiff, 0, len(filesToDiff)) 73 - 74 - for _, file := range filesToDiff { 75 - oldContent, err := getFileContent(repo, fromRef, file) 76 - if err != nil { 77 - oldContent = "" 78 - } 79 - 80 - newContent, err := getFileContent(repo, toRef, file) 81 - if err != nil { 82 - newContent = "" 83 - } 84 - 85 - oldLines := strings.Split(oldContent, "\n") 86 - newLines := strings.Split(newContent, "\n") 87 - 88 - myers := &diff.Myers{} 89 - edits, err := myers.Compute(oldLines, newLines) 90 - if err != nil { 91 - return fmt.Errorf("diff computation failed for %s: %w", file, err) 92 - } 93 - 94 - allDiffs = append(allDiffs, ui.FileDiff{ 95 - Edits: edits, 96 - OldPath: fromRef + ":" + file, 97 - NewPath: toRef + ":" + file, 98 - }) 99 - } 100 - 101 - model := ui.NewMultiFileDiffModel(allDiffs, expanded) 102 - 103 - p := tea.NewProgram(model, tea.WithAltScreen()) 104 - if _, err := p.Run(); err != nil { 105 - return fmt.Errorf("TUI failed: %w", err) 106 - } 107 - 108 - return nil 109 - } 110 - 111 - // getFileContent reads the content of a file at a specific ref (commit, tag, or branch). 112 - func getFileContent(repo *git.Repository, ref, filePath string) (string, error) { 113 - hash, err := repo.ResolveRevision(plumbing.Revision(ref)) 114 - if err != nil { 115 - return "", fmt.Errorf("failed to resolve %s: %w", ref, err) 116 - } 117 - 118 - commit, err := repo.CommitObject(*hash) 119 - if err != nil { 120 - return "", fmt.Errorf("failed to get commit: %w", err) 121 - } 122 - 123 - tree, err := commit.Tree() 124 - if err != nil { 125 - return "", fmt.Errorf("failed to get tree: %w", err) 126 - } 127 - 128 - file, err := tree.File(filePath) 129 - if err != nil { 130 - return "", fmt.Errorf("file not found: %w", err) 131 - } 132 - 133 - content, err := file.Contents() 134 - if err != nil { 135 - return "", fmt.Errorf("failed to read file content: %w", err) 136 - } 137 - 138 - return content, nil 139 - } 140 - 141 - // getChangedFiles returns the list of files that changed between two commits. 142 - func getChangedFiles(repo *git.Repository, fromRef, toRef string) ([]string, error) { 143 - fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 144 - if err != nil { 145 - return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 146 - } 147 - 148 - toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 149 - if err != nil { 150 - return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 151 - } 152 - 153 - fromCommit, err := repo.CommitObject(*fromHash) 154 - if err != nil { 155 - return nil, fmt.Errorf("failed to get commit %s: %w", fromRef, err) 156 - } 157 - 158 - toCommit, err := repo.CommitObject(*toHash) 159 - if err != nil { 160 - return nil, fmt.Errorf("failed to get commit %s: %w", toRef, err) 161 - } 162 - 163 - fromTree, err := fromCommit.Tree() 164 - if err != nil { 165 - return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef, err) 166 - } 167 - 168 - toTree, err := toCommit.Tree() 169 - if err != nil { 170 - return nil, fmt.Errorf("failed to get tree for %s: %w", toRef, err) 171 - } 172 - 173 - changes, err := fromTree.Diff(toTree) 174 - if err != nil { 175 - return nil, fmt.Errorf("failed to compute diff: %w", err) 176 - } 177 - 178 - files := make([]string, 0, len(changes)) 179 - for _, change := range changes { 180 - if change.To.Name != "" { 181 - files = append(files, change.To.Name) 182 - } else { 183 - files = append(files, change.From.Name) 184 - } 185 - } 186 - 187 - return files, nil 188 - } 189 - 190 32 func versionCmd() *cobra.Command { 191 33 return &cobra.Command{ 192 34 Use: "version", ··· 196 38 return nil 197 39 }, 198 40 } 199 - } 200 - 201 - func diffCmd() *cobra.Command { 202 - var filePath string 203 - var expanded bool 204 - 205 - c := &cobra.Command{ 206 - Use: "diff <from>..<to> | diff <from> <to>", 207 - Short: "Show a line-based diff between two commits or tags", 208 - Long: `Displays an inline diff (added/removed/unchanged lines) between two refs. 209 - 210 - Supports multiple input formats: 211 - - Range syntax: commit1..commit2 212 - - Separate args: commit1 commit2 213 - - Truncated hashes: 7de6f6d..18363c2 214 - 215 - If --file is not specified, shows all changed files with pagination. 216 - 217 - By default, large blocks of unchanged lines are compressed. Use --expanded 218 - to show all lines. You can also toggle this with 'e' in the TUI.`, 219 - Args: cobra.RangeArgs(1, 2), 220 - RunE: func(cmd *cobra.Command, args []string) error { 221 - from, to := parseRefArgs(args) 222 - return runDiff(from, to, filePath, expanded) 223 - }, 224 - } 225 - 226 - c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)") 227 - c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)") 228 - 229 - return c 230 41 } 231 42 232 43 func releaseCmd() *cobra.Command {
-163
cmd/main_test.go
··· 1 - package main 2 - 3 - import ( 4 - "strings" 5 - "testing" 6 - 7 - "github.com/stormlightlabs/git-storm/internal/testutils" 8 - ) 9 - 10 - func TestParseRefArgs(t *testing.T) { 11 - tests := []struct { 12 - name string 13 - args []string 14 - expectedFrom string 15 - expectedTo string 16 - }{ 17 - { 18 - name: "range syntax with full hashes", 19 - args: []string{"abc123..def456"}, 20 - expectedFrom: "abc123", 21 - expectedTo: "def456", 22 - }, 23 - { 24 - name: "range syntax with truncated hashes", 25 - args: []string{"7de6f6d..18363c2"}, 26 - expectedFrom: "7de6f6d", 27 - expectedTo: "18363c2", 28 - }, 29 - { 30 - name: "range syntax with tags", 31 - args: []string{"v1.0.0..v2.0.0"}, 32 - expectedFrom: "v1.0.0", 33 - expectedTo: "v2.0.0", 34 - }, 35 - { 36 - name: "two separate arguments", 37 - args: []string{"abc123", "def456"}, 38 - expectedFrom: "abc123", 39 - expectedTo: "def456", 40 - }, 41 - { 42 - name: "single argument compares with HEAD", 43 - args: []string{"abc123"}, 44 - expectedFrom: "abc123", 45 - expectedTo: "HEAD", 46 - }, 47 - { 48 - name: "branch names", 49 - args: []string{"main", "feature-branch"}, 50 - expectedFrom: "main", 51 - expectedTo: "feature-branch", 52 - }, 53 - } 54 - 55 - for _, tt := range tests { 56 - t.Run(tt.name, func(t *testing.T) { 57 - from, to := parseRefArgs(tt.args) 58 - 59 - if from != tt.expectedFrom { 60 - t.Errorf("parseRefArgs() from = %v, want %v", from, tt.expectedFrom) 61 - } 62 - if to != tt.expectedTo { 63 - t.Errorf("parseRefArgs() to = %v, want %v", to, tt.expectedTo) 64 - } 65 - }) 66 - } 67 - } 68 - 69 - func TestGetChangedFiles(t *testing.T) { 70 - repo := testutils.SetupTestRepo(t) 71 - commits := testutils.GetCommitHistory(t, repo) 72 - 73 - if len(commits) < 2 { 74 - t.Fatal("Test repo should have at least 2 commits") 75 - } 76 - 77 - fromHash := commits[1].Hash.String() 78 - toHash := commits[0].Hash.String() 79 - 80 - files, err := getChangedFiles(repo, fromHash, toHash) 81 - if err != nil { 82 - t.Fatalf("getChangedFiles() error = %v", err) 83 - } 84 - 85 - if len(files) == 0 { 86 - t.Error("Expected at least one changed file") 87 - } 88 - 89 - for _, file := range files { 90 - if file == "" { 91 - t.Error("File path should not be empty") 92 - } 93 - } 94 - } 95 - 96 - func TestGetChangedFiles_NoChanges(t *testing.T) { 97 - repo := testutils.SetupTestRepo(t) 98 - 99 - commits := testutils.GetCommitHistory(t, repo) 100 - if len(commits) == 0 { 101 - t.Fatal("Test repo should have at least 1 commit") 102 - } 103 - 104 - hash := commits[0].Hash.String() 105 - 106 - files, err := getChangedFiles(repo, hash, hash) 107 - if err != nil { 108 - t.Fatalf("getChangedFiles() error = %v", err) 109 - } 110 - 111 - if len(files) != 0 { 112 - t.Errorf("Expected no changed files when comparing commit with itself, got %d", len(files)) 113 - } 114 - } 115 - 116 - func TestGetFileContent(t *testing.T) { 117 - repo := testutils.SetupTestRepo(t) 118 - 119 - commits := testutils.GetCommitHistory(t, repo) 120 - if len(commits) == 0 { 121 - t.Fatal("Test repo should have at least 1 commit") 122 - } 123 - 124 - hash := commits[0].Hash.String() 125 - 126 - content, err := getFileContent(repo, hash, "README.md") 127 - if err != nil { 128 - t.Fatalf("getFileContent() error = %v", err) 129 - } 130 - 131 - if content == "" { 132 - t.Error("Expected non-empty content for README.md") 133 - } 134 - 135 - if !strings.Contains(content, "Project") { 136 - t.Error("README.md should contain 'Project'") 137 - } 138 - } 139 - 140 - func TestGetFileContent_FileNotFound(t *testing.T) { 141 - repo := testutils.SetupTestRepo(t) 142 - 143 - commits := testutils.GetCommitHistory(t, repo) 144 - if len(commits) == 0 { 145 - t.Fatal("Test repo should have at least 1 commit") 146 - } 147 - 148 - hash := commits[0].Hash.String() 149 - 150 - _, err := getFileContent(repo, hash, "nonexistent.txt") 151 - if err == nil { 152 - t.Error("Expected error when reading nonexistent file") 153 - } 154 - } 155 - 156 - func TestGetFileContent_InvalidRef(t *testing.T) { 157 - repo := testutils.SetupTestRepo(t) 158 - 159 - _, err := getFileContent(repo, "invalid-ref-12345", "README.md") 160 - if err == nil { 161 - t.Error("Expected error when using invalid ref") 162 - } 163 - }
+165
internal/gitlog/gitlog.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 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" 8 11 ) 9 12 10 13 // CommitKind represents the kind of commit according to Conventional Commits. ··· 266 269 } 267 270 return lines 268 271 } 272 + 273 + // ParseRefArgs parses command arguments to extract from/to refs. 274 + // Supports both "from..to" and "from to" syntax. 275 + // If only one arg, treats it as from with to=HEAD. 276 + func ParseRefArgs(args []string) (from, to string) { 277 + if len(args) == 0 { 278 + return "", "" 279 + } 280 + if len(args) == 1 { 281 + parts := strings.Split(args[0], "..") 282 + if len(parts) == 2 { 283 + return parts[0], parts[1] 284 + } 285 + return args[0], "HEAD" 286 + } 287 + return args[0], args[1] 288 + } 289 + 290 + // GetCommitRange returns commits reachable from toRef but not from fromRef. 291 + // This implements git log from..to range semantics. 292 + func GetCommitRange(repo *git.Repository, fromRef, toRef string) ([]*object.Commit, error) { 293 + fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 294 + if err != nil { 295 + return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 296 + } 297 + 298 + toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 299 + if err != nil { 300 + return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 301 + } 302 + 303 + toCommits := make(map[plumbing.Hash]bool) 304 + toIter, err := repo.Log(&git.LogOptions{From: *toHash}) 305 + if err != nil { 306 + return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 307 + } 308 + 309 + err = toIter.ForEach(func(c *object.Commit) error { 310 + toCommits[c.Hash] = true 311 + return nil 312 + }) 313 + if err != nil { 314 + return nil, fmt.Errorf("failed to iterate commits from %s: %w", toRef, err) 315 + } 316 + 317 + fromCommits := make(map[plumbing.Hash]bool) 318 + fromIter, err := repo.Log(&git.LogOptions{From: *fromHash}) 319 + if err != nil { 320 + return nil, fmt.Errorf("failed to get commits from %s: %w", fromRef, err) 321 + } 322 + 323 + err = fromIter.ForEach(func(c *object.Commit) error { 324 + fromCommits[c.Hash] = true 325 + return nil 326 + }) 327 + if err != nil { 328 + return nil, fmt.Errorf("failed to iterate commits from %s: %w", fromRef, err) 329 + } 330 + 331 + // Collect commits that are in toCommits but not in fromCommits 332 + result := []*object.Commit{} 333 + toIter, err = repo.Log(&git.LogOptions{From: *toHash}) 334 + if err != nil { 335 + return nil, fmt.Errorf("failed to get commits from %s: %w", toRef, err) 336 + } 337 + 338 + err = toIter.ForEach(func(c *object.Commit) error { 339 + if !fromCommits[c.Hash] { 340 + result = append(result, c) 341 + } 342 + return nil 343 + }) 344 + if err != nil { 345 + return nil, fmt.Errorf("failed to collect commit range: %w", err) 346 + } 347 + 348 + // Reverse to get chronological order (oldest first) 349 + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { 350 + result[i], result[j] = result[j], result[i] 351 + } 352 + 353 + return result, nil 354 + } 355 + 356 + // GetFileContent reads the content of a file at a specific ref (commit, tag, or branch). 357 + func GetFileContent(repo *git.Repository, ref, filePath string) (string, error) { 358 + hash, err := repo.ResolveRevision(plumbing.Revision(ref)) 359 + if err != nil { 360 + return "", fmt.Errorf("failed to resolve %s: %w", ref, err) 361 + } 362 + 363 + commit, err := repo.CommitObject(*hash) 364 + if err != nil { 365 + return "", fmt.Errorf("failed to get commit: %w", err) 366 + } 367 + 368 + tree, err := commit.Tree() 369 + if err != nil { 370 + return "", fmt.Errorf("failed to get tree: %w", err) 371 + } 372 + 373 + file, err := tree.File(filePath) 374 + if err != nil { 375 + return "", fmt.Errorf("file not found: %w", err) 376 + } 377 + 378 + content, err := file.Contents() 379 + if err != nil { 380 + return "", fmt.Errorf("failed to read file content: %w", err) 381 + } 382 + 383 + return content, nil 384 + } 385 + 386 + // GetChangedFiles returns the list of files that changed between two commits. 387 + func GetChangedFiles(repo *git.Repository, fromRef, toRef string) ([]string, error) { 388 + fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) 389 + if err != nil { 390 + return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) 391 + } 392 + 393 + toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) 394 + if err != nil { 395 + return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) 396 + } 397 + 398 + fromCommit, err := repo.CommitObject(*fromHash) 399 + if err != nil { 400 + return nil, fmt.Errorf("failed to get commit %s: %w", fromRef, err) 401 + } 402 + 403 + toCommit, err := repo.CommitObject(*toHash) 404 + if err != nil { 405 + return nil, fmt.Errorf("failed to get commit %s: %w", toRef, err) 406 + } 407 + 408 + fromTree, err := fromCommit.Tree() 409 + if err != nil { 410 + return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef, err) 411 + } 412 + 413 + toTree, err := toCommit.Tree() 414 + if err != nil { 415 + return nil, fmt.Errorf("failed to get tree for %s: %w", toRef, err) 416 + } 417 + 418 + changes, err := fromTree.Diff(toTree) 419 + if err != nil { 420 + return nil, fmt.Errorf("failed to compute diff: %w", err) 421 + } 422 + 423 + files := make([]string, 0, len(changes)) 424 + for _, change := range changes { 425 + if change.To.Name != "" { 426 + files = append(files, change.To.Name) 427 + } else { 428 + files = append(files, change.From.Name) 429 + } 430 + } 431 + 432 + return files, nil 433 + }
+160 -10
internal/gitlog/gitlog_test.go
··· 3 3 import ( 4 4 "testing" 5 5 "time" 6 + 7 + "github.com/stormlightlabs/git-storm/internal/testutils" 6 8 ) 7 9 8 10 func TestConventionalParser_Parse(t *testing.T) { ··· 10 12 testTime := time.Now() 11 13 12 14 tests := []struct { 13 - name string 14 - subject string 15 - body string 16 - wantType string 17 - wantScope string 18 - wantDesc string 19 - wantBreak bool 15 + name string 16 + subject string 17 + body string 18 + wantType string 19 + wantScope string 20 + wantDesc string 21 + wantBreak bool 20 22 }{ 21 23 { 22 24 name: "simple feat", ··· 128 130 parser := &ConventionalParser{} 129 131 130 132 tests := []struct { 131 - name string 132 - meta CommitMeta 133 - wantCat string 133 + name string 134 + meta CommitMeta 135 + wantCat string 134 136 }{ 135 137 { 136 138 name: "feat -> added", ··· 218 220 }) 219 221 } 220 222 } 223 + 224 + func TestParseRefArgs(t *testing.T) { 225 + tests := []struct { 226 + name string 227 + args []string 228 + wantFrom string 229 + wantTo string 230 + }{ 231 + { 232 + name: "range syntax", 233 + args: []string{"v1.0.0..v1.1.0"}, 234 + wantFrom: "v1.0.0", 235 + wantTo: "v1.1.0", 236 + }, 237 + { 238 + name: "two separate args", 239 + args: []string{"v1.0.0", "v1.1.0"}, 240 + wantFrom: "v1.0.0", 241 + wantTo: "v1.1.0", 242 + }, 243 + { 244 + name: "single arg defaults to HEAD", 245 + args: []string{"v1.0.0"}, 246 + wantFrom: "v1.0.0", 247 + wantTo: "HEAD", 248 + }, 249 + { 250 + name: "empty args", 251 + args: []string{}, 252 + wantFrom: "", 253 + wantTo: "", 254 + }, 255 + } 256 + 257 + for _, tt := range tests { 258 + t.Run(tt.name, func(t *testing.T) { 259 + from, to := ParseRefArgs(tt.args) 260 + if from != tt.wantFrom { 261 + t.Errorf("ParseRefArgs() from = %v, want %v", from, tt.wantFrom) 262 + } 263 + if to != tt.wantTo { 264 + t.Errorf("ParseRefArgs() to = %v, want %v", to, tt.wantTo) 265 + } 266 + }) 267 + } 268 + } 269 + 270 + func TestGetCommitRange(t *testing.T) { 271 + repo := testutils.SetupTestRepo(t) 272 + 273 + commits := testutils.GetCommitHistory(t, repo) 274 + if len(commits) < 3 { 275 + t.Fatalf("Expected at least 3 commits, got %d", len(commits)) 276 + } 277 + 278 + oldCommit := commits[len(commits)-2] 279 + if err := testutils.CreateTagAtCommit(t, repo, "v1.0.0", oldCommit.Hash.String()); err != nil { 280 + t.Fatalf("Failed to create tag: %v", err) 281 + } 282 + 283 + testutils.AddCommit(t, repo, "d.txt", "content d", "feat: add d feature") 284 + testutils.AddCommit(t, repo, "e.txt", "content e", "fix: fix e bug") 285 + 286 + rangeCommits, err := GetCommitRange(repo, "v1.0.0", "HEAD") 287 + if err != nil { 288 + t.Fatalf("GetCommitRange() error = %v", err) 289 + } 290 + 291 + if len(rangeCommits) < 2 { 292 + t.Errorf("Expected at least 2 commits in range, got %d", len(rangeCommits)) 293 + } 294 + 295 + for i := 1; i < len(rangeCommits); i++ { 296 + if rangeCommits[i].Author.When.Before(rangeCommits[i-1].Author.When) { 297 + t.Errorf("Commits are not in chronological order") 298 + } 299 + } 300 + } 301 + 302 + func TestGetCommitRange_SameRef(t *testing.T) { 303 + repo := testutils.SetupTestRepo(t) 304 + 305 + rangeCommits, err := GetCommitRange(repo, "HEAD", "HEAD") 306 + if err != nil { 307 + t.Fatalf("GetCommitRange() error = %v", err) 308 + } 309 + 310 + if len(rangeCommits) != 0 { 311 + t.Errorf("Expected 0 commits when from and to are the same, got %d", len(rangeCommits)) 312 + } 313 + } 314 + 315 + func TestGetFileContent(t *testing.T) { 316 + repo := testutils.SetupTestRepo(t) 317 + 318 + content, err := GetFileContent(repo, "HEAD", "README.md") 319 + if err != nil { 320 + t.Fatalf("GetFileContent() error = %v", err) 321 + } 322 + 323 + if content == "" { 324 + t.Errorf("Expected non-empty content for README.md") 325 + } 326 + 327 + if content != "# Project\n\nInitial version" { 328 + t.Errorf("GetFileContent() content = %v, want %v", content, "# Project\\n\\nInitial version") 329 + } 330 + } 331 + 332 + func TestGetFileContent_InvalidFile(t *testing.T) { 333 + repo := testutils.SetupTestRepo(t) 334 + 335 + _, err := GetFileContent(repo, "HEAD", "nonexistent.txt") 336 + if err == nil { 337 + t.Errorf("Expected error for non-existent file, got nil") 338 + } 339 + } 340 + 341 + func TestGetChangedFiles(t *testing.T) { 342 + repo := testutils.SetupTestRepo(t) 343 + 344 + commits := testutils.GetCommitHistory(t, repo) 345 + if len(commits) < 2 { 346 + t.Fatalf("Expected at least 2 commits, got %d", len(commits)) 347 + } 348 + 349 + files, err := GetChangedFiles(repo, commits[1].Hash.String(), commits[0].Hash.String()) 350 + if err != nil { 351 + t.Fatalf("GetChangedFiles() error = %v", err) 352 + } 353 + 354 + if len(files) == 0 { 355 + t.Errorf("Expected at least 1 changed file, got 0") 356 + } 357 + } 358 + 359 + func TestGetChangedFiles_NoChanges(t *testing.T) { 360 + repo := testutils.SetupTestRepo(t) 361 + 362 + files, err := GetChangedFiles(repo, "HEAD", "HEAD") 363 + if err != nil { 364 + t.Fatalf("GetChangedFiles() error = %v", err) 365 + } 366 + 367 + if len(files) != 0 { 368 + t.Errorf("Expected 0 changed files when refs are the same, got %d", len(files)) 369 + } 370 + }
+6 -6
internal/ui/ui.go
··· 118 118 return m, tea.Quit 119 119 120 120 case key.Matches(msg, keys.Up): 121 - m.viewport.LineUp(1) 121 + m.viewport.ScrollUp(1) 122 122 123 123 case key.Matches(msg, keys.Down): 124 - m.viewport.LineDown(1) 124 + m.viewport.ScrollDown(1) 125 125 126 126 case key.Matches(msg, keys.PageUp): 127 - m.viewport.ViewUp() 127 + m.viewport.PageUp() 128 128 129 129 case key.Matches(msg, keys.PageDown): 130 - m.viewport.ViewDown() 130 + m.viewport.PageDown() 131 131 132 132 case key.Matches(msg, keys.HalfUp): 133 - m.viewport.HalfViewUp() 133 + m.viewport.HalfPageUp() 134 134 135 135 case key.Matches(msg, keys.HalfDown): 136 - m.viewport.HalfViewDown() 136 + m.viewport.HalfPageDown() 137 137 138 138 case key.Matches(msg, keys.Top): 139 139 m.viewport.GotoTop()