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 - 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 - Search commits by hash, message, author name, or email
- Bulk replace author names and emails using case-insensitive partial or exact matching - 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 - Track edited commits and reset the selected commit
- Apply all staged metadata changes in one action - Apply all staged metadata changes in one action
- Optionally force push rewritten branches and tags to `origin` - 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 - `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits
- `/`: search commits - `/`: search commits
- `b`: bulk replace matching author names and emails; `Ctrl+T` toggles partial/exact matching - `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 - `p`: switch repository path
- `r`: reload history - `r`: reload history
- `x`: reset the selected commit - `x`: reset the selected commit
+172 -2
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"math/rand"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -32,6 +34,14 @@ const (
bulkAuthorFieldCount bulkAuthorFieldCount
) )
const (
dateSpreadStartDay = iota
dateSpreadEndDay
dateSpreadFromTime
dateSpreadToTime
dateSpreadFieldCount
)
type uiFocus int type uiFocus int
const ( const (
@@ -50,6 +60,7 @@ const (
overlayConfirmForce overlayConfirmForce
overlayHelp overlayHelp
overlayDatePicker overlayDatePicker
overlayDateSpread
) )
type statusKind string type statusKind string
@@ -160,6 +171,8 @@ type tuiModel struct {
bulkAuthorFocus int bulkAuthorFocus int
bulkAuthorInputs []textinput.Model bulkAuthorInputs []textinput.Model
bulkAuthorExact bool bulkAuthorExact bool
dateSpreadFocus int
dateSpreadInputs []textinput.Model
forcePush bool forcePush bool
pushTags bool pushTags bool
@@ -215,7 +228,17 @@ func newTUIModel(app *App, initialPath string) tuiModel {
bulkAuthorInputs[bulkAuthorName].Placeholder = "Replacement author name" bulkAuthorInputs[bulkAuthorName].Placeholder = "Replacement author name"
bulkAuthorInputs[bulkAuthorEmail].Placeholder = "Replacement author email" 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{ return tuiModel{
dateSpreadInputs: dateSpreadInputs,
app: app, app: app,
initialPath: initialPath, initialPath: initialPath,
originalByHash: map[string]CommitRecord{}, originalByHash: map[string]CommitRecord{},
@@ -341,6 +364,8 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updatePathEntry(msg) return m.updatePathEntry(msg)
case overlayBulkAuthor: case overlayBulkAuthor:
return m.updateBulkAuthor(msg) return m.updateBulkAuthor(msg)
case overlayDateSpread:
return m.updateDateSpread(msg)
case overlayConfirmApply, overlayConfirmForce: case overlayConfirmApply, overlayConfirmForce:
return m.updateConfirmation(msg) return m.updateConfirmation(msg)
case overlayDatePicker: case overlayDatePicker:
@@ -369,6 +394,12 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else { } else {
m.setStatus("Load a repository before replacing authors.", statusError) 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": case "r":
if m.repository.Path != "" { if m.repository.Path != "" {
m.busy = true m.busy = true
@@ -509,6 +540,40 @@ func (m tuiModel) updateBulkAuthor(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, 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) { func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String() key := msg.String()
if key == "esc" || key == "n" { 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)) 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 { if m.focus == focusForm {
help = "Tab next field Shift+Tab previous Ctrl+T set focused date to now Esc list Ctrl+C quit" 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 { if m.overlay == overlayBulkAuthor {
help = "Tab next field Shift+Tab previous Ctrl+T partial/exact Enter stage Esc cancel" 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 { if m.overlay == overlayConfirmApply {
help = "Press y to rewrite history, n/Esc to cancel" 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) content = m.renderPathPane(innerWidth, innerHeight)
case overlayBulkAuthor: case overlayBulkAuthor:
content = m.renderBulkAuthorPane(innerWidth, innerHeight) content = m.renderBulkAuthorPane(innerWidth, innerHeight)
case overlayDateSpread:
content = m.renderDateSpreadPane(innerWidth, innerHeight)
case overlayConfirmApply, overlayConfirmForce: case overlayConfirmApply, overlayConfirmForce:
content = m.renderConfirmPane(innerWidth, innerHeight) content = m.renderConfirmPane(innerWidth, innerHeight)
case overlayHelp: case overlayHelp:
@@ -931,6 +1001,28 @@ func (m tuiModel) renderBulkAuthorPane(width int, height int) string {
return fitLines(wrapBlockLines(lines, width), height, width) 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 { func (m tuiModel) renderBulkAuthorField(index int, label string, width int) string {
style := blurredFieldStyle style := blurredFieldStyle
if m.overlay == overlayBulkAuthor && m.bulkAuthorFocus == index { 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.", "List: Up/Down, PgUp/PgDn, Home/End select commits.",
"Tab moves from the list into the commit form.", "Tab moves from the list into the commit form.",
"Form: Tab and Shift+Tab move through every editable value. Esc returns to the list.", "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.", "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.", "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) 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) { func (m *tuiModel) focusBulkAuthorField(index int) {
m.blurBulkAuthorInputs() m.blurBulkAuthorInputs()
m.bulkAuthorFocus = clamp(index, 0, bulkAuthorFieldCount-1) m.bulkAuthorFocus = clamp(index, 0, bulkAuthorFieldCount-1)
@@ -1406,6 +1521,61 @@ func stageAuthorReplacement(commits []CommitRecord, draftByHash map[string]Commi
return matched, nil 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 { func authorMatchMode(exact bool) string {
if exact { if exact {
return "Exact" return "Exact"
+36
View File
@@ -366,3 +366,39 @@ func maxRenderedLineWidth(value string) int {
} }
return width 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")
}
}