From 411e033a8e7f8b900f881df981b10ab313ee60cd Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 21 Jul 2026 14:27:58 +0100 Subject: [PATCH] Multiple fixes --- README.md | 13 ++- app.go | 85 ++++++++++++--- app_apply_test.go | 67 ++++++++++++ tui.go | 264 +++++++++++++++++++++++++++++++++++++++++++--- tui_test.go | 54 ++++++++++ 5 files changed, 447 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index b18a533..8481108 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Repository: https://github.com/andydixon/nexus - author name, email, and date/time - committer name, email, and date/time - 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 - Track edited commits and reset the selected commit - Apply all staged metadata changes in one action @@ -27,20 +29,24 @@ Repository: https://github.com/andydixon/nexus - `r`: reload history - `x`: reset the selected commit - `a`: apply changes +- `c`: toggle bulk removal of `Co-authored-by:` lines - `f`: toggle force push - `t`: toggle tag push - `?`: show help - `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 When you apply changes, Nexus: 1. Checks that the selected repository is clean. 2. Creates a backup tag (`nexus-backup-`). -3. Rewrites history with `git filter-branch` across all refs. -4. Removes temporary rewrite references and runs repository cleanup. -5. Optionally force pushes branches and tags using `--force-with-lease`. +3. Applies timestamp, metadata, message, and optional co-author trailer changes. +4. Rewrites history with `git filter-branch` across all refs. +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. @@ -67,4 +73,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/app.go b/app.go index 1d441ec..6b23b72 100644 --- a/app.go +++ b/app.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -47,13 +48,15 @@ type CommitRecord struct { } type ApplyRequest struct { - Commits []CommitRecord `json:"commits"` - ForcePush bool `json:"forcePush"` - PushTags bool `json:"pushTags"` + Commits []CommitRecord `json:"commits"` + ForcePush bool `json:"forcePush"` + PushTags bool `json:"pushTags"` + RemoveCoAuthors bool `json:"removeCoAuthors"` } type ApplyResult struct { RewrittenCommits int `json:"rewrittenCommits"` + RemovedCoAuthors int `json:"removedCoAuthors"` ForcePushed bool `json:"forcePushed"` BackupReference string `json:"backupReference"` Warnings []string `json:"warnings"` @@ -156,18 +159,19 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) { return ApplyResult{}, err } - currentByHash := make(map[string]CommitRecord, len(currentHistory)) - for _, commit := range currentHistory { - currentByHash[commit.Hash] = commit + incomingByHash := make(map[string]CommitRecord, len(req.Commits)) + for _, incoming := range req.Commits { + incomingByHash[incoming.Hash] = incoming } updatedByHash := make(map[string]CommitRecord) messageOverride := make(map[string]bool) + removedCoAuthors := 0 - for _, incoming := range req.Commits { - current, exists := currentByHash[incoming.Hash] + for _, current := range currentHistory { + incoming, exists := incomingByHash[current.Hash] if !exists { - continue + 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) } + if req.RemoveCoAuthors { + var removed int + normalized.Message, removed = removeCoAuthorLines(normalized.Message) + normalized.Subject = firstLine(normalized.Message) + removedCoAuthors += removed + } + if editableFieldsChanged(current, normalized) { updatedByHash[incoming.Hash] = normalized messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message) @@ -183,6 +194,7 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) { result := ApplyResult{ RewrittenCommits: len(updatedByHash), + RemovedCoAuthors: removedCoAuthors, Warnings: []string{}, } @@ -468,17 +480,29 @@ func normalizeIncomingCommit(incoming CommitRecord, current CommitRecord) (Commi } 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) if date == "" { - return "", errors.New("date cannot be empty") + return time.Time{}, errors.New("date cannot be empty") } layouts := []string{ time.RFC3339Nano, time.RFC3339, + "2006-01-02 15:04:05 -0700", + time.RFC1123Z, + time.RFC822Z, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", + "2006-01-02 15:04", } for _, layout := range layouts { @@ -487,29 +511,58 @@ func normalizeGitDate(input string) (string, 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) } else { t, err = time.ParseInLocation(layout, date, time.Local) } 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 { return current.AuthorName != incoming.AuthorName || current.AuthorEmail != incoming.AuthorEmail || - current.AuthorDate != incoming.AuthorDate || + !gitDatesEqual(current.AuthorDate, incoming.AuthorDate) || current.CommitterName != incoming.CommitterName || current.CommitterEmail != incoming.CommitterEmail || - current.CommitterDate != incoming.CommitterDate || + !gitDatesEqual(current.CommitterDate, incoming.CommitterDate) || 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 { parts := strings.SplitN(message, "\n", 2) return strings.TrimSpace(parts[0]) @@ -539,7 +592,7 @@ func shellQuote(value string) string { func runGit(repo string, args ...string) (string, error) { 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 stderr bytes.Buffer diff --git a/app_apply_test.go b/app_apply_test.go index 1c0087d..05841a1 100644 --- a/app_apply_test.go +++ b/app_apply_test.go @@ -124,3 +124,70 @@ func TestRewriteHistoryFailsWhenNoRefsAreRewritten(t *testing.T) { 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 \nco-authored by: Two ") + + 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 \r\n CO-AUTHORED BY: Grace \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) + } +} diff --git a/tui.go b/tui.go index 73392ec..35402ba 100644 --- a/tui.go +++ b/tui.go @@ -40,6 +40,7 @@ const ( overlayConfirmApply overlayConfirmForce overlayHelp + overlayDatePicker ) type statusKind string @@ -142,13 +143,16 @@ 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 + datePickerTime time.Time + datePickerTarget int - forcePush bool - pushTags bool - busy bool + forcePush bool + pushTags bool + removeCoAuthors bool + busy bool status string statusKind statusKind @@ -283,6 +287,7 @@ func (m *tuiModel) handleApplyFinished(msg applyFinishedMsg) { m.repoLoaded = true } if msg.commits != nil { + m.removeCoAuthors = false m.commits = newestFirst(msg.commits) m.originalByHash = mapByHash(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) case overlayConfirmApply, overlayConfirmForce: return m.updateConfirmation(msg) + case overlayDatePicker: + return m.updateDatePicker(msg) } if m.focus == focusForm { @@ -347,6 +354,10 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "t": m.pushTags = !m.pushTags 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": return m.beginApply() 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) { key := msg.String() 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": + m.normalizeFocusedDateInput() m.syncFormToDraft() m.focus = focusList m.blurForm() return m, nil case "tab", "shift+tab": + m.normalizeFocusedDateInput() m.syncFormToDraft() next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab") if returnToList { @@ -478,6 +512,50 @@ func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.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) { if !m.repoLoaded { m.setStatus("Load a repository before applying changes.", statusError) @@ -485,9 +563,13 @@ func (m tuiModel) beginApply() (tea.Model, tea.Cmd) { } 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 { - m.setStatus("No history edits to apply.", statusInfo) + m.setStatus("No history edits or co-author lines to apply.", statusInfo) 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) } - 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 { flags = "working..." } @@ -599,9 +681,9 @@ 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 p path r reload x reset a apply c co-authors 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" + help = "Tab next field Shift+Tab previous Ctrl+T set focused date to now Esc list Ctrl+C quit" } if m.overlay == overlayPath { help = "Enter load repository Esc close" @@ -612,6 +694,9 @@ func (m tuiModel) renderBottomBar(width int) string { if m.overlay == overlayConfirmForce { 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))) } @@ -706,6 +791,8 @@ func (m tuiModel) renderDetailPane(width int, height int) string { content = m.renderConfirmPane(innerWidth, innerHeight) case overlayHelp: content = m.renderHelpPane(innerWidth, innerHeight) + case overlayDatePicker: + content = m.renderDatePickerPane(innerWidth, innerHeight) default: content = m.renderFormPane(innerWidth, innerHeight) } @@ -713,6 +800,38 @@ func (m tuiModel) renderDetailPane(width int, height int) string { 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 { lines := []string{ 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 { - 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{ 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)), } + 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 { 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.", "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.", - "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"), "", @@ -1004,6 +1128,15 @@ func (m *tuiModel) syncFormToDraft() { 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() { if m.selectedHash == "" { return @@ -1027,9 +1160,10 @@ func (m tuiModel) applyRequest() ApplyRequest { commits = append(commits, cloneCommitRecord(draft)) } return ApplyRequest{ - Commits: commits, - ForcePush: m.forcePush, - PushTags: m.pushTags, + Commits: commits, + ForcePush: m.forcePush, + PushTags: m.pushTags, + RemoveCoAuthors: m.removeCoAuthors, } } @@ -1146,6 +1280,46 @@ func dirtyCommitCount(commits []CommitRecord, originalByHash map[string]CommitRe 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 { original, hasOriginal := originalByHash[hash] draft, hasDraft := draftByHash[hash] @@ -1273,6 +1447,61 @@ func formatCommitTime(value string) string { 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 { parts := []string{} if result.RewrittenCommits == 0 { @@ -1283,6 +1512,9 @@ func applyResultSummary(result ApplyResult) string { if 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 { parts = append(parts, "Force push completed.") } diff --git a/tui_test.go b/tui_test.go index 6d2fc40..545f513 100644 --- a/tui_test.go +++ b/tui_test.go @@ -5,7 +5,9 @@ import ( "path/filepath" "strings" "testing" + "time" + tea "github.com/charmbracelet/bubbletea" "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 "}, + {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 { width := 0 for _, line := range strings.Split(value, "\n") {