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