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