changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1/*
2NAME
3
4 storm diff — Display an inline diff between two refs or commits.
5
6SYNOPSIS
7
8 storm diff <from>..<to> [options]
9 storm diff <from> <to> [options]
10
11DESCRIPTION
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*/
26package main
27
28import (
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/tty"
38 "github.com/stormlightlabs/git-storm/internal/ui"
39)
40
41func diffCmd() *cobra.Command {
42 var filePath string
43 var expanded bool
44 var viewName string
45
46 c := &cobra.Command{
47 Use: "diff <from>..<to> | diff <from> <to>",
48 Short: "Show a line-based diff between two commits or tags",
49 Long: `Displays an inline diff (added/removed/unchanged lines) between two refs.
50
51Supports multiple input formats:
52 - Range syntax: commit1..commit2
53 - Separate args: commit1 commit2
54 - Truncated hashes: 7de6f6d..18363c2
55
56If --file is not specified, shows all changed files with pagination.
57
58By default, large blocks of unchanged lines are compressed. Use --expanded
59to show all lines. You can also toggle this with 'e' in the TUI.`,
60 Args: cobra.RangeArgs(1, 2),
61 RunE: func(cmd *cobra.Command, args []string) error {
62 from, to := gitlog.ParseRefArgs(args)
63 viewKind, err := parseDiffView(viewName)
64 if err != nil {
65 return err
66 }
67 return runDiff(from, to, filePath, expanded, viewKind)
68 },
69 }
70
71 c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)")
72 c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)")
73 c.Flags().StringVarP(&viewName, "view", "v", "split", "Diff rendering: split or unified")
74
75 return c
76}
77
78// runDiff executes the diff command by reading file contents from two git refs and launching the TUI.
79func runDiff(fromRef, toRef, filePath string, expanded bool, view diff.DiffViewKind) error {
80 repo, err := git.PlainOpen(repoPath)
81 if err != nil {
82 return fmt.Errorf("failed to open repository: %w", err)
83 }
84
85 var filesToDiff []string
86 if filePath != "" {
87 filesToDiff = []string{filePath}
88 } else {
89 filesToDiff, err = gitlog.GetChangedFiles(repo, fromRef, toRef)
90 if err != nil {
91 return fmt.Errorf("failed to get changed files: %w", err)
92 }
93 if len(filesToDiff) == 0 {
94 fmt.Println("No files changed between", fromRef, "and", toRef)
95 return nil
96 }
97 }
98
99 allDiffs := make([]ui.FileDiff, 0, len(filesToDiff))
100
101 for _, file := range filesToDiff {
102 oldContent, err := gitlog.GetFileContent(repo, fromRef, file)
103 if err != nil {
104 oldContent = ""
105 }
106
107 newContent, err := gitlog.GetFileContent(repo, toRef, file)
108 if err != nil {
109 newContent = ""
110 }
111
112 oldLines := strings.Split(oldContent, "\n")
113 newLines := strings.Split(newContent, "\n")
114
115 myers := &diff.Myers{}
116 edits, err := myers.Compute(oldLines, newLines)
117 if err != nil {
118 return fmt.Errorf("diff computation failed for %s: %w", file, err)
119 }
120
121 allDiffs = append(allDiffs, ui.FileDiff{
122 Edits: edits,
123 OldPath: fromRef + ":" + file,
124 NewPath: toRef + ":" + file,
125 })
126 }
127
128 if !tty.IsInteractive() {
129 return outputPlainDiff(allDiffs, expanded, view)
130 }
131
132 model := ui.NewMultiFileDiffModel(allDiffs, expanded, view)
133
134 p := tea.NewProgram(model, tea.WithAltScreen())
135 if _, err := p.Run(); err != nil {
136 return fmt.Errorf("TUI failed: %w", err)
137 }
138
139 return nil
140}
141
142func parseDiffView(viewName string) (diff.DiffViewKind, error) {
143 switch strings.ToLower(strings.TrimSpace(viewName)) {
144 case "", "split", "side-by-side", "s":
145 return diff.ViewSplit, nil
146 case "unified", "u":
147 return diff.ViewUnified, nil
148 default:
149 return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName)
150 }
151}
152
153// outputPlainDiff outputs diffs in plain text format for non-interactive environments.
154//
155// TODO: move this to package [diff]
156func outputPlainDiff(allDiffs []ui.FileDiff, expanded bool, view diff.DiffViewKind) error {
157 for i, fileDiff := range allDiffs {
158 fmt.Printf("=== File %d/%d ===\n", i+1, len(allDiffs))
159 fmt.Printf("--- %s\n", fileDiff.OldPath)
160 fmt.Printf("+++ %s\n", fileDiff.NewPath)
161 fmt.Println()
162
163 var formatter diff.Formatter
164 switch view {
165 case diff.ViewUnified:
166 formatter = &diff.UnifiedFormatter{
167 TerminalWidth: 80,
168 ShowLineNumbers: true,
169 Expanded: expanded,
170 EnableWordWrap: false,
171 }
172 default:
173 formatter = &diff.SideBySideFormatter{
174 TerminalWidth: 80,
175 ShowLineNumbers: true,
176 Expanded: expanded,
177 EnableWordWrap: false,
178 }
179 }
180
181 output := formatter.Format(fileDiff.Edits)
182 fmt.Println(output)
183
184 if i < len(allDiffs)-1 {
185 fmt.Println()
186 }
187 }
188
189 return nil
190}