Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -12,6 +12,8 @@ Repository: https://github.com/andydixon/nexus
|
|||||||
- author name, email, and date/time
|
- author name, email, and date/time
|
||||||
- committer name, email, and date/time
|
- committer name, email, and date/time
|
||||||
- full commit message
|
- 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
|
- Search commits by hash, message, author name, or email
|
||||||
- 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
|
||||||
@@ -27,20 +29,24 @@ Repository: https://github.com/andydixon/nexus
|
|||||||
- `r`: reload history
|
- `r`: reload history
|
||||||
- `x`: reset the selected commit
|
- `x`: reset the selected commit
|
||||||
- `a`: apply changes
|
- `a`: apply changes
|
||||||
|
- `c`: toggle bulk removal of `Co-authored-by:` lines
|
||||||
- `f`: toggle force push
|
- `f`: toggle force push
|
||||||
- `t`: toggle tag push
|
- `t`: toggle tag push
|
||||||
- `?`: show help
|
- `?`: show help
|
||||||
- `q` or `Ctrl+C`: quit
|
- `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
|
## Apply Workflow
|
||||||
|
|
||||||
When you apply changes, Nexus:
|
When you apply changes, Nexus:
|
||||||
|
|
||||||
1. Checks that the selected repository is clean.
|
1. Checks that the selected repository is clean.
|
||||||
2. Creates a backup tag (`nexus-backup-<timestamp>`).
|
2. Creates a backup tag (`nexus-backup-<timestamp>`).
|
||||||
3. Rewrites history with `git filter-branch` across all refs.
|
3. Applies timestamp, metadata, message, and optional co-author trailer changes.
|
||||||
4. Removes temporary rewrite references and runs repository cleanup.
|
4. Rewrites history with `git filter-branch` across all refs.
|
||||||
5. Optionally force pushes branches and tags using `--force-with-lease`.
|
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.
|
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
|
## Licence
|
||||||
|
|
||||||
This project is proprietary software. It is **not** released under the MIT licence.
|
This project is proprietary software. It is **not** released under the MIT licence.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -50,10 +51,12 @@ type ApplyRequest struct {
|
|||||||
Commits []CommitRecord `json:"commits"`
|
Commits []CommitRecord `json:"commits"`
|
||||||
ForcePush bool `json:"forcePush"`
|
ForcePush bool `json:"forcePush"`
|
||||||
PushTags bool `json:"pushTags"`
|
PushTags bool `json:"pushTags"`
|
||||||
|
RemoveCoAuthors bool `json:"removeCoAuthors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApplyResult struct {
|
type ApplyResult struct {
|
||||||
RewrittenCommits int `json:"rewrittenCommits"`
|
RewrittenCommits int `json:"rewrittenCommits"`
|
||||||
|
RemovedCoAuthors int `json:"removedCoAuthors"`
|
||||||
ForcePushed bool `json:"forcePushed"`
|
ForcePushed bool `json:"forcePushed"`
|
||||||
BackupReference string `json:"backupReference"`
|
BackupReference string `json:"backupReference"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
@@ -156,18 +159,19 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
|
|||||||
return ApplyResult{}, err
|
return ApplyResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentByHash := make(map[string]CommitRecord, len(currentHistory))
|
incomingByHash := make(map[string]CommitRecord, len(req.Commits))
|
||||||
for _, commit := range currentHistory {
|
for _, incoming := range req.Commits {
|
||||||
currentByHash[commit.Hash] = commit
|
incomingByHash[incoming.Hash] = incoming
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedByHash := make(map[string]CommitRecord)
|
updatedByHash := make(map[string]CommitRecord)
|
||||||
messageOverride := make(map[string]bool)
|
messageOverride := make(map[string]bool)
|
||||||
|
removedCoAuthors := 0
|
||||||
|
|
||||||
for _, incoming := range req.Commits {
|
for _, current := range currentHistory {
|
||||||
current, exists := currentByHash[incoming.Hash]
|
incoming, exists := incomingByHash[current.Hash]
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
incoming = current
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized, err := normalizeIncomingCommit(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)
|
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) {
|
if editableFieldsChanged(current, normalized) {
|
||||||
updatedByHash[incoming.Hash] = normalized
|
updatedByHash[incoming.Hash] = normalized
|
||||||
messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message)
|
messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message)
|
||||||
@@ -183,6 +194,7 @@ func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
|
|||||||
|
|
||||||
result := ApplyResult{
|
result := ApplyResult{
|
||||||
RewrittenCommits: len(updatedByHash),
|
RewrittenCommits: len(updatedByHash),
|
||||||
|
RemovedCoAuthors: removedCoAuthors,
|
||||||
Warnings: []string{},
|
Warnings: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,17 +480,29 @@ func normalizeIncomingCommit(incoming CommitRecord, current CommitRecord) (Commi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGitDate(input string) (string, error) {
|
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)
|
date := strings.TrimSpace(input)
|
||||||
if date == "" {
|
if date == "" {
|
||||||
return "", errors.New("date cannot be empty")
|
return time.Time{}, errors.New("date cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
layouts := []string{
|
layouts := []string{
|
||||||
time.RFC3339Nano,
|
time.RFC3339Nano,
|
||||||
time.RFC3339,
|
time.RFC3339,
|
||||||
|
"2006-01-02 15:04:05 -0700",
|
||||||
|
time.RFC1123Z,
|
||||||
|
time.RFC822Z,
|
||||||
"2006-01-02T15:04:05",
|
"2006-01-02T15:04:05",
|
||||||
"2006-01-02T15:04",
|
"2006-01-02T15:04",
|
||||||
"2006-01-02 15:04:05",
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, layout := range layouts {
|
for _, layout := range layouts {
|
||||||
@@ -487,29 +511,58 @@ func normalizeGitDate(input string) (string, error) {
|
|||||||
err 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)
|
t, err = time.Parse(layout, date)
|
||||||
} else {
|
} else {
|
||||||
t, err = time.ParseInLocation(layout, date, time.Local)
|
t, err = time.ParseInLocation(layout, date, time.Local)
|
||||||
}
|
}
|
||||||
if err == nil {
|
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 {
|
func editableFieldsChanged(current CommitRecord, incoming CommitRecord) bool {
|
||||||
return current.AuthorName != incoming.AuthorName ||
|
return current.AuthorName != incoming.AuthorName ||
|
||||||
current.AuthorEmail != incoming.AuthorEmail ||
|
current.AuthorEmail != incoming.AuthorEmail ||
|
||||||
current.AuthorDate != incoming.AuthorDate ||
|
!gitDatesEqual(current.AuthorDate, incoming.AuthorDate) ||
|
||||||
current.CommitterName != incoming.CommitterName ||
|
current.CommitterName != incoming.CommitterName ||
|
||||||
current.CommitterEmail != incoming.CommitterEmail ||
|
current.CommitterEmail != incoming.CommitterEmail ||
|
||||||
current.CommitterDate != incoming.CommitterDate ||
|
!gitDatesEqual(current.CommitterDate, incoming.CommitterDate) ||
|
||||||
normalizeMessage(current.Message) != normalizeMessage(incoming.Message)
|
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 {
|
func firstLine(message string) string {
|
||||||
parts := strings.SplitN(message, "\n", 2)
|
parts := strings.SplitN(message, "\n", 2)
|
||||||
return strings.TrimSpace(parts[0])
|
return strings.TrimSpace(parts[0])
|
||||||
@@ -539,7 +592,7 @@ func shellQuote(value string) string {
|
|||||||
|
|
||||||
func runGit(repo string, args ...string) (string, error) {
|
func runGit(repo string, args ...string) (string, error) {
|
||||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
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 stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|||||||
@@ -124,3 +124,70 @@ func TestRewriteHistoryFailsWhenNoRefsAreRewritten(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
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
|
overlayConfirmApply
|
||||||
overlayConfirmForce
|
overlayConfirmForce
|
||||||
overlayHelp
|
overlayHelp
|
||||||
|
overlayDatePicker
|
||||||
)
|
)
|
||||||
|
|
||||||
type statusKind string
|
type statusKind string
|
||||||
@@ -145,9 +146,12 @@ type tuiModel struct {
|
|||||||
searchInput textinput.Model
|
searchInput textinput.Model
|
||||||
pathInput textinput.Model
|
pathInput textinput.Model
|
||||||
confirmInput textinput.Model
|
confirmInput textinput.Model
|
||||||
|
datePickerTime time.Time
|
||||||
|
datePickerTarget int
|
||||||
|
|
||||||
forcePush bool
|
forcePush bool
|
||||||
pushTags bool
|
pushTags bool
|
||||||
|
removeCoAuthors bool
|
||||||
busy bool
|
busy bool
|
||||||
|
|
||||||
status string
|
status string
|
||||||
@@ -283,6 +287,7 @@ func (m *tuiModel) handleApplyFinished(msg applyFinishedMsg) {
|
|||||||
m.repoLoaded = true
|
m.repoLoaded = true
|
||||||
}
|
}
|
||||||
if msg.commits != nil {
|
if msg.commits != nil {
|
||||||
|
m.removeCoAuthors = false
|
||||||
m.commits = newestFirst(msg.commits)
|
m.commits = newestFirst(msg.commits)
|
||||||
m.originalByHash = mapByHash(m.commits)
|
m.originalByHash = mapByHash(m.commits)
|
||||||
m.draftByHash = cloneMapByHash(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)
|
return m.updatePathEntry(msg)
|
||||||
case overlayConfirmApply, overlayConfirmForce:
|
case overlayConfirmApply, overlayConfirmForce:
|
||||||
return m.updateConfirmation(msg)
|
return m.updateConfirmation(msg)
|
||||||
|
case overlayDatePicker:
|
||||||
|
return m.updateDatePicker(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.focus == focusForm {
|
if m.focus == focusForm {
|
||||||
@@ -347,6 +354,10 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "t":
|
case "t":
|
||||||
m.pushTags = !m.pushTags
|
m.pushTags = !m.pushTags
|
||||||
m.setStatus(fmt.Sprintf("Push tags %s", onOff(m.pushTags)), statusInfo)
|
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":
|
case "a":
|
||||||
return m.beginApply()
|
return m.beginApply()
|
||||||
case "x":
|
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) {
|
func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
switch key {
|
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":
|
case "esc":
|
||||||
|
m.normalizeFocusedDateInput()
|
||||||
m.syncFormToDraft()
|
m.syncFormToDraft()
|
||||||
m.focus = focusList
|
m.focus = focusList
|
||||||
m.blurForm()
|
m.blurForm()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "tab", "shift+tab":
|
case "tab", "shift+tab":
|
||||||
|
m.normalizeFocusedDateInput()
|
||||||
m.syncFormToDraft()
|
m.syncFormToDraft()
|
||||||
next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab")
|
next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab")
|
||||||
if returnToList {
|
if returnToList {
|
||||||
@@ -478,6 +512,50 @@ func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, 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) {
|
func (m tuiModel) beginApply() (tea.Model, tea.Cmd) {
|
||||||
if !m.repoLoaded {
|
if !m.repoLoaded {
|
||||||
m.setStatus("Load a repository before applying changes.", statusError)
|
m.setStatus("Load a repository before applying changes.", statusError)
|
||||||
@@ -485,9 +563,13 @@ func (m tuiModel) beginApply() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.syncFormToDraft()
|
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 {
|
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
|
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)
|
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 {
|
if m.busy {
|
||||||
flags = "working..."
|
flags = "working..."
|
||||||
}
|
}
|
||||||
@@ -599,9 +681,9 @@ 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 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 {
|
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 {
|
if m.overlay == overlayPath {
|
||||||
help = "Enter load repository Esc close"
|
help = "Enter load repository Esc close"
|
||||||
@@ -612,6 +694,9 @@ func (m tuiModel) renderBottomBar(width int) string {
|
|||||||
if m.overlay == overlayConfirmForce {
|
if m.overlay == overlayConfirmForce {
|
||||||
help = "Type FORCE then Enter to rewrite and force push, Esc to cancel"
|
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)))
|
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)
|
content = m.renderConfirmPane(innerWidth, innerHeight)
|
||||||
case overlayHelp:
|
case overlayHelp:
|
||||||
content = m.renderHelpPane(innerWidth, innerHeight)
|
content = m.renderHelpPane(innerWidth, innerHeight)
|
||||||
|
case overlayDatePicker:
|
||||||
|
content = m.renderDatePickerPane(innerWidth, innerHeight)
|
||||||
default:
|
default:
|
||||||
content = m.renderFormPane(innerWidth, innerHeight)
|
content = m.renderFormPane(innerWidth, innerHeight)
|
||||||
}
|
}
|
||||||
@@ -713,6 +800,38 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
|
|||||||
return renderBox(style, width, height, content)
|
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 {
|
func (m tuiModel) renderPathPane(width int, height int) string {
|
||||||
lines := []string{
|
lines := []string{
|
||||||
headerStyle.Render("Open Repository"),
|
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 {
|
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{
|
lines := []string{
|
||||||
headerStyle.Render("Confirm Rewrite"),
|
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)),
|
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 {
|
if m.forcePush {
|
||||||
lines = append(lines,
|
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.",
|
"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. p opens a repository path prompt. r reloads history.",
|
"/ 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"),
|
headerStyle.Render("Safety"),
|
||||||
"",
|
"",
|
||||||
@@ -1004,6 +1128,15 @@ func (m *tuiModel) syncFormToDraft() {
|
|||||||
m.draftByHash[m.selectedHash] = draft
|
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() {
|
func (m *tuiModel) resetSelectedCommit() {
|
||||||
if m.selectedHash == "" {
|
if m.selectedHash == "" {
|
||||||
return
|
return
|
||||||
@@ -1030,6 +1163,7 @@ func (m tuiModel) applyRequest() ApplyRequest {
|
|||||||
Commits: commits,
|
Commits: commits,
|
||||||
ForcePush: m.forcePush,
|
ForcePush: m.forcePush,
|
||||||
PushTags: m.pushTags,
|
PushTags: m.pushTags,
|
||||||
|
RemoveCoAuthors: m.removeCoAuthors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,6 +1280,46 @@ func dirtyCommitCount(commits []CommitRecord, originalByHash map[string]CommitRe
|
|||||||
return count
|
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 {
|
func commitDirty(hash string, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) bool {
|
||||||
original, hasOriginal := originalByHash[hash]
|
original, hasOriginal := originalByHash[hash]
|
||||||
draft, hasDraft := draftByHash[hash]
|
draft, hasDraft := draftByHash[hash]
|
||||||
@@ -1273,6 +1447,61 @@ func formatCommitTime(value string) string {
|
|||||||
return parsed.Local().Format("2006-01-02 15:04")
|
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 {
|
func applyResultSummary(result ApplyResult) string {
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
if result.RewrittenCommits == 0 {
|
if result.RewrittenCommits == 0 {
|
||||||
@@ -1283,6 +1512,9 @@ func applyResultSummary(result ApplyResult) string {
|
|||||||
if result.BackupReference != "" {
|
if result.BackupReference != "" {
|
||||||
parts = append(parts, "Backup "+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 {
|
if result.ForcePushed {
|
||||||
parts = append(parts, "Force push completed.")
|
parts = append(parts, "Force push completed.")
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -5,7 +5,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func maxRenderedLineWidth(value string) int {
|
||||||
width := 0
|
width := 0
|
||||||
for _, line := range strings.Split(value, "\n") {
|
for _, line := range strings.Split(value, "\n") {
|
||||||
|
|||||||
Reference in New Issue
Block a user