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 `!`)
This commit is contained in:
@@ -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{})
|
||||
}
|
||||
Reference in New Issue
Block a user