Multiple fixes

This commit is contained in:
Andy Dixon
2026-07-21 14:27:58 +01:00
parent b9b8635c33
commit 411e033a8e
5 changed files with 447 additions and 36 deletions
+9 -4
View File
@@ -12,6 +12,8 @@ Repository: https://github.com/andydixon/nexus
- author name, email, and date/time - author name, email, and date/time
- committer name, email, and date/time - committer name, email, and date/time
- full commit message - full commit message
- Keyboard-driven calendar and time picker for author and committer timestamps
- Opt-in bulk removal of `Co-authored-by:` trailer lines across commit messages
- Search commits by hash, message, author name, or email - Search commits by hash, message, author name, or email
- 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
@@ -27,20 +29,24 @@ Repository: https://github.com/andydixon/nexus
- `r`: reload history - `r`: reload history
- `x`: reset the selected commit - `x`: reset the selected commit
- `a`: apply changes - `a`: apply changes
- `c`: toggle bulk removal of `Co-authored-by:` lines
- `f`: toggle force push - `f`: toggle force push
- `t`: toggle tag push - `t`: toggle tag push
- `?`: show help - `?`: show help
- `q` or `Ctrl+C`: quit - `q` or `Ctrl+C`: quit
When an author or committer date field is focused, press `Enter` to open the date/time picker. Arrow keys adjust the day and hour, `PgUp`/`PgDn` adjust the month, `+`/`-` adjust by five minutes, and `H`/`L` adjust the year. Use `t` for now, `z` for local time, `u` for UTC, `Enter` to save, or `Esc` to cancel. `Ctrl+T` remains a quick shortcut for setting the focused date to now.
## Apply Workflow ## Apply Workflow
When you apply changes, Nexus: When you apply changes, Nexus:
1. Checks that the selected repository is clean. 1. Checks that the selected repository is clean.
2. Creates a backup tag (`nexus-backup-<timestamp>`). 2. Creates a backup tag (`nexus-backup-<timestamp>`).
3. Rewrites history with `git filter-branch` across all refs. 3. Applies timestamp, metadata, message, and optional co-author trailer changes.
4. Removes temporary rewrite references and runs repository cleanup. 4. Rewrites history with `git filter-branch` across all refs.
5. Optionally force pushes branches and tags using `--force-with-lease`. 5. Removes temporary rewrite references and runs repository cleanup.
6. Optionally force pushes branches and tags using `--force-with-lease`.
Force push requires an explicit `FORCE` confirmation in the terminal. Force push requires an explicit `FORCE` confirmation in the terminal.
@@ -67,4 +73,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.
+66 -13
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -50,10 +51,12 @@ type ApplyRequest struct {
Commits []CommitRecord `json:"commits"` Commits []CommitRecord `json:"commits"`
ForcePush bool `json:"forcePush"` ForcePush bool `json:"forcePush"`
PushTags bool `json:"pushTags"` PushTags bool `json:"pushTags"`
RemoveCoAuthors bool `json:"removeCoAuthors"`
} }
type ApplyResult struct { type ApplyResult struct {
RewrittenCommits int `json:"rewrittenCommits"` RewrittenCommits int `json:"rewrittenCommits"`
RemovedCoAuthors int `json:"removedCoAuthors"`
ForcePushed bool `json:"forcePushed"` ForcePushed bool `json:"forcePushed"`
BackupReference string `json:"backupReference"` BackupReference string `json:"backupReference"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
@@ -156,18 +159,19 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
return ApplyResult{}, err return ApplyResult{}, err
} }
currentByHash := make(map[string]CommitRecord, len(currentHistory)) incomingByHash := make(map[string]CommitRecord, len(req.Commits))
for _, commit := range currentHistory { for _, incoming := range req.Commits {
currentByHash[commit.Hash] = commit incomingByHash[incoming.Hash] = incoming
} }
updatedByHash := make(map[string]CommitRecord) updatedByHash := make(map[string]CommitRecord)
messageOverride := make(map[string]bool) messageOverride := make(map[string]bool)
removedCoAuthors := 0
for _, incoming := range req.Commits { for _, current := range currentHistory {
current, exists := currentByHash[incoming.Hash] incoming, exists := incomingByHash[current.Hash]
if !exists { if !exists {
continue incoming = current
} }
normalized, err := normalizeIncomingCommit(incoming, current) normalized, err := normalizeIncomingCommit(incoming, current)
@@ -175,6 +179,13 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
return ApplyResult{}, fmt.Errorf("commit %s is invalid: %w", incoming.ShortHash, err) return ApplyResult{}, fmt.Errorf("commit %s is invalid: %w", incoming.ShortHash, err)
} }
if req.RemoveCoAuthors {
var removed int
normalized.Message, removed = removeCoAuthorLines(normalized.Message)
normalized.Subject = firstLine(normalized.Message)
removedCoAuthors += removed
}
if editableFieldsChanged(current, normalized) { if editableFieldsChanged(current, normalized) {
updatedByHash[incoming.Hash] = normalized updatedByHash[incoming.Hash] = normalized
messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message) messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message)
@@ -183,6 +194,7 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
result := ApplyResult{ result := ApplyResult{
RewrittenCommits: len(updatedByHash), RewrittenCommits: len(updatedByHash),
RemovedCoAuthors: removedCoAuthors,
Warnings: []string{}, Warnings: []string{},
} }
@@ -468,17 +480,29 @@ func normalizeIncomingCommit(incoming CommitRecord, current CommitRecord) (Commi
} }
func normalizeGitDate(input string) (string, error) { func normalizeGitDate(input string) (string, error) {
parsed, err := parseGitDate(input)
if err != nil {
return "", err
}
return formatGitDate(parsed), nil
}
func parseGitDate(input string) (time.Time, error) {
date := strings.TrimSpace(input) date := strings.TrimSpace(input)
if date == "" { if date == "" {
return "", errors.New("date cannot be empty") return time.Time{}, errors.New("date cannot be empty")
} }
layouts := []string{ layouts := []string{
time.RFC3339Nano, time.RFC3339Nano,
time.RFC3339, time.RFC3339,
"2006-01-02 15:04:05 -0700",
time.RFC1123Z,
time.RFC822Z,
"2006-01-02T15:04:05", "2006-01-02T15:04:05",
"2006-01-02T15:04", "2006-01-02T15:04",
"2006-01-02 15:04:05", "2006-01-02 15:04:05",
"2006-01-02 15:04",
} }
for _, layout := range layouts { for _, layout := range layouts {
@@ -487,29 +511,58 @@ func normalizeGitDate(input string) (string, error) {
err error err error
) )
if layout == time.RFC3339 || layout == time.RFC3339Nano { if strings.Contains(layout, "Z07") || strings.Contains(layout, "-0700") || layout == time.RFC1123Z || layout == time.RFC822Z {
t, err = time.Parse(layout, date) t, err = time.Parse(layout, date)
} else { } else {
t, err = time.ParseInLocation(layout, date, time.Local) t, err = time.ParseInLocation(layout, date, time.Local)
} }
if err == nil { if err == nil {
return t.Format("2006-01-02T15:04:05-07:00"), nil return t, nil
} }
} }
return "", errors.New("date must be RFC3339 or YYYY-MM-DDTHH:MM[:SS]") return time.Time{}, errors.New("date must be ISO 8601, a standard Git date, or YYYY-MM-DD HH:MM[:SS]")
}
func formatGitDate(value time.Time) string {
return value.Format("2006-01-02T15:04:05-07:00")
}
var coAuthorLinePattern = regexp.MustCompile(`(?i)^\s*co-authored(?:-|\s+)by\s*:`)
func removeCoAuthorLines(message string) (string, int) {
lines := strings.Split(strings.ReplaceAll(message, "\r\n", "\n"), "\n")
kept := make([]string, 0, len(lines))
removed := 0
for _, line := range lines {
if coAuthorLinePattern.MatchString(line) {
removed++
continue
}
kept = append(kept, line)
}
return normalizeMessage(strings.Join(kept, "\n")), removed
} }
func editableFieldsChanged(current CommitRecord, incoming CommitRecord) bool { func editableFieldsChanged(current CommitRecord, incoming CommitRecord) bool {
return current.AuthorName != incoming.AuthorName || return current.AuthorName != incoming.AuthorName ||
current.AuthorEmail != incoming.AuthorEmail || current.AuthorEmail != incoming.AuthorEmail ||
current.AuthorDate != incoming.AuthorDate || !gitDatesEqual(current.AuthorDate, incoming.AuthorDate) ||
current.CommitterName != incoming.CommitterName || current.CommitterName != incoming.CommitterName ||
current.CommitterEmail != incoming.CommitterEmail || current.CommitterEmail != incoming.CommitterEmail ||
current.CommitterDate != incoming.CommitterDate || !gitDatesEqual(current.CommitterDate, incoming.CommitterDate) ||
normalizeMessage(current.Message) != normalizeMessage(incoming.Message) normalizeMessage(current.Message) != normalizeMessage(incoming.Message)
} }
func gitDatesEqual(left string, right string) bool {
leftDate, leftErr := normalizeGitDate(left)
rightDate, rightErr := normalizeGitDate(right)
if leftErr != nil || rightErr != nil {
return strings.TrimSpace(left) == strings.TrimSpace(right)
}
return leftDate == rightDate
}
func firstLine(message string) string { func firstLine(message string) string {
parts := strings.SplitN(message, "\n", 2) parts := strings.SplitN(message, "\n", 2)
return strings.TrimSpace(parts[0]) return strings.TrimSpace(parts[0])
@@ -539,7 +592,7 @@ func shellQuote(value string) string {
func runGit(repo string, args ...string) (string, error) { func runGit(repo string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = os.Environ() cmd.Env = append(os.Environ(), "FILTER_BRANCH_SQUELCH_WARNING=1")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
+67
View File
@@ -124,3 +124,70 @@ func TestRewriteHistoryFailsWhenNoRefsAreRewritten(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }
func TestApplyChangesUpdatesDatesAndRemovesCoAuthorTrailers(t *testing.T) {
repo := t.TempDir()
run := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
}
return string(out)
}
run("init")
run("config", "user.name", "Original User")
run("config", "user.email", "original@example.com")
if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
run("add", "a.txt")
run("commit", "-m", "subject", "-m", "Context: Co-authored-by: stays because it is prose.\n\nCo-authored-by: One <one@example.com>\nco-authored by: Two <two@example.com>")
app := NewApp()
if _, err := app.SetRepository(repo); err != nil {
t.Fatal(err)
}
history, err := app.LoadHistory()
if err != nil {
t.Fatal(err)
}
history[0].AuthorDate = "2020-06-15 09:30:45 +0530"
history[0].CommitterDate = "Mon, 15 Jun 2020 04:00:45 +0000"
result, err := app.ApplyChanges(ApplyRequest{Commits: history, RemoveCoAuthors: true})
if err != nil {
t.Fatal(err)
}
if result.RemovedCoAuthors != 2 {
t.Fatalf("expected two removed co-author lines, got %d", result.RemovedCoAuthors)
}
log := run("log", "-1", "--pretty=format:%aI%x1f%cI%x1f%B")
fields := strings.SplitN(log, "\x1f", 3)
if len(fields) != 3 || !gitDatesEqual(fields[0], "2020-06-15T09:30:45+05:30") || !gitDatesEqual(fields[1], "2020-06-15T04:00:45Z") {
t.Fatalf("expected exact rewritten dates, got: %s", log)
}
if strings.Contains(strings.ToLower(log), "co-authored-by: one") || strings.Contains(strings.ToLower(log), "co-authored by: two") {
t.Fatalf("expected trailer lines to be removed, got: %s", log)
}
if !strings.Contains(log, "Context: Co-authored-by: stays because it is prose.") {
t.Fatalf("expected non-trailer prose to remain, got: %s", log)
}
}
func TestRemoveCoAuthorLinesOnlyMatchesWholeTrailerLines(t *testing.T) {
message := "subject\r\n\r\nCo-authored-by: Ada <ada@example.com>\r\n CO-AUTHORED BY: Grace <grace@example.com>\r\nNote: Co-authored-by: this is prose"
cleaned, removed := removeCoAuthorLines(message)
if removed != 2 {
t.Fatalf("expected two removed lines, got %d", removed)
}
if strings.Contains(cleaned, "Ada") || strings.Contains(cleaned, "Grace") {
t.Fatalf("expected co-author trailers to be removed: %q", cleaned)
}
if !strings.Contains(cleaned, "Note: Co-authored-by: this is prose") {
t.Fatalf("expected ordinary prose to remain: %q", cleaned)
}
}
+239 -7
View File
@@ -40,6 +40,7 @@ const (
overlayConfirmApply overlayConfirmApply
overlayConfirmForce overlayConfirmForce
overlayHelp overlayHelp
overlayDatePicker
) )
type statusKind string type statusKind string
@@ -145,9 +146,12 @@ type tuiModel struct {
searchInput textinput.Model searchInput textinput.Model
pathInput textinput.Model pathInput textinput.Model
confirmInput textinput.Model confirmInput textinput.Model
datePickerTime time.Time
datePickerTarget int
forcePush bool forcePush bool
pushTags bool pushTags bool
removeCoAuthors bool
busy bool busy bool
status string status string
@@ -283,6 +287,7 @@ func (m *tuiModel) handleApplyFinished(msg applyFinishedMsg) {
m.repoLoaded = true m.repoLoaded = true
} }
if msg.commits != nil { if msg.commits != nil {
m.removeCoAuthors = false
m.commits = newestFirst(msg.commits) m.commits = newestFirst(msg.commits)
m.originalByHash = mapByHash(m.commits) m.originalByHash = mapByHash(m.commits)
m.draftByHash = cloneMapByHash(m.commits) m.draftByHash = cloneMapByHash(m.commits)
@@ -315,6 +320,8 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updatePathEntry(msg) return m.updatePathEntry(msg)
case overlayConfirmApply, overlayConfirmForce: case overlayConfirmApply, overlayConfirmForce:
return m.updateConfirmation(msg) return m.updateConfirmation(msg)
case overlayDatePicker:
return m.updateDatePicker(msg)
} }
if m.focus == focusForm { if m.focus == focusForm {
@@ -347,6 +354,10 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "t": case "t":
m.pushTags = !m.pushTags m.pushTags = !m.pushTags
m.setStatus(fmt.Sprintf("Push tags %s", onOff(m.pushTags)), statusInfo) m.setStatus(fmt.Sprintf("Push tags %s", onOff(m.pushTags)), statusInfo)
case "c":
m.removeCoAuthors = !m.removeCoAuthors
count, lines := coAuthorCleanupImpact(m.commits, m.draftByHash)
m.setStatus(fmt.Sprintf("Co-author cleanup %s: %d line%s across %d commit%s", onOff(m.removeCoAuthors), lines, plural(lines), count, plural(count)), statusInfo)
case "a": case "a":
return m.beginApply() return m.beginApply()
case "x": case "x":
@@ -451,12 +462,35 @@ func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String() key := msg.String()
switch key { switch key {
case "enter":
if m.formFocus == formAuthorDate || m.formFocus == formCommitterDate {
selected, err := parseGitDate(m.inputs[m.formFocus].Value())
if err != nil {
m.setStatus(fmt.Sprintf("Cannot open date picker: %v", err), statusError)
return m, nil
}
m.datePickerTime = selected
m.datePickerTarget = m.formFocus
m.overlay = overlayDatePicker
m.blurForm()
m.setStatus("Date/time picker opened.", statusInfo)
return m, nil
}
case "ctrl+t":
if m.formFocus == formAuthorDate || m.formFocus == formCommitterDate {
m.inputs[m.formFocus].SetValue(time.Now().Format("2006-01-02T15:04:05-07:00"))
m.syncFormToDraft()
m.setStatus("Updated the focused date/time to now.", statusInfo)
}
return m, nil
case "esc": case "esc":
m.normalizeFocusedDateInput()
m.syncFormToDraft() m.syncFormToDraft()
m.focus = focusList m.focus = focusList
m.blurForm() m.blurForm()
return m, nil return m, nil
case "tab", "shift+tab": case "tab", "shift+tab":
m.normalizeFocusedDateInput()
m.syncFormToDraft() m.syncFormToDraft()
next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab") next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab")
if returnToList { if returnToList {
@@ -478,6 +512,50 @@ func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m tuiModel) updateDatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.overlay = overlayNone
m.focusFormField(m.datePickerTarget)
m.setStatus("Date/time change cancelled.", statusInfo)
case "enter":
m.inputs[m.datePickerTarget].SetValue(formatGitDate(m.datePickerTime))
m.overlay = overlayNone
m.focusFormField(m.datePickerTarget)
m.syncFormToDraft()
m.setStatus("Date/time updated.", statusInfo)
case "left", "h":
m.datePickerTime = m.datePickerTime.AddDate(0, 0, -1)
case "right", "l":
m.datePickerTime = m.datePickerTime.AddDate(0, 0, 1)
case "up", "k":
m.datePickerTime = m.datePickerTime.Add(time.Hour)
case "down", "j":
m.datePickerTime = m.datePickerTime.Add(-time.Hour)
case "pgup", "[":
m.datePickerTime = shiftCalendarMonth(m.datePickerTime, -1)
case "pgdown", "]":
m.datePickerTime = shiftCalendarMonth(m.datePickerTime, 1)
case "shift+left", "H":
m.datePickerTime = shiftCalendarYear(m.datePickerTime, -1)
case "shift+right", "L":
m.datePickerTime = shiftCalendarYear(m.datePickerTime, 1)
case "-", "_":
m.datePickerTime = m.datePickerTime.Add(-5 * time.Minute)
case "+", "=":
m.datePickerTime = m.datePickerTime.Add(5 * time.Minute)
case "t":
m.datePickerTime = time.Now()
case "u":
m.datePickerTime = sameWallClockIn(m.datePickerTime, time.UTC)
case "z":
m.datePickerTime = sameWallClockIn(m.datePickerTime, time.Local)
case "0":
m.datePickerTime = m.datePickerTime.Add(-time.Duration(m.datePickerTime.Second()) * time.Second)
}
return m, nil
}
func (m tuiModel) beginApply() (tea.Model, tea.Cmd) { func (m tuiModel) beginApply() (tea.Model, tea.Cmd) {
if !m.repoLoaded { if !m.repoLoaded {
m.setStatus("Load a repository before applying changes.", statusError) m.setStatus("Load a repository before applying changes.", statusError)
@@ -485,9 +563,13 @@ func (m tuiModel) beginApply() (tea.Model, tea.Cmd) {
} }
m.syncFormToDraft() m.syncFormToDraft()
count := dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash) if err := validateDraftDates(m.commits, m.draftByHash); err != nil {
m.setStatus(err.Error(), statusError)
return m, nil
}
count := plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors)
if count == 0 && !m.forcePush { if count == 0 && !m.forcePush {
m.setStatus("No history edits to apply.", statusInfo) m.setStatus("No history edits or co-author lines to apply.", statusInfo)
return m, nil return m, nil
} }
@@ -561,7 +643,7 @@ func (m tuiModel) renderTopBar(width int) string {
repoText = fmt.Sprintf("%s %s %s", filepath.Base(m.repository.Path), branch, clean) repoText = fmt.Sprintf("%s %s %s", filepath.Base(m.repository.Path), branch, clean)
} }
flags := fmt.Sprintf("force:%s tags:%s edits:%d", onOff(m.forcePush), onOff(m.pushTags), dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash)) flags := fmt.Sprintf("force:%s tags:%s coauthors:%s edits:%d", onOff(m.forcePush), onOff(m.pushTags), onOff(m.removeCoAuthors), plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors))
if m.busy { if m.busy {
flags = "working..." flags = "working..."
} }
@@ -599,9 +681,9 @@ 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 p path r reload x reset a apply c co-authors 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 Ctrl+T set focused date to now Esc list Ctrl+C quit"
} }
if m.overlay == overlayPath { if m.overlay == overlayPath {
help = "Enter load repository Esc close" help = "Enter load repository Esc close"
@@ -612,6 +694,9 @@ func (m tuiModel) renderBottomBar(width int) string {
if m.overlay == overlayConfirmForce { if m.overlay == overlayConfirmForce {
help = "Type FORCE then Enter to rewrite and force push, Esc to cancel" help = "Type FORCE then Enter to rewrite and force push, Esc to cancel"
} }
if m.overlay == overlayDatePicker {
help = "←/→ day ↑/↓ hour PgUp/PgDn month +/- 5 min H/L year t now z local u UTC Enter save Esc cancel"
}
return barStyle.Render(status + "\n" + dimStyle.Render(truncateText(help, contentWidth))) return barStyle.Render(status + "\n" + dimStyle.Render(truncateText(help, contentWidth)))
} }
@@ -706,6 +791,8 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
content = m.renderConfirmPane(innerWidth, innerHeight) content = m.renderConfirmPane(innerWidth, innerHeight)
case overlayHelp: case overlayHelp:
content = m.renderHelpPane(innerWidth, innerHeight) content = m.renderHelpPane(innerWidth, innerHeight)
case overlayDatePicker:
content = m.renderDatePickerPane(innerWidth, innerHeight)
default: default:
content = m.renderFormPane(innerWidth, innerHeight) content = m.renderFormPane(innerWidth, innerHeight)
} }
@@ -713,6 +800,38 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
return renderBox(style, width, height, content) return renderBox(style, width, height, content)
} }
func (m tuiModel) renderDatePickerPane(width int, height int) string {
target := "Author date"
if m.datePickerTarget == formCommitterDate {
target = "Committer date"
}
zoneName, offset := m.datePickerTime.Zone()
if zoneName == "" {
zoneName = formatZoneOffset(offset)
} else {
zoneName = fmt.Sprintf("%s (%s)", zoneName, formatZoneOffset(offset))
}
lines := []string{
headerStyle.Render("Date & Time Picker"),
mutedStyle.Render(target),
"",
lipgloss.NewStyle().Bold(true).Foreground(colorGreen).Render(m.datePickerTime.Format("Monday, 02 January 2006")),
lipgloss.NewStyle().Bold(true).Foreground(colorCyan).Render(m.datePickerTime.Format("15:04:05") + " " + zoneName),
"",
}
lines = append(lines, renderMonthCalendar(m.datePickerTime)...)
lines = append(lines,
"",
"←/→ or h/l day ↑/↓ or k/j hour",
"PgUp/PgDn or [/] month H/L year +/- five minutes",
"t now z local timezone u UTC 0 clear seconds",
"",
dimStyle.Render("Enter saves the selection. Esc keeps the original timestamp."),
)
return fitLines(lines, height, width)
}
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"),
@@ -727,12 +846,16 @@ func (m tuiModel) renderPathPane(width int, height int) string {
} }
func (m tuiModel) renderConfirmPane(width int, height int) string { func (m tuiModel) renderConfirmPane(width int, height int) string {
count := dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash) count := plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors)
coAuthorCommits, coAuthorLines := coAuthorCleanupImpact(m.commits, m.draftByHash)
lines := []string{ lines := []string{
headerStyle.Render("Confirm Rewrite"), headerStyle.Render("Confirm Rewrite"),
"", "",
fmt.Sprintf("This will rewrite Git history for %d edited commit%s. A backup tag will be created first.", count, plural(count)), fmt.Sprintf("This will rewrite Git history for %d edited commit%s. A backup tag will be created first.", count, plural(count)),
} }
if m.removeCoAuthors {
lines = append(lines, "", fmt.Sprintf("Co-author cleanup will remove %d matching line%s from %d commit%s.", coAuthorLines, plural(coAuthorLines), coAuthorCommits, plural(coAuthorCommits)))
}
if m.forcePush { if m.forcePush {
lines = append(lines, lines = append(lines,
@@ -757,7 +880,8 @@ func (m tuiModel) renderHelpPane(width int, height int) string {
"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. 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. c toggles bulk Co-authored-by cleanup.",
"f toggles force push. t toggles tag push. Ctrl+T stamps the focused date field with the current local time.",
"", "",
headerStyle.Render("Safety"), headerStyle.Render("Safety"),
"", "",
@@ -1004,6 +1128,15 @@ func (m *tuiModel) syncFormToDraft() {
m.draftByHash[m.selectedHash] = draft m.draftByHash[m.selectedHash] = draft
} }
func (m *tuiModel) normalizeFocusedDateInput() {
if m.formFocus != formAuthorDate && m.formFocus != formCommitterDate {
return
}
if normalized, err := normalizeGitDate(m.inputs[m.formFocus].Value()); err == nil {
m.inputs[m.formFocus].SetValue(normalized)
}
}
func (m *tuiModel) resetSelectedCommit() { func (m *tuiModel) resetSelectedCommit() {
if m.selectedHash == "" { if m.selectedHash == "" {
return return
@@ -1030,6 +1163,7 @@ func (m tuiModel) applyRequest() ApplyRequest {
Commits: commits, Commits: commits,
ForcePush: m.forcePush, ForcePush: m.forcePush,
PushTags: m.pushTags, PushTags: m.pushTags,
RemoveCoAuthors: m.removeCoAuthors,
} }
} }
@@ -1146,6 +1280,46 @@ func dirtyCommitCount(commits []CommitRecord, originalByHash map[string]CommitRe
return count return count
} }
func plannedRewriteCount(commits []CommitRecord, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord, removeCoAuthors bool) int {
count := 0
for _, commit := range commits {
dirty := commitDirty(commit.Hash, originalByHash, draftByHash)
draft := draftByHash[commit.Hash]
_, coAuthorLines := removeCoAuthorLines(draft.Message)
if dirty || (removeCoAuthors && coAuthorLines > 0) {
count++
}
}
return count
}
func coAuthorCleanupImpact(commits []CommitRecord, draftByHash map[string]CommitRecord) (int, int) {
commitCount := 0
lineCount := 0
for _, commit := range commits {
draft := draftByHash[commit.Hash]
_, removed := removeCoAuthorLines(draft.Message)
if removed > 0 {
commitCount++
lineCount += removed
}
}
return commitCount, lineCount
}
func validateDraftDates(commits []CommitRecord, draftByHash map[string]CommitRecord) error {
for _, commit := range commits {
draft := draftByHash[commit.Hash]
if _, err := normalizeGitDate(draft.AuthorDate); err != nil {
return fmt.Errorf("commit %s has an invalid author date: %v", draft.ShortHash, err)
}
if _, err := normalizeGitDate(draft.CommitterDate); err != nil {
return fmt.Errorf("commit %s has an invalid committer date: %v", draft.ShortHash, err)
}
}
return nil
}
func commitDirty(hash string, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) bool { func commitDirty(hash string, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) bool {
original, hasOriginal := originalByHash[hash] original, hasOriginal := originalByHash[hash]
draft, hasDraft := draftByHash[hash] draft, hasDraft := draftByHash[hash]
@@ -1273,6 +1447,61 @@ func formatCommitTime(value string) string {
return parsed.Local().Format("2006-01-02 15:04") return parsed.Local().Format("2006-01-02 15:04")
} }
func sameWallClockIn(value time.Time, location *time.Location) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), location)
}
func shiftCalendarMonth(value time.Time, months int) time.Time {
targetMonth := time.Date(value.Year(), value.Month()+time.Month(months), 1, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
day := minInt(value.Day(), targetMonth.AddDate(0, 1, -1).Day())
return time.Date(targetMonth.Year(), targetMonth.Month(), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
}
func shiftCalendarYear(value time.Time, years int) time.Time {
targetYear := value.Year() + years
lastDay := time.Date(targetYear, value.Month()+1, 0, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()).Day()
return time.Date(targetYear, value.Month(), minInt(value.Day(), lastDay), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
}
func formatZoneOffset(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
return fmt.Sprintf("%s%02d:%02d", sign, offsetSeconds/3600, (offsetSeconds%3600)/60)
}
func renderMonthCalendar(selected time.Time) []string {
first := time.Date(selected.Year(), selected.Month(), 1, 0, 0, 0, 0, selected.Location())
leading := (int(first.Weekday()) + 6) % 7
daysInMonth := first.AddDate(0, 1, -1).Day()
lines := []string{
lipgloss.NewStyle().Bold(true).Foreground(colorMagenta).Render(selected.Format("January 2006")),
dimStyle.Render("Mo Tu We Th Fr Sa Su"),
}
week := make([]string, 7)
for day := 1; day <= daysInMonth; day++ {
position := leading + day - 1
cell := fmt.Sprintf("%2d", day)
if day == selected.Day() {
cell = lipgloss.NewStyle().Bold(true).Foreground(colorPanel).Background(colorCyan).Render(cell)
}
week[position%7] = cell
if position%7 == 6 || day == daysInMonth {
for index := range week {
if week[index] == "" {
week[index] = " "
}
}
lines = append(lines, strings.Join(week, " "))
week = make([]string, 7)
}
}
return lines
}
func applyResultSummary(result ApplyResult) string { func applyResultSummary(result ApplyResult) string {
parts := []string{} parts := []string{}
if result.RewrittenCommits == 0 { if result.RewrittenCommits == 0 {
@@ -1283,6 +1512,9 @@ func applyResultSummary(result ApplyResult) string {
if result.BackupReference != "" { if result.BackupReference != "" {
parts = append(parts, "Backup "+result.BackupReference+".") parts = append(parts, "Backup "+result.BackupReference+".")
} }
if result.RemovedCoAuthors > 0 {
parts = append(parts, fmt.Sprintf("Removed %d co-author line%s.", result.RemovedCoAuthors, plural(result.RemovedCoAuthors)))
}
if result.ForcePushed { if result.ForcePushed {
parts = append(parts, "Force push completed.") parts = append(parts, "Force push completed.")
} }
+54
View File
@@ -5,7 +5,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -147,6 +149,58 @@ func TestViewFitsTerminalDimensions(t *testing.T) {
} }
} }
func TestDatePickerOpensAndSavesFromDateField(t *testing.T) {
model := newTUIModel(NewApp(), "/tmp/repo")
commit := CommitRecord{
Hash: "one", ShortHash: "one", AuthorName: "A", AuthorEmail: "a@example.com",
AuthorDate: "2024-01-02T10:00:00+00:00", CommitterName: "A", CommitterEmail: "a@example.com",
CommitterDate: "2024-01-02T10:00:00+00:00", Message: "subject",
}
model.handleRepoLoaded(repoLoadedMsg{repo: RepositoryState{Path: "/tmp/repo"}, commits: []CommitRecord{commit}})
model.focus = focusForm
model.focusFormField(formAuthorDate)
updated, _ := model.updateForm(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(tuiModel)
if model.overlay != overlayDatePicker {
t.Fatalf("expected date picker overlay, got %v", model.overlay)
}
model.datePickerTime = time.Date(2025, time.March, 4, 12, 35, 45, 0, time.FixedZone("test", 90*60))
updated, _ = model.updateDatePicker(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(tuiModel)
if got := model.draftByHash["one"].AuthorDate; got != "2025-03-04T12:35:45+01:30" {
t.Fatalf("expected selected date to be saved, got %q", got)
}
}
func TestPlannedRewriteCountIncludesCoAuthorCleanup(t *testing.T) {
commits := []CommitRecord{
{Hash: "one", Message: "subject\n\nCo-authored-by: A <a@example.com>"},
{Hash: "two", Message: "another subject"},
}
original := mapByHash(commits)
drafts := cloneMapByHash(commits)
if got := plannedRewriteCount(commits, original, drafts, false); got != 0 {
t.Fatalf("expected no planned rewrites with cleanup disabled, got %d", got)
}
if got := plannedRewriteCount(commits, original, drafts, true); got != 1 {
t.Fatalf("expected one planned cleanup rewrite, got %d", got)
}
}
func TestCalendarShiftsClampToValidDays(t *testing.T) {
jan31 := time.Date(2024, time.January, 31, 9, 15, 30, 0, time.UTC)
if got := shiftCalendarMonth(jan31, 1); got != time.Date(2024, time.February, 29, 9, 15, 30, 0, time.UTC) {
t.Fatalf("expected leap-year February to clamp to the 29th, got %s", got)
}
leapDay := time.Date(2024, time.February, 29, 9, 15, 30, 0, time.UTC)
if got := shiftCalendarYear(leapDay, 1); got != time.Date(2025, time.February, 28, 9, 15, 30, 0, time.UTC) {
t.Fatalf("expected non-leap year to clamp to 28 February, got %s", got)
}
}
func maxRenderedLineWidth(value string) int { func maxRenderedLineWidth(value string) int {
width := 0 width := 0
for _, line := range strings.Split(value, "\n") { for _, line := range strings.Split(value, "\n") {