From 6faa2e3274b71c9056f3b8d8445f2d3fc7e609ff Mon Sep 17 00:00:00 2001 From: VERSE Date: Sun, 2 Aug 2026 14:30:46 +0700 Subject: [PATCH] feat(spec): support tool aliases (#107) closes #57 IRIS now automatically read and suggest internal configuration aliases for Git (`.gitconfig`, `.git/config`) and Cargo (`.cargo/config.toml`) - Added the `Provider` interface and extended registry for CLI tools that define aliases - Git: parse aliases via `git config --show-scope`, supporting raw tab/space separation and scope differentiation - Cargo: traverse the directory tree upwards to root to read the `.cargo/config.toml` configuration - 0ms hot-path caching: key caching is calculated entirely via `os.Stat` mtime (no subprocess spawn) combined with a 2-second TTL window to ensure 0ms latency when typing - Lookup integration: display alias suggestions at the subcommand level and automatically expand aliases as the user enters further arguments (ignore shell pipeline commands starting with `!`) --- root/mock_git_provider_test.go | 10 ++ root/suggestions.go | 5 +- root/suggestions_test.go | 17 ++++ root/wrapper.go | 1 + spec/alias/cargo_provider.go | 137 +++++++++++++++++++++++++++ spec/alias/git_provider.go | 166 +++++++++++++++++++++++++++++++++ spec/alias/provider.go | 40 ++++++++ spec/alias/provider_test.go | 123 ++++++++++++++++++++++++ spec/lookup.go | 76 ++++++++++++++- spec/lookup_test.go | 159 +++++++++++++++++++++++++++++++ 10 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 root/mock_git_provider_test.go create mode 100644 spec/alias/cargo_provider.go create mode 100644 spec/alias/git_provider.go create mode 100644 spec/alias/provider.go create mode 100644 spec/alias/provider_test.go diff --git a/root/mock_git_provider_test.go b/root/mock_git_provider_test.go new file mode 100644 index 0000000..cd456e9 --- /dev/null +++ b/root/mock_git_provider_test.go @@ -0,0 +1,10 @@ +package root + +import "github.com/versenilvis/iris/spec/alias" + +type mockGitProvider struct{} + +func (m *mockGitProvider) ToolName() string { return "git" } +func (m *mockGitProvider) GetAliases(cwd string) []alias.AliasEntry { + return []alias.AliasEntry{{Name: "co", Expansion: "checkout", Scope: "local"}} +} diff --git a/root/suggestions.go b/root/suggestions.go index d198b8c..8567ec1 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -25,7 +25,10 @@ func MergeResults(query string, mode string) []spec.Suggestion { // add suggestion helper to deduplicate addSuggestion := func(s spec.Suggestion) { normalizedCmd := strings.TrimSpace(s.Cmd) - if normalizedCmd == "" || normalizedCmd == normalizedQuery { + if normalizedCmd == "" { + return + } + if s.Source != "alias" && normalizedCmd == normalizedQuery { return } if s.Source == "" { diff --git a/root/suggestions_test.go b/root/suggestions_test.go index 6e594ac..ec68d55 100644 --- a/root/suggestions_test.go +++ b/root/suggestions_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/versenilvis/iris/spec" + "github.com/versenilvis/iris/spec/alias" ) func TestMergeResults(t *testing.T) { @@ -48,6 +49,22 @@ func TestMergeResults(t *testing.T) { t.Errorf("Expected promoted source 'ai', got %q", res[0].Source) } }) + + t.Run("Tool Alias Preserved When Matching Query", func(t *testing.T) { + alias.Register(&mockGitProvider{}) + defer alias.Reset() + res := MergeResults("git co", "default") + foundCo := false + for _, r := range res { + if r.Cmd == "git co" { + foundCo = true + break + } + } + if !foundCo { + t.Errorf("expected 'git co' alias suggestion in results for query 'git co', got %v", res) + } + }) } func TestPrevRecordedCommandState(t *testing.T) { diff --git a/root/wrapper.go b/root/wrapper.go index e85af89..eb77fe1 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -821,6 +821,7 @@ func runWrapper() { overlay.ClearGhostTextState() userNavigated.Store(false) writeStdout([]byte(overlay.Render())) + renderOverlay() } i += consumed - 1 continue diff --git a/spec/alias/cargo_provider.go b/spec/alias/cargo_provider.go new file mode 100644 index 0000000..43caea8 --- /dev/null +++ b/spec/alias/cargo_provider.go @@ -0,0 +1,137 @@ +package alias + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/BurntSushi/toml" +) + +type CargoProvider struct { + cacheKey string + cached []AliasEntry + mu sync.Mutex +} + +func (p *CargoProvider) ToolName() string { + return "cargo" +} + +func (p *CargoProvider) GetAliases(cwd string) []AliasEntry { + key := p.buildCacheKey(cwd) + p.mu.Lock() + defer p.mu.Unlock() + + if p.cacheKey != "" && p.cacheKey == key { + return p.cached + } + + p.cached = p.parse(cwd) + p.cacheKey = key + return p.cached +} + +func (p *CargoProvider) buildCacheKey(cwd string) string { + var sb strings.Builder + sb.WriteString(cwd) + + dir := cwd + for { + localConfig := filepath.Join(dir, ".cargo", "config.toml") + if info, err := os.Stat(localConfig); err == nil { + fmt.Fprintf(&sb, "|local:%s", info.ModTime().String()) + } + localConfig2 := filepath.Join(dir, ".cargo", "config") + if info, err := os.Stat(localConfig2); err == nil { + fmt.Fprintf(&sb, "|local:%s", info.ModTime().String()) + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + if cargoHome := os.Getenv("CARGO_HOME"); cargoHome != "" { + globalConfig := filepath.Join(cargoHome, "config.toml") + if info, err := os.Stat(globalConfig); err == nil { + fmt.Fprintf(&sb, "|global:%s", info.ModTime().String()) + } + } else if home, err := os.UserHomeDir(); err == nil { + globalConfig := filepath.Join(home, ".cargo", "config.toml") + if info, err := os.Stat(globalConfig); err == nil { + fmt.Fprintf(&sb, "|global:%s", info.ModTime().String()) + } + } + + return sb.String() +} + +func (p *CargoProvider) parse(cwd string) []AliasEntry { + var rawEntries []AliasEntry + + dir := cwd + for { + rawEntries = append(rawEntries, p.parseFile(filepath.Join(dir, ".cargo", "config.toml"), "local")...) + rawEntries = append(rawEntries, p.parseFile(filepath.Join(dir, ".cargo", "config"), "local")...) + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + if cargoHome := os.Getenv("CARGO_HOME"); cargoHome != "" { + rawEntries = append(rawEntries, p.parseFile(filepath.Join(cargoHome, "config.toml"), "global")...) + rawEntries = append(rawEntries, p.parseFile(filepath.Join(cargoHome, "config"), "global")...) + } else if home, err := os.UserHomeDir(); err == nil { + rawEntries = append(rawEntries, p.parseFile(filepath.Join(home, ".cargo", "config.toml"), "global")...) + rawEntries = append(rawEntries, p.parseFile(filepath.Join(home, ".cargo", "config"), "global")...) + } + + seen := make(map[string]bool) + var entries []AliasEntry + for _, e := range rawEntries { + if !seen[e.Name] { + seen[e.Name] = true + entries = append(entries, e) + } + } + + return entries +} + +func (p *CargoProvider) parseFile(path, scope string) []AliasEntry { + var config struct { + Alias map[string]interface{} `toml:"alias"` + } + if _, err := toml.DecodeFile(path, &config); err != nil { + return nil + } + + var entries []AliasEntry + for k, v := range config.Alias { + switch val := v.(type) { + case string: + entries = append(entries, AliasEntry{Name: k, Expansion: val, Scope: scope}) + case []interface{}: + var parts []string + for _, item := range val { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + entries = append(entries, AliasEntry{Name: k, Expansion: strings.Join(parts, " "), Scope: scope}) + } + } + return entries +} + +func init() { + Register(&CargoProvider{}) +} diff --git a/spec/alias/git_provider.go b/spec/alias/git_provider.go new file mode 100644 index 0000000..05472ed --- /dev/null +++ b/spec/alias/git_provider.go @@ -0,0 +1,166 @@ +package alias + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +type GitProvider struct { + cacheKey string + cached []AliasEntry + lastCheck time.Time + mu sync.Mutex +} + +func (p *GitProvider) ToolName() string { + return "git" +} + +func (p *GitProvider) GetAliases(cwd string) []AliasEntry { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + if p.cached != nil && now.Sub(p.lastCheck) < 2*time.Second { + return p.cached + } + + key := p.buildCacheKey(cwd) + p.lastCheck = now + + if p.cacheKey != "" && p.cacheKey == key { + return p.cached + } + + p.cached = p.parse(cwd) + p.cacheKey = key + return p.cached +} + +func (p *GitProvider) buildCacheKey(cwd string) string { + var sb strings.Builder + sb.WriteString(cwd) + + // Check local config via fast os.Stat traversal + if configPath := resolveGitConfigPath(cwd); configPath != "" { + if info, err := os.Stat(configPath); err == nil { + fmt.Fprintf(&sb, "|local:%s", info.ModTime().String()) + } + } + + // Check global config + if home, err := os.UserHomeDir(); err == nil { + globalConfig := filepath.Join(home, ".gitconfig") + if info, err := os.Stat(globalConfig); err == nil { + fmt.Fprintf(&sb, "|global:%s", info.ModTime().String()) + } + } + + return sb.String() +} + +func resolveGitConfigPath(cwd string) string { + dir := cwd + for dir != "" { + gitPath := filepath.Join(dir, ".git") + info, err := os.Stat(gitPath) + if err == nil { + if info.IsDir() { + return filepath.Join(gitPath, "config") + } + 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 filepath.Join(gitDir, "config") + } + } + return filepath.Join(dir, ".git", "config") + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +func (p *GitProvider) parse(cwd string) []AliasEntry { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Try with --show-scope first + cmd := exec.CommandContext(ctx, "git", "config", "--get-regexp", "--show-scope", "^alias\\.") + cmd.Dir = cwd + out, err := cmd.Output() + hasScope := true + if err != nil { + ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel2() + // Fallback for older git + cmd = exec.CommandContext(ctx2, "git", "config", "--get-regexp", "^alias\\.") + cmd.Dir = cwd + out, err = cmd.Output() + hasScope = false + if err != nil { + return nil + } + } + return p.parseOutput(out, hasScope) +} + +func (p *GitProvider) parseOutput(out []byte, hasScope bool) []AliasEntry { + var entries []AliasEntry + lines := strings.Split(string(bytes.TrimSpace(out)), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if hasScope { + idx := strings.IndexAny(line, " \t") + if idx == -1 { + continue + } + scope := line[:idx] + rest := strings.TrimSpace(line[idx:]) + parts := strings.SplitN(rest, " ", 2) + if len(parts) == 2 { + name := strings.TrimPrefix(parts[0], "alias.") + entries = append(entries, AliasEntry{ + Name: name, + Expansion: parts[1], + Scope: scope, + }) + } + } else { + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + name := strings.TrimPrefix(parts[0], "alias.") + entries = append(entries, AliasEntry{ + Name: name, + Expansion: parts[1], + Scope: "", + }) + } + } + } + return entries +} + +func init() { + Register(&GitProvider{}) +} diff --git a/spec/alias/provider.go b/spec/alias/provider.go new file mode 100644 index 0000000..ac243a6 --- /dev/null +++ b/spec/alias/provider.go @@ -0,0 +1,40 @@ +package alias + +import ( + "sync" +) + +type AliasEntry struct { + Name string + Expansion string + Scope string // "local" | "global" +} + +type Provider interface { + ToolName() string + GetAliases(cwd string) []AliasEntry +} + +var ( + providers = make(map[string]Provider) + mu sync.RWMutex +) + +func Register(p Provider) { + mu.Lock() + defer mu.Unlock() + providers[p.ToolName()] = p +} + +// Reset clears the registered providers map, restoring it to its initial empty state. +func Reset() { + mu.Lock() + defer mu.Unlock() + providers = make(map[string]Provider) +} + +func GetProvider(toolName string) Provider { + mu.RLock() + defer mu.RUnlock() + return providers[toolName] +} diff --git a/spec/alias/provider_test.go b/spec/alias/provider_test.go new file mode 100644 index 0000000..4eb22ba --- /dev/null +++ b/spec/alias/provider_test.go @@ -0,0 +1,123 @@ +package alias + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCargoProvider(t *testing.T) { + t.Setenv("CARGO_HOME", t.TempDir()) + tempDir := t.TempDir() + cargoDir := filepath.Join(tempDir, ".cargo") + if err := os.Mkdir(cargoDir, 0755); err != nil { + t.Fatal(err) + } + + configToml := ` +[alias] +b = "build" +c = "check" +t = ["test", "--", "--nocapture"] +` + if err := os.WriteFile(filepath.Join(cargoDir, "config.toml"), []byte(configToml), 0644); err != nil { + t.Fatal(err) + } + + p := &CargoProvider{} + aliases := p.parse(tempDir) + + expected := map[string]string{ + "b": "build", + "c": "check", + "t": "test -- --nocapture", + } + + aliasMap := make(map[string]string) + for _, a := range aliases { + aliasMap[a.Name] = a.Expansion + } + + for k, wantExp := range expected { + gotExp, ok := aliasMap[k] + if !ok { + t.Errorf("expected alias %q to be present", k) + } else if gotExp != wantExp { + t.Errorf("expected alias %q to map to %q, got %q", k, wantExp, gotExp) + } + } +} + +func TestGitProviderParseOutput(t *testing.T) { + outputWithScope := []byte("global\talias.recent !git for-each-ref --sort=committerdate --format=\"%(committerdate:relative) %(refname:short)\" refs/heads/ | tail -10\nlocal\talias.co checkout\n") + p := &GitProvider{} + entries := p.parseOutput(outputWithScope, true) + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(entries), entries) + } + + if entries[0].Name != "recent" { + t.Errorf("expected name 'recent', got %q", entries[0].Name) + } + if entries[0].Scope != "global" { + t.Errorf("expected scope 'global', got %q", entries[0].Scope) + } + if entries[0].Expansion != "!git for-each-ref --sort=committerdate --format=\"%(committerdate:relative) %(refname:short)\" refs/heads/ | tail -10" { + t.Errorf("unexpected expansion: %q", entries[0].Expansion) + } + + if entries[1].Name != "co" { + t.Errorf("expected name 'co', got %q", entries[1].Name) + } + if entries[1].Scope != "local" { + t.Errorf("expected scope 'local', got %q", entries[1].Scope) + } + if entries[1].Expansion != "checkout" { + t.Errorf("expected expansion 'checkout', got %q", entries[1].Expansion) + } +} + +func TestGetProvider_UnregisteredCommand(t *testing.T) { + p := GetProvider("unregistered_command_xyz") + if p != nil { + t.Errorf("expected nil for unregistered command, got %v", p) + } +} + +func TestResolveGitConfigPath_WalkUp(t *testing.T) { + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + _ = os.Mkdir(gitDir, 0755) + configPath := filepath.Join(gitDir, "config") + _ = os.WriteFile(configPath, []byte("[alias]\nst = status"), 0644) + + subDir := filepath.Join(tempDir, "a", "b", "c") + _ = os.MkdirAll(subDir, 0755) + + resolved := resolveGitConfigPath(subDir) + if resolved != configPath { + t.Errorf("expected %q, got %q", configPath, resolved) + } +} + +func TestResolveGitConfigPath_WorktreeFile(t *testing.T) { + tempDir := t.TempDir() + mainGitDir := filepath.Join(tempDir, "mainrepo", ".git") + _ = os.MkdirAll(mainGitDir, 0755) + mainConfig := filepath.Join(mainGitDir, "config") + _ = os.WriteFile(mainConfig, []byte("[alias]\nst = status"), 0644) + + wtDir := filepath.Join(tempDir, "wt") + _ = os.MkdirAll(wtDir, 0755) + wtGitFile := filepath.Join(wtDir, ".git") + _ = os.WriteFile(wtGitFile, []byte("gitdir: "+mainGitDir), 0644) + + wtSubDir := filepath.Join(wtDir, "sub") + _ = os.MkdirAll(wtSubDir, 0755) + + resolved := resolveGitConfigPath(wtSubDir) + if resolved != mainConfig { + t.Errorf("expected %q, got %q", mainConfig, resolved) + } +} diff --git a/spec/lookup.go b/spec/lookup.go index 55c3fa4..ecdf8cc 100644 --- a/spec/lookup.go +++ b/spec/lookup.go @@ -8,6 +8,7 @@ import ( "github.com/versenilvis/iris/integration/shell" "github.com/versenilvis/iris/internal/logger" + "github.com/versenilvis/iris/spec/alias" ) var ( @@ -51,6 +52,9 @@ func Lookup(input string) []Suggestion { } tokens := Tokenize(input) + originalTokens := append([]string(nil), tokens...) + originalPrefix := "" + expandedPrefix := "" if len(tokens) == 1 && tokens[0] == "" { return topLevelSuggestions("", aliases) @@ -77,6 +81,44 @@ func Lookup(input string) []Suggestion { } } + // Expand tool aliases + isAliasExpanded := false + aliasExpansionEnd := 0 + if len(tokens) > 2 { + rootCmdName := tokens[0] + if provider := alias.GetProvider(rootCmdName); provider != nil { + cwd := GetCWD() + toolAliases := provider.GetAliases(cwd) + subCmd := tokens[1] + for _, a := range toolAliases { + if a.Name == subCmd { + if strings.HasPrefix(a.Expansion, "!") { + return nil + } + aliasTokens := Tokenize(a.Expansion) + if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" { + aliasTokens = aliasTokens[:len(aliasTokens)-1] + } + if len(aliasTokens) > 0 && aliasTokens[0] == rootCmdName { + aliasTokens = aliasTokens[1:] + } + newTokens := make([]string, 0, len(tokens)-1+len(aliasTokens)) + newTokens = append(newTokens, tokens[0]) + newTokens = append(newTokens, aliasTokens...) + newTokens = append(newTokens, tokens[2:]...) + tokens = newTokens + isAliasExpanded = true + aliasExpansionEnd = len(aliasTokens) + expandedPrefix = strings.Join(tokens[:1+aliasExpansionEnd], " ") + if len(originalTokens) >= 2 { + originalPrefix = strings.Join(originalTokens[:2], " ") + } + break + } + } + } + } + if len(tokens) == 1 { query := tokens[0] results := topLevelSuggestions(query, aliases) @@ -152,7 +194,7 @@ func Lookup(input string) []Suggestion { continue } // invalid subcommand word typed - if len(currentSubs) > 0 { + if len(currentSubs) > 0 && (!isAliasExpanded || depth > aliasExpansionEnd) { return nil } break @@ -280,6 +322,30 @@ func Lookup(input string) []Suggestion { }) } } + + if depth == 1 { + if provider := alias.GetProvider(rootCmdName); provider != nil { + cwd := GetCWD() + for _, a := range provider.GetAliases(cwd) { + if partial == "" || HasPrefix(a.Name, partial) { + priority := 70 + switch a.Scope { + case "local", "worktree", "command": + priority = 85 + case "system": + priority = 65 + } + results = append(results, Suggestion{ + Cmd: prefix + " " + a.Name, + Desc: "Alias for: " + a.Expansion, + Icon: rootCmdName, + Priority: priority, + Source: "alias", + }) + } + } + } + } } usedOpts := make(map[string]bool) @@ -305,6 +371,14 @@ func Lookup(input string) []Suggestion { } } + if isAliasExpanded && expandedPrefix != "" && originalPrefix != "" { + for i := range results { + if strings.HasPrefix(results[i].Cmd, expandedPrefix) { + results[i].Cmd = originalPrefix + strings.TrimPrefix(results[i].Cmd, expandedPrefix) + } + } + } + return results } diff --git a/spec/lookup_test.go b/spec/lookup_test.go index e7143d0..bb487fb 100644 --- a/spec/lookup_test.go +++ b/spec/lookup_test.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "testing" + + "github.com/versenilvis/iris/spec/alias" ) func TestLookup(t *testing.T) { @@ -231,3 +233,160 @@ func TestLookup_NestedDirectoryTrailingSpace(t *testing.T) { t.Errorf("expected hello.txt in results, got %v", results) } } + +type mockGitProvider struct{} + +func (m *mockGitProvider) ToolName() string { return "git" } +func (m *mockGitProvider) GetAliases(cwd string) []alias.AliasEntry { + return []alias.AliasEntry{ + { + Name: "co", + Expansion: "checkout", + Scope: "global", + }, + { + Name: "recent", + Expansion: "!git for-each-ref --sort=committerdate --format='%(committerdate:relative) %(refname:short)' refs/heads/ | tail -10", + Scope: "global", + }, + { + Name: "local-alias", + Expansion: "status", + Scope: "local", + }, + } +} + +func TestLookup_GitRecentAlias(t *testing.T) { + ResetRegistry() + Register(&Spec{ + Name: "git", + Subcommands: []Subcommand{ + {Name: "commit"}, + {Name: "checkout", Options: []Option{{Name: "-b"}}}, + }, + }) + alias.Register(&mockGitProvider{}) + t.Cleanup(alias.Reset) + + // Test Type 1: Standard subcommand alias 'git co' -> 'git checkout' + resultsCo := Lookup("git co ") + foundCo := false + for _, r := range resultsCo { + if strings.Contains(r.Cmd, "-b") { + foundCo = true + break + } + } + if !foundCo { + t.Errorf("expected '-b' option after expanding 'git co ', got %v", resultsCo) + } + + // Test Type 2: Shell pipeline alias 'git rec' -> 'git recent' + resultsRec := Lookup("git rec") + foundRec := false + for _, r := range resultsRec { + if strings.Contains(r.Cmd, "git recent") { + foundRec = true + if r.Priority != 70 { + t.Errorf("expected global alias priority 70, got %d", r.Priority) + } + break + } + } + if !foundRec { + t.Errorf("expected 'git recent' suggestion for 'git rec', got %v", resultsRec) + } + + // Test Local Scope Priority (Priority = 85) + resultsLocal := Lookup("git local") + foundLocal := false + for _, r := range resultsLocal { + if strings.Contains(r.Cmd, "git local-alias") { + foundLocal = true + if r.Priority != 85 { + t.Errorf("expected local alias priority 85, got %d", r.Priority) + } + break + } + } + if !foundLocal { + t.Errorf("expected 'git local-alias' suggestion, got %v", resultsLocal) + } + + // Test Type 2 expansion with trailing space: 'git recent ' (shell pipeline alias starting with !) should be skipped and return nil + resultsRecentSpace := Lookup("git recent ") + if len(resultsRecentSpace) != 0 { + t.Errorf("expected 0 suggestions for shell pipeline alias 'git recent ', got %v", resultsRecentSpace) + } +} + +func TestLookup_InvalidSubcommandAfterAliasExpansion(t *testing.T) { + ResetRegistry() + Register(&Spec{ + Name: "git", + Subcommands: []Subcommand{ + {Name: "commit"}, + {Name: "status", Subcommands: []Subcommand{{Name: "porcelain"}}}, + }, + }) + alias.Register(&mockGitProvider{}) + + // token beyond the alias expansion must be validated against the expanded subcommand + results := Lookup("git local-alias badcmd ") + if len(results) != 0 { + t.Errorf("expected 0 suggestions for invalid subcommand after alias expansion, got %v", results) + } + + // expansion itself is still allowed even when it is not a registered subcommand + resultsOk := Lookup("git co ") + if len(resultsOk) == 0 { + t.Errorf("expected suggestions for expanded alias 'git co ', got none") + } +} + +func TestLookup_RealGitProvider(t *testing.T) { + ResetRegistry() + Register(&Spec{ + Name: "git", + }) + alias.Reset() + + // Isolate from the developer's real git configuration by pointing HOME at a temp dir + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte("[alias]\nrecent = for-each-ref --sort=committerdate refs/heads\n"), 0644); err != nil { + t.Fatal(err) + } + + cwd, _ := os.Getwd() + provider := &alias.GitProvider{} + aliases := provider.GetAliases(cwd) + t.Logf("Real Git aliases: %v", aliases) + foundAlias := false + for _, a := range aliases { + if a.Name == "recent" { + foundAlias = true + break + } + } + if !foundAlias { + t.Skipf("git unavailable or could not read isolated config, got aliases: %v", aliases) + } + alias.Register(provider) + t.Cleanup(alias.Reset) + + results := Lookup("git rec") + t.Logf("Lookup('git rec') results: %v", results) + found := false + for _, r := range results { + if strings.Contains(r.Cmd, "recent") { + found = true + break + } + } + if !found { + t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results) + } +} +