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
- 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-<timestamp>`).
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.
+69 -16
View File
@@ -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
+67
View File
@@ -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 <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)
}
}
+248 -16
View File
@@ -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.")
}
+54
View File
@@ -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 <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 {
width := 0
for _, line := range strings.Split(value, "\n") {