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.
+66 -13
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
@@ -50,10 +51,12 @@ type ApplyRequest struct {
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)
}
}
+239 -7
View File
@@ -40,6 +40,7 @@ const (
overlayConfirmApply
overlayConfirmForce
overlayHelp
overlayDatePicker
)
type statusKind string
@@ -145,9 +146,12 @@ type tuiModel struct {
searchInput textinput.Model
pathInput textinput.Model
confirmInput textinput.Model
datePickerTime time.Time
datePickerTarget int
forcePush bool
pushTags bool
removeCoAuthors bool
busy bool
status string
@@ -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
@@ -1030,6 +1163,7 @@ func (m tuiModel) applyRequest() ApplyRequest {
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") {