From 285bc8328c91388643686866cd696f7c3846a9c8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 26 Sep 2026 17:19:12 +0100 Subject: [PATCH] Date fiddlin' --- README.md | 1 + tui.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++----- tui_test.go | 23 +++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a231ea..4f6b8e3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Repository: https://github.com/andydixon/nexus - `r`: reload history - `x`: reset the selected commit - `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 - `f`: toggle force push - `t`: toggle tag push diff --git a/tui.go b/tui.go index e4a4686..6bda8a8 100644 --- a/tui.go +++ b/tui.go @@ -5,6 +5,7 @@ import ( "fmt" "math/rand" "path/filepath" + "regexp" "sort" "strings" "time" @@ -177,6 +178,8 @@ type tuiModel struct { forcePush bool pushTags bool removeCoAuthors bool + aiOnly bool + aiHashes map[string]bool busy bool status string @@ -313,6 +316,7 @@ func (m *tuiModel) handleRepoLoaded(msg repoLoadedMsg) { m.commits = newestFirst(msg.commits) m.originalByHash = mapByHash(m.commits) m.draftByHash = cloneMapByHash(m.commits) + m.aiHashes = aiCommitHashes(m.commits) m.pathInput.SetValue(msg.repo.Path) m.reconcileSelection() m.loadSelectedCommitIntoForm() @@ -418,6 +422,11 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 "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": return m.beginApply() case "x": @@ -438,7 +447,7 @@ func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "home": m.moveSelectionTo(0) 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) } @@ -825,7 +834,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 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 { 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)) 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))) + if m.aiOnly { + header += " " + dimStyle.Render("[AI]") + } if m.searchInput.Value() != "" { 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.", "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.", - "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.", "", headerStyle.Render("Safety"), @@ -1282,7 +1294,7 @@ func (m *tuiModel) blurForm() { func (m *tuiModel) reconcileSelection() { 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 { m.selectedHash = "" m.selectedIndex = 0 @@ -1314,7 +1326,7 @@ func (m *tuiModel) ensureSelectionVisible(total 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 { return } @@ -1327,7 +1339,7 @@ func (m *tuiModel) moveSelection(delta 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 { return } @@ -1596,6 +1608,64 @@ func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitR 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 { query = strings.ToLower(strings.TrimSpace(query)) if query == "" { diff --git a/tui_test.go b/tui_test.go index 9541425..4293e02 100644 --- a/tui_test.go +++ b/tui_test.go @@ -402,3 +402,26 @@ func TestStageDateSpreadOrdersTimestampsWithinWindow(t *testing.T) { 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 "}, + {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) + } + } +}