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.

feat(generate): add interactive commit selector behind -i/interactive flag

+988 -36
+2 -8
README.md
··· 27 27 │ ├── diff # Minimal line diff for display and review 28 28 │ ├── changeset # Manage `.changes/*.md` files 29 29 │ ├── changelog # Build and update `CHANGELOG.md` sections 30 - │ ├── tui # Bubble Tea–based interactive interface 30 + │ ├── ui # Bubble Tea–based interactive interface 31 31 │ └── style # Centralized Lip Gloss palette and formatting 32 32 ├── PROJECT.md 33 33 └── README.md ··· 75 75 76 76 3. Consistent Palette 77 77 78 - | Type | Color | 79 - | -------- | --------- | 80 - | Added | `#10b981` | 81 - | Changed | `#0ea5e9` | 82 - | Fixed | `#f43f5e` | 83 - | Removed | `#f59e0b` | 84 - | Security | `#9333ea` | 78 + See package style for the color palette. 85 79 86 80 4. Commands should chain naturally and script cleanly: 87 81
+69 -28
cmd/generate.go
··· 16 16 "fmt" 17 17 "strings" 18 18 19 + tea "github.com/charmbracelet/bubbletea" 19 20 "github.com/go-git/go-git/v6" 20 21 "github.com/spf13/cobra" 21 22 "github.com/stormlightlabs/git-storm/internal/changeset" 22 23 "github.com/stormlightlabs/git-storm/internal/gitlog" 23 24 "github.com/stormlightlabs/git-storm/internal/style" 25 + "github.com/stormlightlabs/git-storm/internal/ui" 24 26 ) 25 27 26 28 var ( ··· 52 54 from, to = gitlog.ParseRefArgs(args) 53 55 } 54 56 55 - if interactive { 56 - style.Headline("Interactive mode not yet implemented") 57 - fmt.Println("Will generate entries in non-interactive mode...") 58 - } 59 - 60 57 repo, err := git.PlainOpen(repoPath) 61 58 if err != nil { 62 59 return fmt.Errorf("failed to open repository: %w", err) ··· 72 69 return nil 73 70 } 74 71 75 - style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) 72 + parser := &gitlog.ConventionalParser{} 73 + var selectedItems []ui.CommitItem 74 + 75 + if interactive { 76 + model := ui.NewCommitSelectorModel(commits, from, to, parser) 77 + p := tea.NewProgram(model, tea.WithAltScreen()) 78 + 79 + finalModel, err := p.Run() 80 + if err != nil { 81 + return fmt.Errorf("failed to run interactive selector: %w", err) 82 + } 83 + 84 + selectorModel, ok := finalModel.(ui.CommitSelectorModel) 85 + if !ok { 86 + return fmt.Errorf("unexpected model type") 87 + } 88 + 89 + if selectorModel.IsCancelled() { 90 + style.Headline("Operation cancelled") 91 + return nil 92 + } 93 + 94 + selectedItems = selectorModel.GetSelectedItems() 95 + 96 + if len(selectedItems) == 0 { 97 + style.Headline("No commits selected") 98 + return nil 99 + } 100 + 101 + style.Headline(fmt.Sprintf("Generating entries for %d selected commits", len(selectedItems))) 102 + } else { 103 + style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) 104 + 105 + for _, commit := range commits { 106 + subject := commit.Message 107 + body := "" 108 + lines := strings.Split(commit.Message, "\n") 109 + if len(lines) > 0 { 110 + subject = lines[0] 111 + if len(lines) > 1 { 112 + body = strings.Join(lines[1:], "\n") 113 + } 114 + } 76 115 77 - parser := &gitlog.ConventionalParser{} 78 - entries := []changeset.Entry{} 79 - skipped := 0 116 + meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) 117 + if err != nil { 118 + fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) 119 + continue 120 + } 80 121 81 - for _, commit := range commits { 82 - subject := commit.Message 83 - body := "" 84 - lines := strings.Split(commit.Message, "\n") 85 - if len(lines) > 0 { 86 - subject = lines[0] 87 - if len(lines) > 1 { 88 - body = strings.Join(lines[1:], "\n") 122 + category := parser.Categorize(meta) 123 + if category == "" { 124 + continue 89 125 } 90 - } 91 126 92 - meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) 93 - if err != nil { 94 - fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) 95 - continue 127 + selectedItems = append(selectedItems, ui.CommitItem{ 128 + Commit: commit, 129 + Meta: meta, 130 + Category: category, 131 + Selected: true, 132 + }) 96 133 } 134 + } 97 135 98 - category := parser.Categorize(meta) 99 - if category == "" { 136 + entries := []changeset.Entry{} 137 + skipped := 0 138 + 139 + for _, item := range selectedItems { 140 + if item.Category == "" { 100 141 skipped++ 101 142 continue 102 143 } 103 144 104 145 entry := changeset.Entry{ 105 - Type: category, 106 - Scope: meta.Scope, 107 - Summary: meta.Description, 108 - Breaking: meta.Breaking, 146 + Type: item.Category, 147 + Scope: item.Meta.Scope, 148 + Summary: item.Meta.Description, 149 + Breaking: item.Meta.Breaking, 109 150 } 110 151 111 152 entries = append(entries, entry)
+440
internal/ui/commit_selector.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/charmbracelet/bubbles/key" 9 + "github.com/charmbracelet/bubbles/viewport" 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/charmbracelet/lipgloss" 12 + "github.com/go-git/go-git/v6/plumbing/object" 13 + "github.com/stormlightlabs/git-storm/internal/gitlog" 14 + "github.com/stormlightlabs/git-storm/internal/style" 15 + ) 16 + 17 + // CommitItem wraps a commit with its selection state and parsed metadata. 18 + type CommitItem struct { 19 + Commit *object.Commit 20 + Meta gitlog.CommitMeta 21 + Category string 22 + Selected bool 23 + } 24 + 25 + // CommitSelectorModel holds the state for the interactive commit selector TUI. 26 + type CommitSelectorModel struct { 27 + viewport viewport.Model 28 + items []CommitItem 29 + cursor int 30 + ready bool 31 + fromRef string 32 + toRef string 33 + width int 34 + height int 35 + confirmed bool 36 + cancelled bool 37 + } 38 + 39 + // commitSelectorKeyMap defines keyboard shortcuts for the commit selector. 40 + type commitSelectorKeyMap struct { 41 + Up key.Binding 42 + Down key.Binding 43 + PageUp key.Binding 44 + PageDown key.Binding 45 + Top key.Binding 46 + Bottom key.Binding 47 + Toggle key.Binding 48 + SelectAll key.Binding 49 + DeselectAll key.Binding 50 + Confirm key.Binding 51 + Quit key.Binding 52 + } 53 + 54 + var commitKeys = commitSelectorKeyMap{ 55 + Up: key.NewBinding( 56 + key.WithKeys("up", "k"), 57 + key.WithHelp("↑/k", "up"), 58 + ), 59 + Down: key.NewBinding( 60 + key.WithKeys("down", "j"), 61 + key.WithHelp("↓/j", "down"), 62 + ), 63 + PageUp: key.NewBinding( 64 + key.WithKeys("pgup", "u"), 65 + key.WithHelp("pgup/u", "page up"), 66 + ), 67 + PageDown: key.NewBinding( 68 + key.WithKeys("pgdown", "d"), 69 + key.WithHelp("pgdn/d", "page down"), 70 + ), 71 + Top: key.NewBinding( 72 + key.WithKeys("g", "home"), 73 + key.WithHelp("g/home", "top"), 74 + ), 75 + Bottom: key.NewBinding( 76 + key.WithKeys("G", "end"), 77 + key.WithHelp("G/end", "bottom"), 78 + ), 79 + Toggle: key.NewBinding( 80 + key.WithKeys(" "), 81 + key.WithHelp("space", "toggle"), 82 + ), 83 + SelectAll: key.NewBinding( 84 + key.WithKeys("a"), 85 + key.WithHelp("a", "select all"), 86 + ), 87 + DeselectAll: key.NewBinding( 88 + key.WithKeys("A"), 89 + key.WithHelp("A", "deselect all"), 90 + ), 91 + Confirm: key.NewBinding( 92 + key.WithKeys("enter", "c"), 93 + key.WithHelp("enter/c", "confirm"), 94 + ), 95 + Quit: key.NewBinding( 96 + key.WithKeys("q", "esc", "ctrl+c"), 97 + key.WithHelp("q", "quit"), 98 + ), 99 + } 100 + 101 + // NewCommitSelectorModel creates a new commit selector model. 102 + func NewCommitSelectorModel(commits []*object.Commit, fromRef, toRef string, parser gitlog.CommitParser) CommitSelectorModel { 103 + items := make([]CommitItem, 0, len(commits)) 104 + 105 + for _, commit := range commits { 106 + subject := commit.Message 107 + body := "" 108 + lines := strings.Split(commit.Message, "\n") 109 + if len(lines) > 0 { 110 + subject = lines[0] 111 + if len(lines) > 1 { 112 + body = strings.Join(lines[1:], "\n") 113 + } 114 + } 115 + 116 + meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) 117 + if err != nil { 118 + meta = gitlog.CommitMeta{ 119 + Type: "unknown", 120 + Description: subject, 121 + Body: body, 122 + } 123 + } 124 + 125 + category := parser.Categorize(meta) 126 + 127 + items = append(items, CommitItem{ 128 + Commit: commit, 129 + Meta: meta, 130 + Category: category, 131 + Selected: category != "", 132 + }) 133 + } 134 + 135 + return CommitSelectorModel{ 136 + items: items, 137 + cursor: 0, 138 + fromRef: fromRef, 139 + toRef: toRef, 140 + ready: false, 141 + } 142 + } 143 + 144 + // Init initializes the model (required by Bubble Tea). 145 + func (m CommitSelectorModel) Init() tea.Cmd { 146 + return nil 147 + } 148 + 149 + // Update handles messages and updates the model state. 150 + func (m CommitSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 151 + var cmd tea.Cmd 152 + 153 + switch msg := msg.(type) { 154 + case tea.KeyMsg: 155 + switch { 156 + case key.Matches(msg, commitKeys.Quit): 157 + m.cancelled = true 158 + return m, tea.Quit 159 + 160 + case key.Matches(msg, commitKeys.Confirm): 161 + m.confirmed = true 162 + return m, tea.Quit 163 + 164 + case key.Matches(msg, commitKeys.Up): 165 + if m.cursor > 0 { 166 + m.cursor-- 167 + m.ensureVisible() 168 + } 169 + 170 + case key.Matches(msg, commitKeys.Down): 171 + if m.cursor < len(m.items)-1 { 172 + m.cursor++ 173 + m.ensureVisible() 174 + } 175 + 176 + case key.Matches(msg, commitKeys.PageUp): 177 + m.cursor -= m.viewport.Height 178 + if m.cursor < 0 { 179 + m.cursor = 0 180 + } 181 + m.ensureVisible() 182 + 183 + case key.Matches(msg, commitKeys.PageDown): 184 + m.cursor += m.viewport.Height 185 + if m.cursor >= len(m.items) { 186 + m.cursor = len(m.items) - 1 187 + } 188 + m.ensureVisible() 189 + 190 + case key.Matches(msg, commitKeys.Top): 191 + m.cursor = 0 192 + m.ensureVisible() 193 + 194 + case key.Matches(msg, commitKeys.Bottom): 195 + m.cursor = len(m.items) - 1 196 + m.ensureVisible() 197 + 198 + case key.Matches(msg, commitKeys.Toggle): 199 + if m.cursor >= 0 && m.cursor < len(m.items) { 200 + m.items[m.cursor].Selected = !m.items[m.cursor].Selected 201 + m.updateContent() 202 + } 203 + 204 + case key.Matches(msg, commitKeys.SelectAll): 205 + for i := range m.items { 206 + m.items[i].Selected = true 207 + } 208 + m.updateContent() 209 + 210 + case key.Matches(msg, commitKeys.DeselectAll): 211 + for i := range m.items { 212 + m.items[i].Selected = false 213 + } 214 + m.updateContent() 215 + } 216 + 217 + case tea.WindowSizeMsg: 218 + m.width = msg.Width 219 + m.height = msg.Height 220 + 221 + if !m.ready { 222 + m.viewport = viewport.New(msg.Width, msg.Height-4) 223 + m.ready = true 224 + m.updateContent() 225 + } else { 226 + m.viewport.Width = msg.Width 227 + m.viewport.Height = msg.Height - 4 228 + m.updateContent() 229 + } 230 + } 231 + 232 + m.viewport, cmd = m.viewport.Update(msg) 233 + return m, cmd 234 + } 235 + 236 + // View renders the current view of the commit selector. 237 + func (m CommitSelectorModel) View() string { 238 + if !m.ready { 239 + return "\n Initializing..." 240 + } 241 + 242 + header := m.renderCommitHeader() 243 + footer := m.renderCommitFooter() 244 + 245 + return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer) 246 + } 247 + 248 + // GetSelectedCommits returns the list of selected commits. 249 + func (m CommitSelectorModel) GetSelectedCommits() []*object.Commit { 250 + selected := make([]*object.Commit, 0) 251 + for _, item := range m.items { 252 + if item.Selected { 253 + selected = append(selected, item.Commit) 254 + } 255 + } 256 + return selected 257 + } 258 + 259 + // GetSelectedItems returns the list of selected commit items with metadata. 260 + func (m CommitSelectorModel) GetSelectedItems() []CommitItem { 261 + selected := make([]CommitItem, 0) 262 + for _, item := range m.items { 263 + if item.Selected { 264 + selected = append(selected, item) 265 + } 266 + } 267 + return selected 268 + } 269 + 270 + // IsCancelled returns true if the user quit without confirming. 271 + func (m CommitSelectorModel) IsCancelled() bool { 272 + return m.cancelled 273 + } 274 + 275 + // IsConfirmed returns true if the user confirmed their selection. 276 + func (m CommitSelectorModel) IsConfirmed() bool { 277 + return m.confirmed 278 + } 279 + 280 + // ensureVisible scrolls the viewport to keep the cursor visible. 281 + func (m *CommitSelectorModel) ensureVisible() { 282 + lineHeight := 1 283 + cursorY := m.cursor * lineHeight 284 + 285 + if cursorY < m.viewport.YOffset { 286 + m.viewport.YOffset = cursorY 287 + } else if cursorY >= m.viewport.YOffset+m.viewport.Height { 288 + m.viewport.YOffset = cursorY - m.viewport.Height + 1 289 + } 290 + 291 + m.updateContent() 292 + } 293 + 294 + // updateContent regenerates the viewport content. 295 + func (m *CommitSelectorModel) updateContent() { 296 + if !m.ready { 297 + return 298 + } 299 + 300 + var content strings.Builder 301 + 302 + for i, item := range m.items { 303 + content.WriteString(m.renderCommitLine(i, item)) 304 + content.WriteString("\n") 305 + } 306 + 307 + m.viewport.SetContent(content.String()) 308 + } 309 + 310 + // renderCommitLine renders a single commit line with selection state. 311 + func (m CommitSelectorModel) renderCommitLine(index int, item CommitItem) string { 312 + checkbox := "[ ]" 313 + if item.Selected { 314 + checkbox = "[✓]" 315 + } 316 + 317 + shortHash := item.Commit.Hash.String()[:7] 318 + subject := item.Meta.Description 319 + if subject == "" { 320 + subject = strings.Split(item.Commit.Message, "\n")[0] 321 + } 322 + 323 + maxSubjectLen := max(m.width-60, 20) 324 + if len(subject) > maxSubjectLen { 325 + subject = subject[:maxSubjectLen-3] + "..." 326 + } 327 + 328 + author := item.Commit.Author.Name 329 + if len(author) > 15 { 330 + author = author[:12] + "..." 331 + } 332 + 333 + timeAgo := fmtTimeAgo(item.Commit.Author.When) 334 + 335 + category := item.Category 336 + if category == "" { 337 + category = "skip" 338 + } 339 + 340 + categoryStyle := getCategoryStyle(category) 341 + lineStyle := lipgloss.NewStyle() 342 + checkboxStyle := lipgloss.NewStyle().Foreground(style.AccentBlue) 343 + hashStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 344 + 345 + if index == m.cursor { 346 + lineStyle = lineStyle.Background(lipgloss.Color("#1f2428")) 347 + checkboxStyle = checkboxStyle.Bold(true) 348 + } 349 + 350 + line := fmt.Sprintf("%s %s %s %s %s %s", 351 + checkboxStyle.Render(checkbox), 352 + hashStyle.Render(shortHash), 353 + categoryStyle.Render(fmt.Sprintf("%-8s", category)), 354 + subject, 355 + lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render(author), 356 + lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true).Render(timeAgo), 357 + ) 358 + 359 + return lineStyle.Render(line) 360 + } 361 + 362 + // renderCommitHeader creates the header showing the range. 363 + func (m CommitSelectorModel) renderCommitHeader() string { 364 + headerStyle := lipgloss.NewStyle(). 365 + Foreground(style.AccentBlue). 366 + Bold(true). 367 + Padding(0, 1) 368 + 369 + return headerStyle.Render( 370 + fmt.Sprintf("Select commits to include (%s..%s)", m.fromRef, m.toRef), 371 + ) 372 + } 373 + 374 + // renderCommitFooter creates the footer with help text and selection count. 375 + func (m CommitSelectorModel) renderCommitFooter() string { 376 + footerStyle := lipgloss.NewStyle(). 377 + Foreground(lipgloss.Color("#6C7A89")). 378 + Faint(true). 379 + Padding(0, 1) 380 + 381 + selectedCount := 0 382 + for _, item := range m.items { 383 + if item.Selected { 384 + selectedCount++ 385 + } 386 + } 387 + 388 + helpText := "↑/↓: navigate • space: toggle • a/A: select/deselect all • enter: confirm • q: quit" 389 + selectionInfo := fmt.Sprintf("%d/%d selected", selectedCount, len(m.items)) 390 + 391 + totalWidth := m.width 392 + helpWidth := lipgloss.Width(helpText) 393 + selWidth := lipgloss.Width(selectionInfo) 394 + padding := max(totalWidth-helpWidth-selWidth-2, 0) 395 + 396 + return footerStyle.Render( 397 + helpText + strings.Repeat(" ", padding) + selectionInfo, 398 + ) 399 + } 400 + 401 + // fmtTimeAgo returns a human-readable relative time string. 402 + func fmtTimeAgo(t time.Time) string { 403 + duration := time.Since(t) 404 + 405 + if duration < time.Minute { 406 + return "just now" 407 + } else if duration < time.Hour { 408 + minutes := int(duration.Minutes()) 409 + return fmt.Sprintf("%dm ago", minutes) 410 + } else if duration < 24*time.Hour { 411 + hours := int(duration.Hours()) 412 + return fmt.Sprintf("%dh ago", hours) 413 + } else if duration < 30*24*time.Hour { 414 + days := int(duration.Hours() / 24) 415 + return fmt.Sprintf("%dd ago", days) 416 + } else if duration < 365*24*time.Hour { 417 + months := int(duration.Hours() / 24 / 30) 418 + return fmt.Sprintf("%dmo ago", months) 419 + } else { 420 + years := int(duration.Hours() / 24 / 365) 421 + return fmt.Sprintf("%dy ago", years) 422 + } 423 + } 424 + 425 + func getCategoryStyle(c string) lipgloss.Style { 426 + s := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 427 + switch c { 428 + case "added": 429 + s = lipgloss.NewStyle().Foreground(style.AddedColor) 430 + case "changed": 431 + s = lipgloss.NewStyle().Foreground(style.ChangedColor) 432 + case "fixed": 433 + s = lipgloss.NewStyle().Foreground(style.AccentBlue) 434 + case "removed": 435 + s = lipgloss.NewStyle().Foreground(style.RemovedColor) 436 + case "security": 437 + s = lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")) 438 + } 439 + return s 440 + }
+477
internal/ui/commit_selector_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/x/exp/teatest" 10 + "github.com/go-git/go-git/v6/plumbing" 11 + "github.com/go-git/go-git/v6/plumbing/object" 12 + "github.com/stormlightlabs/git-storm/internal/gitlog" 13 + ) 14 + 15 + type mockParser struct{} 16 + 17 + func (p *mockParser) Parse(hash, subject, body string, date time.Time) (gitlog.CommitMeta, error) { 18 + meta := gitlog.CommitMeta{ 19 + Type: "feat", 20 + Scope: "", 21 + Description: subject, 22 + Body: body, 23 + Breaking: false, 24 + Footers: make(map[string]string), 25 + } 26 + return meta, nil 27 + } 28 + 29 + func (p *mockParser) IsValidType(kind gitlog.CommitKind) bool { 30 + return kind != gitlog.CommitTypeUnknown 31 + } 32 + 33 + func (p *mockParser) Categorize(meta gitlog.CommitMeta) string { 34 + switch meta.Type { 35 + case "feat": 36 + return "added" 37 + case "fix": 38 + return "fixed" 39 + default: 40 + return "changed" 41 + } 42 + } 43 + 44 + func createMockCommit(hash, message string, when time.Time) *object.Commit { 45 + return &object.Commit{ 46 + Hash: plumbing.NewHash(hash), 47 + Message: message, 48 + Author: object.Signature{ 49 + Name: "Test Author", 50 + Email: "test@example.com", 51 + When: when, 52 + }, 53 + } 54 + } 55 + 56 + func TestCommitSelectorModel_Init(t *testing.T) { 57 + commits := []*object.Commit{ 58 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", time.Now()), 59 + } 60 + 61 + parser := &mockParser{} 62 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 63 + 64 + cmd := model.Init() 65 + if cmd != nil { 66 + t.Errorf("Init() should return nil, got %v", cmd) 67 + } 68 + } 69 + 70 + func TestCommitSelectorModel_AutoSelect(t *testing.T) { 71 + now := time.Now() 72 + commits := []*object.Commit{ 73 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 74 + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "fix: bug fix", now), 75 + } 76 + 77 + parser := &gitlog.ConventionalParser{} 78 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 79 + 80 + selectedItems := model.GetSelectedItems() 81 + if len(selectedItems) != 2 { 82 + t.Errorf("Expected 2 auto-selected items, got %d", len(selectedItems)) 83 + } 84 + } 85 + 86 + func TestCommitSelectorModel_GetSelectedCommits(t *testing.T) { 87 + now := time.Now() 88 + commits := []*object.Commit{ 89 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: feature 1", now), 90 + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: feature 2", now), 91 + createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: feature 3", now), 92 + } 93 + 94 + parser := &mockParser{} 95 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 96 + 97 + model.items[1].Selected = false 98 + 99 + selected := model.GetSelectedCommits() 100 + if len(selected) != 2 { 101 + t.Errorf("Expected 2 selected commits, got %d", len(selected)) 102 + } 103 + 104 + if selected[0].Hash != commits[0].Hash { 105 + t.Error("First selected commit should match first commit") 106 + } 107 + if selected[1].Hash != commits[2].Hash { 108 + t.Error("Second selected commit should match third commit") 109 + } 110 + } 111 + 112 + func TestCommitSelectorModel_ToggleSelection(t *testing.T) { 113 + now := time.Now() 114 + commits := []*object.Commit{ 115 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 116 + } 117 + 118 + parser := &mockParser{} 119 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 120 + 121 + initialSelected := model.items[0].Selected 122 + 123 + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 124 + model = updatedModel.(CommitSelectorModel) 125 + 126 + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) 127 + model = updatedModel.(CommitSelectorModel) 128 + 129 + if model.items[0].Selected == initialSelected { 130 + t.Error("Selection should have been toggled") 131 + } 132 + } 133 + 134 + func TestCommitSelectorModel_SelectAll(t *testing.T) { 135 + now := time.Now() 136 + commits := []*object.Commit{ 137 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 138 + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 139 + createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: test 3", now), 140 + } 141 + 142 + parser := &mockParser{} 143 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 144 + 145 + for i := range model.items { 146 + model.items[i].Selected = false 147 + } 148 + 149 + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 150 + model = updatedModel.(CommitSelectorModel) 151 + 152 + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) 153 + model = updatedModel.(CommitSelectorModel) 154 + 155 + for i, item := range model.items { 156 + if !item.Selected { 157 + t.Errorf("Item %d should be selected", i) 158 + } 159 + } 160 + } 161 + 162 + func TestCommitSelectorModel_DeselectAll(t *testing.T) { 163 + now := time.Now() 164 + commits := []*object.Commit{ 165 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 166 + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 167 + } 168 + 169 + parser := &mockParser{} 170 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 171 + 172 + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 173 + model = updatedModel.(CommitSelectorModel) 174 + 175 + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) 176 + model = updatedModel.(CommitSelectorModel) 177 + 178 + for i, item := range model.items { 179 + if item.Selected { 180 + t.Errorf("Item %d should be deselected", i) 181 + } 182 + } 183 + } 184 + 185 + func TestCommitSelectorModel_Navigation(t *testing.T) { 186 + now := time.Now() 187 + commits := make([]*object.Commit, 50) 188 + for i := range commits { 189 + hash := strings.Repeat("a", 40) 190 + commits[i] = createMockCommit(hash, "feat: test", now) 191 + } 192 + 193 + parser := &mockParser{} 194 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 195 + 196 + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 20)) 197 + 198 + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) 199 + tm.Send(tea.KeyMsg{Type: tea.KeyUp}) 200 + tm.Send(tea.KeyMsg{Type: tea.KeyPgDown}) 201 + tm.Send(tea.KeyMsg{Type: tea.KeyPgUp}) 202 + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 203 + 204 + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 205 + } 206 + 207 + func TestCommitSelectorModel_TopBottom(t *testing.T) { 208 + now := time.Now() 209 + commits := make([]*object.Commit, 20) 210 + for i := range commits { 211 + hash := strings.Repeat("a", 40) 212 + commits[i] = createMockCommit(hash, "feat: test", now) 213 + } 214 + 215 + parser := &mockParser{} 216 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 217 + 218 + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) 219 + model = updatedModel.(CommitSelectorModel) 220 + 221 + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) 222 + model = updatedModel.(CommitSelectorModel) 223 + 224 + if model.cursor != len(commits)-1 { 225 + t.Errorf("Cursor should be at bottom (index %d), got %d", len(commits)-1, model.cursor) 226 + } 227 + 228 + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) 229 + model = updatedModel.(CommitSelectorModel) 230 + 231 + if model.cursor != 0 { 232 + t.Errorf("Cursor should be at top (index 0), got %d", model.cursor) 233 + } 234 + } 235 + 236 + func TestCommitSelectorModel_Confirm(t *testing.T) { 237 + now := time.Now() 238 + commits := []*object.Commit{ 239 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 240 + } 241 + 242 + parser := &mockParser{} 243 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 244 + 245 + tm := teatest.NewTestModel(t, model) 246 + 247 + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 248 + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 249 + 250 + finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) 251 + selectorModel, ok := finalModel.(CommitSelectorModel) 252 + if !ok { 253 + t.Fatal("Expected CommitSelectorModel") 254 + } 255 + 256 + if !selectorModel.IsConfirmed() { 257 + t.Error("Model should be confirmed after pressing enter") 258 + } 259 + if selectorModel.IsCancelled() { 260 + t.Error("Model should not be cancelled") 261 + } 262 + } 263 + 264 + func TestCommitSelectorModel_QuitKeys(t *testing.T) { 265 + now := time.Now() 266 + commits := []*object.Commit{ 267 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 268 + } 269 + 270 + quitKeys := []struct { 271 + name string 272 + keyType tea.KeyType 273 + runes []rune 274 + }{ 275 + {"q", tea.KeyRunes, []rune{'q'}}, 276 + {"esc", tea.KeyEsc, nil}, 277 + {"ctrl+c", tea.KeyCtrlC, nil}, 278 + } 279 + 280 + for _, tc := range quitKeys { 281 + t.Run(tc.name, func(t *testing.T) { 282 + parser := &mockParser{} 283 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 284 + tm := teatest.NewTestModel(t, model) 285 + 286 + var msg tea.Msg 287 + if tc.runes != nil { 288 + msg = tea.KeyMsg{Type: tc.keyType, Runes: tc.runes} 289 + } else { 290 + msg = tea.KeyMsg{Type: tc.keyType} 291 + } 292 + 293 + tm.Send(msg) 294 + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 295 + 296 + finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) 297 + selectorModel, ok := finalModel.(CommitSelectorModel) 298 + if !ok { 299 + t.Fatal("Expected CommitSelectorModel") 300 + } 301 + 302 + if !selectorModel.IsCancelled() { 303 + t.Error("Model should be cancelled after quit key") 304 + } 305 + if selectorModel.IsConfirmed() { 306 + t.Error("Model should not be confirmed") 307 + } 308 + }) 309 + } 310 + } 311 + 312 + func TestCommitSelectorModel_View(t *testing.T) { 313 + now := time.Now() 314 + commits := []*object.Commit{ 315 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 316 + } 317 + 318 + parser := &mockParser{} 319 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 320 + 321 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 322 + model = updated.(CommitSelectorModel) 323 + 324 + view := model.View() 325 + 326 + if !strings.Contains(view, "v1.0.0") { 327 + t.Error("View should contain fromRef") 328 + } 329 + if !strings.Contains(view, "HEAD") { 330 + t.Error("View should contain toRef") 331 + } 332 + if !strings.Contains(view, "a1b2c3d") { 333 + t.Error("View should contain commit hash") 334 + } 335 + } 336 + 337 + func TestCommitSelectorModel_RenderHeader(t *testing.T) { 338 + now := time.Now() 339 + commits := []*object.Commit{ 340 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 341 + } 342 + 343 + parser := &mockParser{} 344 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 345 + model.width = 100 346 + 347 + header := model.renderCommitHeader() 348 + 349 + if !strings.Contains(header, "v1.0.0") { 350 + t.Error("Header should contain fromRef") 351 + } 352 + if !strings.Contains(header, "HEAD") { 353 + t.Error("Header should contain toRef") 354 + } 355 + if !strings.Contains(header, "Select commits") { 356 + t.Error("Header should contain instruction text") 357 + } 358 + } 359 + 360 + func TestCommitSelectorModel_RenderFooter(t *testing.T) { 361 + now := time.Now() 362 + commits := []*object.Commit{ 363 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 364 + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 365 + } 366 + 367 + parser := &mockParser{} 368 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 369 + model.width = 100 370 + 371 + footer := model.renderCommitFooter() 372 + 373 + if !strings.Contains(footer, "navigate") { 374 + t.Error("Footer should contain navigation help") 375 + } 376 + if !strings.Contains(footer, "toggle") { 377 + t.Error("Footer should contain toggle help") 378 + } 379 + if !strings.Contains(footer, "confirm") { 380 + t.Error("Footer should contain confirm help") 381 + } 382 + if !strings.Contains(footer, "selected") { 383 + t.Error("Footer should contain selection count") 384 + } 385 + } 386 + 387 + func TestCommitSelectorModel_RenderCommitLine(t *testing.T) { 388 + now := time.Now() 389 + commits := []*object.Commit{ 390 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 391 + } 392 + 393 + parser := &mockParser{} 394 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 395 + model.width = 100 396 + 397 + line := model.renderCommitLine(0, model.items[0]) 398 + 399 + if !strings.Contains(line, "[") && !strings.Contains(line, "]") { 400 + t.Error("Line should contain checkbox") 401 + } 402 + if !strings.Contains(line, "a1b2c3d") { 403 + t.Error("Line should contain short commit hash") 404 + } 405 + if !strings.Contains(line, "added") { 406 + t.Error("Line should contain category") 407 + } 408 + } 409 + 410 + func TestFormatTimeAgo(t *testing.T) { 411 + now := time.Now() 412 + 413 + tests := []struct { 414 + name string 415 + time time.Time 416 + expected string 417 + }{ 418 + {"just now", now, "just now"}, 419 + {"minutes ago", now.Add(-5 * time.Minute), "5m ago"}, 420 + {"hours ago", now.Add(-2 * time.Hour), "2h ago"}, 421 + {"days ago", now.Add(-3 * 24 * time.Hour), "3d ago"}, 422 + {"months ago", now.Add(-45 * 24 * time.Hour), "1mo ago"}, 423 + {"years ago", now.Add(-400 * 24 * time.Hour), "1y ago"}, 424 + } 425 + 426 + for _, tc := range tests { 427 + t.Run(tc.name, func(t *testing.T) { 428 + result := fmtTimeAgo(tc.time) 429 + if result != tc.expected { 430 + t.Errorf("Expected %q, got %q", tc.expected, result) 431 + } 432 + }) 433 + } 434 + } 435 + 436 + func TestCommitSelectorModel_EmptyCommits(t *testing.T) { 437 + commits := []*object.Commit{} 438 + 439 + parser := &mockParser{} 440 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 441 + 442 + if len(model.items) != 0 { 443 + t.Errorf("Expected 0 items, got %d", len(model.items)) 444 + } 445 + 446 + selected := model.GetSelectedCommits() 447 + if len(selected) != 0 { 448 + t.Errorf("Expected 0 selected commits, got %d", len(selected)) 449 + } 450 + } 451 + 452 + func TestCommitSelectorModel_WindowResize(t *testing.T) { 453 + now := time.Now() 454 + commits := []*object.Commit{ 455 + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 456 + } 457 + 458 + parser := &mockParser{} 459 + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 460 + 461 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 462 + model = updated.(CommitSelectorModel) 463 + 464 + if model.width != 80 || model.height != 24 { 465 + t.Errorf("Expected dimensions 80x24, got %dx%d", model.width, model.height) 466 + } 467 + if !model.ready { 468 + t.Error("Model should be ready after window size message") 469 + } 470 + 471 + updated, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) 472 + model = updated.(CommitSelectorModel) 473 + 474 + if model.width != 120 || model.height != 40 { 475 + t.Errorf("Expected dimensions 120x40, got %dx%d", model.width, model.height) 476 + } 477 + }