merge: combine zoxide and Atuin context navigation

# Conflicts:
#	README.md
#	internal/config/config.go
#	internal/config/config_test.go
#	root/config_cmd.go
#	root/config_cmd_test.go
#	root/init.go
This commit is contained in:
2026-08-14 13:31:01 +03:00
13 changed files with 560 additions and 30 deletions
+3
View File
@@ -327,6 +327,9 @@ deduplicate-branches = true # merge same-name local/remote branches
[zoxide] [zoxide]
extend-cd = false # include zoxide's frecent directories in cd suggestions extend-cd = false # include zoxide's frecent directories in cd suggestions
[history]
scope = "global" # "global", "directory", "workspace", or "hybrid"
[updater] [updater]
check-on-startup = true # check for updates on startup check-on-startup = true # check for updates on startup
channel = "stable" # "stable" or "nightly" channel = "stable" # "stable" or "nightly"
+231 -25
View File
@@ -9,11 +9,13 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
"unicode/utf8" "unicode/utf8"
"github.com/versenilvis/fuzzy" "github.com/versenilvis/fuzzy"
"github.com/versenilvis/iris/integration/shell" "github.com/versenilvis/iris/integration/shell"
"github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/internal/config"
"github.com/versenilvis/iris/internal/workspace"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -21,18 +23,28 @@ var (
sessionHistory []string sessionHistory []string
sessionHistoryMu sync.Mutex sessionHistoryMu sync.Mutex
historyCache []string historyCache []string
idMapCache map[string]int idMapCache map[string]int
sourceMapCache map[string]string sourceMapCache map[string]string
searcherCache *fuzzy.Searcher searcherCache *fuzzy.Searcher
mu sync.Mutex mu sync.Mutex
lastModTime int64 lastModTime int64
atuinCmds []string atuinCmds []string
atuinLastMod int64 atuinEntries []atuinEntry
atuinLastMod int64
lastAtuinMode int = -1 lastAtuinMode int = -1
) )
type atuinEntry struct {
Command string
CWD string
Timestamp int64
Exit int
Session string
Hostname string
}
func RecordSessionCommand(cmd string) { func RecordSessionCommand(cmd string) {
cmd = strings.TrimSpace(cmd) cmd = strings.TrimSpace(cmd)
if cmd == "" { if cmd == "" {
@@ -79,7 +91,7 @@ func sanitizeUTF8(s string) string {
return result.String() return result.String()
} }
func loadAtuinCmds() ([]string, error) { func loadAtuinEntries() ([]atuinEntry, error) {
dbPath, err := config.AtuinDBPath() dbPath, err := config.AtuinDBPath()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -89,36 +101,69 @@ func loadAtuinCmds() ([]string, error) {
return nil, err return nil, err
} }
defer db.Close() defer db.Close()
db.SetMaxOpenConns(1)
rows, err := db.QueryContext(context.Background(), `SELECT command FROM history WHERE deleted_at IS NULL ORDER BY timestamp DESC LIMIT 10000`) ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, `
SELECT command, cwd, timestamp, exit, session, hostname
FROM history
WHERE deleted_at IS NULL
ORDER BY timestamp DESC
LIMIT 10000`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
seen := make(map[string]bool) var entries []atuinEntry
var cmds []string
for rows.Next() { for rows.Next() {
var cmd string var entry atuinEntry
if err := rows.Scan(&cmd); err != nil { if err := rows.Scan(
&entry.Command,
&entry.CWD,
&entry.Timestamp,
&entry.Exit,
&entry.Session,
&entry.Hostname,
); err != nil {
continue continue
} }
cmd = strings.TrimSpace(sanitizeUTF8(cmd)) entry.Command = strings.TrimSpace(sanitizeUTF8(entry.Command))
cmd = strings.ReplaceAll(cmd, "\n", " ") entry.Command = strings.ReplaceAll(entry.Command, "\n", " ")
cmd = strings.ReplaceAll(cmd, "\r", "") entry.Command = strings.ReplaceAll(entry.Command, "\r", "")
if cmd != "" && !seen[cmd] { entry.CWD = sanitizeUTF8(entry.CWD)
seen[cmd] = true if entry.Command != "" {
cmds = append(cmds, cmd) entries = append(entries, entry)
} }
} }
// reverse array to be oldest-first return entries, rows.Err()
}
func atuinCommands(entries []atuinEntry) []string {
seen := make(map[string]bool, len(entries))
cmds := make([]string, 0, len(entries))
for _, entry := range entries {
if !seen[entry.Command] {
seen[entry.Command] = true
cmds = append(cmds, entry.Command)
}
}
// Existing history merge expects oldest-first input.
for i, j := 0, len(cmds)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(cmds)-1; i < j; i, j = i+1, j-1 {
cmds[i], cmds[j] = cmds[j], cmds[i] cmds[i], cmds[j] = cmds[j], cmds[i]
} }
return cmds, rows.Err() return cmds
} }
func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) { func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
return SearchHistoryIn(query, aliases, "")
}
// SearchHistoryIn applies configured Atuin context to a non-empty query.
func SearchHistoryIn(query string, aliases map[string]string, cwd string) ([]HistResult, error) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -159,11 +204,13 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
if mod != atuinLastMod { if mod != atuinLastMod {
atuinLastMod = mod atuinLastMod = mod
atuinCmds = nil atuinCmds = nil
atuinEntries = nil
historyCache = nil historyCache = nil
} }
} }
if atuinCmds == nil { if atuinCmds == nil {
atuinCmds, _ = loadAtuinCmds() atuinEntries, _ = loadAtuinEntries()
atuinCmds = atuinCommands(atuinEntries)
} }
} }
@@ -293,8 +340,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
for i := range limit { for i := range limit {
cmd := historyCache[i] cmd := historyCache[i]
results = append(results, HistResult{ results = append(results, HistResult{
ID: idMapCache[cmd], ID: idMapCache[cmd],
Cmd: cmd, Cmd: cmd,
Source: sourceMapCache[cmd], Source: sourceMapCache[cmd],
}) })
} }
@@ -439,5 +486,164 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
return results[i].ID > results[j].ID return results[i].ID > results[j].ID
}) })
return results, nil return applyAtuinScope(results, query, aliases, cwd), nil
}
func applyAtuinScope(global []HistResult, query string, aliases map[string]string, cwd string) []HistResult {
scope := config.Get().History.Scope
if query == "" || cwd == "" || config.Get().Core.Atuin == 0 || scope == "global" {
return global
}
directory, repository := contextualAtuinCommands(atuinEntries, cwd)
queries := historyQueries(query, aliases)
directoryMatches := matchContextCommands(directory, queries)
repositoryMatches := matchContextCommands(repository, queries)
switch scope {
case "directory":
return directoryMatches
case "workspace":
if workspace.GitRoot(cwd) == "" {
return directoryMatches
}
return mergeHistoryResults(directoryMatches, repositoryMatches)
case "hybrid":
contextual := mergeHistoryResults(directoryMatches, repositoryMatches)
return mergeHistoryResults(contextual, global)
default:
return global
}
}
func contextualAtuinCommands(entries []atuinEntry, cwd string) (directory, repository []string) {
cwd = filepath.Clean(cwd)
root := workspace.GitRoot(cwd)
seenDirectory := make(map[string]bool)
seenRepository := make(map[string]bool)
for _, entry := range entries {
entryCWD := filepath.Clean(entry.CWD)
if entry.CWD == "" || entry.CWD == "unknown" || !filepath.IsAbs(entryCWD) {
continue
}
if entryCWD == cwd {
if !seenDirectory[entry.Command] {
directory = append(directory, entry.Command)
seenDirectory[entry.Command] = true
}
continue
}
if root != "" && pathWithin(root, entryCWD) && !seenRepository[entry.Command] {
repository = append(repository, entry.Command)
seenRepository[entry.Command] = true
}
}
return directory, repository
}
func pathWithin(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
func historyQueries(query string, aliases map[string]string) []string {
queries := []string{query}
qLow := strings.ToLower(query)
for name, target := range aliases {
if target == "" {
continue
}
tLow := strings.ToLower(target)
nLow := strings.ToLower(name)
if qLow == tLow {
queries = append(queries, name)
} else if strings.HasPrefix(qLow, tLow+" ") {
queries = append(queries, name+query[len(target):])
}
if qLow == nLow {
queries = append(queries, target)
} else if strings.HasPrefix(qLow, nLow+" ") {
queries = append(queries, target+query[len(name):])
}
}
return queries
}
func matchContextCommands(commands, queries []string) []HistResult {
if len(commands) == 0 || len(queries) == 0 {
return nil
}
results := make([]HistResult, 0, min(len(commands), 100))
seen := make(map[string]bool, len(commands))
for tier := 1; tier <= 3; tier++ {
for i, cmd := range commands {
if seen[cmd] || bestHistoryTier(cmd, queries) != tier {
continue
}
seen[cmd] = true
results = append(results, HistResult{ID: len(commands) - i, Cmd: cmd, FuzzyScore: 10000, Source: "atuin"})
}
}
searcher := fuzzy.NewPlainSearcher(commands)
for _, query := range queries {
matches := searcher.SearchWithScores(query, &fuzzy.SearchOptions{Limit: min(len(commands), 200)})
for _, match := range matches {
if seen[match.Str] || len(query) == 0 || match.Score/len(query) < 150 {
continue
}
seen[match.Str] = true
results = append(results, HistResult{Cmd: match.Str, FuzzyScore: match.Score, Source: "atuin"})
}
}
return results
}
func bestHistoryTier(cmd string, queries []string) int {
cmdLow := strings.ToLower(cmd)
best := 4
for _, query := range queries {
queryLow := strings.ToLower(query)
switch {
case cmdLow == queryLow:
return 1
case strings.HasPrefix(cmdLow, queryLow):
best = min(best, 2)
case containsAllWords(cmdLow, queryLow):
best = min(best, 3)
}
}
return best
}
func containsAllWords(cmd, query string) bool {
words := strings.Fields(query)
if len(words) == 0 {
return false
}
for _, word := range words {
if !strings.Contains(cmd, word) {
return false
}
}
return true
}
func mergeHistoryResults(groups ...[]HistResult) []HistResult {
seen := make(map[string]bool)
var results []HistResult
for _, group := range groups {
for _, result := range group {
if seen[result.Cmd] {
continue
}
seen[result.Cmd] = true
results = append(results, result)
}
}
return results
} }
+229
View File
@@ -0,0 +1,229 @@
package integration
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/versenilvis/iris/internal/config"
)
func TestLoadAtuinEntriesPreservesContext(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "history.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE history (
command TEXT NOT NULL,
cwd TEXT NOT NULL,
timestamp INTEGER NOT NULL,
exit INTEGER NOT NULL,
session TEXT NOT NULL,
hostname TEXT NOT NULL,
deleted_at INTEGER
)`)
if err != nil {
t.Fatal(err)
}
rows := []struct {
command, cwd, session, hostname string
timestamp, exit, deletedAt int
}{
{"old command", "/repo", "s1", "host", 10, 0, 0},
{"new\ncommand", "/repo/pkg", "s2", "host", 30, 1, 0},
{"deleted command", "/repo", "s3", "host", 40, 0, 99},
}
for _, row := range rows {
var deletedAt any
if row.deletedAt != 0 {
deletedAt = row.deletedAt
}
_, err = db.Exec(
"INSERT INTO history VALUES (?, ?, ?, ?, ?, ?, ?)",
row.command, row.cwd, row.timestamp, row.exit, row.session, row.hostname, deletedAt,
)
if err != nil {
t.Fatal(err)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
originalConfig := config.Get()
cfg := config.DefaultConfig()
cfg.Core.AtuinDBPath = dbPath
config.Init(cfg)
t.Cleanup(func() { config.Init(originalConfig) })
entries, err := loadAtuinEntries()
if err != nil {
t.Fatalf("load Atuin entries: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected two active entries, got %d", len(entries))
}
if entries[0].Command != "new command" || entries[0].CWD != "/repo/pkg" || entries[0].Exit != 1 {
t.Fatalf("unexpected newest entry: %#v", entries[0])
}
if entries[1].Command != "old command" || entries[1].Session != "s1" {
t.Fatalf("unexpected oldest entry: %#v", entries[1])
}
}
func TestApplyAtuinScope(t *testing.T) {
repo := t.TempDir()
cwd := filepath.Join(repo, "internal", "pkg")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(cwd, 0755); err != nil {
t.Fatal(err)
}
originalConfig := config.Get()
originalEntries := atuinEntries
t.Cleanup(func() {
config.Init(originalConfig)
atuinEntries = originalEntries
})
cfg := config.DefaultConfig()
cfg.Core.Atuin = 1
config.Init(cfg)
atuinEntries = []atuinEntry{
{Command: "git status", CWD: cwd, Timestamp: 50},
{Command: "git pull", CWD: repo, Timestamp: 40},
{Command: "git status", CWD: cwd, Timestamp: 30},
{Command: "git push", CWD: filepath.Join(t.TempDir(), "other"), Timestamp: 20},
{Command: "git unknown", CWD: "unknown", Timestamp: 10},
}
global := []HistResult{
{Cmd: "git push", Source: "atuin"},
{Cmd: "git pull", Source: "atuin"},
{Cmd: "git status", Source: "atuin"},
}
tests := []struct {
scope string
want []string
}{
{"global", []string{"git push", "git pull", "git status"}},
{"directory", []string{"git status"}},
{"workspace", []string{"git status", "git pull"}},
{"hybrid", []string{"git status", "git pull", "git push"}},
}
for _, tt := range tests {
t.Run(tt.scope, func(t *testing.T) {
cfg.History.Scope = tt.scope
got := resultCommands(applyAtuinScope(global, "g", map[string]string{"g": "git"}, cwd))
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("scope %s: got %v, want %v", tt.scope, got, tt.want)
}
})
}
cfg.History.Scope = "hybrid"
if got := resultCommands(applyAtuinScope(global, "", nil, cwd)); !reflect.DeepEqual(got, resultCommands(global)) {
t.Fatalf("empty query must stay global: got %v", got)
}
}
func TestWorkspaceScopeOutsideGitUsesDirectory(t *testing.T) {
cwd := t.TempDir()
originalConfig := config.Get()
originalEntries := atuinEntries
t.Cleanup(func() {
config.Init(originalConfig)
atuinEntries = originalEntries
})
cfg := config.DefaultConfig()
cfg.Core.Atuin = 1
cfg.History.Scope = "workspace"
config.Init(cfg)
atuinEntries = []atuinEntry{
{Command: "make local", CWD: cwd},
{Command: "make elsewhere", CWD: t.TempDir()},
}
got := resultCommands(applyAtuinScope(nil, "make", nil, cwd))
if !reflect.DeepEqual(got, []string{"make local"}) {
t.Fatalf("got %v, want directory-only result", got)
}
}
func BenchmarkContextualAtuinCommands(b *testing.B) {
repo := b.TempDir()
cwd := filepath.Join(repo, "internal", "pkg")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
b.Fatal(err)
}
if err := os.MkdirAll(cwd, 0755); err != nil {
b.Fatal(err)
}
entries := make([]atuinEntry, 10000)
for i := range entries {
entryCWD := repo
if i%3 == 0 {
entryCWD = cwd
}
entries[i] = atuinEntry{Command: fmt.Sprintf("command %d", i), CWD: entryCWD}
}
b.ResetTimer()
for range b.N {
contextualAtuinCommands(entries, cwd)
}
}
func BenchmarkApplyAtuinScope(b *testing.B) {
repo := b.TempDir()
cwd := filepath.Join(repo, "internal", "pkg")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
b.Fatal(err)
}
if err := os.MkdirAll(cwd, 0755); err != nil {
b.Fatal(err)
}
originalConfig := config.Get()
originalEntries := atuinEntries
b.Cleanup(func() {
config.Init(originalConfig)
atuinEntries = originalEntries
})
cfg := config.DefaultConfig()
cfg.Core.Atuin = 1
cfg.History.Scope = "hybrid"
config.Init(cfg)
atuinEntries = make([]atuinEntry, 10000)
global := make([]HistResult, 10000)
for i := range atuinEntries {
entryCWD := repo
if i%3 == 0 {
entryCWD = cwd
}
command := fmt.Sprintf("command %d", i)
atuinEntries[i] = atuinEntry{Command: command, CWD: entryCWD}
global[i] = HistResult{Cmd: command, Source: "atuin"}
}
b.ResetTimer()
for range b.N {
applyAtuinScope(global, "command 999", nil, cwd)
}
}
func resultCommands(results []HistResult) []string {
commands := make([]string, len(results))
for i, result := range results {
commands[i] = result.Cmd
}
return commands
}
+10
View File
@@ -2,10 +2,12 @@ package integration
import ( import (
"testing" "testing"
"github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/internal/config"
) )
func TestSearchHistory_AtuinSourceMapping(t *testing.T) { func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
originalConfig := config.Get()
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Core.Atuin = 2 cfg.Core.Atuin = 2
cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db" cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db"
@@ -19,11 +21,16 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
mu.Lock() mu.Lock()
origHistoryCache := historyCache origHistoryCache := historyCache
origAtuinCmds := atuinCmds origAtuinCmds := atuinCmds
origAtuinEntries := atuinEntries
origAtuinLastMod := atuinLastMod
origLastAtuinMode := lastAtuinMode
historyCache = nil historyCache = nil
atuinCmds = []string{"git push", "ls -l"} atuinCmds = []string{"git push", "ls -l"}
mu.Unlock() mu.Unlock()
t.Cleanup(func() { t.Cleanup(func() {
config.Init(originalConfig)
sessionHistoryMu.Lock() sessionHistoryMu.Lock()
sessionHistory = origSessionHistory sessionHistory = origSessionHistory
sessionHistoryMu.Unlock() sessionHistoryMu.Unlock()
@@ -31,6 +38,9 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
mu.Lock() mu.Lock()
historyCache = origHistoryCache historyCache = origHistoryCache
atuinCmds = origAtuinCmds atuinCmds = origAtuinCmds
atuinEntries = origAtuinEntries
atuinLastMod = origAtuinLastMod
lastAtuinMode = origLastAtuinMode
mu.Unlock() mu.Unlock()
}) })
+11
View File
@@ -83,6 +83,11 @@ type ZoxideConfig struct {
ExtendCd bool `toml:"extend-cd"` ExtendCd bool `toml:"extend-cd"`
} }
type HistoryConfig struct {
// Scope controls Atuin filtering for non-empty history searches.
Scope string `toml:"scope"`
}
type SuggestOnEmptyConfig struct { type SuggestOnEmptyConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
DebounceMS int `toml:"debounce_ms"` DebounceMS int `toml:"debounce_ms"`
@@ -130,6 +135,7 @@ type Config struct {
Core CoreConfig `toml:"core"` Core CoreConfig `toml:"core"`
UI UIConfig `toml:"ui"` UI UIConfig `toml:"ui"`
Git GitConfig `toml:"git"` Git GitConfig `toml:"git"`
History HistoryConfig `toml:"history"`
Updater UpdaterConfig `toml:"updater"` Updater UpdaterConfig `toml:"updater"`
AI AIConfig `toml:"ai"` AI AIConfig `toml:"ai"`
Zoxide ZoxideConfig `toml:"zoxide"` Zoxide ZoxideConfig `toml:"zoxide"`
@@ -288,6 +294,11 @@ func validate(cfg *Config) error {
return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell) return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell)
} }
validHistoryScopes := map[string]bool{"global": true, "directory": true, "workspace": true, "hybrid": true}
if !validHistoryScopes[cfg.History.Scope] {
return fmt.Errorf("history.scope: invalid value %q (want: global|directory|workspace|hybrid)", cfg.History.Scope)
}
validChannels := map[string]bool{"stable": true, "nightly": true} validChannels := map[string]bool{"stable": true, "nightly": true}
if !validChannels[cfg.Updater.Channel] { if !validChannels[cfg.Updater.Channel] {
return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel) return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel)
+18
View File
@@ -33,6 +33,9 @@ func TestDefaultConfigAndState(t *testing.T) {
if cfg.Zoxide.ExtendCd { if cfg.Zoxide.ExtendCd {
t.Errorf("expected zoxide cd extension to be disabled by default") t.Errorf("expected zoxide cd extension to be disabled by default")
} }
if cfg.History.Scope != "global" {
t.Errorf("expected global history scope, got %q", cfg.History.Scope)
}
// test manual provider registration // test manual provider registration
cfg.AI.Provider = "custom" cfg.AI.Provider = "custom"
@@ -210,6 +213,21 @@ func TestValidateAutoUpdateRange(t *testing.T) {
} }
} }
func TestValidateHistoryScope(t *testing.T) {
cfg := DefaultConfig()
for _, scope := range []string{"global", "directory", "workspace", "hybrid"} {
cfg.History.Scope = scope
if err := validate(cfg); err != nil {
t.Errorf("expected history scope %q to be valid: %v", scope, err)
}
}
cfg.History.Scope = "session"
if err := validate(cfg); err == nil {
t.Error("expected unsupported history scope to be rejected")
}
}
func TestLoadSave(t *testing.T) { func TestLoadSave(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-config-test") tmpDir, err := os.MkdirTemp("", "iris-config-test")
if err != nil { if err != nil {
+3
View File
@@ -29,6 +29,9 @@ func DefaultConfig() *Config {
FilterActiveBranch: true, FilterActiveBranch: true,
DeduplicateBranches: true, DeduplicateBranches: true,
}, },
History: HistoryConfig{
Scope: "global",
},
Updater: UpdaterConfig{ Updater: UpdaterConfig{
CheckOnStartup: true, CheckOnStartup: true,
Channel: "stable", Channel: "stable",
+15
View File
@@ -108,6 +108,21 @@ func detectGitInfo(cwd string) (hasGit bool, branch string) {
return hasGit, "" return hasGit, ""
} }
// GitRoot returns the repository root containing cwd, or an empty string.
func GitRoot(cwd string) string {
dir := filepath.Clean(cwd)
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
type cacheEntry struct { type cacheEntry struct {
key string // cwd + "|" + dirModTime key string // cwd + "|" + dirModTime
info WorkspaceInfo info WorkspaceInfo
+19 -1
View File
@@ -44,6 +44,24 @@ func TestDetect_EmptyDirectory(t *testing.T) {
} }
} }
func TestGitRoot(t *testing.T) {
repo := t.TempDir()
nested := filepath.Join(repo, "internal", "pkg")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(nested, 0755); err != nil {
t.Fatal(err)
}
if got := GitRoot(nested); got != repo {
t.Fatalf("expected git root %q, got %q", repo, got)
}
if got := GitRoot(t.TempDir()); got != "" {
t.Fatalf("expected no git root, got %q", got)
}
}
func TestDetect_NodeProject(t *testing.T) { func TestDetect_NodeProject(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
_ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644) _ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644)
@@ -135,7 +153,7 @@ func TestDetectCached_BranchSwitchWithoutDirChange(t *testing.T) {
// Simulate branch switch by updating .git/HEAD only // 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) // (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) _ = 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 // Force the modtime of HEAD to be distinct to avoid flakiness on low-res file systems
infoAfter, _ := os.Stat(headPath) infoAfter, _ := os.Stat(headPath)
newMod := infoAfter.ModTime().Add(2 * time.Second) newMod := infoAfter.ModTime().Add(2 * time.Second)
+4
View File
@@ -101,6 +101,10 @@ deduplicate-branches = true
# include zoxide directories in cd suggestions # include zoxide directories in cd suggestions
extend-cd = false extend-cd = false
[history]
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
scope = "global"
[updater] [updater]
# check for updates on startup # check for updates on startup
check-on-startup = true check-on-startup = true
+3
View File
@@ -40,6 +40,9 @@ func TestConfigCommands(t *testing.T) {
if !strings.Contains(string(content), "extend-cd = false") { if !strings.Contains(string(content), "extend-cd = false") {
t.Error("expected initialized config to include extend-cd = false") t.Error("expected initialized config to include extend-cd = false")
} }
if !strings.Contains(string(content), "scope = \"global\"") {
t.Error("expected initialized config to include global history scope")
}
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
ConfigShowCmd.SetOut(buf) ConfigShowCmd.SetOut(buf)
+10
View File
@@ -247,6 +247,12 @@ expand-alias = true
# automatically execute command after accepting suggestion # automatically execute command after accepting suggestion
auto-execute = false auto-execute = false
# 0 = off, 1 = atuin history only, 2 = atuin history + default history
atuin-history = 0
# custom atuin database path (leave empty for default)
atuin-db-path = ""
[ui] [ui]
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
style = "modern" style = "modern"
@@ -280,6 +286,10 @@ deduplicate-branches = true
# include zoxide directories in cd suggestions # include zoxide directories in cd suggestions
extend-cd = false extend-cd = false
[history]
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
scope = "global"
[updater] [updater]
# check for updates on startup # check for updates on startup
check-on-startup = true check-on-startup = true
+4 -4
View File
@@ -49,18 +49,18 @@ func MergeResults(query string, mode string) []spec.Suggestion {
if mode == "history" { if mode == "history" {
aliases := spec.GetAliasesCopy() aliases := spec.GetAliasesCopy()
histResults, _ := integration.SearchHistory(query, aliases) histResults, _ := integration.SearchHistoryIn(query, aliases, spec.GetCWD())
// scale confidence based on recency (index in histResults) so the most recent commands stay on top // scale confidence based on recency (index in histResults) so the most recent commands stay on top
baseConf := 75 baseConf := 75
for i, h := range histResults { for i, h := range histResults {
conf := max(baseConf-(i*2), 60) conf := max(baseConf-(i*2), 60)
icon := "history" icon := "history"
if h.Source == "atuin" { if h.Source == "atuin" {
icon = "atuin" icon = "atuin"
} }
addSuggestion(spec.Suggestion{ addSuggestion(spec.Suggestion{
Cmd: h.Cmd, Cmd: h.Cmd,
Desc: h.Source, Desc: h.Source,
@@ -97,7 +97,7 @@ func MergeResults(query string, mode string) []spec.Suggestion {
if len(tokens) > 0 { if len(tokens) > 0 {
rootCmd = tokens[0] rootCmd = tokens[0]
} }
ctxTimeout, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) ctxTimeout, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() defer cancel()
store, _ := scoring.GetFrecencyStore() store, _ := scoring.GetFrecencyStore()