rewrite timestamps

This commit is contained in:
Andy Dixon
2026-09-26 16:19:13 +01:00
parent 40d9cb1087
commit 9e79931d27
3 changed files with 210 additions and 2 deletions
+2
View File
@@ -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
+172 -2
View File
@@ -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"
+36
View File
@@ -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")
}
}