From 6ffb9f83f21ee71d40ad77c9feb1cd494584e316 Mon Sep 17 00:00:00 2001 From: VERSE Date: Sun, 26 Jul 2026 19:10:56 +0700 Subject: [PATCH] feat(scoring): transition workflow learning and fix history UX (#39) - add transition scoring engine to learn sequential workflows per directory using command skeletons - prioritize current active git branch over older branches in suggestions - restore strict chronological order for history navigation and bypass AI re-ranking - add in-memory session history for instant access to just-run commands - fix PTY prompt not syncing when using arrow keys in the history menu - fix history tie-breaker to properly prioritize newer commands by assigning larger IDs - fix potential mutex deadlock between bufferMu and pty write during history navigation - fix workspace git detection to remove depth limits and correctly identify repositories with detached HEADs - fix SQLite connection leaks in frecency queries - fix global state leaks in history tests by snapshotting and restoring registry states - modernize string prefix checks using strings.CutPrefix --- commands/git/git.go | 9 +- integration/history.go | 108 ++++++++++----- integration/history_test.go | 52 ++++++++ internal/scoring/context_rules.go | 16 +++ internal/scoring/context_rules_test.go | 12 ++ internal/scoring/frecency.go | 174 ++++++++++++++++++++++++- internal/scoring/frecency_test.go | 85 ++++++++++-- internal/scoring/scorer.go | 31 ++++- internal/scoring/scorer_test.go | 80 ++++++++++++ internal/scoring/signals.go | 38 ++++-- internal/scoring/signals_test.go | 10 +- internal/scoring/skeleton.go | 23 ++++ internal/scoring/skeleton_test.go | 54 ++++++++ internal/workspace/workspace.go | 57 +++++++- internal/workspace/workspace_test.go | 36 +++++ root/suggestions.go | 9 +- root/suggestions_test.go | 30 ++++- root/wrapper.go | 68 +++++++++- spec/skeleton.go | 87 +++++++++++++ spec/skeleton_test.go | 97 ++++++++++++++ 20 files changed, 1004 insertions(+), 72 deletions(-) create mode 100644 integration/history_test.go create mode 100644 internal/scoring/skeleton.go create mode 100644 internal/scoring/skeleton_test.go create mode 100644 spec/skeleton.go create mode 100644 spec/skeleton_test.go diff --git a/commands/git/git.go b/commands/git/git.go index c880482..55b6d3a 100644 --- a/commands/git/git.go +++ b/commands/git/git.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/versenilvis/iris/internal/workspace" "github.com/versenilvis/iris/spec" ) @@ -67,8 +68,8 @@ func getGitResultsFiltered(tokens []string, localOnly bool, args ...string) []sp return nil } - activeBranch := "" - if args[0] == "branch" { + activeBranch := workspace.DetectCached(cwd).GitBranch + if activeBranch == "" && args[0] == "branch" { activeCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") activeCmd.Dir = cwd if activeOut, err := activeCmd.Output(); err == nil { @@ -152,9 +153,13 @@ func getGitResultsFiltered(tokens []string, localOnly bool, args ...string) []sp }) } + if activeBranch == "" { + activeBranch = workspace.DetectCached(cwd).GitBranch + } if activeBranch != "" { for i, r := range results { if r.Cmd == activeBranch { + r.Priority = 100 copy(results[1:i+1], results[0:i]) results[0] = r break diff --git a/integration/history.go b/integration/history.go index c990797..b06c537 100644 --- a/integration/history.go +++ b/integration/history.go @@ -13,6 +13,9 @@ import ( ) var ( + sessionHistory []string + sessionHistoryMu sync.Mutex + historyCache []string idMapCache map[string]int searcherCache *fuzzy.Searcher @@ -20,6 +23,24 @@ var ( lastModTime int64 ) +func RecordSessionCommand(cmd string) { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return + } + mu.Lock() + defer mu.Unlock() + + sessionHistoryMu.Lock() + defer sessionHistoryMu.Unlock() + + if len(sessionHistory) > 0 && sessionHistory[len(sessionHistory)-1] == cmd { + return + } + sessionHistory = append(sessionHistory, cmd) + historyCache = nil // invalidate to merge session history on next search +} + type HistResult struct { ID int Cmd string @@ -65,61 +86,82 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error // lazy load history if cache is empty if len(historyCache) == 0 { file, err := os.Open(histFile) - if err != nil { + if err != nil && !os.IsNotExist(err) { return nil, err } - defer func() { _ = file.Close() }() + if file != nil { + defer func() { _ = file.Close() }() + } var allCmds []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - cmd := line + if file != nil { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + cmd := line - if shellName == "zsh" { - parts := strings.SplitN(line, ";", 2) - if len(parts) == 2 { - cmd = parts[1] - } - } else if shellName == "bash" { - if strings.HasPrefix(line, "#") && len(line) > 1 { - isTimestamp := true - for _, c := range line[1:] { - if c < '0' || c > '9' { - isTimestamp = false - break + if shellName == "zsh" { + parts := strings.SplitN(line, ";", 2) + if len(parts) == 2 { + cmd = parts[1] + } + } else if shellName == "bash" { + if strings.HasPrefix(line, "#") && len(line) > 1 { + isTimestamp := true + for _, c := range line[1:] { + if c < '0' || c > '9' { + isTimestamp = false + break + } + } + if isTimestamp { + continue } } - if isTimestamp { + } else if shellName == "fish" { + if after, ok := strings.CutPrefix(line, "- cmd: "); ok { + cmd = after + } else { continue } } - } else if shellName == "fish" { - if after, ok := strings.CutPrefix(line, "- cmd: "); ok { - cmd = after - } else { - continue + + cmd = strings.TrimSpace(cmd) + if cmd != "" { + allCmds = append(allCmds, cmd) } } - - cmd = strings.TrimSpace(cmd) - if cmd != "" { - allCmds = append(allCmds, cmd) + if err := scanner.Err(); err != nil { + return nil, err } } - if err := scanner.Err(); err != nil { - return nil, err - } // build historyCache backwards so newest commands come first seen := make(map[string]bool) + historyCache = nil + idMapCache = make(map[string]int) + + currentID := len(sessionHistory) + len(allCmds) + + sessionHistoryMu.Lock() + for i := len(sessionHistory) - 1; i >= 0; i-- { + cmd := sessionHistory[i] + if !seen[cmd] { + historyCache = append(historyCache, cmd) + seen[cmd] = true + idMapCache[cmd] = currentID + currentID-- + } + } + sessionHistoryMu.Unlock() + for i := len(allCmds) - 1; i >= 0; i-- { cmd := allCmds[i] if !seen[cmd] { historyCache = append(historyCache, cmd) seen[cmd] = true - // we assign the ID as the original line number (1-indexed based on allCmds length) - idMapCache[cmd] = i + 1 + idMapCache[cmd] = currentID + currentID-- } } diff --git a/integration/history_test.go b/integration/history_test.go new file mode 100644 index 0000000..9c9c68d --- /dev/null +++ b/integration/history_test.go @@ -0,0 +1,52 @@ +package integration + +import ( + "testing" +) + +func TestRecordSessionCommand_MergeAndDeduplicate(t *testing.T) { + sessionHistoryMu.Lock() + origSessionHistory := sessionHistory + sessionHistory = nil + sessionHistoryMu.Unlock() + + mu.Lock() + origHistoryCache := historyCache + historyCache = nil + mu.Unlock() + + t.Cleanup(func() { + sessionHistoryMu.Lock() + sessionHistory = origSessionHistory + sessionHistoryMu.Unlock() + + mu.Lock() + historyCache = origHistoryCache + mu.Unlock() + }) + + RecordSessionCommand("git status") + RecordSessionCommand("npm run dev") + RecordSessionCommand("npm run dev") // duplicate subsequent command should be ignored + RecordSessionCommand("git push origin fix/scoring") + + results, err := SearchHistory("", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) < 3 { + t.Fatalf("expected at least 3 session commands in search results, got %d", len(results)) + } + + // newest session command must be results[0] + if results[0].Cmd != "git push origin fix/scoring" { + t.Errorf("expected results[0] to be 'git push origin fix/scoring', got %q", results[0].Cmd) + } + if results[1].Cmd != "npm run dev" { + t.Errorf("expected results[1] to be 'npm run dev', got %q", results[1].Cmd) + } + if results[2].Cmd != "git status" { + t.Errorf("expected results[2] to be 'git status', got %q", results[2].Cmd) + } +} diff --git a/internal/scoring/context_rules.go b/internal/scoring/context_rules.go index e29150d..3a1e388 100644 --- a/internal/scoring/context_rules.go +++ b/internal/scoring/context_rules.go @@ -36,6 +36,22 @@ var DefaultContextRules = []ContextRule{ }, bonus: 40, }, + &SimpleContextRule{ + check: func(ws workspace.WorkspaceInfo, cmd string) bool { + if !ws.HasGit || ws.GitBranch == "" { + return false + } + isRelevantCmd := strings.HasPrefix(cmd, "git push") || strings.HasPrefix(cmd, "git pull") || + strings.HasPrefix(cmd, "git checkout") || strings.HasPrefix(cmd, "git switch") || + strings.HasPrefix(cmd, "git branch") || strings.HasPrefix(cmd, "git merge") || + strings.HasPrefix(cmd, "git rebase") + if !isRelevantCmd { + return false + } + return strings.HasSuffix(cmd, " "+ws.GitBranch) || strings.Contains(cmd, " "+ws.GitBranch+" ") + }, + bonus: 60, + }, &SimpleContextRule{ check: func(ws workspace.WorkspaceInfo, cmd string) bool { return ws.HasGit && (strings.HasPrefix(cmd, "git init") || strings.HasPrefix(cmd, "git clone")) diff --git a/internal/scoring/context_rules_test.go b/internal/scoring/context_rules_test.go index ce4aa07..ddef5f8 100644 --- a/internal/scoring/context_rules_test.go +++ b/internal/scoring/context_rules_test.go @@ -61,6 +61,18 @@ func TestApplyContextRules_MultiEcosystem(t *testing.T) { cmd: "kubectl get pods", expected: 40, }, + { + name: "git push active branch gets double bonus clamped to 100", + ws: workspace.WorkspaceInfo{HasGit: true, GitBranch: "fix/scoring"}, + cmd: "git push origin fix/scoring", + expected: 100, // 40 (git push) + 60 (active branch) = 100 + }, + { + name: "git push other branch gets normal git push bonus only", + ws: workspace.WorkspaceInfo{HasGit: true, GitBranch: "fix/scoring"}, + cmd: "git push origin fix/alias", + expected: 40, + }, { name: "unrelated command gets no bonus", ws: workspace.WorkspaceInfo{HasGit: true}, diff --git a/internal/scoring/frecency.go b/internal/scoring/frecency.go index 25362d0..bc4a51b 100644 --- a/internal/scoring/frecency.go +++ b/internal/scoring/frecency.go @@ -22,6 +22,14 @@ type FrecencyEntry struct { RawScore float64 } +type TransitionEntry struct { + PrevSkeleton string + NextSkeleton string + Cwd string + Count int + LastUsed time.Time +} + type FrecencyStore struct { db *sql.DB mu sync.Mutex @@ -51,6 +59,7 @@ func NewFrecencyStore(dbPath string) (*FrecencyStore, error) { if err != nil { return nil, fmt.Errorf("failed to open sqlite database: %w", err) } + db.SetMaxOpenConns(1) store := &FrecencyStore{db: db} if err := store.initSchema(context.Background()); err != nil { @@ -89,16 +98,29 @@ CREATE TABLE IF NOT EXISTS history_entries ( ); CREATE INDEX IF NOT EXISTS idx_history_cwd_cmd ON history_entries(cwd, cmd); + +CREATE TABLE IF NOT EXISTS command_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prev_skeleton TEXT NOT NULL, + next_skeleton TEXT NOT NULL, + cwd TEXT NOT NULL, + count INTEGER DEFAULT 1, + last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prev_skeleton, next_skeleton, cwd) +); + +CREATE INDEX IF NOT EXISTS idx_transitions_prev_cwd ON command_transitions(prev_skeleton, cwd); ` _, err := f.db.ExecContext(ctxTimeout, schema) return err } -func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error { +func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string, exitCode int) error { if f == nil { return nil } cmd = strings.TrimSpace(cmd) + cwd = strings.TrimSpace(cwd) if cmd == "" || cwd == "" { return nil } @@ -112,17 +134,165 @@ func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error { ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond) defer cancel() - query := ` + var query string + if exitCode == 0 { + query = ` INSERT INTO history_entries (cmd, cwd, count, last_used) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(cmd, cwd) DO UPDATE SET count = count + 1, last_used = CURRENT_TIMESTAMP; ` + } else { + query = ` +INSERT INTO history_entries (cmd, cwd, count, last_used) +VALUES (?, ?, 0, CURRENT_TIMESTAMP) +ON CONFLICT(cmd, cwd) DO UPDATE SET + last_used = CURRENT_TIMESTAMP; +` + } _, err := f.db.ExecContext(ctxTimeout, query, cmd, cwd) return err } +func (f *FrecencyStore) RecordTransition(ctx context.Context, prevSkeleton, nextSkeleton, cwd string, nextExitCode int) error { + if f == nil { + return nil + } + prevSkeleton = strings.TrimSpace(prevSkeleton) + nextSkeleton = strings.TrimSpace(nextSkeleton) + cwd = strings.TrimSpace(cwd) + if prevSkeleton == "" || nextSkeleton == "" || cwd == "" { + return nil + } + + f.mu.Lock() + defer f.mu.Unlock() + + if ctx == nil { + ctx = context.Background() + } + ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond) + defer cancel() + + var query string + if nextExitCode == 0 { + query = ` +INSERT INTO command_transitions (prev_skeleton, next_skeleton, cwd, count, last_used) +VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) +ON CONFLICT(prev_skeleton, next_skeleton, cwd) DO UPDATE SET + count = count + 1, + last_used = CURRENT_TIMESTAMP; +` + } else { + query = ` +INSERT INTO command_transitions (prev_skeleton, next_skeleton, cwd, count, last_used) +VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP) +ON CONFLICT(prev_skeleton, next_skeleton, cwd) DO UPDATE SET + last_used = CURRENT_TIMESTAMP; +` + } + _, err := f.db.ExecContext(ctxTimeout, query, prevSkeleton, nextSkeleton, cwd) + return err +} + +func (f *FrecencyStore) QueryTransitionsWithFallback(ctx context.Context, prevSkeleton, cwd string) ([]TransitionEntry, bool) { + if f == nil { + return nil, false + } + prevSkeleton = strings.TrimSpace(prevSkeleton) + cwd = strings.TrimSpace(cwd) + if prevSkeleton == "" { + return nil, false + } + + f.mu.Lock() + defer f.mu.Unlock() + + if ctx == nil { + ctx = context.Background() + } + ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond) + defer cancel() + + // Phase 1: Local query with depth fallback + parts := strings.Fields(prevSkeleton) + for len(parts) > 0 { + key := strings.Join(parts, " ") + var loopEntries []TransitionEntry + func() { + rows, err := f.db.QueryContext(ctxTimeout, ` +SELECT prev_skeleton, next_skeleton, cwd, count, last_used +FROM command_transitions +WHERE prev_skeleton = ? AND cwd = ? AND count > 0 +ORDER BY count DESC +`, key, cwd) + if err == nil { + defer rows.Close() + for rows.Next() { + var prev, next, rCwd string + var count int + var lastUsedRaw string + if err := rows.Scan(&prev, &next, &rCwd, &count, &lastUsedRaw); err == nil { + t, _ := parseTimestamp(lastUsedRaw) + loopEntries = append(loopEntries, TransitionEntry{ + PrevSkeleton: prev, + NextSkeleton: next, + Cwd: rCwd, + Count: count, + LastUsed: t, + }) + } + } + } + }() + if len(loopEntries) > 0 { + return loopEntries, true + } + parts = parts[:len(parts)-1] + } + + // Phase 2: Global query with depth fallback + parts = strings.Fields(prevSkeleton) + for len(parts) > 0 { + key := strings.Join(parts, " ") + var loopEntries []TransitionEntry + func() { + rows, err := f.db.QueryContext(ctxTimeout, ` +SELECT prev_skeleton, next_skeleton, SUM(count) as total_count, MAX(last_used) as max_last_used +FROM command_transitions +WHERE prev_skeleton = ? AND count > 0 +GROUP BY next_skeleton +ORDER BY total_count DESC +`, key) + if err == nil { + defer rows.Close() + for rows.Next() { + var prev, next string + var count int + var lastUsedRaw string + if err := rows.Scan(&prev, &next, &count, &lastUsedRaw); err == nil { + t, _ := parseTimestamp(lastUsedRaw) + loopEntries = append(loopEntries, TransitionEntry{ + PrevSkeleton: prev, + NextSkeleton: next, + Cwd: "", + Count: count, + LastUsed: t, + }) + } + } + } + }() + if len(loopEntries) > 0 { + return loopEntries, false + } + parts = parts[:len(parts)-1] + } + + return nil, false +} + func (f *FrecencyStore) RawScore(count int, lastUsed time.Time) float64 { if count <= 0 { return 0 diff --git a/internal/scoring/frecency_test.go b/internal/scoring/frecency_test.go index 1e7b6ed..cb091e1 100644 --- a/internal/scoring/frecency_test.go +++ b/internal/scoring/frecency_test.go @@ -19,10 +19,10 @@ func TestFrecencyStore_RecordAndQueryLocal(t *testing.T) { defer store.Close() cwd := "/home/user/project" - _ = store.Record(context.Background(), "git status", cwd) - _ = store.Record(context.Background(), "git status", cwd) - _ = store.Record(context.Background(), "git status", cwd) - _ = store.Record(context.Background(), "git commit -m 'test'", cwd) + _ = store.Record(context.Background(), "git status", cwd, 0) + _ = store.Record(context.Background(), "git status", cwd, 0) + _ = store.Record(context.Background(), "git status", cwd, 0) + _ = store.Record(context.Background(), "git commit -m 'test'", cwd, 0) entries, err := store.QueryLocal(context.Background(), cwd, "git", 10) if err != nil { @@ -60,9 +60,9 @@ func TestFrecencyStore_QueryGlobalDedupe(t *testing.T) { } defer store.Close() - _ = store.Record(context.Background(), "make build", "/repo/a") - _ = store.Record(context.Background(), "make build", "/repo/a") - _ = store.Record(context.Background(), "make build", "/repo/b") + _ = store.Record(context.Background(), "make build", "/repo/a", 0) + _ = store.Record(context.Background(), "make build", "/repo/a", 0) + _ = store.Record(context.Background(), "make build", "/repo/b", 0) entries, err := store.QueryGlobal(context.Background(), "make", 10) if err != nil { @@ -139,7 +139,7 @@ func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) { ctxCanceled, cancel := context.WithCancel(context.Background()) cancel() - err = store.Record(ctxCanceled, "git status", tmpDir) + err = store.Record(ctxCanceled, "git status", tmpDir, 0) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled from Record with canceled context, got %v", err) } @@ -147,7 +147,7 @@ func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) { func TestFrecencyStore_NilReceiver(t *testing.T) { var nilStore *FrecencyStore - if err := nilStore.Record(context.Background(), "cmd", "cwd"); err != nil { + if err := nilStore.Record(context.Background(), "cmd", "cwd", 0); err != nil { t.Errorf("expected nil error on nil store Record, got %v", err) } if entries, err := nilStore.QueryLocal(context.Background(), "cwd", "", 10); err != nil || entries != nil { @@ -160,3 +160,70 @@ func TestFrecencyStore_NilReceiver(t *testing.T) { t.Errorf("expected nil error on nil store Close, got %v", err) } } + +func TestFrecencyStore_ExitCodeBehavior(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "history.db") + store, err := NewFrecencyStore(dbPath) + if err != nil { + t.Fatalf("NewFrecencyStore failed: %v", err) + } + defer store.Close() + + cwd := "/home/user/test" + _ = store.Record(context.Background(), "grep foo", cwd, 0) // count=1 + _ = store.Record(context.Background(), "grep foo", cwd, 1) // count unchanged (1) + + entries, _ := store.QueryLocal(context.Background(), cwd, "grep", 10) + if len(entries) != 1 || entries[0].Count != 1 { + t.Errorf("expected grep count to be 1 after non-zero exit code, got %v", entries) + } + + _ = store.RecordTransition(context.Background(), "git checkout", "git status", cwd, 0) + _ = store.RecordTransition(context.Background(), "git checkout", "git status", cwd, 1) + + transitions, isLocal := store.QueryTransitionsWithFallback(context.Background(), "git checkout", cwd) + if !isLocal || len(transitions) != 1 || transitions[0].Count != 1 { + t.Errorf("expected transition count 1 after non-zero exit code, got %v, isLocal=%v", transitions, isLocal) + } +} + +func TestFrecencyStore_TransitionCwdIsolationAndDepthFallback(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "history.db") + store, err := NewFrecencyStore(dbPath) + if err != nil { + t.Fatalf("NewFrecencyStore failed: %v", err) + } + defer store.Close() + + projectA := "/repo/a" + projectB := "/repo/b" + + _ = store.RecordTransition(context.Background(), "git checkout", "npm run dev", projectA, 0) + _ = store.RecordTransition(context.Background(), "git checkout", "go test", projectB, 0) + _ = store.RecordTransition(context.Background(), "git checkout", "go test", projectB, 0) + + // query in project B should return go test (Local) and not npm run dev + transB, isLocalB := store.QueryTransitionsWithFallback(context.Background(), "git checkout", projectB) + if !isLocalB || len(transB) != 1 || transB[0].NextSkeleton != "go test" { + t.Errorf("expected local transition 'go test' for project B, got %v (isLocal=%v)", transB, isLocalB) + } + + // query in project C (no local data) should fallback to Global (returning both aggregated) + projectC := "/repo/c" + transC, isLocalC := store.QueryTransitionsWithFallback(context.Background(), "git checkout", projectC) + if isLocalC || len(transC) != 2 { + t.Errorf("expected global transitions for project C, got %v (isLocal=%v)", transC, isLocalC) + } + if transC[0].NextSkeleton != "go test" { + t.Errorf("expected global top transition to be 'go test' (count 2), got %s", transC[0].NextSkeleton) + } + + // depth fallback test: query deep skeleton with no exact match should fallback to shallower prefix + _ = store.RecordTransition(context.Background(), "git remote", "git fetch", projectA, 0) + transDeep, isLocalDeep := store.QueryTransitionsWithFallback(context.Background(), "git remote add", projectA) + if !isLocalDeep || len(transDeep) != 1 || transDeep[0].NextSkeleton != "git fetch" { + t.Errorf("expected depth fallback to 'git fetch' from 'git remote', got %v", transDeep) + } +} diff --git a/internal/scoring/scorer.go b/internal/scoring/scorer.go index fb90b45..33ae2c4 100644 --- a/internal/scoring/scorer.go +++ b/internal/scoring/scorer.go @@ -12,6 +12,7 @@ type ScoreBreakdown struct { BasePriority int ContextBonus int Frecency int + Transition int MatchQuality int } @@ -25,13 +26,15 @@ type ScoreConfig struct { WeightBasePriority float64 WeightContextBonus float64 WeightFrecency float64 + WeightTransition float64 WeightMatchQuality float64 } var DefaultScoreConfig = ScoreConfig{ WeightBasePriority: 0.30, WeightContextBonus: 0.25, - WeightFrecency: 0.25, + WeightFrecency: 0.15, + WeightTransition: 0.10, WeightMatchQuality: 0.20, } @@ -71,11 +74,13 @@ func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config Sc bp := basePriorityFor(s) cb := ApplyContextRules(signals.Workspace, s.Cmd) frec := normFrec[i] + trans := transitionScoreFor(ExtractSkeleton(s.Cmd), signals.TransitionEntries, signals.TransitionIsLocal) mq := matchQualityScore(s.Cmd, signals.Query) total := config.WeightBasePriority*float64(bp) + config.WeightContextBonus*float64(cb) + config.WeightFrecency*float64(frec) + + config.WeightTransition*float64(trans) + config.WeightMatchQuality*float64(mq) scored[i] = ScoredSuggestion{ @@ -85,6 +90,7 @@ func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config Sc BasePriority: bp, ContextBonus: cb, Frecency: frec, + Transition: trans, MatchQuality: mq, }, } @@ -94,6 +100,9 @@ func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config Sc if scored[i].Score != scored[j].Score { return scored[i].Score > scored[j].Score } + if scored[i].Breakdown.Transition != scored[j].Breakdown.Transition { + return scored[i].Breakdown.Transition > scored[j].Breakdown.Transition + } if scored[i].Breakdown.Frecency != scored[j].Breakdown.Frecency { return scored[i].Breakdown.Frecency > scored[j].Breakdown.Frecency } @@ -106,6 +115,26 @@ func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config Sc return scored } +func transitionScoreFor(cmdSkeleton string, entries []TransitionEntry, isLocal bool) int { + if len(entries) == 0 { + return 0 // cold-start: no data, contributes 0 (must check before accessing entries[0]) + } + maxCount := entries[0].Count + if maxCount <= 0 { + return 0 + } + for _, e := range entries { + if e.NextSkeleton == cmdSkeleton { + score := (float64(e.Count) / float64(maxCount)) * 100.0 + if !isLocal { + score *= 0.7 + } + return int(math.Round(score)) + } + } + return 0 +} + func basePriorityFor(s spec.Suggestion) int { if s.Priority > 0 { if s.Priority > 100 { diff --git a/internal/scoring/scorer_test.go b/internal/scoring/scorer_test.go index 51c5a16..f1bc701 100644 --- a/internal/scoring/scorer_test.go +++ b/internal/scoring/scorer_test.go @@ -156,3 +156,83 @@ func TestIsSubsequence_UTF8(t *testing.T) { t.Error("expected multi-byte rune 'αγ' to be subsequence of 'αβγδε'") } } + +func TestScore_TransitionAndColdStartGuard(t *testing.T) { + spec.ResetRegistry() + spec.Register(&spec.Spec{ + Name: "git", + Subcommands: []spec.Subcommand{ + {Name: "pull"}, + {Name: "status"}, + }, + }) + + suggestions := []spec.Suggestion{ + {Cmd: "git pull --rebase origin main", Source: "spec"}, + {Cmd: "git status", Source: "spec"}, + } + + // Cold-start test: nil/empty TransitionEntries should not panic and contribute 0 + signalsCold := SignalSet{ + Query: "git", + TransitionEntries: nil, + } + scoredCold := Score(suggestions, signalsCold) + if len(scoredCold) != 2 || scoredCold[0].Breakdown.Transition != 0 { + t.Errorf("expected 0 transition score on cold-start without panic, got %v", scoredCold) + } + + // Transition Local test + signalsLocal := SignalSet{ + Query: "git", + TransitionEntries: []TransitionEntry{{NextSkeleton: "git pull", Count: 10}}, + TransitionIsLocal: true, + } + scoredLocal := Score(suggestions, signalsLocal) + if scoredLocal[0].Breakdown.Transition != 100 { + t.Errorf("expected transition score 100 for git pull when local, got %d", scoredLocal[0].Breakdown.Transition) + } + + // Transition Global test (damping 70%) + signalsGlobal := SignalSet{ + Query: "git", + TransitionEntries: []TransitionEntry{{NextSkeleton: "git pull", Count: 10}}, + TransitionIsLocal: false, + } + scoredGlobal := Score(suggestions, signalsGlobal) + if scoredGlobal[0].Breakdown.Transition != 70 { + t.Errorf("expected transition score 70 for git pull when global (70%% damping), got %d", scoredGlobal[0].Breakdown.Transition) + } +} + +func TestScore_TieBreakingOrder(t *testing.T) { + // All three suggestions will be constructed to have identical total scores (0), + // but different breakdown scores to verify tie-break priority: Transition > Frecency > ContextBonus > Alphabetical. + suggestions := []spec.Suggestion{ + {Cmd: "cmdA", Source: "test", Priority: 0}, + {Cmd: "cmdB", Source: "test", Priority: 0}, + } + + // We set weight configuration with 0 weights so total scores are identical (0), triggering tie-break logic + zeroConfig := ScoreConfig{ + WeightBasePriority: 0, + WeightContextBonus: 0, + WeightFrecency: 0, + WeightTransition: 0, + WeightMatchQuality: 0, + } + + signals := SignalSet{ + Query: "", + TransitionEntries: []TransitionEntry{ + {NextSkeleton: "cmdB", Count: 10}, + {NextSkeleton: "cmdA", Count: 5}, + }, + TransitionIsLocal: true, + } + + scored := ScoreWithConfig(suggestions, signals, zeroConfig) + if len(scored) != 2 || scored[0].Cmd != "cmdB" { + t.Errorf("expected cmdB to win tie-break due to higher Transition score, got %v", scored) + } +} diff --git a/internal/scoring/signals.go b/internal/scoring/signals.go index 9d70e7a..d8d1699 100644 --- a/internal/scoring/signals.go +++ b/internal/scoring/signals.go @@ -8,16 +8,18 @@ import ( ) type SignalSet struct { - Workspace workspace.WorkspaceInfo - LocalFrecency []FrecencyEntry - GlobalFrecency []FrecencyEntry - Query string - RootCommand string - Cwd string + Workspace workspace.WorkspaceInfo + LocalFrecency []FrecencyEntry + GlobalFrecency []FrecencyEntry + TransitionEntries []TransitionEntry + TransitionIsLocal bool + Query string + RootCommand string + Cwd string } -// CollectSignals gathers environment, workspace, and historical frecency signals for the given query and directory -func CollectSignals(ctx context.Context, cwd, query, rootCmd string, frecency *FrecencyStore) SignalSet { +// CollectSignals gathers environment, workspace, and historical frecency/transition signals for the given query and directory +func CollectSignals(ctx context.Context, cwd, query, rootCmd string, frecency *FrecencyStore, prevCmdSkeleton string) SignalSet { ws := workspace.DetectCached(cwd) if ctx == nil { @@ -25,17 +27,25 @@ func CollectSignals(ctx context.Context, cwd, query, rootCmd string, frecency *F } var local, global []FrecencyEntry + var trans []TransitionEntry + var transIsLocal bool + if frecency != nil { local, _ = frecency.QueryLocal(ctx, cwd, query, 50) global, _ = frecency.QueryGlobal(ctx, query, 50) + if prevCmdSkeleton != "" { + trans, transIsLocal = frecency.QueryTransitionsWithFallback(ctx, prevCmdSkeleton, cwd) + } } return SignalSet{ - Workspace: ws, - LocalFrecency: local, - GlobalFrecency: global, - Query: strings.TrimSpace(query), - RootCommand: strings.TrimSpace(rootCmd), - Cwd: cwd, + Workspace: ws, + LocalFrecency: local, + GlobalFrecency: global, + TransitionEntries: trans, + TransitionIsLocal: transIsLocal, + Query: strings.TrimSpace(query), + RootCommand: strings.TrimSpace(rootCmd), + Cwd: cwd, } } diff --git a/internal/scoring/signals_test.go b/internal/scoring/signals_test.go index 5ae08bd..a27cb37 100644 --- a/internal/scoring/signals_test.go +++ b/internal/scoring/signals_test.go @@ -18,10 +18,11 @@ func TestCollectSignals(t *testing.T) { } defer store.Close() - _ = store.Record(context.Background(), "npm run dev", tmpDir) - _ = store.Record(context.Background(), "npm test", "/other/dir") + _ = store.Record(context.Background(), "npm run dev", tmpDir, 0) + _ = store.Record(context.Background(), "npm test", "/other/dir", 0) + _ = store.RecordTransition(context.Background(), "git checkout", "npm run dev", tmpDir, 0) - signals := CollectSignals(context.Background(), tmpDir, "npm", "npm", store) + signals := CollectSignals(context.Background(), tmpDir, "npm", "npm", store, "git checkout") if !signals.Workspace.HasNodeProject { t.Error("expected HasNodeProject to be true in collected signals") @@ -32,4 +33,7 @@ func TestCollectSignals(t *testing.T) { if len(signals.GlobalFrecency) != 2 { t.Errorf("expected global frecency to contain 2 entries, got %d", len(signals.GlobalFrecency)) } + if !signals.TransitionIsLocal || len(signals.TransitionEntries) != 1 || signals.TransitionEntries[0].NextSkeleton != "npm run dev" { + t.Errorf("expected transition entry 'npm run dev', got %v (isLocal=%v)", signals.TransitionEntries, signals.TransitionIsLocal) + } } diff --git a/internal/scoring/skeleton.go b/internal/scoring/skeleton.go new file mode 100644 index 0000000..ce8c13a --- /dev/null +++ b/internal/scoring/skeleton.go @@ -0,0 +1,23 @@ +package scoring + +import ( + "strings" + + "github.com/versenilvis/iris/spec" +) + +// ExtractSkeleton extracts the subcommand skeleton of a command string for transition tracking. +// If a spec is registered, it walks the subcommand tree. +// If no spec is registered (or fallback occurs), it returns the first token (binary name). +// Never returns empty string unless input has no tokens. +func ExtractSkeleton(buf string) string { + if skeleton, ok := spec.TryExtractSkeleton(buf); ok && skeleton != "" { + return skeleton + } + + fields := strings.Fields(buf) + if len(fields) == 0 { + return "" + } + return fields[0] +} diff --git a/internal/scoring/skeleton_test.go b/internal/scoring/skeleton_test.go new file mode 100644 index 0000000..778f995 --- /dev/null +++ b/internal/scoring/skeleton_test.go @@ -0,0 +1,54 @@ +package scoring + +import ( + "testing" + + "github.com/versenilvis/iris/spec" +) + +func TestExtractSkeleton(t *testing.T) { + spec.ResetRegistry() + spec.Register(&spec.Spec{ + Name: "git", + Subcommands: []spec.Subcommand{ + {Name: "checkout"}, + {Name: "push"}, + }, + }) + + tests := []struct { + name string + buf string + want string + }{ + { + name: "spec command with args", + buf: "git checkout feature-x", + want: "git checkout", + }, + { + name: "fallback no spec binary only", + buf: "cargo build --release", + want: "cargo", + }, + { + name: "fallback single command", + buf: "ls -la", + want: "ls", + }, + { + name: "empty input", + buf: " ", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ExtractSkeleton(tc.buf) + if got != tc.want { + t.Errorf("ExtractSkeleton(%q) = %q, want %q", tc.buf, got, tc.want) + } + }) + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 64d1584..7b7b006 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -3,11 +3,13 @@ package workspace import ( "os" "path/filepath" + "strings" "sync" ) type WorkspaceInfo struct { HasGit bool + GitBranch string HasNodeProject bool HasGoProject bool HasRustProject bool @@ -57,9 +59,55 @@ func Detect(cwd string) WorkspaceInfo { } } + info.HasGit, info.GitBranch = detectGitInfo(cwd) + return info } +func resolveGitHeadPath(cwd string) (hasGit bool, headPath string) { + dir := cwd + for dir != "" { + gitPath := filepath.Join(dir, ".git") + info, err := os.Stat(gitPath) + if err == nil { + if info.IsDir() { + return true, filepath.Join(gitPath, "HEAD") + } + content, errRead := os.ReadFile(gitPath) + if errRead == nil { + s := strings.TrimSpace(string(content)) + if after, ok := strings.CutPrefix(s, "gitdir: "); ok { + gitDir := strings.TrimSpace(after) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(dir, gitDir) + } + return true, filepath.Join(gitDir, "HEAD") + } + } + return true, "" // found .git but couldn't resolve HEAD + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return false, "" +} + +func detectGitInfo(cwd string) (hasGit bool, branch string) { + hasGit, headPath := resolveGitHeadPath(cwd) + if headPath != "" { + if data, errHead := os.ReadFile(headPath); errHead == nil { + s := strings.TrimSpace(string(data)) + if after, ok := strings.CutPrefix(s, "ref: refs/heads/"); ok { + return hasGit, after + } + } + } + return hasGit, "" +} + type cacheEntry struct { key string // cwd + "|" + dirModTime info WorkspaceInfo @@ -71,7 +119,7 @@ var ( ) // DetectCached returns cached workspace info, invalidating when directory modtime changes -// this handles mid-session file creation (e.g. go mod init) without requiring cd +// or when the Git HEAD file changes (to catch branch switches that don't affect cwd modtime). func DetectCached(cwd string) WorkspaceInfo { dirInfo, err := os.Stat(cwd) if err != nil { @@ -79,6 +127,13 @@ func DetectCached(cwd string) WorkspaceInfo { } key := cwd + "|" + dirInfo.ModTime().String() + // Incorporate Git HEAD modtime into cache key to catch branch switches + if _, headPath := resolveGitHeadPath(cwd); headPath != "" { + if headInfo, err := os.Stat(headPath); err == nil { + key += "|HEAD:" + headInfo.ModTime().String() + } + } + wsCacheMu.Lock() defer wsCacheMu.Unlock() diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 4c9b27a..231c651 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestDetect_GitAndGoProject(t *testing.T) { @@ -115,3 +116,38 @@ func TestDetect_MultiEcosystems(t *testing.T) { t.Error("expected HasK8s to be true") } } + +func TestDetectCached_BranchSwitchWithoutDirChange(t *testing.T) { + tmp := t.TempDir() + gitDir := filepath.Join(tmp, ".git") + _ = os.Mkdir(gitDir, 0755) + + headPath := filepath.Join(gitDir, "HEAD") + _ = os.WriteFile(headPath, []byte("ref: refs/heads/main\n"), 0644) + + dirInfoBefore, _ := os.Stat(tmp) + + info1 := DetectCached(tmp) + if info1.GitBranch != "main" { + t.Fatalf("expected branch 'main', got %q", info1.GitBranch) + } + + // Simulate branch switch by updating .git/HEAD only + // (this does not update the modtime of tmp on typical filesystems since tmp's direct children didn't change) + _ = os.WriteFile(headPath, []byte("ref: refs/heads/feature\n"), 0644) + + // Force the modtime of HEAD to be distinct to avoid flakiness on low-res file systems + infoAfter, _ := os.Stat(headPath) + newMod := infoAfter.ModTime().Add(2 * time.Second) + _ = os.Chtimes(headPath, newMod, newMod) + + dirInfoAfter, _ := os.Stat(tmp) + if dirInfoBefore.ModTime() != dirInfoAfter.ModTime() { + t.Log("Note: directory modtime changed automatically on this filesystem") + } + + info2 := DetectCached(tmp) + if info2.GitBranch != "feature" { + t.Fatalf("expected branch 'feature', got %q", info2.GitBranch) + } +} diff --git a/root/suggestions.go b/root/suggestions.go index 61034a5..b756807 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -76,6 +76,13 @@ func MergeResults(query string, mode string) []spec.Suggestion { } } + if mode == "history" && normalizedQuery == "" { + if len(deduped) > maxSugg { + deduped = deduped[:maxSugg] + } + return deduped + } + if aiSugg := GetCurrentAISuggestion(); aiSugg != nil { normalizedCmd := strings.TrimSpace(aiSugg.Cmd) if normalizedCmd != "" && normalizedCmd != normalizedQuery && strings.HasPrefix(strings.ToLower(normalizedCmd), strings.ToLower(normalizedQuery)) { @@ -105,7 +112,7 @@ func MergeResults(query string, mode string) []spec.Suggestion { ctxTimeout, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() store, _ := scoring.GetFrecencyStore() - signals := scoring.CollectSignals(ctxTimeout, cwd, query, rootCmd, store) + signals := scoring.CollectSignals(ctxTimeout, cwd, query, rootCmd, store, getPrevSkeleton()) scored := scoring.Score(deduped, signals) finalResults := make([]spec.Suggestion, 0, len(scored)) diff --git a/root/suggestions_test.go b/root/suggestions_test.go index 0232383..6e594ac 100644 --- a/root/suggestions_test.go +++ b/root/suggestions_test.go @@ -20,7 +20,6 @@ func TestMergeResults(t *testing.T) { } }) - t.Run("Limit 100", func(t *testing.T) { res := MergeResults("a", "history") if len(res) > 100 { @@ -50,3 +49,32 @@ func TestMergeResults(t *testing.T) { } }) } + +func TestPrevRecordedCommandState(t *testing.T) { + origRegistry := spec.Registry + t.Cleanup(func() { + spec.Registry = origRegistry + }) + + spec.ResetRegistry() + spec.Register(&spec.Spec{ + Name: "git", + Subcommands: []spec.Subcommand{ + {Name: "checkout"}, + }, + }) + + setPrevRecordedInfo("git checkout feature-abc", "/repo/dir") + skel, cwd := getPrevRecordedInfo() + if skel != "git checkout" || cwd != "/repo/dir" { + t.Errorf("expected skel='git checkout' and cwd='/repo/dir', got skel=%q, cwd=%q", skel, cwd) + } + if gotSkel := getPrevSkeleton(); gotSkel != "git checkout" { + t.Errorf("expected getPrevSkeleton()='git checkout', got %q", gotSkel) + } + + setPrevRecordedInfo("", "") + if gotSkel := getPrevSkeleton(); gotSkel != "" { + t.Errorf("expected empty getPrevSkeleton() after reset, got %q", gotSkel) + } +} diff --git a/root/wrapper.go b/root/wrapper.go index c43a634..c51421a 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -10,6 +10,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -28,6 +29,37 @@ import ( "golang.org/x/term" ) +var ( + prevRecordedCommand string + prevCmdCwd string + prevCmdMu sync.Mutex +) + +func getPrevSkeleton() string { + prevCmdMu.Lock() + defer prevCmdMu.Unlock() + if prevRecordedCommand == "" { + return "" + } + return scoring.ExtractSkeleton(prevRecordedCommand) +} + +func getPrevRecordedInfo() (string, string) { + prevCmdMu.Lock() + defer prevCmdMu.Unlock() + if prevRecordedCommand == "" { + return "", "" + } + return scoring.ExtractSkeleton(prevRecordedCommand), prevCmdCwd +} + +func setPrevRecordedInfo(cmd, cwd string) { + prevCmdMu.Lock() + defer prevCmdMu.Unlock() + prevRecordedCommand = cmd + prevCmdCwd = cwd +} + func loadMode() string { mode := config.Get().Core.Mode if mode == "last" { @@ -322,7 +354,13 @@ func runWrapper() { continue } - if query == "IRIS_CMD_STOP" { + if query == "IRIS_CMD_STOP" || strings.HasPrefix(query, "IRIS_CMD_STOP:") { + exitCode := 0 + if after, ok := strings.CutPrefix(query, "IRIS_CMD_STOP:"); ok { + if code, err := strconv.Atoi(after); err == nil { + exitCode = code + } + } isCommandActive.Store(false) SetCurrentAISuggestion(nil) bufferMu.Lock() @@ -330,8 +368,11 @@ func runWrapper() { lastSubmittedCommand = "" bufferMu.Unlock() if cmdToRecord != "" { + integration.RecordSessionCommand(cmdToRecord) cwd := spec.GetCWD() - go func(c, d string) { + prevSkeleton, prevCwd := getPrevRecordedInfo() + currSkeleton := scoring.ExtractSkeleton(cmdToRecord) + go func(c, d string, code int, pSkel, pCwd, cSkel string) { defer func() { if r := recover(); r != nil { WriteCrashLog(r) @@ -340,9 +381,13 @@ func runWrapper() { ctxRecord, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) defer cancel() if store, err := scoring.GetFrecencyStore(); err == nil && store != nil { - _ = store.Record(ctxRecord, c, d) + _ = store.Record(ctxRecord, c, d, code) + if pSkel != "" && cSkel != "" { + _ = store.RecordTransition(ctxRecord, pSkel, cSkel, d, code) + } } - }(cmdToRecord, cwd) + }(cmdToRecord, cwd, exitCode, prevSkeleton, prevCwd, currSkeleton) + setPrevRecordedInfo(cmdToRecord, cwd) } // hook: after user executes a command, print the update notice exactly once per session if !updatePrinted { @@ -605,17 +650,30 @@ func runWrapper() { if inputSlice[i+2] == 'A' { arrowDir = "up" } - moved, _ := overlay.MoveCursor(arrowDir) + moved, selectedCmd := overlay.MoveCursor(arrowDir) if !moved { i += 2 continue } bufferMu.Lock() + activeModeMu.RLock() + isHistMode := activeMode == "history" + activeModeMu.RUnlock() + var toWrite []byte + if isHistMode && selectedCmd != "" { + naiveBuffer = selectedCmd + cursorOffset = 0 + toWrite = append([]byte{0x15}, selectedCmd...) + } bufCopy := naiveBuffer offsetCopy := cursorOffset bufferMu.Unlock() + if len(toWrite) > 0 { + _, _ = ptmx.Write(toWrite) + } + var b strings.Builder if !disableGhostText.Load() { b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0)) diff --git a/spec/skeleton.go b/spec/skeleton.go new file mode 100644 index 0000000..d7a8145 --- /dev/null +++ b/spec/skeleton.go @@ -0,0 +1,87 @@ +package spec + +import ( + "slices" + "strings" +) + +// TryExtractSkeleton walks the registered subcommand tree for the given command buffer. +// It stops when a token doesn't match any registered subcommand name (treating that token as an argument/value) +// or when encountering a flag ('-' prefix). +// Returns the normalized subcommand skeleton (e.g. "git checkout feature-x" -> "git checkout") and true if a spec exists. +func TryExtractSkeleton(buf string) (string, bool) { + buf = strings.TrimSpace(buf) + if buf == "" { + return "", false + } + + aliases := GetAliasesCopy() + tokens := Tokenize(buf) + // filter out empty tokens (e.g. from trailing space) + var cleanTokens []string + for _, t := range tokens { + if t != "" { + cleanTokens = append(cleanTokens, t) + } + } + if len(cleanTokens) == 0 { + return "", false + } + + // expand shell alias for the root command if present + if target, ok := aliases[cleanTokens[0]]; ok { + aliasTokens := Tokenize(target) + var cleanAliasTokens []string + for _, t := range aliasTokens { + if t != "" { + cleanAliasTokens = append(cleanAliasTokens, t) + } + } + cleanTokens = append(cleanAliasTokens, cleanTokens[1:]...) + } + + if len(cleanTokens) == 0 { + return "", false + } + + rootName := cleanTokens[0] + spec, exists := Registry[rootName] + if !exists || spec == nil { + return "", false + } + + skeletonTokens := []string{rootName} + currentSubs := spec.Subcommands + + for i := 1; i < len(cleanTokens); i++ { + tok := cleanTokens[i] + if strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") { + continue + } + + found := false + for _, sub := range currentSubs { + if sub.Name == tok || slices.Contains(sub.Aliases, tok) { + skeletonTokens = append(skeletonTokens, sub.Name) + currentSubs = sub.Subcommands + found = true + break + } + } + + if found { + continue + } + + // If the previous token was a flag (started with '-'), this token might be the flag's argument/value (e.g. -C /tmp). + // In that case, we skip this token and continue looking for subcommands in subsequent tokens. + if i > 0 && strings.HasPrefix(cleanTokens[i-1], "-") { + continue + } + + // token did not match any registered subcommand and is not a flag argument -> must be a positional argument (branch, file, etc.) + break + } + + return strings.Join(skeletonTokens, " "), true +} diff --git a/spec/skeleton_test.go b/spec/skeleton_test.go new file mode 100644 index 0000000..aee8565 --- /dev/null +++ b/spec/skeleton_test.go @@ -0,0 +1,97 @@ +package spec + +import ( + "testing" +) + +func TestTryExtractSkeleton(t *testing.T) { + ResetRegistry() + Register(&Spec{ + Name: "git", + Subcommands: []Subcommand{ + { + Name: "checkout", + Aliases: []string{"co"}, + }, + { + Name: "remote", + Subcommands: []Subcommand{ + {Name: "add"}, + }, + }, + }, + }) + Register(&Spec{ + Name: "docker", + Subcommands: []Subcommand{ + { + Name: "compose", + Subcommands: []Subcommand{ + {Name: "up"}, + }, + }, + }, + }) + + tests := []struct { + name string + input string + wantSkeleton string + wantOk bool + }{ + { + name: "git checkout with argument", + input: "git checkout feature-x -b", + wantSkeleton: "git checkout", + wantOk: true, + }, + { + name: "git co alias with argument", + input: "git co feature-y", + wantSkeleton: "git checkout", + wantOk: true, + }, + { + name: "git remote add with arguments", + input: "git remote add origin https://github.com/test/test.git", + wantSkeleton: "git remote add", + wantOk: true, + }, + { + name: "git global flag before subcommand", + input: "git -C /tmp checkout main", + wantSkeleton: "git checkout", + wantOk: true, + }, + { + name: "docker compose up with flags", + input: "docker compose up -d", + wantSkeleton: "docker compose up", + wantOk: true, + }, + { + name: "unregistered spec", + input: "cargo build --release", + wantSkeleton: "", + wantOk: false, + }, + { + name: "empty input", + input: " ", + wantSkeleton: "", + wantOk: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := TryExtractSkeleton(tc.input) + if ok != tc.wantOk { + t.Fatalf("TryExtractSkeleton(%q) ok = %v, want %v", tc.input, ok, tc.wantOk) + } + if got != tc.wantSkeleton { + t.Errorf("TryExtractSkeleton(%q) = %q, want %q", tc.input, got, tc.wantSkeleton) + } + }) + } +}