diff --git a/README.md b/README.md index f457ff3..5a231ea 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Repository: https://github.com/andydixon/nexus - Opt-in bulk removal of `Co-authored-by:` and `Claude-Session:` lines across commit messages; Claude and Codex attribution blocks are removed whole - Search commits by hash, message, author name, or email - Bulk replace author names and emails using case-insensitive partial or exact matching +- Rewrite every commit's author and committer timestamps to random, in-order times within a date range and daily time window - Track edited commits and reset the selected commit - Apply all staged metadata changes in one action - Optionally force push rewritten branches and tags to `origin` @@ -27,6 +28,7 @@ Repository: https://github.com/andydixon/nexus - `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits - `/`: search commits - `b`: bulk replace matching author names and emails; `Ctrl+T` toggles partial/exact matching +- `d`: rewrite all timestamps: pick a start/end date and a daily window (e.g. `08:00`–`12:00`); from the oldest commit forwards each commit gets a random time in range, never earlier than the previous one - `p`: switch repository path - `r`: reload history - `x`: reset the selected commit diff --git a/tui.go b/tui.go index 3115f43..e4a4686 100644 --- a/tui.go +++ b/tui.go @@ -3,7 +3,9 @@ package main import ( "errors" "fmt" + "math/rand" "path/filepath" + "sort" "strings" "time" @@ -32,6 +34,14 @@ const ( bulkAuthorFieldCount ) +const ( + dateSpreadStartDay = iota + dateSpreadEndDay + dateSpreadFromTime + dateSpreadToTime + dateSpreadFieldCount +) + type uiFocus int const ( @@ -50,6 +60,7 @@ const ( overlayConfirmForce overlayHelp overlayDatePicker + overlayDateSpread ) type statusKind string @@ -160,6 +171,8 @@ type tuiModel struct { bulkAuthorFocus int bulkAuthorInputs []textinput.Model bulkAuthorExact bool + dateSpreadFocus int + dateSpreadInputs []textinput.Model forcePush bool pushTags bool @@ -215,7 +228,17 @@ func newTUIModel(app *App, initialPath string) tuiModel { bulkAuthorInputs[bulkAuthorName].Placeholder = "Replacement author name" bulkAuthorInputs[bulkAuthorEmail].Placeholder = "Replacement author email" + dateSpreadInputs := make([]textinput.Model, dateSpreadFieldCount) + for i := range dateSpreadInputs { + dateSpreadInputs[i] = newTextInput() + } + dateSpreadInputs[dateSpreadStartDay].Placeholder = "YYYY-MM-DD" + dateSpreadInputs[dateSpreadEndDay].Placeholder = "YYYY-MM-DD" + dateSpreadInputs[dateSpreadFromTime].Placeholder = "08:00" + dateSpreadInputs[dateSpreadToTime].Placeholder = "12:00" + return tuiModel{ + dateSpreadInputs: dateSpreadInputs, app: app, initialPath: initialPath, originalByHash: map[string]CommitRecord{}, @@ -341,6 +364,8 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.updatePathEntry(msg) case overlayBulkAuthor: return m.updateBulkAuthor(msg) + case overlayDateSpread: + return m.updateDateSpread(msg) case overlayConfirmApply, overlayConfirmForce: return m.updateConfirmation(msg) case overlayDatePicker: @@ -369,6 +394,12 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else { m.setStatus("Load a repository before replacing authors.", statusError) } + case "d": + if m.repoLoaded { + m.openDateSpread() + } else { + m.setStatus("Load a repository before rewriting dates.", statusError) + } case "r": if m.repository.Path != "" { m.busy = true @@ -509,6 +540,40 @@ func (m tuiModel) updateBulkAuthor(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m tuiModel) updateDateSpread(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.overlay = overlayNone + m.blurInputs(m.dateSpreadInputs) + m.setStatus("Date rewrite cancelled.", statusInfo) + return m, nil + case "tab", "shift+tab": + delta := 1 + if msg.String() == "shift+tab" { + delta = -1 + } + m.focusDateSpreadField((m.dateSpreadFocus + delta + dateSpreadFieldCount) % dateSpreadFieldCount) + return m, nil + case "enter": + m.syncFormToDraft() + v := func(i int) string { return m.dateSpreadInputs[i].Value() } + count, err := stageDateSpread(m.commits, m.draftByHash, v(dateSpreadStartDay), v(dateSpreadEndDay), v(dateSpreadFromTime), v(dateSpreadToTime), rand.Int63n) + if err != nil { + m.setStatus(err.Error(), statusError) + return m, nil + } + m.overlay = overlayNone + m.blurInputs(m.dateSpreadInputs) + m.loadSelectedCommitIntoForm() + m.setStatus(fmt.Sprintf("Staged new timestamps for %d commit%s.", count, plural(count)), statusSuccess) + return m, nil + } + + var cmd tea.Cmd + m.dateSpreadInputs[m.dateSpreadFocus], cmd = m.dateSpreadInputs[m.dateSpreadFocus].Update(msg) + return m, cmd +} + func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "esc" || key == "n" { @@ -760,7 +825,7 @@ func (m tuiModel) renderBottomBar(width int) string { } status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, contentWidth)) - help := "Tab form/list / search b bulk author p path r reload x reset a apply c co-authors f force t tags ? help q quit" + help := "Tab form/list / search b bulk author d dates 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 Ctrl+T set focused date to now Esc list Ctrl+C quit" } @@ -770,6 +835,9 @@ func (m tuiModel) renderBottomBar(width int) string { if m.overlay == overlayBulkAuthor { help = "Tab next field Shift+Tab previous Ctrl+T partial/exact Enter stage Esc cancel" } + if m.overlay == overlayDateSpread { + help = "Tab next field Shift+Tab previous Enter stage Esc cancel" + } if m.overlay == overlayConfirmApply { help = "Press y to rewrite history, n/Esc to cancel" } @@ -871,6 +939,8 @@ func (m tuiModel) renderDetailPane(width int, height int) string { content = m.renderPathPane(innerWidth, innerHeight) case overlayBulkAuthor: content = m.renderBulkAuthorPane(innerWidth, innerHeight) + case overlayDateSpread: + content = m.renderDateSpreadPane(innerWidth, innerHeight) case overlayConfirmApply, overlayConfirmForce: content = m.renderConfirmPane(innerWidth, innerHeight) case overlayHelp: @@ -931,6 +1001,28 @@ func (m tuiModel) renderBulkAuthorPane(width int, height int) string { return fitLines(wrapBlockLines(lines, width), height, width) } +func (m tuiModel) renderDateSpreadPane(width int, height int) string { + field := func(index int, label string) string { + style := blurredFieldStyle + if m.dateSpreadFocus == index { + style = focusedFieldStyle + } + return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.dateSpreadInputs[index].View()) + } + lines := []string{ + headerStyle.Render("Rewrite all timestamps"), + "Give every commit, oldest first, a random author and committer timestamp between the start and end dates, within the daily time window. Each timestamp is never earlier than the one before it.", + "", + field(dateSpreadStartDay, "Start date"), + field(dateSpreadEndDay, "End date"), + field(dateSpreadFromTime, "Daily window from (HH:MM)"), + field(dateSpreadToTime, "Daily window to (HH:MM)"), + "", + dimStyle.Render("Press Enter to stage these changes. Use a to apply the history rewrite."), + } + return fitLines(wrapBlockLines(lines, width), height, width) +} + func (m tuiModel) renderBulkAuthorField(index int, label string, width int) string { style := blurredFieldStyle if m.overlay == overlayBulkAuthor && m.bulkAuthorFocus == index { @@ -986,7 +1078,7 @@ func (m tuiModel) renderHelpPane(width int, height int) string { "List: Up/Down, PgUp/PgDn, Home/End select commits.", "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. b bulk-replaces author identities. p opens a repository path prompt. r reloads history.", + "/ searches commits. b bulk-replaces author identities. d spreads all timestamps across a date range. p opens a repository path prompt. r reloads history.", "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.", "", @@ -1126,6 +1218,29 @@ func (m *tuiModel) openBulkAuthor() { m.setStatus("Enter an author search and replacement identity.", statusInfo) } +func (m *tuiModel) openDateSpread() { + m.blurForm() + m.searchInput.Blur() + m.pathInput.Blur() + m.confirmInput.Blur() + m.overlay = overlayDateSpread + m.focusDateSpreadField(dateSpreadStartDay) + m.setStatus("Enter a date range and daily time window.", statusInfo) +} + +func (m *tuiModel) focusDateSpreadField(index int) { + m.blurInputs(m.dateSpreadInputs) + m.dateSpreadFocus = clamp(index, 0, dateSpreadFieldCount-1) + m.dateSpreadInputs[m.dateSpreadFocus].Focus() + m.dateSpreadInputs[m.dateSpreadFocus].CursorEnd() +} + +func (m *tuiModel) blurInputs(inputs []textinput.Model) { + for i := range inputs { + inputs[i].Blur() + } +} + func (m *tuiModel) focusBulkAuthorField(index int) { m.blurBulkAuthorInputs() m.bulkAuthorFocus = clamp(index, 0, bulkAuthorFieldCount-1) @@ -1406,6 +1521,61 @@ func stageAuthorReplacement(commits []CommitRecord, draftByHash map[string]Commi return matched, nil } +// stageDateSpread gives every commit (oldest first) the same author and +// committer timestamp: a random day in [startDay, endDay] at a random time in +// [fromTime, toTime), in local time. Sorting the random instants keeps them +// non-decreasing. randN returns a value in [0, n). +func stageDateSpread(commits []CommitRecord, draftByHash map[string]CommitRecord, startDay, endDay, fromTime, toTime string, randN func(int64) int64) (int, error) { + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startDay), time.Local) + if err != nil { + return 0, errors.New("start date must be YYYY-MM-DD") + } + end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endDay), time.Local) + if err != nil { + return 0, errors.New("end date must be YYYY-MM-DD") + } + if end.Before(start) { + return 0, errors.New("end date must not be before start date") + } + from, err := time.Parse("15:04", strings.TrimSpace(fromTime)) + if err != nil { + return 0, errors.New("window start must be HH:MM") + } + to, err := time.Parse("15:04", strings.TrimSpace(toTime)) + if err != nil { + return 0, errors.New("window end must be HH:MM") + } + window := int64(to.Sub(from) / time.Second) + if window <= 0 { + return 0, errors.New("window end must be after window start") + } + + days := int64(0) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + days++ + } + stamps := make([]time.Time, len(commits)) + for i := range stamps { + day := start.AddDate(0, 0, int(randN(days))) + base := time.Date(day.Year(), day.Month(), day.Day(), from.Hour(), from.Minute(), 0, 0, time.Local) + stamps[i] = base.Add(time.Duration(randN(window)) * time.Second) + } + sort.Slice(stamps, func(i, j int) bool { return stamps[i].Before(stamps[j]) }) + + // commits is newest first; walk it oldest first. + for i := range commits { + commit := commits[len(commits)-1-i] + draft, ok := draftByHash[commit.Hash] + if !ok { + draft = cloneCommitRecord(commit) + } + draft.AuthorDate = formatGitDate(stamps[i]) + draft.CommitterDate = draft.AuthorDate + draftByHash[commit.Hash] = draft + } + return len(commits), nil +} + func authorMatchMode(exact bool) string { if exact { return "Exact" diff --git a/tui_test.go b/tui_test.go index b70ac2c..9541425 100644 --- a/tui_test.go +++ b/tui_test.go @@ -366,3 +366,39 @@ func maxRenderedLineWidth(value string) int { } return width } + +func TestStageDateSpreadOrdersTimestampsWithinWindow(t *testing.T) { + // newest first, as the TUI holds them + commits := []CommitRecord{{Hash: "c"}, {Hash: "b"}, {Hash: "a"}} + drafts := cloneMapByHash(commits) + // Reverse-ordered random draws would break ordering without the sort. + draws := []int64{2, 3000, 0, 100, 1, 50} + randN := func(n int64) int64 { v := draws[0] % n; draws = draws[1:]; return v } + + count, err := stageDateSpread(commits, drafts, "2024-03-01", "2024-03-03", "08:00", "12:00", randN) + if err != nil || count != 3 { + t.Fatalf("expected 3 staged commits, got %d, %v", count, err) + } + var prev time.Time + for _, hash := range []string{"a", "b", "c"} { + d := drafts[hash] + if d.AuthorDate != d.CommitterDate { + t.Fatalf("%s: author and committer dates differ: %s vs %s", hash, d.AuthorDate, d.CommitterDate) + } + ts, err := parseGitDate(d.AuthorDate) + if err != nil { + t.Fatal(err) + } + if ts.Before(prev) { + t.Fatalf("%s: %s is before previous %s", hash, ts, prev) + } + if ts.Hour() < 8 || ts.Hour() >= 12 || ts.Day() < 1 || ts.Day() > 3 { + t.Fatalf("%s: %s outside range", hash, ts) + } + prev = ts + } + + if _, err := stageDateSpread(commits, drafts, "2024-03-01", "2024-03-03", "12:00", "08:00", randN); err == nil { + t.Fatal("expected inverted window to be rejected") + } +}