diff --git a/README.md b/README.md index b18a533..548efb7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Repository: https://github.com/andydixon/nexus - committer name, email, and date/time - full commit message - Search commits by hash, message, author name, or email +- Bulk replace author names and emails using case-insensitive partial or exact matching - Track edited commits and reset the selected commit - Apply all staged metadata changes in one action - Optionally force push rewritten branches and tags to `origin` @@ -23,6 +24,7 @@ Repository: https://github.com/andydixon/nexus - `Shift+Tab`: move backward through form fields - `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits - `/`: search commits +- `b`: bulk replace matching author names and emails; `Ctrl+T` toggles partial/exact matching - `p`: switch repository path - `r`: reload history - `x`: reset the selected commit @@ -67,4 +69,3 @@ History rewriting changes commit hashes and can disrupt shared branches. Coordin ## Licence This project is proprietary software. It is **not** released under the MIT licence. - diff --git a/tui.go b/tui.go index 73392ec..d7da631 100644 --- a/tui.go +++ b/tui.go @@ -24,6 +24,13 @@ const ( formFieldCount ) +const ( + bulkAuthorSearch = iota + bulkAuthorName + bulkAuthorEmail + bulkAuthorFieldCount +) + type uiFocus int const ( @@ -37,6 +44,7 @@ const ( overlayNone uiOverlay = iota overlaySearch overlayPath + overlayBulkAuthor overlayConfirmApply overlayConfirmForce overlayHelp @@ -142,9 +150,12 @@ type tuiModel struct { inputs []textinput.Model message textarea.Model - searchInput textinput.Model - pathInput textinput.Model - confirmInput textinput.Model + searchInput textinput.Model + pathInput textinput.Model + confirmInput textinput.Model + bulkAuthorFocus int + bulkAuthorInputs []textinput.Model + bulkAuthorExact bool forcePush bool pushTags bool @@ -191,19 +202,28 @@ func newTUIModel(app *App, initialPath string) tuiModel { confirmInput := newTextInput() confirmInput.Placeholder = "FORCE" + bulkAuthorInputs := make([]textinput.Model, bulkAuthorFieldCount) + for i := range bulkAuthorInputs { + bulkAuthorInputs[i] = newTextInput() + } + bulkAuthorInputs[bulkAuthorSearch].Placeholder = "Existing author name or email" + bulkAuthorInputs[bulkAuthorName].Placeholder = "Replacement author name" + bulkAuthorInputs[bulkAuthorEmail].Placeholder = "Replacement author email" + return tuiModel{ - app: app, - initialPath: initialPath, - originalByHash: map[string]CommitRecord{}, - draftByHash: map[string]CommitRecord{}, - inputs: inputs, - message: message, - searchInput: searchInput, - pathInput: pathInput, - confirmInput: confirmInput, - pushTags: true, - status: "Loading repository...", - statusKind: statusInfo, + app: app, + initialPath: initialPath, + originalByHash: map[string]CommitRecord{}, + draftByHash: map[string]CommitRecord{}, + inputs: inputs, + message: message, + searchInput: searchInput, + pathInput: pathInput, + confirmInput: confirmInput, + bulkAuthorInputs: bulkAuthorInputs, + pushTags: true, + status: "Loading repository...", + statusKind: statusInfo, } } @@ -313,6 +333,8 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.updateSearch(msg) case overlayPath: return m.updatePathEntry(msg) + case overlayBulkAuthor: + return m.updateBulkAuthor(msg) case overlayConfirmApply, overlayConfirmForce: return m.updateConfirmation(msg) } @@ -333,6 +355,12 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "p", "o": m.overlay = overlayPath m.focusPathInput() + case "b": + if m.repoLoaded { + m.openBulkAuthor() + } else { + m.setStatus("Load a repository before replacing authors.", statusError) + } case "r": if m.repository.Path != "" { m.busy = true @@ -419,6 +447,56 @@ func (m tuiModel) updatePathEntry(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m tuiModel) updateBulkAuthor(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.overlay = overlayNone + m.blurBulkAuthorInputs() + m.setStatus("Bulk author replacement cancelled.", statusInfo) + return m, nil + case "tab", "shift+tab": + delta := 1 + if msg.String() == "shift+tab" { + delta = -1 + } + next := (m.bulkAuthorFocus + delta + bulkAuthorFieldCount) % bulkAuthorFieldCount + m.focusBulkAuthorField(next) + return m, nil + case "ctrl+t": + m.bulkAuthorExact = !m.bulkAuthorExact + m.setStatus(fmt.Sprintf("Author match mode: %s.", authorMatchMode(m.bulkAuthorExact)), statusInfo) + return m, nil + case "enter": + m.syncFormToDraft() + matched, err := stageAuthorReplacement( + m.commits, + m.draftByHash, + m.bulkAuthorInputs[bulkAuthorSearch].Value(), + m.bulkAuthorInputs[bulkAuthorName].Value(), + m.bulkAuthorInputs[bulkAuthorEmail].Value(), + m.bulkAuthorExact, + ) + if err != nil { + m.setStatus(err.Error(), statusError) + return m, nil + } + if matched == 0 { + m.setStatus("No author names or emails matched the search.", statusInfo) + return m, nil + } + m.overlay = overlayNone + m.blurBulkAuthorInputs() + m.reconcileSelection() + m.loadSelectedCommitIntoForm() + m.setStatus(fmt.Sprintf("Staged author replacement for %d commit%s.", matched, plural(matched)), statusSuccess) + return m, nil + } + + var cmd tea.Cmd + m.bulkAuthorInputs[m.bulkAuthorFocus], cmd = m.bulkAuthorInputs[m.bulkAuthorFocus].Update(msg) + return m, cmd +} + func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "esc" || key == "n" { @@ -599,13 +677,16 @@ func (m tuiModel) renderBottomBar(width int) string { } status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, contentWidth)) - help := "Tab form/list / search p path r reload x reset a apply f force t tags ? help q quit" + help := "Tab form/list / search b bulk author p path r reload x reset a apply f force t tags ? help q quit" if m.focus == focusForm { help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit" } if m.overlay == overlayPath { help = "Enter load repository Esc close" } + if m.overlay == overlayBulkAuthor { + help = "Tab next field Shift+Tab previous Ctrl+T partial/exact Enter stage Esc cancel" + } if m.overlay == overlayConfirmApply { help = "Press y to rewrite history, n/Esc to cancel" } @@ -702,6 +783,8 @@ func (m tuiModel) renderDetailPane(width int, height int) string { switch m.overlay { case overlayPath: content = m.renderPathPane(innerWidth, innerHeight) + case overlayBulkAuthor: + content = m.renderBulkAuthorPane(innerWidth, innerHeight) case overlayConfirmApply, overlayConfirmForce: content = m.renderConfirmPane(innerWidth, innerHeight) case overlayHelp: @@ -713,6 +796,29 @@ func (m tuiModel) renderDetailPane(width int, height int) string { return renderBox(style, width, height, content) } +func (m tuiModel) renderBulkAuthorPane(width int, height int) string { + lines := []string{ + headerStyle.Render("Bulk replace author"), + "Search current draft author names and emails, then stage a new author identity on every matching commit.", + "", + m.renderBulkAuthorField(bulkAuthorSearch, "Search", width), + m.renderBulkAuthorField(bulkAuthorName, "Replacement author name", width), + m.renderBulkAuthorField(bulkAuthorEmail, "Replacement author email", width), + "", + fmt.Sprintf("Match mode: %s %s", authorMatchMode(m.bulkAuthorExact), dimStyle.Render("(Ctrl+T to toggle)")), + dimStyle.Render("Press Enter to stage these changes. Use a to apply the history rewrite."), + } + return fitLines(wrapBlockLines(lines, width), height, width) +} + +func (m tuiModel) renderBulkAuthorField(index int, label string, width int) string { + style := blurredFieldStyle + if m.overlay == overlayBulkAuthor && m.bulkAuthorFocus == index { + style = focusedFieldStyle + } + return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.bulkAuthorInputs[index].View()) +} + func (m tuiModel) renderPathPane(width int, height int) string { lines := []string{ headerStyle.Render("Open Repository"), @@ -756,7 +862,7 @@ func (m tuiModel) renderHelpPane(width int, height int) string { "List: Up/Down, PgUp/PgDn, Home/End select commits.", "Tab moves from the list into the commit form.", "Form: Tab and Shift+Tab move through every editable value. Esc returns to the list.", - "/ searches commits. p opens a repository path prompt. r reloads history.", + "/ searches commits. b bulk-replaces author identities. p opens a repository path prompt. r reloads history.", "x resets the selected commit. a applies edits. f toggles force push. t toggles tag push.", "", headerStyle.Render("Safety"), @@ -876,6 +982,36 @@ func (m *tuiModel) updateComponentSizes() { m.searchInput.Width = maxInt(16, width-10) m.pathInput.Width = maxInt(4, contentWidthFor(focusedFieldStyle, innerRight)) m.confirmInput.Width = 12 + for i := range m.bulkAuthorInputs { + m.bulkAuthorInputs[i].Width = maxInt(4, contentWidthFor(blurredFieldStyle, innerRight)) + } +} + +func (m *tuiModel) openBulkAuthor() { + m.blurForm() + m.searchInput.Blur() + m.pathInput.Blur() + m.confirmInput.Blur() + for i := range m.bulkAuthorInputs { + m.bulkAuthorInputs[i].SetValue("") + } + m.bulkAuthorExact = false + m.overlay = overlayBulkAuthor + m.focusBulkAuthorField(bulkAuthorSearch) + m.setStatus("Enter an author search and replacement identity.", statusInfo) +} + +func (m *tuiModel) focusBulkAuthorField(index int) { + m.blurBulkAuthorInputs() + m.bulkAuthorFocus = clamp(index, 0, bulkAuthorFieldCount-1) + m.bulkAuthorInputs[m.bulkAuthorFocus].Focus() + m.bulkAuthorInputs[m.bulkAuthorFocus].CursorEnd() +} + +func (m *tuiModel) blurBulkAuthorInputs() { + for i := range m.bulkAuthorInputs { + m.bulkAuthorInputs[i].Blur() + } } func (m *tuiModel) focusPathInput() { @@ -1097,6 +1233,50 @@ func cloneCommitRecord(commit CommitRecord) CommitRecord { return clone } +func stageAuthorReplacement(commits []CommitRecord, draftByHash map[string]CommitRecord, query string, replacementName string, replacementEmail string, exact bool) (int, error) { + query = strings.TrimSpace(query) + replacementName = strings.TrimSpace(replacementName) + replacementEmail = strings.TrimSpace(replacementEmail) + if query == "" { + return 0, errors.New("author search is required") + } + if replacementName == "" { + return 0, errors.New("replacement author name is required") + } + if replacementEmail == "" { + return 0, errors.New("replacement author email is required") + } + matched := 0 + for _, commit := range commits { + draft, ok := draftByHash[commit.Hash] + if !ok { + draft = cloneCommitRecord(commit) + } + nameMatches := strings.EqualFold(draft.AuthorName, query) + emailMatches := strings.EqualFold(draft.AuthorEmail, query) + if !exact { + lowerQuery := strings.ToLower(query) + nameMatches = strings.Contains(strings.ToLower(draft.AuthorName), lowerQuery) + emailMatches = strings.Contains(strings.ToLower(draft.AuthorEmail), lowerQuery) + } + if !nameMatches && !emailMatches { + continue + } + draft.AuthorName = replacementName + draft.AuthorEmail = replacementEmail + draftByHash[commit.Hash] = draft + matched++ + } + return matched, nil +} + +func authorMatchMode(exact bool) string { + if exact { + return "Exact" + } + return "Partial" +} + func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitRecord, selectedHash string, query string) ([]CommitRecord, int, string) { filtered := filterCommits(commits, draftByHash, query) if len(filtered) == 0 { diff --git a/tui_test.go b/tui_test.go index 6d2fc40..5c9f3c7 100644 --- a/tui_test.go +++ b/tui_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -37,6 +38,88 @@ func TestDirtyCommitCount(t *testing.T) { } } +func TestStageAuthorReplacementMatchesPartialIdentityCaseInsensitively(t *testing.T) { + commits := []CommitRecord{ + {Hash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", CommitterName: "Release Bot", CommitterEmail: "bot@example.com"}, + {Hash: "two", AuthorName: "Someone Else", AuthorEmail: "FOO@EXAMPLE.COM", CommitterName: "Other Bot", CommitterEmail: "other@example.com"}, + {Hash: "three", AuthorName: "Unaffected", AuthorEmail: "unaffected@example.com", CommitterName: "Keep Me", CommitterEmail: "keep@example.com"}, + } + drafts := cloneMapByHash(commits) + + matched, err := stageAuthorReplacement(commits, drafts, "fOo", "New Author", "new@example.com", false) + if err != nil { + t.Fatal(err) + } + if matched != 2 { + t.Fatalf("expected two matching commits, got %d", matched) + } + if got := drafts["one"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { + t.Fatalf("expected first author identity to be replaced, got %#v", got) + } else if got.CommitterName != "Release Bot" || got.CommitterEmail != "bot@example.com" { + t.Fatalf("expected first committer identity to be preserved, got %#v", got) + } + if got := drafts["two"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { + t.Fatalf("expected second author identity to be replaced, got %#v", got) + } else if got.CommitterName != "Other Bot" || got.CommitterEmail != "other@example.com" { + t.Fatalf("expected second committer identity to be preserved, got %#v", got) + } + if got := drafts["three"]; got.AuthorName != "Unaffected" || got.AuthorEmail != "unaffected@example.com" { + t.Fatalf("expected non-matching author identity to be preserved, got %#v", got) + } +} + +func TestStageAuthorReplacementExactModeRequiresWholeFieldMatch(t *testing.T) { + commits := []CommitRecord{ + {Hash: "partial", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz"}, + {Hash: "exact", AuthorName: "FOO", AuthorEmail: "someone@example.com"}, + } + drafts := cloneMapByHash(commits) + + matched, err := stageAuthorReplacement(commits, drafts, "foo", "New Author", "new@example.com", true) + if err != nil { + t.Fatal(err) + } + if matched != 1 { + t.Fatalf("expected one exact match, got %d", matched) + } + if got := drafts["partial"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { + t.Fatalf("expected partial match to remain unchanged in exact mode, got %#v", got) + } + if got := drafts["exact"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { + t.Fatalf("expected exact match to be replaced, got %#v", got) + } +} + +func TestStageAuthorReplacementRejectsBlankInputsWithoutChangingDrafts(t *testing.T) { + commits := []CommitRecord{{Hash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz"}} + tests := []struct { + name string + query string + replacementName string + replacementEmail string + }{ + {name: "search", query: " ", replacementName: "New Author", replacementEmail: "new@example.com"}, + {name: "author name", query: "Foo", replacementName: " ", replacementEmail: "new@example.com"}, + {name: "author email", query: "Foo", replacementName: "New Author", replacementEmail: " "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + drafts := cloneMapByHash(commits) + matched, err := stageAuthorReplacement(commits, drafts, tt.query, tt.replacementName, tt.replacementEmail, false) + if err == nil { + t.Fatal("expected blank input to be rejected") + } + if matched != 0 { + t.Fatalf("expected no matches on validation failure, got %d", matched) + } + if got := drafts["one"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { + t.Fatalf("expected drafts to remain unchanged on validation failure, got %#v", got) + } + }) + } +} + func TestSelectionAfterFilterPreservesSelectedCommit(t *testing.T) { commits := []CommitRecord{ {Hash: "one", ShortHash: "one", AuthorName: "A", Message: "alpha"}, @@ -113,6 +196,62 @@ func TestRepositoryLoadErrorOpensPathEntryState(t *testing.T) { } } +func TestBulkAuthorKeyOpensReplacementDialogInPartialMode(t *testing.T) { + model := newTUIModel(NewApp(), "/tmp/repo") + model.width = 100 + model.height = 30 + model.handleRepoLoaded(repoLoadedMsg{ + repo: RepositoryState{Path: "/tmp/repo", Clean: true, CurrentBranch: "main"}, + commits: []CommitRecord{ + {Hash: "one", ShortHash: "one", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", Message: "first"}, + }, + }) + + updated, _ := model.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) + view := updated.(tuiModel).View() + if !strings.Contains(view, "Bulk replace author") { + t.Fatalf("expected bulk author dialog after pressing b, got:\n%s", view) + } + if !strings.Contains(view, "Match mode: Partial") { + t.Fatalf("expected partial match mode by default, got:\n%s", view) + } +} + +func TestBulkAuthorDialogStagesExactAuthorReplacement(t *testing.T) { + model := newTUIModel(NewApp(), "/tmp/repo") + model.handleRepoLoaded(repoLoadedMsg{ + repo: RepositoryState{Path: "/tmp/repo", Clean: true, CurrentBranch: "main"}, + commits: []CommitRecord{ + {Hash: "partial", ShortHash: "partial", AuthorName: "Foo Bar", AuthorEmail: "foo@bar.baz", Message: "partial"}, + {Hash: "exact", ShortHash: "exact", AuthorName: "FOO", AuthorEmail: "someone@example.com", Message: "exact"}, + }, + }) + send := func(msg tea.KeyMsg) { + t.Helper() + updated, _ := model.handleKey(msg) + model = updated.(tuiModel) + } + + send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) + send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("foo")}) + send(tea.KeyMsg{Type: tea.KeyTab}) + send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New Author")}) + send(tea.KeyMsg{Type: tea.KeyTab}) + send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("new@example.com")}) + send(tea.KeyMsg{Type: tea.KeyCtrlT}) + send(tea.KeyMsg{Type: tea.KeyEnter}) + + if got := model.draftByHash["partial"]; got.AuthorName != "Foo Bar" || got.AuthorEmail != "foo@bar.baz" { + t.Fatalf("expected partial-only match to remain unchanged in exact mode, got %#v", got) + } + if got := model.draftByHash["exact"]; got.AuthorName != "New Author" || got.AuthorEmail != "new@example.com" { + t.Fatalf("expected exact match to be staged through the dialog, got %#v", got) + } + if model.overlay != overlayNone { + t.Fatalf("expected successful replacement to close the dialog, got overlay %v", model.overlay) + } +} + func TestViewFitsTerminalDimensions(t *testing.T) { model := newTUIModel(NewApp(), "/tmp/repo") model.width = 80