Date fiddlin'
This commit is contained in:
@@ -33,6 +33,7 @@ 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
|
||||||
|
- `i`: show only commits made by Claude or Codex (author, committer, or message mentions), plus merge commits that brought them in from another branch
|
||||||
- `c`: toggle bulk removal of `Co-authored-by:` trailers and `Claude-Session:` lines
|
- `c`: toggle bulk removal of `Co-authored-by:` trailers and `Claude-Session:` lines
|
||||||
- `f`: toggle force push
|
- `f`: toggle force push
|
||||||
- `t`: toggle tag push
|
- `t`: toggle tag push
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -177,6 +178,8 @@ type tuiModel struct {
|
|||||||
forcePush bool
|
forcePush bool
|
||||||
pushTags bool
|
pushTags bool
|
||||||
removeCoAuthors bool
|
removeCoAuthors bool
|
||||||
|
aiOnly bool
|
||||||
|
aiHashes map[string]bool
|
||||||
busy bool
|
busy bool
|
||||||
|
|
||||||
status string
|
status string
|
||||||
@@ -313,6 +316,7 @@ func (m *tuiModel) handleRepoLoaded(msg repoLoadedMsg) {
|
|||||||
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)
|
||||||
|
m.aiHashes = aiCommitHashes(m.commits)
|
||||||
m.pathInput.SetValue(msg.repo.Path)
|
m.pathInput.SetValue(msg.repo.Path)
|
||||||
m.reconcileSelection()
|
m.reconcileSelection()
|
||||||
m.loadSelectedCommitIntoForm()
|
m.loadSelectedCommitIntoForm()
|
||||||
@@ -418,6 +422,11 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.removeCoAuthors = !m.removeCoAuthors
|
m.removeCoAuthors = !m.removeCoAuthors
|
||||||
count, lines := coAuthorCleanupImpact(m.commits, m.draftByHash)
|
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)
|
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 "i":
|
||||||
|
m.aiOnly = !m.aiOnly
|
||||||
|
m.reconcileSelection()
|
||||||
|
m.loadSelectedCommitIntoForm()
|
||||||
|
m.setStatus(fmt.Sprintf("Claude/Codex filter %s: %d commit%s", onOff(m.aiOnly), len(m.aiHashes), plural(len(m.aiHashes))), statusInfo)
|
||||||
case "a":
|
case "a":
|
||||||
return m.beginApply()
|
return m.beginApply()
|
||||||
case "x":
|
case "x":
|
||||||
@@ -438,7 +447,7 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "home":
|
case "home":
|
||||||
m.moveSelectionTo(0)
|
m.moveSelectionTo(0)
|
||||||
case "end":
|
case "end":
|
||||||
filtered, _, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
|
filtered, _, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
|
||||||
m.moveSelectionTo(len(filtered) - 1)
|
m.moveSelectionTo(len(filtered) - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +834,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 d dates 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 i claude/codex 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"
|
||||||
}
|
}
|
||||||
@@ -859,8 +868,11 @@ func (m tuiModel) renderCommitPane(width int, height int) string {
|
|||||||
innerWidth := maxInt(1, contentWidthFor(style, width))
|
innerWidth := maxInt(1, contentWidthFor(style, width))
|
||||||
innerHeight := maxInt(1, contentHeightFor(style, height))
|
innerHeight := maxInt(1, contentHeightFor(style, height))
|
||||||
|
|
||||||
filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
|
filtered, selectedIndex, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
|
||||||
header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits)))
|
header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits)))
|
||||||
|
if m.aiOnly {
|
||||||
|
header += " " + dimStyle.Render("[AI]")
|
||||||
|
}
|
||||||
if m.searchInput.Value() != "" {
|
if m.searchInput.Value() != "" {
|
||||||
header += " " + dimStyle.Render("/"+truncateText(m.searchInput.Value(), innerWidth-12))
|
header += " " + dimStyle.Render("/"+truncateText(m.searchInput.Value(), innerWidth-12))
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1091,7 @@ 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. b bulk-replaces author identities. d spreads all timestamps across a date range. 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. i shows only Claude/Codex commits and merges that brought them in.",
|
||||||
"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.",
|
||||||
"",
|
"",
|
||||||
headerStyle.Render("Safety"),
|
headerStyle.Render("Safety"),
|
||||||
@@ -1282,7 +1294,7 @@ func (m *tuiModel) blurForm() {
|
|||||||
|
|
||||||
func (m *tuiModel) reconcileSelection() {
|
func (m *tuiModel) reconcileSelection() {
|
||||||
previousHash := m.selectedHash
|
previousHash := m.selectedHash
|
||||||
filtered, index, hash := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
|
filtered, index, hash := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
m.selectedHash = ""
|
m.selectedHash = ""
|
||||||
m.selectedIndex = 0
|
m.selectedIndex = 0
|
||||||
@@ -1314,7 +1326,7 @@ func (m *tuiModel) ensureSelectionVisible(total int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) moveSelection(delta int) {
|
func (m *tuiModel) moveSelection(delta int) {
|
||||||
filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
|
filtered, selectedIndex, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1327,7 +1339,7 @@ func (m *tuiModel) moveSelection(delta int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) moveSelectionTo(index int) {
|
func (m *tuiModel) moveSelectionTo(index int) {
|
||||||
filtered, _, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
|
filtered, _, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1596,6 +1608,64 @@ func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitR
|
|||||||
return filtered, 0, filtered[0].Hash
|
return filtered, 0, filtered[0].Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m tuiModel) listCommits() []CommitRecord {
|
||||||
|
if !m.aiOnly {
|
||||||
|
return m.commits
|
||||||
|
}
|
||||||
|
filtered := make([]CommitRecord, 0, len(m.aiHashes))
|
||||||
|
for _, commit := range m.commits {
|
||||||
|
if m.aiHashes[commit.Hash] {
|
||||||
|
filtered = append(filtered, commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
var aiIdentityPattern = regexp.MustCompile(`(?i)\b(claude|codex|anthropic)\b`)
|
||||||
|
|
||||||
|
// aiCommitHashes marks commits whose author, committer, or message mentions
|
||||||
|
// Claude/Codex, plus merge commits whose merged-in side contains such a commit.
|
||||||
|
func aiCommitHashes(commits []CommitRecord) map[string]bool {
|
||||||
|
byHash := mapByHash(commits)
|
||||||
|
ai := map[string]bool{}
|
||||||
|
for _, c := range commits {
|
||||||
|
if aiIdentityPattern.MatchString(strings.Join([]string{c.AuthorName, c.AuthorEmail, c.CommitterName, c.CommitterEmail, c.Message}, "\n")) {
|
||||||
|
ai[c.Hash] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ponytail: full ancestor walk per merge, O(commits x merges); memoise if huge repos crawl.
|
||||||
|
walk := func(start []string, skip map[string]bool, visit func(string) bool) map[string]bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
stack := append([]string(nil), start...)
|
||||||
|
for len(stack) > 0 {
|
||||||
|
h := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if seen[h] || skip[h] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[h] = true
|
||||||
|
if visit(h) {
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
stack = append(stack, byHash[h].Parents...)
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
for _, c := range commits {
|
||||||
|
if len(c.Parents) < 2 || ai[c.Hash] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := walk(c.Parents[:1], nil, func(string) bool { return false })
|
||||||
|
walk(c.Parents[1:], base, func(h string) bool {
|
||||||
|
if ai[h] {
|
||||||
|
ai[c.Hash] = true
|
||||||
|
}
|
||||||
|
return ai[c.Hash]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
|
||||||
func filterCommits(commits []CommitRecord, draftByHash map[string]CommitRecord, query string) []CommitRecord {
|
func filterCommits(commits []CommitRecord, draftByHash map[string]CommitRecord, query string) []CommitRecord {
|
||||||
query = strings.ToLower(strings.TrimSpace(query))
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
if query == "" {
|
if query == "" {
|
||||||
|
|||||||
+23
@@ -402,3 +402,26 @@ func TestStageDateSpreadOrdersTimestampsWithinWindow(t *testing.T) {
|
|||||||
t.Fatal("expected inverted window to be rejected")
|
t.Fatal("expected inverted window to be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAICommitHashesIncludesMergesOfAIBranches(t *testing.T) {
|
||||||
|
commits := []CommitRecord{
|
||||||
|
{Hash: "root", Message: "init"},
|
||||||
|
{Hash: "human", Parents: []string{"root"}, Message: "human work"},
|
||||||
|
{Hash: "bot", Parents: []string{"root"}, AuthorName: "Codex", Message: "feature"},
|
||||||
|
{Hash: "trailer", Parents: []string{"human"}, Message: "fix\n\nCo-Authored-By: Claude <noreply@anthropic.com>"},
|
||||||
|
{Hash: "mergeAI", Parents: []string{"trailer", "bot"}, Message: "Merge branch 'feature'"},
|
||||||
|
{Hash: "side", Parents: []string{"root"}, Message: "plain"},
|
||||||
|
{Hash: "mergePlain", Parents: []string{"mergeAI", "side"}, Message: "Merge branch 'other'"},
|
||||||
|
}
|
||||||
|
got := aiCommitHashes(commits)
|
||||||
|
for _, h := range []string{"bot", "trailer", "mergeAI"} {
|
||||||
|
if !got[h] {
|
||||||
|
t.Errorf("%s should be flagged", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range []string{"root", "human", "side", "mergePlain"} {
|
||||||
|
if got[h] {
|
||||||
|
t.Errorf("%s should not be flagged", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user