rewrite timestamps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user