feat: add directory-scoped host defaults (match_dirs) and repo list --limit
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions

Add match_dirs field to host config entries for directory-based host
resolution. When no --hostname flag, FGJ_HOST env var, or git remote is
detected, the longest matching directory prefix determines the host.
Symlinks are resolved on both sides for macOS compatibility (/tmp →
/private/tmp). Also adds --limit/-L flag to repo list.
This commit is contained in:
sid 2026-03-23 12:39:51 -06:00
parent 113505de95
commit c293e233d2
17 changed files with 252 additions and 79 deletions

View file

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -14,10 +15,11 @@ type Config struct {
}
type HostConfig struct {
Hostname string `yaml:"hostname"`
Token string `yaml:"token"`
User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"`
Hostname string `yaml:"hostname"`
Token string `yaml:"token"`
User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"`
MatchDirs []string `yaml:"match_dirs,omitempty"`
}
func GetConfigDir() (string, error) {
@ -96,8 +98,9 @@ func (c *Config) SaveToPath(path string) error {
// 2. CLI flag (--hostname)
// 3. Environment variable (FGJ_HOST)
// 4. Auto-detected hostname from git remote
// 5. Default to codeberg.org
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
// 5. match_dirs lookup (longest prefix match)
// 6. Default to codeberg.org
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
if hostname == "" {
hostname = viper.GetString("hostname")
}
@ -110,6 +113,10 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro
hostname = detectedHost
}
if hostname == "" {
hostname = c.ResolveHostByPath(cwd)
}
if hostname == "" {
hostname = "codeberg.org"
}
@ -122,6 +129,50 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro
return host, nil
}
// ResolveHostByPath finds the host whose match_dirs entry is the longest
// prefix of cwd. Returns "" if no match is found.
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
// to handle symlinks (e.g. macOS /tmp → /private/tmp).
func (c *Config) ResolveHostByPath(cwd string) string {
if cwd == "" {
return ""
}
// Resolve symlinks in cwd so /tmp becomes /private/tmp on macOS, etc.
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
cwd = resolved
}
bestHost := ""
bestLen := 0
for hostname, host := range c.Hosts {
for _, dir := range host.MatchDirs {
if dir == "" {
continue
}
// Resolve symlinks in the configured dir as well
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
// Normalize: ensure trailing slash for prefix matching
prefix := dir
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
// Match if cwd equals dir exactly or is under it
if cwd == dir || strings.HasPrefix(cwd, prefix) {
if len(dir) > bestLen {
bestLen = len(dir)
bestHost = hostname
}
}
}
}
return bestHost
}
func (c *Config) SetHost(hostname string, host HostConfig) {
if c.Hosts == nil {
c.Hosts = make(map[string]HostConfig)

View file

@ -49,7 +49,7 @@ func TestConfig_GetHost(t *testing.T) {
},
}
host, err := cfg.GetHost("codeberg.org", "")
host, err := cfg.GetHost("codeberg.org", "", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -58,7 +58,7 @@ func TestConfig_GetHost(t *testing.T) {
t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname)
}
_, err = cfg.GetHost("nonexistent.org", "")
_, err = cfg.GetHost("nonexistent.org", "", "")
if err == nil {
t.Error("Expected error for nonexistent host")
}
@ -275,7 +275,7 @@ func TestConfig_GetHost_EmptyString(t *testing.T) {
}
// Empty hostname should default to codeberg.org
host, err := cfg.GetHost("", "")
host, err := cfg.GetHost("", "", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -296,7 +296,7 @@ func TestConfig_GetHost_WhitespaceString(t *testing.T) {
}
// Whitespace-only hostname should default to codeberg.org
host, err := cfg.GetHost(" ", "")
host, err := cfg.GetHost(" ", "", "")
if err == nil {
t.Logf("Got host: %+v (this may be expected behavior)", host)
} else {
@ -315,7 +315,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) {
cfg.SetHost("codeberg.org", hostConfig)
host, err := cfg.GetHost("codeberg.org", "")
host, err := cfg.GetHost("codeberg.org", "", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -345,7 +345,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) {
cfg.SetHost("codeberg.org", newConfig)
host, err := cfg.GetHost("codeberg.org", "")
host, err := cfg.GetHost("codeberg.org", "", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -388,7 +388,7 @@ func TestConfig_MultipleHosts(t *testing.T) {
// Verify each host can be retrieved correctly
for _, h := range hosts {
host, err := cfg.GetHost(h.hostname, "")
host, err := cfg.GetHost(h.hostname, "", "")
if err != nil {
t.Errorf("Failed to get host %s: %v", h.hostname, err)
continue
@ -422,13 +422,120 @@ func TestConfig_GitProtocol(t *testing.T) {
})
// Verify protocols are stored correctly
sshHost, _ := cfg.GetHost("test-ssh.org", "")
sshHost, _ := cfg.GetHost("test-ssh.org", "", "")
if sshHost.GitProtocol != "ssh" {
t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol)
}
httpsHost, _ := cfg.GetHost("test-https.org", "")
httpsHost, _ := cfg.GetHost("test-https.org", "", "")
if httpsHost.GitProtocol != "https" {
t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol)
}
}
func TestResolveHostByPath(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fgj", "/Users/sid/repos/zerova"},
},
"codeberg.org": {
Hostname: "codeberg.org",
Token: "token2",
MatchDirs: []string{"/"},
},
"gitea.example.com": {
Hostname: "gitea.example.com",
Token: "token3",
// no match_dirs — should never be selected by path
},
},
}
tests := []struct {
name string
cwd string
want string
}{
{"exact dir match", "/Users/sid/repos/fgj", "forgejo.zerova.net"},
{"nested dir match", "/Users/sid/repos/fgj/cmd/root.go", "forgejo.zerova.net"},
{"second match dir", "/Users/sid/repos/zerova/pkg", "forgejo.zerova.net"},
{"longest prefix wins over /", "/Users/sid/repos/fgj/internal", "forgejo.zerova.net"},
{"/ as global catch-all", "/tmp", "codeberg.org"},
{"/ matches root itself", "/", "codeberg.org"},
{"no match_dirs host not selected", "/some/random/path", "codeberg.org"},
{"empty cwd returns empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cfg.ResolveHostByPath(tt.cwd)
if got != tt.want {
t.Errorf("ResolveHostByPath(%q) = %q, want %q", tt.cwd, got, tt.want)
}
})
}
}
func TestResolveHostByPath_LongestPrefixAcrossHosts(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"broad.org": {
Hostname: "broad.org",
Token: "t1",
MatchDirs: []string{"/Users/sid"},
},
"specific.org": {
Hostname: "specific.org",
Token: "t2",
MatchDirs: []string{"/Users/sid/repos/myproject"},
},
},
}
got := cfg.ResolveHostByPath("/Users/sid/repos/myproject/main.go")
if got != "specific.org" {
t.Errorf("expected specific.org, got %q", got)
}
got = cfg.ResolveHostByPath("/Users/sid/other")
if got != "broad.org" {
t.Errorf("expected broad.org, got %q", got)
}
}
func TestGetHost_MatchDirsIntegration(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fgj"},
},
"codeberg.org": {
Hostname: "codeberg.org",
Token: "token2",
},
},
}
// cwd match should resolve to forgejo.zerova.net
host, err := cfg.GetHost("", "", "/Users/sid/repos/fgj/cmd")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host.Hostname != "forgejo.zerova.net" {
t.Errorf("expected forgejo.zerova.net, got %s", host.Hostname)
}
// no cwd match falls through to codeberg.org default
host, err = cfg.GetHost("", "", "/tmp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host.Hostname != "codeberg.org" {
t.Errorf("expected codeberg.org, got %s", host.Hostname)
}
}