Add bulk author replacement

This commit is contained in:
Andy Dixon
2026-08-27 09:29:45 +01:00
parent 0920c53de4
commit f20a0bea28
3 changed files with 338 additions and 18 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ Repository: https://github.com/andydixon/nexus
- committer name, email, and date/time - committer name, email, and date/time
- full commit message - full commit message
- Search commits by hash, message, author name, or email - 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 - Track edited commits and reset the selected commit
- Apply all staged metadata changes in one action - Apply all staged metadata changes in one action
- Optionally force push rewritten branches and tags to `origin` - 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 - `Shift+Tab`: move backward through form fields
- `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits - `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits
- `/`: search commits - `/`: search commits
- `b`: bulk replace matching author names and emails; `Ctrl+T` toggles partial/exact matching
- `p`: switch repository path - `p`: switch repository path
- `r`: reload history - `r`: reload history
- `x`: reset the selected commit - `x`: reset the selected commit
@@ -67,4 +69,3 @@ History rewriting changes commit hashes and can disrupt shared branches. Coordin
## Licence ## Licence
This project is proprietary software. It is **not** released under the MIT licence. This project is proprietary software. It is **not** released under the MIT licence.
+182 -2
View File
@@ -24,6 +24,13 @@ const (
formFieldCount formFieldCount
) )
const (
bulkAuthorSearch = iota
bulkAuthorName
bulkAuthorEmail
bulkAuthorFieldCount
)
type uiFocus int type uiFocus int
const ( const (
@@ -37,6 +44,7 @@ const (
overlayNone uiOverlay = iota overlayNone uiOverlay = iota
overlaySearch overlaySearch
overlayPath overlayPath
overlayBulkAuthor
overlayConfirmApply overlayConfirmApply
overlayConfirmForce overlayConfirmForce
overlayHelp overlayHelp
@@ -145,6 +153,9 @@ type tuiModel struct {
searchInput textinput.Model searchInput textinput.Model
pathInput textinput.Model pathInput textinput.Model
confirmInput textinput.Model confirmInput textinput.Model
bulkAuthorFocus int
bulkAuthorInputs []textinput.Model
bulkAuthorExact bool
forcePush bool forcePush bool
pushTags bool pushTags bool
@@ -191,6 +202,14 @@ func newTUIModel(app *App, initialPath string) tuiModel {
confirmInput := newTextInput() confirmInput := newTextInput()
confirmInput.Placeholder = "FORCE" 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{ return tuiModel{
app: app, app: app,
initialPath: initialPath, initialPath: initialPath,
@@ -201,6 +220,7 @@ func newTUIModel(app *App, initialPath string) tuiModel {
searchInput: searchInput, searchInput: searchInput,
pathInput: pathInput, pathInput: pathInput,
confirmInput: confirmInput, confirmInput: confirmInput,
bulkAuthorInputs: bulkAuthorInputs,
pushTags: true, pushTags: true,
status: "Loading repository...", status: "Loading repository...",
statusKind: statusInfo, statusKind: statusInfo,
@@ -313,6 +333,8 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updateSearch(msg) return m.updateSearch(msg)
case overlayPath: case overlayPath:
return m.updatePathEntry(msg) return m.updatePathEntry(msg)
case overlayBulkAuthor:
return m.updateBulkAuthor(msg)
case overlayConfirmApply, overlayConfirmForce: case overlayConfirmApply, overlayConfirmForce:
return m.updateConfirmation(msg) return m.updateConfirmation(msg)
} }
@@ -333,6 +355,12 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "p", "o": case "p", "o":
m.overlay = overlayPath m.overlay = overlayPath
m.focusPathInput() m.focusPathInput()
case "b":
if m.repoLoaded {
m.openBulkAuthor()
} else {
m.setStatus("Load a repository before replacing authors.", statusError)
}
case "r": case "r":
if m.repository.Path != "" { if m.repository.Path != "" {
m.busy = true m.busy = true
@@ -419,6 +447,56 @@ func (m tuiModel) updatePathEntry(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, 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) { func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String() key := msg.String()
if key == "esc" || key == "n" { 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)) 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 { if m.focus == focusForm {
help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit" help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit"
} }
if m.overlay == overlayPath { if m.overlay == overlayPath {
help = "Enter load repository Esc close" 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 { if m.overlay == overlayConfirmApply {
help = "Press y to rewrite history, n/Esc to cancel" 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 { switch m.overlay {
case overlayPath: case overlayPath:
content = m.renderPathPane(innerWidth, innerHeight) content = m.renderPathPane(innerWidth, innerHeight)
case overlayBulkAuthor:
content = m.renderBulkAuthorPane(innerWidth, innerHeight)
case overlayConfirmApply, overlayConfirmForce: case overlayConfirmApply, overlayConfirmForce:
content = m.renderConfirmPane(innerWidth, innerHeight) content = m.renderConfirmPane(innerWidth, innerHeight)
case overlayHelp: case overlayHelp:
@@ -713,6 +796,29 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
return renderBox(style, width, height, content) 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 { func (m tuiModel) renderPathPane(width int, height int) string {
lines := []string{ lines := []string{
headerStyle.Render("Open Repository"), 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.", "List: Up/Down, PgUp/PgDn, Home/End select commits.",
"Tab moves from the list into the commit form.", "Tab moves from the list into the commit form.",
"Form: Tab and Shift+Tab move through every editable value. Esc returns to the list.", "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.", "x resets the selected commit. a applies edits. f toggles force push. t toggles tag push.",
"", "",
headerStyle.Render("Safety"), headerStyle.Render("Safety"),
@@ -876,6 +982,36 @@ func (m *tuiModel) updateComponentSizes() {
m.searchInput.Width = maxInt(16, width-10) m.searchInput.Width = maxInt(16, width-10)
m.pathInput.Width = maxInt(4, contentWidthFor(focusedFieldStyle, innerRight)) m.pathInput.Width = maxInt(4, contentWidthFor(focusedFieldStyle, innerRight))
m.confirmInput.Width = 12 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() { func (m *tuiModel) focusPathInput() {
@@ -1097,6 +1233,50 @@ func cloneCommitRecord(commit CommitRecord) CommitRecord {
return clone 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) { func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitRecord, selectedHash string, query string) ([]CommitRecord, int, string) {
filtered := filterCommits(commits, draftByHash, query) filtered := filterCommits(commits, draftByHash, query)
if len(filtered) == 0 { if len(filtered) == 0 {
+139
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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) { func TestSelectionAfterFilterPreservesSelectedCommit(t *testing.T) {
commits := []CommitRecord{ commits := []CommitRecord{
{Hash: "one", ShortHash: "one", AuthorName: "A", Message: "alpha"}, {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) { func TestViewFitsTerminalDimensions(t *testing.T) {
model := newTUIModel(NewApp(), "/tmp/repo") model := newTUIModel(NewApp(), "/tmp/repo")
model.width = 80 model.width = 80